- 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
156 lines
4.7 KiB
Go
156 lines
4.7 KiB
Go
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,
|
|
})
|
|
}
|