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:
@@ -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
|
||||
|
||||
155
modules/pier/mcp_seam_test.go
Normal file
155
modules/pier/mcp_seam_test.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user