Files
pad/internal/server/task2308_mcp_scope_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

211 lines
8.5 KiB
Go

package server
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-2308: the /mcp 401 names the scope set, and an OAuth connection that
// can only read gets a 403 insufficient_scope challenge for a write tool
// call, before the transport sees it. Driven through the router, so the
// middleware's place in the /mcp chain is what is tested (CONVE-19).
const wantInsufficientScopeChallenge = `Bearer realm="pad", error="insufficient_scope", ` +
`error_description="This connection can only read. Reconnect Pad and allow it to edit your workspaces.", ` +
`scope="pad:read pad:write", ` +
`resource_metadata="` + testCanonicalAudience + `/.well-known/oauth-protected-resource"`
// recordingMCPTransport answers 200 "mcp" and keeps the body of every
// request that reached it.
type recordingMCPTransport struct{ bodies []string }
func (rt *recordingMCPTransport) ServeHTTP(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
rt.bodies = append(rt.bodies, string(b))
_, _ = w.Write([]byte("mcp"))
}
func (rt *recordingMCPTransport) take() []string {
out := rt.bodies
rt.bodies = nil
return out
}
// scopeTestServer is an OAuth-capable server whose /mcp transport records
// what reaches it, with a classifier calling pad_item.create a write.
func scopeTestServer(t *testing.T) (*Server, *recordingMCPTransport) {
t.Helper()
srv := twoResourceOAuthServer(t)
rt := &recordingMCPTransport{}
srv.SetMCPTransport(rt, testCanonicalAudience, testAuthServerURL, nil)
srv.SetMCPWriteCallClassifier(func(tool, action string) bool {
return tool == "pad_item" && action == "create"
})
return srv, rt
}
func postMCPBody(srv *Server, path, token, body string) *httptest.ResponseRecorder {
return postMCPBodyAs(srv, path, token, body, "")
}
// postMCPBodyAs is postMCPBody with a Content-Type ("" means
// application/json).
func postMCPBodyAs(srv *Server, path, token, body, contentType string) *httptest.ResponseRecorder {
if contentType == "" {
contentType = "application/json"
}
req := httptest.NewRequest("POST", path, strings.NewReader(body))
req.Header.Set("Content-Type", contentType)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.RemoteAddr = "192.0.2.1:1234"
// A writer that takes a write deadline: /mcp refuses one that cannot (BUG-3808).
rr := newDeadlineRecorder()
srv.ServeHTTP(rr, req)
return rr.ResponseRecorder
}
func toolCallBody(tool, action string) string {
b, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": map[string]any{"name": tool, "arguments": map[string]any{"action": action, "workspace": "ws"}},
})
return string(b)
}
func TestTASK2308_UnauthorizedChallengeNamesScope(t *testing.T) {
srv, _ := scopeTestServer(t)
rr := postMCPBody(srv, "/mcp", "", toolCallBody("pad_item", "create"))
want := `Bearer realm="pad", scope="pad:read pad:write", resource_metadata="` +
testCanonicalAudience + `/.well-known/oauth-protected-resource"`
if rr.Code != http.StatusUnauthorized || rr.Header().Get("WWW-Authenticate") != want {
t.Fatalf("401: %d %q, want %q", rr.Code, rr.Header().Get("WWW-Authenticate"), want)
}
}
func TestTASK2308_ReadOnlyOAuthWriteCallIsChallenged(t *testing.T) {
srv, rt := scopeTestServer(t)
tok, code := mintWithResource(t, srv, newOAuthSession(t, srv), testCanonicalAudience)
if code != http.StatusOK {
t.Fatalf("mint: %d", code)
}
rr := postMCPBody(srv, "/mcp", tok, toolCallBody("pad_item", "create"))
if rr.Code != http.StatusForbidden {
t.Fatalf("read-only OAuth write call: %d %s, want 403", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("WWW-Authenticate"); got != wantInsufficientScopeChallenge {
t.Errorf("challenge = %q\nwant %q", got, wantInsufficientScopeChallenge)
}
var body struct {
Error struct{ Code string } `json:"error"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil || body.Error.Code != "insufficient_scope" {
t.Errorf("body = %s, want error.code insufficient_scope", rr.Body.String())
}
if got := rt.take(); len(got) != 0 {
t.Errorf("the refused call reached the transport: %q", got)
}
}
// Everything the check is not about reaches the transport, with its body
// byte for byte.
func TestTASK2308_PassThroughCases(t *testing.T) {
srv, rt := scopeTestServer(t)
sess := newOAuthSession(t, srv)
readTok, code := mintWithResource(t, srv, sess, testCanonicalAudience)
if code != http.StatusOK {
t.Fatalf("mint read: %d", code)
}
writeTok, code := mintWithResourceTier(t, srv, sess, testCanonicalAudience, "pad:read pad:write", "write")
if code != http.StatusOK {
t.Fatalf("mint write: %d", code)
}
user, err := srv.store.GetUserByEmail("oauth-test@example.com")
if err != nil || user == nil {
t.Fatal(err)
}
pat, err := srv.store.CreateAPIToken(user.ID, models.APITokenCreate{Name: "read pat", Scopes: `["read"]`}, 90, 365)
if err != nil {
t.Fatal(err)
}
oversized := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"pad_item","arguments":{"action":"create","content":"` +
strings.Repeat("x", mcpScopePeekMaxBytes) + `"}}}`
cases := []struct {
name, token, body, contentType string
}{
{"read token, read action", readTok, toolCallBody("pad_item", "get"), ""},
{"read token, unknown tool", readTok, toolCallBody("pad_nope", "create"), ""},
{"read token, initialize", readTok, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`, ""},
{"read token, batch", readTok, `[` + toolCallBody("pad_item", "create") + `]`, ""},
{"read token, action under another key's case", readTok,
`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"pad_item","arguments":{"Action":"create"}}}`, ""},
{"read token, body past the peek bound", readTok, oversized, ""},
// Messages mcp-go itself refuses or runs no tool for: answered as
// before, never challenged (codex round 1).
{"read token, no jsonrpc member", readTok,
`{"id":1,"method":"tools/call","params":{"name":"pad_item","arguments":{"action":"create"}}}`, ""},
{"read token, jsonrpc 1.0", readTok,
`{"jsonrpc":"1.0","id":1,"method":"tools/call","params":{"name":"pad_item","arguments":{"action":"create"}}}`, ""},
{"read token, notification (no id)", readTok,
`{"jsonrpc":"2.0","method":"tools/call","params":{"name":"pad_item","arguments":{"action":"create"}}}`, ""},
{"read token, null id", readTok,
`{"jsonrpc":"2.0","id":null,"method":"tools/call","params":{"name":"pad_item","arguments":{"action":"create"}}}`, ""},
{"read token, not application/json", readTok, toolCallBody("pad_item", "create"), "text/plain"},
{"write token, write action", writeTok, toolCallBody("pad_item", "create"), ""},
// A PAT cannot re-authorize, so it keeps the dispatcher's tool error.
{"read PAT, write action", pat.Token, toolCallBody("pad_item", "create"), ""},
}
for _, c := range cases {
rr := postMCPBodyAs(srv, "/mcp", c.token, c.body, c.contentType)
if rr.Code != http.StatusOK || rr.Body.String() != "mcp" {
t.Errorf("%s: %d %q, want 200 from the transport", c.name, rr.Code, rr.Body.String())
continue
}
got := rt.take()
if len(got) != 1 || got[0] != c.body {
t.Errorf("%s: the transport did not receive the body intact (%d requests)", c.name, len(got))
}
}
}
// With no classifier wired the check is off, and a read-only write call
// reaches the transport as it did before TASK-2308.
func TestTASK2308_NoClassifierNoCheck(t *testing.T) {
srv, rt := scopeTestServer(t)
srv.SetMCPWriteCallClassifier(nil)
tok, code := mintWithResource(t, srv, newOAuthSession(t, srv), testCanonicalAudience)
if code != http.StatusOK {
t.Fatalf("mint: %d", code)
}
if rr := postMCPBody(srv, "/mcp", tok, toolCallBody("pad_item", "create")); rr.Code != http.StatusOK {
t.Fatalf("no classifier: %d, want 200", rr.Code)
}
if len(rt.take()) != 1 {
t.Fatal("the call did not reach the transport")
}
}
// The ChatGPT mount keeps its own tool-result challenge (TASK-3321 U2c):
// the 403 is a /mcp-chain middleware and must not run there.
func TestTASK2308_ChatGPTMountIsNotChallengedHere(t *testing.T) {
srv := chatGPTMountServer(t, true)
srv.SetMCPWriteCallClassifier(func(tool, action string) bool { return true })
gptTok, code := mintWithResource(t, srv, newOAuthSession(t, srv), testChatGPTResource)
if code != http.StatusOK {
t.Fatalf("mint: %d", code)
}
rr := postMCPBody(srv, "/mcp/chatgpt", gptTok, toolCallBody("pad_item", "create"))
if rr.Code != http.StatusOK || rr.Body.String() != "chatgpt" {
t.Fatalf("ChatGPT mount: %d %q, want 200 from its transport", rr.Code, rr.Body.String())
}
}