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
This commit is contained in:
2026-10-28 14:32:31 +01:00
parent 2cea3127cc
commit 63d05e8acb
11 changed files with 322 additions and 27 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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")
}
}