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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
32
modules/pact/mcp_call_test.go
Normal file
32
modules/pact/mcp_call_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user