test(32.1-05): prove MCP call context, both guards, and PRM scopes

- A bare context and a plain request carry no MCP call
- Frontend and backend tool calls record the guard that owns the tool
- PRM uses an advertised scope list and falls back to PRMScopes
This commit is contained in:
2026-10-28 16:09:30 +01:00
parent 06a144e19f
commit 9094bf9ad9
3 changed files with 191 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
package pact
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// TestMCPCallFromBareContext pins D-11: a context that never stored an
// MCP call answers ok false, including a plain HTTP request.
func TestMCPCallFromBareContext(t *testing.T) {
if _, ok := MCPCallFrom(context.Background()); ok {
t.Fatal("MCPCallFrom(context.Background()) ok = true, want false")
}
req := httptest.NewRequest(http.MethodGet, "http://acme.test/items", nil)
if _, ok := MCPCallFrom(req.Context()); ok {
t.Fatal("MCPCallFrom on a request that never called WithMCPCall ok = true, want false")
}
}
// TestFrontendTokenScopeLiteralWithoutImplies pins that a composite
// literal written before Implies existed still compiles and leaves the
// field empty, so existing declarations stay valid.
func TestFrontendTokenScopeLiteralWithoutImplies(t *testing.T) {
scope := FrontendTokenScope{Name: "servers:read", Label: "Read servers", Description: "List servers."}
if scope.Implies != "" {
t.Fatalf("Implies = %q, want empty", scope.Implies)
}
}

View File

@@ -0,0 +1,103 @@
package pier_test
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/pier"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// TestDispatchMCPCallBothGuards pins D-11: one frontend tool call and one
// backend tool call each carry MCPCall for the guard that owns the tool.
// SessionID is the stateful session, never the word ephemeral. A
// personal-token versus OAuth difference is not asserted here.
func TestDispatchMCPCallBothGuards(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
links := newFakeLinks()
links.set(10, consentedAdmin)
front := &fakeGuard{}
back := &fakeGuard{name: pier.BackendGuard, tokenMW: pier.BackendGuard}
verifier := newFakeVerifier(map[string]pact.MCPGrant{
"oas_at_admin": grant(10, "c4", 4, "read", "write", "backend"),
})
app := newApp(t, appSpec{
guards: []pact.MCPGuard{front, back},
verifier: verifier,
links: links,
})
acme := routePlugin{
id: "test.acme",
routes: []testRoute{{
method: http.MethodGet,
path: "/acme/ping",
mw: []string{"acme.auth"},
h: mcpCallHandler,
}},
tools: []pact.MCPTool{{
Name: "ping",
Method: http.MethodGet,
Path: "/acme/ping",
Input: struct{}{},
}},
}
office := routePlugin{
id: "test.office", guard: pier.BackendGuard, middleware: pier.BackendGuard,
routes: []testRoute{{
method: http.MethodGet,
path: "/office/ping",
mw: []string{pier.BackendGuard},
h: mcpCallHandler,
}},
tools: []pact.MCPTool{{
Name: "ping",
Method: http.MethodGet,
Path: "/office/ping",
Input: struct{}{},
}},
}
in := startInstance(t, app, acme, office)
cs := sdkClient(t, ctx, in.url(), "oas_at_admin", clientOpts{})
frontBody := callMCP(t, ctx, cs, "acme_ping")
if frontBody["ok"] != true || frontBody["guard"] != "acme" || frontBody["tool"] != "acme_ping" {
t.Fatalf("frontend MCPCall = %+v, want guard acme tool acme_ping", frontBody)
}
if session, _ := frontBody["session"].(string); session == "" || session == "ephemeral" {
t.Fatalf("frontend session = %q, want the stateful session id", session)
}
backBody := callMCP(t, ctx, cs, "office_ping")
if backBody["ok"] != true || backBody["guard"] != pier.BackendGuard || backBody["tool"] != "office_ping" {
t.Fatalf("backend MCPCall = %+v, want guard %s tool office_ping", backBody, pier.BackendGuard)
}
if session, _ := backBody["session"].(string); session == "" || session == "ephemeral" {
t.Fatalf("backend session = %q, want the stateful session id", session)
}
}
func mcpCallHandler(w http.ResponseWriter, r *http.Request) {
call, ok := pact.MCPCallFrom(r.Context())
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": ok,
"guard": call.Subject.Guard,
"tool": call.Tool,
"session": call.SessionID,
})
}
func callMCP(t *testing.T, ctx context.Context, cs *mcp.ClientSession, name string) map[string]any {
t.Helper()
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: name})
if err != nil || res.IsError {
t.Fatalf("tools/call %s: %+v, %v", name, res, err)
}
return structured(t, res)
}

View File

@@ -0,0 +1,58 @@
package pier_test
import (
"net/http"
"slices"
"testing"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/pier"
)
// TestPRMUsesAdvertisedScopes pins D-04: a verifier that implements
// MCPAdvertisedScopes supplies the protected-resource scopes, including a
// declared name.
func TestPRMUsesAdvertisedScopes(t *testing.T) {
declared := []string{"read", "write", "ai", "offline_access", "backend", "acme:read"}
app := newApp(t, appSpec{
verifier: advertisedVerifier{scopes: declared},
guards: []pact.MCPGuard{&acmeGuard{}},
})
in := startInstance(t, app, prmPlugin())
got := prmScopes(t, in.srv.URL)
if !slices.Equal(got, declared) || !slices.Contains(got, "acme:read") {
t.Fatalf("PRM scopes = %v, want %v", got, declared)
}
}
// TestPRMFallsBackToPRMScopes pins D-04: a verifier that does not implement
// MCPAdvertisedScopes leaves the fixed list, which includes backend.
func TestPRMFallsBackToPRMScopes(t *testing.T) {
app := newApp(t, appSpec{
verifier: acmeVerifier{},
guards: []pact.MCPGuard{&acmeGuard{}},
})
in := startInstance(t, app, prmPlugin())
got := prmScopes(t, in.srv.URL)
if !slices.Equal(got, pier.PRMScopes) || !slices.Contains(got, "backend") {
t.Fatalf("PRM scopes = %v, want %v", got, pier.PRMScopes)
}
}
func prmPlugin() routePlugin {
return routePlugin{
id: "test.acme",
routes: []testRoute{{
method: http.MethodGet,
path: "/acme/items",
mw: []string{"acme.auth"},
h: func(http.ResponseWriter, *http.Request) {},
}},
tools: []pact.MCPTool{{
Name: "show",
Method: http.MethodGet,
Path: "/acme/items",
Input: struct{}{},
}},
}
}