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:
30
modules/pact/mcp_call_negative_test.go
Normal file
30
modules/pact/mcp_call_negative_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
103
modules/pier/dispatch_mcpcall_test.go
Normal file
103
modules/pier/dispatch_mcpcall_test.go
Normal 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)
|
||||
}
|
||||
58
modules/pier/prm_scopes_test.go
Normal file
58
modules/pier/prm_scopes_test.go
Normal 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{}{},
|
||||
}},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user