package pier_test import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "slices" "testing" "time" "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/pier" "git.golem15.com/golem15/summercms/modules/wristband" "github.com/modelcontextprotocol/go-sdk/mcp" ) // fixedMCPScopes is the MCP flavour's resolved list when ScopesSupported is empty. var fixedMCPScopes = []string{"read", "write", "ai", "offline_access", "backend"} // advertisedVerifier is the fake OAuth verifier plus a declared scope list. type advertisedVerifier struct { acmeVerifier scopes []string } func (v advertisedVerifier) MCPScopesSupported() []string { return append([]string(nil), v.scopes...) } // TestMCPSeam is the tracer for one advertised scope list and one MCP call // on the dispatch context: empty MCP metadata includes backend, a non-empty // list is used as given, pier copies a verifier's advertised scopes (and // keeps PRMScopes otherwise), and one frontend tool call observes MCPCall // beside the bouncer dispatch marker. func TestMCPSeam(t *testing.T) { opts := wristband.DefaultOptions() opts.Issuer = "https://issuer.example.test" opts.Resource = "https://resource.example.test/mcp" opts.ScopesSupported = nil empty := wristband.NewServer(opts) if got := metadataScopes(t, empty); !slices.Equal(got, fixedMCPScopes) { t.Fatalf("empty MCP scopes_supported = %v", got) } declared := append(append([]string(nil), fixedMCPScopes...), "acme:read") opts.ScopesSupported = declared listed := wristband.NewServer(opts) if got := metadataScopes(t, listed); !slices.Equal(got, declared) { t.Fatalf("declared MCP scopes_supported = %v", got) } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() advertised := append(append([]string(nil), fixedMCPScopes...), "acme:read") app := newApp(t, appSpec{ verifier: advertisedVerifier{scopes: advertised}, guards: []pact.MCPGuard{&acmeGuard{}}, }) plugin := routePlugin{ id: "test.acme", routes: []testRoute{{ method: http.MethodPost, path: "/acme/seam", mw: []string{"acme.auth"}, h: seamHandler, }}, tools: []pact.MCPTool{{ Name: "seam", Method: http.MethodPost, Path: "/acme/seam", Input: struct{}{}, }}, } in := startInstance(t, app, plugin) if got := prmScopes(t, in.srv.URL); !slices.Equal(got, advertised) { t.Fatalf("advertised PRM scopes = %v, want %v", got, advertised) } cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{}) res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "acme_seam"}) if err != nil { t.Fatalf("tools/call: %v", err) } if res.IsError { t.Fatalf("tools/call isError: %+v", res.Content) } body := structured(t, res) if body["ok"] != true || body["tool"] != "acme_seam" || body["dispatched"] != true || body["guard"] != "acme" { t.Fatalf("handler saw %+v, want MCPCall tool acme_seam and a dispatch", body) } if sid, _ := body["session"].(string); sid == "" || sid == "ephemeral" { t.Fatalf("session = %q, want the stateful session id", sid) } plain := startInstance(t, newApp(t, appSpec{ verifier: acmeVerifier{}, guards: []pact.MCPGuard{&acmeGuard{}}, }), plugin) if got := prmScopes(t, plain.srv.URL); !slices.Equal(got, pier.PRMScopes) { t.Fatalf("PRM without MCPAdvertisedScopes = %v, want %v", got, pier.PRMScopes) } } func metadataScopes(t *testing.T, srv *wristband.Server) []string { t.Helper() rec := httptest.NewRecorder() srv.Metadata(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil)) if rec.Code != http.StatusOK { t.Fatalf("metadata status = %d", rec.Code) } var doc struct { Scopes []string `json:"scopes_supported"` } if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { t.Fatal(err) } return doc.Scopes } func prmScopes(t *testing.T, base string) []string { t.Helper() resp, err := http.Get(base + "/.well-known/oauth-protected-resource") if err != nil { t.Fatal(err) } defer resp.Body.Close() raw, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { t.Fatalf("PRM status = %d body %s", resp.StatusCode, raw) } var doc struct { Scopes []string `json:"scopes_supported"` } if err := json.Unmarshal(raw, &doc); err != nil { t.Fatal(err) } return doc.Scopes } func seamHandler(w http.ResponseWriter, r *http.Request) { call, ok := pact.MCPCallFrom(r.Context()) _, dispatched := bouncer.Dispatched(r.Context(), "acme") w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": ok, "tool": call.Tool, "session": call.SessionID, "guard": call.Subject.Guard, "dispatched": dispatched, }) }