mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-27 06:47:07 +00:00
fdcf0867cd
* 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
211 lines
8.5 KiB
Go
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())
|
|
}
|
|
}
|