From 63d05e8acbdb894303704e4a3bf44811bd82b9c9 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 28 Oct 2026 14:32:31 +0100 Subject: [PATCH] feat(32.1-01): publish one MCP scope list and call context - pact gains MemberTokenScopeCatalog, MCPAdvertisedScopes and MCPCall - an empty MCP ScopesSupported resolves to the five fixed names including backend - pier copies advertised scopes and sets MCPCall beside WithDispatch --- modules/pact/capabilities.go | 5 +- modules/pact/mcp.go | 50 +++++++ modules/pact/mcp_call_test.go | 32 +++++ modules/pier/dispatch.go | 10 +- modules/pier/mcp_seam_test.go | 155 +++++++++++++++++++++ modules/pier/prm.go | 11 +- modules/pier/session.go | 2 +- modules/pier/stateless.go | 8 ++ modules/wristband/authorize.go | 23 ++- modules/wristband/mcp_scopes_smoke_test.go | 28 +++- modules/wristband/server.go | 25 +++- 11 files changed, 322 insertions(+), 27 deletions(-) create mode 100644 modules/pact/mcp_call_test.go create mode 100644 modules/pier/mcp_seam_test.go diff --git a/modules/pact/capabilities.go b/modules/pact/capabilities.go index 4f0fede0..783b8e76 100644 --- a/modules/pact/capabilities.go +++ b/modules/pact/capabilities.go @@ -817,11 +817,14 @@ type HasTokenScopes interface { // member token screens show. Label and Description are phrase keys // (containing "::") or literal text. It is the frontend counterpart of // TokenScope and carries no permission patterns or method filter: the route -// middleware alone decides what a member scope allows. +// middleware is what enforces a member scope. Implies is only "", "read" or +// "write", for a later OAuth MCP check; the zero value means the scope is +// not implied. This package does not perform that check. type FrontendTokenScope struct { Name string Label string Description string + Implies string } // HasFrontendTokenScopes is implemented by plugins that declare member diff --git a/modules/pact/mcp.go b/modules/pact/mcp.go index 52288545..8db8b453 100644 --- a/modules/pact/mcp.go +++ b/modules/pact/mcp.go @@ -139,6 +139,40 @@ type MCPSubject struct { TokenID uint } +// mcpCallKey is the unexported context key of an MCPCall. A plain HTTP +// request never carries one. +type mcpCallKey struct{} + +// MCPCall is one in-process MCP tool call. Subject is the subject pier +// resolved for the tool's guard, not an expansion of its scopes. SessionID +// is the MCP session id, empty on the stateless path and when the call was +// served without a session. Tool is the exposed tool name. +type MCPCall struct { + Subject MCPSubject + SessionID string + Tool string +} + +// WithMCPCall stores call on ctx. A nil ctx is returned as nil: the +// dispatch context is never nil, and this does not invent a background +// context. +func WithMCPCall(ctx context.Context, call MCPCall) context.Context { + if ctx == nil { + return nil + } + return context.WithValue(ctx, mcpCallKey{}, call) +} + +// MCPCallFrom returns the MCPCall on ctx. ok is false when ctx is nil or +// carries none, including a plain HTTP request. +func MCPCallFrom(ctx context.Context) (MCPCall, bool) { + if ctx == nil { + return MCPCall{}, false + } + call, ok := ctx.Value(mcpCallKey{}).(MCPCall) + return call, ok +} + // MCPDispatch is a guard provider's answer for one in-process call: the // principal (a *bouncer.Principal, carried as any so pact stays free of // bouncer), the optional credential stored next to it, and the bouncer @@ -224,6 +258,22 @@ type MCPTokenVerifier interface { MCPResource() string } +// MCPAdvertisedScopes is implemented by a verifier that publishes the scopes +// the MCP authorization server advertises. pier copies MCPScopesSupported +// into protected-resource metadata when the slice is non-empty, and +// otherwise keeps its own fallback list. It is a separate interface so +// MCPTokenVerifier stays MCPVerifyToken and MCPResource. +type MCPAdvertisedScopes interface { + MCPScopesSupported() []string +} + +// MemberTokenScopeCatalog is the compiled member personal-token scope list, +// published by the plugin that owns member tokens. The method is not named +// FrontendTokenScopes: HasFrontendTokenScopes already has that method. +type MemberTokenScopeCatalog interface { + MemberTokenScopes() []FrontendTokenScope +} + // MCPGrant is a verified OAuth access token: the frontend user, the client, // the token row, the granted scopes, the expiry and, for a grant carrying // the backend scope, the backend account the user consented to. diff --git a/modules/pact/mcp_call_test.go b/modules/pact/mcp_call_test.go new file mode 100644 index 00000000..9d1927fa --- /dev/null +++ b/modules/pact/mcp_call_test.go @@ -0,0 +1,32 @@ +package pact + +import ( + "context" + "testing" +) + +// TestMCPCallRoundTrip proves the context accessor stores and reads one +// call, and that a context without one answers ok false. +func TestMCPCallRoundTrip(t *testing.T) { + want := MCPCall{ + Subject: MCPSubject{Guard: "user", UserID: 7, Scopes: []string{"read"}, ClientID: "c1", TokenID: 3}, + SessionID: "sess-1", + Tool: "acme_echo", + } + got, ok := MCPCallFrom(WithMCPCall(context.Background(), want)) + if !ok { + t.Fatal("MCPCallFrom ok = false, want true") + } + if got.Subject.Guard != want.Subject.Guard || got.SessionID != want.SessionID || got.Tool != want.Tool { + t.Fatalf("MCPCallFrom = %+v, want guard %q session %q tool %q", got, want.Subject.Guard, want.SessionID, want.Tool) + } + if _, ok := MCPCallFrom(context.Background()); ok { + t.Fatal("MCPCallFrom(context.Background()) ok = true, want false") + } + if _, ok := MCPCallFrom(nil); ok { + t.Fatal("MCPCallFrom(nil) ok = true, want false") + } + if WithMCPCall(nil, want) != nil { + t.Fatal("WithMCPCall(nil) invented a context") + } +} diff --git a/modules/pier/dispatch.go b/modules/pier/dispatch.go index ceab8756..639d2dee 100644 --- a/modules/pier/dispatch.go +++ b/modules/pier/dispatch.go @@ -35,7 +35,9 @@ const tokenInvalid = "pier: the caller's token is no longer valid" // callInfo is what a dispatched call copies from the outer MCP request: the // client address, the Host, the forwarding, real-ip and language headers, -// plus the session the call belongs to, for the audit line. +// plus the session the call belongs to, for the audit line. stateless is +// set on the stateless path so the MCP call's session id stays empty +// instead of a synthesized SDK session id. type callInfo struct { remoteAddr string host string @@ -45,6 +47,7 @@ type callInfo struct { realIP string acceptLanguage string session string + stateless bool } // callInfoFrom reads callInfo off an HTTP request. @@ -205,6 +208,11 @@ func (d *dispatcher) run(ctx context.Context, id Identity, info callInfo, t *too if credential == nil && t.guardName == BackendGuard { credential = id.credential } + sessionID := info.session + if sessionID == ephemeralSession { + sessionID = "" + } + callCtx = pact.WithMCPCall(callCtx, pact.MCPCall{Subject: *subject, SessionID: sessionID, Tool: t.name}) callCtx = bouncer.WithDispatch(callCtx, &bouncer.Dispatch{Guards: disp.Guards, Principal: principal, Credential: credential}) var req *http.Request diff --git a/modules/pier/mcp_seam_test.go b/modules/pier/mcp_seam_test.go new file mode 100644 index 00000000..62eaedde --- /dev/null +++ b/modules/pier/mcp_seam_test.go @@ -0,0 +1,155 @@ +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, + }) +} diff --git a/modules/pier/prm.go b/modules/pier/prm.go index b34f64b5..15a68952 100644 --- a/modules/pier/prm.go +++ b/modules/pier/prm.go @@ -10,7 +10,8 @@ import ( ) // PRMScopes is the scopes_supported list of the protected resource -// metadata: the OAuth server's MCP scopes plus backend (D-21). +// metadata when the published verifier does not advertise a non-empty +// list: the fixed MCP scopes, including backend. var PRMScopes = []string{"read", "write", "ai", "offline_access", "backend"} // mountPRM serves the RFC 9728 protected resource metadata at @@ -22,6 +23,7 @@ var PRMScopes = []string{"read", "write", "ai", "offline_access", "backend"} // resource is logged as a warning at boot (Pitfall 8). func (p *Pier) mountPRM() error { issuer := p.cfg.AppURL + scopes := append([]string(nil), PRMScopes...) if verifier, ok := p.app.Lookup[pact.MCPTokenVerifier](); ok && verifier != nil { if res := verifier.MCPResource(); res != "" { if u, err := url.Parse(res); err == nil && u.Scheme != "" && u.Host != "" { @@ -32,10 +34,15 @@ func (p *Pier) mountPRM() error { "mcp_resource", p.cfg.Resource, "verifier_resource", res) } } + if advertised, ok := verifier.(pact.MCPAdvertisedScopes); ok { + if list := advertised.MCPScopesSupported(); len(list) > 0 { + scopes = append([]string(nil), list...) + } + } } meta := &oauthex.ProtectedResourceMetadata{ Resource: p.cfg.Resource, - ScopesSupported: append([]string(nil), PRMScopes...), + ScopesSupported: scopes, BearerMethodsSupported: []string{"header"}, } if issuer != "" { diff --git a/modules/pier/session.go b/modules/pier/session.go index 57082a45..9dad7fbf 100644 --- a/modules/pier/session.go +++ b/modules/pier/session.go @@ -704,7 +704,7 @@ func containsPrompt(prompts []*prompt, name string) bool { func (p *Pier) toolHandler(t *tool, session Identity) mcp.ToolHandler { return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { id, info := identityFromExtra(req.Extra, session) - if req.Session != nil && req.Session.ID() != "" { + if !info.stateless && req.Session != nil && req.Session.ID() != "" { info.session = req.Session.ID() } return p.dispatch.call(ctx, id, info, t, req.Params.Arguments), nil diff --git a/modules/pier/stateless.go b/modules/pier/stateless.go index 72cf684b..95a03af2 100644 --- a/modules/pier/stateless.go +++ b/modules/pier/stateless.go @@ -12,6 +12,7 @@ import ( "time" "git.golem15.com/golem15/summercms/modules/towel" + "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -102,6 +103,13 @@ func (sp *statelessPath) ServeHTTP(w http.ResponseWriter, r *http.Request) { use := &entryUse{} r = r.WithContext(context.WithValue(r.Context(), entryUseKey{}, use)) defer sp.release(use) + // The stateless path has no MCP session. Mark the call before the + // handler runs so a synthesized SDK session id is not copied into it. + if ti := auth.TokenInfoFromContext(r.Context()); ti != nil && ti.Extra != nil { + info, _ := ti.Extra[extraCall].(callInfo) + info.stateless = true + ti.Extra[extraCall] = info + } if r.Method != http.MethodPost || r.Body == nil { sp.handler.ServeHTTP(w, r) return diff --git a/modules/wristband/authorize.go b/modules/wristband/authorize.go index 9b416b34..2d5a4439 100644 --- a/modules/wristband/authorize.go +++ b/modules/wristband/authorize.go @@ -18,22 +18,17 @@ import ( "strings" ) -// authorizeAllowedScopes mirrors PHP OAuthAuthorizeController::ALLOWED_SCOPES: -// the allow-list of a server whose Options.ScopesSupported is empty. -var authorizeAllowedScopes = map[string]bool{ - "read": true, "write": true, "ai": true, "offline_access": true, -} - // allowedAuthorizeScopes is the set parseAuthorizeScopes accepts on this -// server: exactly Options.ScopesSupported, the values the metadata document -// advertises, so a host that lists an extra scope (such as backend for the -// MCP module) accepts it at authorize; an empty list keeps the PHP default. +// server: the same resolvedScopes slice Metadata advertises. A non-empty +// Options.ScopesSupported is used as given, so a host that lists an extra +// scope accepts it and a list without backend still refuses it. An empty +// list on the MCP flavour resolves to the five fixed names, backend +// included. Scope resolution runs after the client, redirect and PKCE +// checks in Authorize. func (s *Server) allowedAuthorizeScopes() map[string]bool { - if s == nil || len(s.opts.ScopesSupported) == 0 { - return authorizeAllowedScopes - } - allowed := make(map[string]bool, len(s.opts.ScopesSupported)) - for _, sc := range s.opts.ScopesSupported { + scopes := s.resolvedScopes() + allowed := make(map[string]bool, len(scopes)) + for _, sc := range scopes { if sc = strings.TrimSpace(sc); sc != "" { allowed[sc] = true } diff --git a/modules/wristband/mcp_scopes_smoke_test.go b/modules/wristband/mcp_scopes_smoke_test.go index dbed11bb..81aa8cc1 100644 --- a/modules/wristband/mcp_scopes_smoke_test.go +++ b/modules/wristband/mcp_scopes_smoke_test.go @@ -8,10 +8,11 @@ import ( "testing" ) -// TestMCPScopesSmoke covers the configurable authorize scope set of phase -// 19.7 (RESEARCH Pitfall 5, D-21 framework side): the allow-list is exactly -// Options.ScopesSupported, so a host that lists backend accepts it, while -// the default server keeps refusing it. +// TestMCPScopesSmoke covers the configurable authorize scope set: a non-empty +// Options.ScopesSupported is the allow-list and the metadata list, so a host +// that lists backend accepts it, while DefaultOptions (the four PHP names, +// which do not include backend) still refuses it. An empty list on the MCP +// flavour resolves to read, write, ai, offline_access and backend. func TestMCPScopesSmoke(t *testing.T) { backend := newMemoryBackend() opts := DefaultOptions() @@ -69,10 +70,25 @@ func TestMCPScopesSmoke(t *testing.T) { t.Fatalf("default server accepted backend: %v", q) } - // An empty ScopesSupported keeps the PHP default allow-list. + // An empty ScopesSupported resolves to the five fixed names and accepts backend. + emptyBackend := newMemoryBackend() empty := DefaultOptions() empty.Issuer, empty.Resource, empty.ScopesSupported = opts.Issuer, opts.Resource, nil - if got := NewServer(empty).allowedAuthorizeScopes(); !got["read"] || !got["offline_access"] || got["backend"] || len(got) != 4 { + emptySrv := NewServer(empty) + emptySrv.SetBackend(emptyBackend) + insertAuthorizeTestClient(emptyBackend, "cli-empty", []string{authorizeTestRedirect}, nil) + if got := emptySrv.allowedAuthorizeScopes(); !got["read"] || !got["write"] || !got["ai"] || !got["offline_access"] || !got["backend"] || len(got) != 5 { t.Fatalf("allowed scopes with empty ScopesSupported = %v", got) } + if q := authorize(emptySrv, "cli-empty"); q["error"] != "" { + t.Fatalf("empty ScopesSupported refused backend: %v", q) + } + rec = httptest.NewRecorder() + emptySrv.Metadata(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil)) + if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { + t.Fatal(err) + } + if !slices.Equal(doc.Scopes, []string{"read", "write", "ai", "offline_access", "backend"}) { + t.Fatalf("empty scopes_supported = %v", doc.Scopes) + } } diff --git a/modules/wristband/server.go b/modules/wristband/server.go index dfcc9634..68b19093 100644 --- a/modules/wristband/server.go +++ b/modules/wristband/server.go @@ -34,7 +34,9 @@ type Options struct { ServiceDocumentationPath string // ScopesSupported is the RFC 8414 scopes_supported list. PHP default: - // ["read","write","ai","offline_access"]. + // ["read","write","ai","offline_access"]. On the MCP flavour an empty + // or nil list resolves to those four plus backend; a non-empty list is + // used as given. OIDC discovery does not read this field. ScopesSupported []string // TokenEndpointAuthMethodsSupported is the RFC 8414 @@ -189,13 +191,32 @@ func (s *Server) Metadata(w http.ResponseWriter, r *http.Request) { GrantTypesSupported: []string{"authorization_code", "refresh_token"}, CodeChallengeMethodsSupported: []string{"S256"}, TokenEndpointAuthMethodsSupported: s.opts.TokenEndpointAuthMethodsSupported, - ScopesSupported: s.opts.ScopesSupported, + ScopesSupported: s.resolvedScopes(), ServiceDocumentation: s.opts.Issuer + s.opts.ServiceDocumentationPath, AuthorizationResponseIssParameterSupported: s.opts.AuthorizationResponseIssParameterSupported, } writeExactJSON(w, http.StatusOK, doc, map[string]string{"Cache-Control": "no-cache, private"}) } +// mcpEmptyScopes is the MCP flavour's scope list when Options.ScopesSupported +// is empty or nil. A non-empty list is not widened to include backend. +var mcpEmptyScopes = []string{"read", "write", "ai", "offline_access", "backend"} + +// resolvedScopes is the one scope slice Metadata advertises and +// allowedAuthorizeScopes accepts. A non-empty Options.ScopesSupported is +// cloned as given. An empty or nil list on the MCP flavour (NewServer, the +// flavour zero value) resolves to mcpEmptyScopes. The OIDC flavour is not +// given that list: its discovery document is separate. +func (s *Server) resolvedScopes() []string { + if s != nil && len(s.opts.ScopesSupported) > 0 { + return append([]string(nil), s.opts.ScopesSupported...) + } + if s != nil && s.flavour == flavourOIDC { + return append([]string(nil), s.opts.ScopesSupported...) + } + return append([]string(nil), mcpEmptyScopes...) +} + // writeExactJSON writes v as an unwrapped, no-trailing-newline JSON document // (matching the wire/response.go WriteJSON technique) but never falls back to // the house opaque-500 envelope: raw RFC responses must never acquire a