From 9094bf9ad99c92b4343d8d3f1062ef4863f58e00 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 28 Oct 2026 16:09:30 +0100 Subject: [PATCH] test(32.1-05): prove MCP call context, both guards, and PRM scopes - A bare context and a plain request carry no MCP call - Frontend and backend tool calls record the guard that owns the tool - PRM uses an advertised scope list and falls back to PRMScopes --- modules/pact/mcp_call_negative_test.go | 30 +++++++ modules/pier/dispatch_mcpcall_test.go | 103 +++++++++++++++++++++++++ modules/pier/prm_scopes_test.go | 58 ++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 modules/pact/mcp_call_negative_test.go create mode 100644 modules/pier/dispatch_mcpcall_test.go create mode 100644 modules/pier/prm_scopes_test.go diff --git a/modules/pact/mcp_call_negative_test.go b/modules/pact/mcp_call_negative_test.go new file mode 100644 index 00000000..5305bde5 --- /dev/null +++ b/modules/pact/mcp_call_negative_test.go @@ -0,0 +1,30 @@ +package pact + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// TestMCPCallFromBareContext pins D-11: a context that never stored an +// MCP call answers ok false, including a plain HTTP request. +func TestMCPCallFromBareContext(t *testing.T) { + if _, ok := MCPCallFrom(context.Background()); ok { + t.Fatal("MCPCallFrom(context.Background()) ok = true, want false") + } + req := httptest.NewRequest(http.MethodGet, "http://acme.test/items", nil) + if _, ok := MCPCallFrom(req.Context()); ok { + t.Fatal("MCPCallFrom on a request that never called WithMCPCall ok = true, want false") + } +} + +// TestFrontendTokenScopeLiteralWithoutImplies pins that a composite +// literal written before Implies existed still compiles and leaves the +// field empty, so existing declarations stay valid. +func TestFrontendTokenScopeLiteralWithoutImplies(t *testing.T) { + scope := FrontendTokenScope{Name: "servers:read", Label: "Read servers", Description: "List servers."} + if scope.Implies != "" { + t.Fatalf("Implies = %q, want empty", scope.Implies) + } +} diff --git a/modules/pier/dispatch_mcpcall_test.go b/modules/pier/dispatch_mcpcall_test.go new file mode 100644 index 00000000..848adcd1 --- /dev/null +++ b/modules/pier/dispatch_mcpcall_test.go @@ -0,0 +1,103 @@ +package pier_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/pier" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// TestDispatchMCPCallBothGuards pins D-11: one frontend tool call and one +// backend tool call each carry MCPCall for the guard that owns the tool. +// SessionID is the stateful session, never the word ephemeral. A +// personal-token versus OAuth difference is not asserted here. +func TestDispatchMCPCallBothGuards(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + links := newFakeLinks() + links.set(10, consentedAdmin) + front := &fakeGuard{} + back := &fakeGuard{name: pier.BackendGuard, tokenMW: pier.BackendGuard} + verifier := newFakeVerifier(map[string]pact.MCPGrant{ + "oas_at_admin": grant(10, "c4", 4, "read", "write", "backend"), + }) + app := newApp(t, appSpec{ + guards: []pact.MCPGuard{front, back}, + verifier: verifier, + links: links, + }) + acme := routePlugin{ + id: "test.acme", + routes: []testRoute{{ + method: http.MethodGet, + path: "/acme/ping", + mw: []string{"acme.auth"}, + h: mcpCallHandler, + }}, + tools: []pact.MCPTool{{ + Name: "ping", + Method: http.MethodGet, + Path: "/acme/ping", + Input: struct{}{}, + }}, + } + office := routePlugin{ + id: "test.office", guard: pier.BackendGuard, middleware: pier.BackendGuard, + routes: []testRoute{{ + method: http.MethodGet, + path: "/office/ping", + mw: []string{pier.BackendGuard}, + h: mcpCallHandler, + }}, + tools: []pact.MCPTool{{ + Name: "ping", + Method: http.MethodGet, + Path: "/office/ping", + Input: struct{}{}, + }}, + } + in := startInstance(t, app, acme, office) + cs := sdkClient(t, ctx, in.url(), "oas_at_admin", clientOpts{}) + + frontBody := callMCP(t, ctx, cs, "acme_ping") + if frontBody["ok"] != true || frontBody["guard"] != "acme" || frontBody["tool"] != "acme_ping" { + t.Fatalf("frontend MCPCall = %+v, want guard acme tool acme_ping", frontBody) + } + if session, _ := frontBody["session"].(string); session == "" || session == "ephemeral" { + t.Fatalf("frontend session = %q, want the stateful session id", session) + } + + backBody := callMCP(t, ctx, cs, "office_ping") + if backBody["ok"] != true || backBody["guard"] != pier.BackendGuard || backBody["tool"] != "office_ping" { + t.Fatalf("backend MCPCall = %+v, want guard %s tool office_ping", backBody, pier.BackendGuard) + } + if session, _ := backBody["session"].(string); session == "" || session == "ephemeral" { + t.Fatalf("backend session = %q, want the stateful session id", session) + } +} + +func mcpCallHandler(w http.ResponseWriter, r *http.Request) { + call, ok := pact.MCPCallFrom(r.Context()) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": ok, + "guard": call.Subject.Guard, + "tool": call.Tool, + "session": call.SessionID, + }) +} + +func callMCP(t *testing.T, ctx context.Context, cs *mcp.ClientSession, name string) map[string]any { + t.Helper() + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: name}) + if err != nil || res.IsError { + t.Fatalf("tools/call %s: %+v, %v", name, res, err) + } + return structured(t, res) +} diff --git a/modules/pier/prm_scopes_test.go b/modules/pier/prm_scopes_test.go new file mode 100644 index 00000000..dfde0c24 --- /dev/null +++ b/modules/pier/prm_scopes_test.go @@ -0,0 +1,58 @@ +package pier_test + +import ( + "net/http" + "slices" + "testing" + + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/pier" +) + +// TestPRMUsesAdvertisedScopes pins D-04: a verifier that implements +// MCPAdvertisedScopes supplies the protected-resource scopes, including a +// declared name. +func TestPRMUsesAdvertisedScopes(t *testing.T) { + declared := []string{"read", "write", "ai", "offline_access", "backend", "acme:read"} + app := newApp(t, appSpec{ + verifier: advertisedVerifier{scopes: declared}, + guards: []pact.MCPGuard{&acmeGuard{}}, + }) + in := startInstance(t, app, prmPlugin()) + got := prmScopes(t, in.srv.URL) + if !slices.Equal(got, declared) || !slices.Contains(got, "acme:read") { + t.Fatalf("PRM scopes = %v, want %v", got, declared) + } +} + +// TestPRMFallsBackToPRMScopes pins D-04: a verifier that does not implement +// MCPAdvertisedScopes leaves the fixed list, which includes backend. +func TestPRMFallsBackToPRMScopes(t *testing.T) { + app := newApp(t, appSpec{ + verifier: acmeVerifier{}, + guards: []pact.MCPGuard{&acmeGuard{}}, + }) + in := startInstance(t, app, prmPlugin()) + got := prmScopes(t, in.srv.URL) + if !slices.Equal(got, pier.PRMScopes) || !slices.Contains(got, "backend") { + t.Fatalf("PRM scopes = %v, want %v", got, pier.PRMScopes) + } +} + +func prmPlugin() routePlugin { + return routePlugin{ + id: "test.acme", + routes: []testRoute{{ + method: http.MethodGet, + path: "/acme/items", + mw: []string{"acme.auth"}, + h: func(http.ResponseWriter, *http.Request) {}, + }}, + tools: []pact.MCPTool{{ + Name: "show", + Method: http.MethodGet, + Path: "/acme/items", + Input: struct{}{}, + }}, + } +}