- 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
104 lines
3.2 KiB
Go
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)
|
|
}
|