Files
summercms/modules/pier/dispatch_mcpcall_test.go
Jakub Zych 9094bf9ad9 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
2026-10-28 16:09:30 +01:00

104 lines
3.2 KiB
Go

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