Files
pad/internal/server/mcp_capability_test.go
T
xarmian fdcf0867cd fix(mcp): a /mcp stream whose client stops reading ends instead of pinning its handler (BUG-3808) (#2182)
* fix(mcp): a /mcp stream whose client stops reading ends instead of pinning its handler (BUG-3808)

The /mcp sibling of BUG-3637. mcp-go's GET stream, and a POST response it
upgrades to SSE, write and flush with no deadline; the server sets no
WriteTimeout and no timeout ends a tool call. A client that stopped reading
pinned the handler, its session registration and the connection.

mcpBoundWrites serves both MCP mounts (/mcp and /mcp/chatgpt) through
#2172's deadline-arming stream writer, on every method, and cancels the
request context when the stream goes dead: the library leaves its loop on a
Write error, but a failed Flush returns none, so ctx.Done() is its way out.
It fails closed like openSSEStream. Tests that drove /mcp through
httptest.ResponseRecorder now use the deadline recorder.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(mcp): the dead-stream context cancellation is tested directly; the flush case is named for what it shows (BUG-3808)

M2 (Dead not wired to the context) survived the integration test: frames still
queued in mcp-go's channels after a failed Flush make the next Write fail on
the sticky error, so the loop leaves anyway. The context is its only way out
when nothing is queued, so that binding gets its own test.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(mcp): both MCP mounts refuse an unbounded writer (BUG-3808)

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(mcp): cmd/pad's /mcp tests serve through a writer that takes a deadline (BUG-3808)

Five cmd/pad tests drove /mcp through httptest.ResponseRecorder, which
mcpBoundWrites now refuses. mcpRecorder is the cmd/pad twin of the server
package's deadline recorder.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* fix(mcp): a header-only /mcp response is committed under a write deadline (BUG-3808)

mcp-go's DELETE calls WriteHeader and returns; net/http commits that
response in finishRequest, after mcpBoundWrites has returned and with the
deadline openSSEStream cleared. mcpBoundWrites now arms one deadline for
that final write; net/http clears it once the response is finished.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(mcp): the /mcp stall tests wait for the failed write, then bound only the release (BUG-3808)

BUG-3824 changed stalledStreamServer to report the first failed write on
the watched connection and split the stalled-reader timing in two: the
buffer fill is the harness, the release after a deadline overrun is the
code under test. The GET and DELETE stall pins had the same single 5s
end-to-end budget that made BUG-3637's flaky under load; they now use the
same two phases and require the failure to be a deadline overrun.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
2026-10-15 06:06:34 -04:00

131 lines
5.7 KiB
Go

package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/config"
"github.com/PerpetualSoftware/pad/internal/oauth"
)
// testHTTPSEndpoints is an https self-host's resolved MCP addressing: a
// usable origin with the two URLs the test harness has always used.
func testHTTPSEndpoints() config.MCPEndpoints {
return config.MCPEndpoints{
Origin: "https://pad.test.example",
OriginVar: "PAD_URL",
ResourceURL: testCanonicalAudience,
AuthServerURL: testAuthServerURL,
}
}
// TestE2E_HTTPSSelfHostFlow is PLAN-2310 DR-4's acceptance for an https
// self-host, off cloud, with MCP turned on through the stored setting:
// the 401 points at a metadata document that exists, discovery names the
// configured URLs, and DCR, authorize and token complete for the audience
// <origin>/mcp, whose access token then reaches /mcp as its user.
func TestE2E_HTTPSSelfHostFlow(t *testing.T) {
t.Parallel()
// The origin's host is httptest's default Host, example.com, so the
// shared OAuth helpers' requests carry the configured Host and pass
// the DR-6 allowlist (port 443 is the https default on both sides).
const (
origin = "https://example.com"
audience = origin + "/mcp"
)
srv := testServer(t)
srv.SetMCPConfig(config.MCPEndpoints{Origin: origin, OriginVar: "PAD_URL", ResourceURL: audience, AuthServerURL: origin}, nil)
if err := srv.store.SetPlatformSetting(settingMCPEnabled, "true"); err != nil {
t.Fatalf("SetPlatformSetting: %v", err)
}
transport := &mcpStubTransport{}
srv.SetMCPTransport(http.HandlerFunc(transport.serve), audience, origin, nil)
o, err := oauth.NewServer(oauth.Config{Store: srv.store, HMACSecret: bytes32ForTest(), AllowedAudience: audience})
if err != nil {
t.Fatalf("oauth.NewServer: %v", err)
}
srv.SetOAuthServer(o)
if srv.IsCloud() {
t.Fatal("this test is about self-host; cloud mode must be off")
}
user, sessionToken := loginTestUser(t, srv)
rr := doRequest(srv, "POST", "/mcp", map[string]any{"jsonrpc": "2.0", "id": 1, "method": "initialize"})
const wantChallenge = `Bearer realm="pad", scope="pad:read pad:write", resource_metadata="` + origin + `/.well-known/oauth-protected-resource/mcp"`
if rr.Code != http.StatusUnauthorized || rr.Header().Get("WWW-Authenticate") != wantChallenge {
t.Fatalf("401 challenge: status %d, WWW-Authenticate %q, want %q", rr.Code, rr.Header().Get("WWW-Authenticate"), wantChallenge)
}
rr = doRequest(srv, "GET", "/.well-known/oauth-protected-resource/mcp", nil)
var prDoc protectedResourceMetadata
parseJSON(t, rr, &prDoc)
if rr.Code != http.StatusOK || prDoc.Resource != audience || !sliceEqual(prDoc.AuthorizationServers, []string{origin}) {
t.Fatalf("protected-resource doc: status %d, %+v", rr.Code, prDoc)
}
rr = doRequest(srv, "GET", "/.well-known/oauth-authorization-server", nil)
var asDoc map[string]any
parseJSON(t, rr, &asDoc)
if rr.Code != http.StatusOK || asDoc["issuer"] != origin || asDoc["token_endpoint"] != origin+"/oauth/token" {
t.Fatalf("auth-server doc: status %d, %v", rr.Code, asDoc)
}
clientID := registerTestClient(t, srv, "https://app.test/cb")
csrfTok := readCSRFFromCookieFor(t, srv, sessionToken, audience)
tokens := runAuthCodeFlowFor(t, srv, sessionToken, csrfTok, clientID,
"verifier-selfhost-quick-brown-fox-jumps-over-the-lazy-dog", audience)
access, _ := tokens["access_token"].(string)
if access == "" {
t.Fatalf("no access token: %v", tokens)
}
req := httptest.NewRequest("POST", "/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":2,"method":"tools/list"}`))
req.Header.Set("Authorization", "Bearer "+access)
req.Header.Set("Content-Type", "application/json")
req.RemoteAddr = "192.0.2.1:1234"
rec := newDeadlineRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !transport.Called || transport.SeenUserID != user.ID {
t.Fatalf("/mcp with the access token: status %d, reached=%v, user %q want %q (body %s)",
rec.Code, transport.Called, transport.SeenUserID, user.ID, rec.Body.String())
}
}
// TestProtectedResourceMetadataURL pins RFC 9728 §3.1's shape for the
// 401's resource_metadata, including byte identity for a path-less
// resource in any spelling (cloud's audience is compared byte-for-byte
// and its historical header was resource + the well-known suffix).
func TestProtectedResourceMetadataURL(t *testing.T) {
for in, want := range map[string]string{
"https://mcp.getpad.dev": "https://mcp.getpad.dev/.well-known/oauth-protected-resource",
"https://mcp.getpad.dev/": "https://mcp.getpad.dev/.well-known/oauth-protected-resource",
"HTTPS://MCP.GetPad.dev:443": "HTTPS://MCP.GetPad.dev:443/.well-known/oauth-protected-resource",
"https://pad.example.com/mcp": "https://pad.example.com/.well-known/oauth-protected-resource/mcp",
"http://pad.lan:7777/mcp/": "http://pad.lan:7777/.well-known/oauth-protected-resource/mcp",
"https://pad.example.com/a/mcp": "https://pad.example.com/.well-known/oauth-protected-resource/a/mcp",
"": "",
} {
if got := protectedResourceMetadataURL(in); got != want {
t.Errorf("protectedResourceMetadataURL(%q) = %q, want %q", in, got, want)
}
}
}
// wireOAuthForTest makes oauthAvailable true the way production does on
// an https self-host (PLAN-2310 DR-1, DR-4): https addressing, the MCP
// setting forced on through the environment override, and an OAuth
// server constructed for the resolved audience. It works on a cloud
// server too, where the addressing is simply unused by the predicate.
func wireOAuthForTest(t *testing.T, srv *Server) *oauth.Server {
t.Helper()
on := true
srv.SetMCPConfig(testHTTPSEndpoints(), &on)
o, err := newTestOAuthServer(t, srv)
if err != nil {
t.Fatalf("oauth.NewServer: %v", err)
}
srv.SetOAuthServer(o)
return o
}