- pier: TestCatalog, TestDispatch, TestGuardRules, TestSessions (Redis
across two instances and the memory store), TestFanOut; the store clock
and stateless-server seams in export_test.go
- surf: TestStreamingRoute (throttle, CORS profile and access-log pattern on
a stream route) and TestPierMount in both builds; the streaming smoke
restores the mount hook it replaces
- wristband TestMCPScopes; cabana TestAccountContributions (Postgres part:
transaction and unchanged My account payloads), TestRecordActionPrompt,
TestScopeLimitForMethods; TestRecordActionPromptRun pins the empty Value
of an action without a prompt
- vitest: 403 and failed-load cases for My account contributions, the
cancelled prompt
- gate: every name required (tagged runs included), account.go unchanged
since 51d088a, admin stage live
938 lines
29 KiB
Go
938 lines
29 KiB
Go
package pier_test
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/party"
|
|
"git.golem15.com/golem15/summercms/modules/pier"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
"github.com/alicebob/miniredis/v2"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// Shared harness of the pier tests (phase 19.7 plan 05, Wave 0 of the
|
|
// validation map): the fixtures the plan 02 smokes introduced (the acme
|
|
// plugin, guard and verifier, the in-test Host adapter, the raw JSON-RPC
|
|
// client), and the configurable fakes the unit tests build on: fakeGuard,
|
|
// fakeVerifier, fakeLinkResolver, fakePersonalResolver, routePlugin,
|
|
// newApp, startInstance, twoInstances (two pier instances on one
|
|
// miniredis), sdkClient (an SDK client with a bearer-injecting transport
|
|
// and an optional list_changed channel) and fireSubjectChanged.
|
|
//
|
|
// The tests live in package pier_test and mount pier through testHost on a
|
|
// real surf router built with the MCP flag off, so they need no build tag.
|
|
|
|
// refusingGuard is the fake plugin's bouncer guard: it never authenticates
|
|
// an HTTP request and writes its own 401, so only the dispatch marker can
|
|
// get a request through the route.
|
|
type refusingGuard struct{}
|
|
|
|
func (refusingGuard) AuthenticateCredential(*http.Request) (*bouncer.Principal, any, error) {
|
|
return nil, nil, errors.New("acme: no token")
|
|
}
|
|
|
|
func (refusingGuard) WriteUnauthorized(w http.ResponseWriter, err error) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = w.Write([]byte(`{"error":true,"message":"Unauthenticated"}`))
|
|
}
|
|
|
|
// echoInput is the tool's input: id fills {id}, text forms the body.
|
|
type echoInput struct {
|
|
ID int `json:"id" path:"id" jsonschema:"the record id"`
|
|
Text string `json:"text" jsonschema:"the text to echo"`
|
|
}
|
|
|
|
// acmePlugin registers bouncer guard "acme" as middleware "acme.auth", one
|
|
// guarded route and the MCP tools it is given.
|
|
type acmePlugin struct {
|
|
tools []pact.MCPTool
|
|
prompts []pact.MCPPrompt
|
|
}
|
|
|
|
func (acmePlugin) ID() string { return "test.acme" }
|
|
func (acmePlugin) Requires() []string { return nil }
|
|
func (acmePlugin) Register(*backpack.App) error { return nil }
|
|
func (acmePlugin) Boot(*backpack.App) error { return nil }
|
|
|
|
func (p acmePlugin) Middlewares() map[string]pact.Middleware {
|
|
guards := bouncer.NewRegistry()
|
|
if err := guards.Register(p.ID(), "acme", refusingGuard{}); err != nil {
|
|
panic(err)
|
|
}
|
|
auth, err := guards.Middleware("acme")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return map[string]pact.Middleware{"acme.auth": auth}
|
|
}
|
|
|
|
func (acmePlugin) Routes(r pact.Router) error {
|
|
r.Group("/acme", []string{"acme.auth"}, func(g pact.Router) {
|
|
g.Get("/items/{id}", func(w http.ResponseWriter, req *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"item":"` + req.PathValue("id") + `"}`))
|
|
})
|
|
g.Post("/echo/{id}", func(w http.ResponseWriter, req *http.Request) {
|
|
user, _ := bouncer.User(req.Context())
|
|
var body map[string]any
|
|
_ = json.NewDecoder(req.Body).Decode(&body)
|
|
id, _ := strconv.Atoi(req.PathValue("id"))
|
|
_, cookieErr := req.Cookie("summer_session")
|
|
hasCookie := cookieErr == nil
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"id": id,
|
|
"body": body,
|
|
"principal": user.ID,
|
|
"auth": req.Header.Get("Authorization") != "" || hasCookie,
|
|
})
|
|
})
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (p acmePlugin) MCP() pact.MCP {
|
|
return pact.MCP{Tools: p.tools, Prompts: p.prompts}
|
|
}
|
|
|
|
// acmeGuard is the fake guard provider for "acme": token-guarded by
|
|
// acme.auth, allows everything unless deny is set, dispatches as the
|
|
// subject's user id.
|
|
type acmeGuard struct {
|
|
deny atomic.Bool
|
|
}
|
|
|
|
func (*acmeGuard) MCPGuardName() string { return "acme" }
|
|
func (*acmeGuard) MCPTokenGuarded(mw []string) bool {
|
|
for _, m := range mw {
|
|
if m == "acme.auth" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
func (*acmeGuard) MCPRequirements([]string) (pact.MCPRequirements, error) {
|
|
return pact.MCPRequirements{}, nil
|
|
}
|
|
func (g *acmeGuard) MCPAllows(context.Context, pact.MCPSubject, pact.MCPRequirements, []string) (bool, error) {
|
|
return !g.deny.Load(), nil
|
|
}
|
|
func (*acmeGuard) MCPDispatch(_ context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
|
|
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: subject.UserID}, Guards: []string{"acme"}}, nil
|
|
}
|
|
|
|
// acmeVerifier accepts oas_at_test for user 7, client c1, read and write,
|
|
// and oas_at_other for user 8, client c2 (another binding).
|
|
type acmeVerifier struct{}
|
|
|
|
func (acmeVerifier) MCPVerifyToken(_ context.Context, raw string) (pact.MCPGrant, error) {
|
|
switch raw {
|
|
case "oas_at_test":
|
|
return pact.MCPGrant{UserID: 7, ClientID: "c1", TokenID: 1, Scopes: []string{"read", "write"}, ExpiresAt: time.Now().Add(time.Hour)}, nil
|
|
case "oas_at_other":
|
|
return pact.MCPGrant{UserID: 8, ClientID: "c2", TokenID: 2, Scopes: []string{"read", "write"}, ExpiresAt: time.Now().Add(time.Hour)}, nil
|
|
case "oas_at_read":
|
|
return pact.MCPGrant{UserID: 9, ClientID: "c3", TokenID: 3, Scopes: []string{"read"}, ExpiresAt: time.Now().Add(time.Hour)}, nil
|
|
}
|
|
return pact.MCPGrant{}, pact.ErrMCPTokenInvalid
|
|
}
|
|
|
|
func (acmeVerifier) MCPResource() string { return "http://acme.test/mcp" }
|
|
|
|
// testHost adapts a built surf router to pier.Host and records the
|
|
// endpoint handler pier mounts instead of compiling a route.
|
|
type testHost struct {
|
|
r *surf.Router
|
|
handlers map[string]http.Handler
|
|
path string
|
|
pier *pier.Pier
|
|
}
|
|
|
|
func (h *testHost) Routes() []pier.RouteInfo {
|
|
var out []pier.RouteInfo
|
|
for _, ri := range h.r.Routes() {
|
|
out = append(out, pier.RouteInfo{Method: ri.Method, Pattern: ri.Pattern, PluginID: ri.PluginID, Middleware: ri.Middleware})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (h *testHost) Handler(method, pattern string) (http.Handler, bool) {
|
|
return h.r.Handler(method, pattern)
|
|
}
|
|
|
|
func (h *testHost) MountStream(method, path string, handler http.Handler, _ ...string) error {
|
|
h.handlers[method] = handler
|
|
h.path = path
|
|
return nil
|
|
}
|
|
|
|
func (h *testHost) MountJSON(method, path string, handler http.Handler) error {
|
|
h.handlers[method+" "+path] = handler
|
|
return nil
|
|
}
|
|
|
|
func (h *testHost) Owner(method, path string) (string, bool) {
|
|
for _, ri := range h.r.Routes() {
|
|
if ri.Method == method && ri.Pattern == path {
|
|
return ri.PluginID, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// ServeHTTP routes the endpoint path to the recorded handler of the
|
|
// request's method and any other recorded route by method and path.
|
|
func (h *testHost) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == h.path {
|
|
if handler, ok := h.handlers[r.Method]; ok {
|
|
handler.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
if handler, ok := h.handlers[r.Method+" "+r.URL.Path]; ok {
|
|
handler.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
http.NotFound(w, r)
|
|
}
|
|
|
|
// pierApp builds an app with mcp.yaml from the given body and publishes
|
|
// the fake verifier and a guard registry with a fresh acme guard.
|
|
func pierApp(t *testing.T, mcpYAML string) *backpack.App {
|
|
t.Helper()
|
|
return pierAppWith(t, map[string]string{"mcp.yaml": mcpYAML}, &acmeGuard{})
|
|
}
|
|
|
|
// pierAppWith builds an app from extra config files (app.yaml and
|
|
// http.yaml have defaults) and publishes the fake verifier and, unless
|
|
// guard is nil, a guard registry with that provider; the router is built
|
|
// with the MCP flag off (so no tag is needed) and the flag is enabled for
|
|
// pier.Mount afterwards.
|
|
func pierAppWith(t *testing.T, extra map[string]string, guard *acmeGuard) *backpack.App {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
files := map[string]string{
|
|
"app.yaml": "name: acme\nurl: http://acme.test\n",
|
|
"http.yaml": "body_limits:\n default_bytes: 1048576\n upload_bytes: 1048576\n",
|
|
}
|
|
for name, body := range extra {
|
|
files[name] = body
|
|
}
|
|
for name, body := range files {
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app := backpack.New(cfg)
|
|
if err := app.Publish[pact.MCPTokenVerifier](acmeVerifier{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if guard == nil {
|
|
return app
|
|
}
|
|
reg := pact.NewMCPGuards()
|
|
if err := reg.Register(guard); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.Publish(reg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return app
|
|
}
|
|
|
|
// mountPier builds the router for plugins with the flag off, then mounts
|
|
// pier with it on, through the test host.
|
|
func mountPier(t *testing.T, app *backpack.App, plugins []party.Plugin) (*testHost, error) {
|
|
t.Helper()
|
|
router, err := surf.BuildRouter(app, plugins)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := app.Config.Set("mcp.enabled", true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
host := &testHost{r: router, handlers: map[string]http.Handler{}}
|
|
err = pier.Mount(host, app, plugins)
|
|
if err == nil {
|
|
host.pier, _ = app.Lookup[*pier.Pier]()
|
|
}
|
|
return host, err
|
|
}
|
|
|
|
// bearerTransport adds the bearer to every request of the SDK client.
|
|
type bearerTransport struct {
|
|
token string
|
|
}
|
|
|
|
func (b bearerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
r = r.Clone(r.Context())
|
|
r.Header.Set("Authorization", "Bearer "+b.token)
|
|
return http.DefaultTransport.RoundTrip(r)
|
|
}
|
|
|
|
func echoTool() pact.MCPTool {
|
|
return pact.MCPTool{
|
|
Name: "echo",
|
|
Title: "Echo",
|
|
Description: "Echoes the text back with the record id and the acting principal.",
|
|
Method: "POST",
|
|
Path: "/acme/echo/{id}",
|
|
Input: echoInput{},
|
|
}
|
|
}
|
|
|
|
// instance is one mounted pier with its app and test server.
|
|
type instance struct {
|
|
app *backpack.App
|
|
host *testHost
|
|
srv *httptest.Server
|
|
}
|
|
|
|
// newInstance mounts pier on a fresh app sharing the miniredis and guard.
|
|
func newInstance(t *testing.T, mr *miniredis.Miniredis, mcpYAML string, guard *acmeGuard) *instance {
|
|
t.Helper()
|
|
files := map[string]string{"mcp.yaml": mcpYAML}
|
|
if mr != nil {
|
|
files["redis.yaml"] = "url: redis://" + mr.Addr() + "/0\n"
|
|
}
|
|
app := pierAppWith(t, files, guard)
|
|
host, err := mountPier(t, app, []party.Plugin{acmePlugin{tools: []pact.MCPTool{echoTool()}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := httptest.NewServer(host)
|
|
t.Cleanup(srv.Close)
|
|
return &instance{app: app, host: host, srv: srv}
|
|
}
|
|
|
|
// rpcReply is one raw JSON-RPC exchange: the status, the session header
|
|
// and the first message (an SSE data line or the JSON body).
|
|
type rpcReply struct {
|
|
status int
|
|
session string
|
|
body string
|
|
header http.Header
|
|
}
|
|
|
|
// rpc posts one JSON-RPC message with a bearer and an optional session id.
|
|
func rpc(t *testing.T, ctx context.Context, url, token, session, body string) rpcReply {
|
|
t.Helper()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json, text/event-stream")
|
|
req.Header.Set("Mcp-Protocol-Version", "2025-11-25")
|
|
if session != "" {
|
|
req.Header.Set("Mcp-Session-Id", session)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
reply := rpcReply{status: resp.StatusCode, session: resp.Header.Get("Mcp-Session-Id"), header: resp.Header}
|
|
if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") {
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
scanner.Buffer(make([]byte, 1<<20), 1<<20)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if strings.HasPrefix(line, "data: ") {
|
|
reply.body = strings.TrimPrefix(line, "data: ")
|
|
break
|
|
}
|
|
}
|
|
return reply
|
|
}
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
reply.body = strings.TrimSpace(string(raw))
|
|
return reply
|
|
}
|
|
|
|
const initializeBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"raw","version":"1"}}}`
|
|
const initializedBody = `{"jsonrpc":"2.0","method":"notifications/initialized"}`
|
|
const callBody = `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"acme_echo","arguments":{"id":9,"text":"hey"}}}`
|
|
|
|
// openSession initialises a session on an instance and completes the
|
|
// handshake, returning the session id.
|
|
func openSession(t *testing.T, ctx context.Context, in *instance, token string) string {
|
|
t.Helper()
|
|
init := rpc(t, ctx, in.srv.URL+"/mcp", token, "", initializeBody)
|
|
if init.status != http.StatusOK || init.session == "" || !strings.Contains(init.body, `"protocolVersion":"2025-11-25"`) {
|
|
t.Fatalf("initialize = %d session %q body %s", init.status, init.session, init.body)
|
|
}
|
|
if done := rpc(t, ctx, in.srv.URL+"/mcp", token, init.session, initializedBody); done.status != http.StatusAccepted {
|
|
t.Fatalf("initialized = %d %s", done.status, done.body)
|
|
}
|
|
return init.session
|
|
}
|
|
|
|
func containsPrefix(keys []string, prefix string) bool {
|
|
for _, k := range keys {
|
|
if strings.HasPrefix(k, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type showInput struct {
|
|
ID int `json:"id" path:"id" jsonschema:"the item id"`
|
|
}
|
|
|
|
func showTool() pact.MCPTool {
|
|
return pact.MCPTool{Name: "show", Title: "Show item", Description: "Shows one item.", Method: "GET", Path: "/acme/items/{id}", Input: showInput{}}
|
|
}
|
|
|
|
func summarizePrompt() pact.MCPPrompt {
|
|
return pact.MCPPrompt{
|
|
Name: "summarize", Title: "Summarize", Description: "Asks for a summary.", Guard: "acme",
|
|
Arguments: []pact.MCPPromptArgument{{Name: "topic", Description: "what to summarize", Required: true}},
|
|
Render: func(_ context.Context, args map[string]string) ([]pact.MCPPromptMessage, error) {
|
|
return []pact.MCPPromptMessage{{Role: "user", Text: "Summarize " + args["topic"] + "."}}, nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func connectPinned(t *testing.T, ctx context.Context, url, token string) *mcp.ClientSession {
|
|
t.Helper()
|
|
client := mcp.NewClient(&mcp.Implementation{Name: "acme-test", Version: "1"}, nil)
|
|
transport := &mcp.StreamableClientTransport{Endpoint: url, HTTPClient: &http.Client{Transport: bearerTransport{token: token}}}
|
|
cs, err := client.Connect(ctx, transport, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"})
|
|
if err != nil {
|
|
t.Fatalf("connect with %s: %v", token, err)
|
|
}
|
|
return cs
|
|
}
|
|
|
|
func toolNamesOf(list *mcp.ListToolsResult) []string {
|
|
var out []string
|
|
for _, tl := range list.Tools {
|
|
out = append(out, tl.Name)
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configurable fakes.
|
|
|
|
// fakeGuard is a configurable guard provider. Name defaults to "acme" and
|
|
// the token-accepting middleware to "acme.auth"; parse turns a route's
|
|
// middleware into requirements (none by default); allow decides a subject
|
|
// (everything by default); dispatch lists the bouncer guards the marker is
|
|
// for (the guard's name by default). Every MCPAllows call is recorded.
|
|
type fakeGuard struct {
|
|
name string
|
|
tokenMW string
|
|
notToken bool
|
|
parse func(mw []string) (pact.MCPRequirements, error)
|
|
allow func(subject pact.MCPSubject, req pact.MCPRequirements, perms []string) bool
|
|
guards []string
|
|
|
|
mu sync.Mutex
|
|
calls []guardCall
|
|
}
|
|
|
|
// guardCall is one recorded MCPAllows call.
|
|
type guardCall struct {
|
|
subject pact.MCPSubject
|
|
req pact.MCPRequirements
|
|
perms []string
|
|
method string
|
|
}
|
|
|
|
func (g *fakeGuard) MCPGuardName() string {
|
|
if g.name == "" {
|
|
return "acme"
|
|
}
|
|
return g.name
|
|
}
|
|
|
|
func (g *fakeGuard) MCPTokenGuarded(mw []string) bool {
|
|
if g.notToken {
|
|
return false
|
|
}
|
|
want := g.tokenMW
|
|
if want == "" {
|
|
want = "acme.auth"
|
|
}
|
|
return slices.Contains(mw, want)
|
|
}
|
|
|
|
func (g *fakeGuard) MCPRequirements(mw []string) (pact.MCPRequirements, error) {
|
|
if g.parse == nil {
|
|
return pact.MCPRequirements{}, nil
|
|
}
|
|
return g.parse(mw)
|
|
}
|
|
|
|
func (g *fakeGuard) MCPAllows(_ context.Context, subject pact.MCPSubject, req pact.MCPRequirements, perms []string) (bool, error) {
|
|
return g.decide(subject, req, perms, ""), nil
|
|
}
|
|
|
|
func (g *fakeGuard) decide(subject pact.MCPSubject, req pact.MCPRequirements, perms []string, method string) bool {
|
|
g.mu.Lock()
|
|
g.calls = append(g.calls, guardCall{subject: subject, req: req, perms: slices.Clone(perms), method: method})
|
|
g.mu.Unlock()
|
|
if g.allow == nil {
|
|
return true
|
|
}
|
|
return g.allow(subject, req, perms)
|
|
}
|
|
|
|
func (g *fakeGuard) MCPDispatch(_ context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
|
|
guards := g.guards
|
|
if len(guards) == 0 {
|
|
guards = []string{g.MCPGuardName()}
|
|
}
|
|
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: subject.UserID, Backend: g.MCPGuardName() == pier.BackendGuard}, Guards: guards}, nil
|
|
}
|
|
|
|
// recorded returns the MCPAllows calls so far.
|
|
func (g *fakeGuard) recorded() []guardCall {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return slices.Clone(g.calls)
|
|
}
|
|
|
|
// methodGuard is a fakeGuard that also answers the method-aware check pier
|
|
// prefers for providers whose answer depends on the route's method (the
|
|
// built-in backend provider and an admin token's scope limit).
|
|
type methodGuard struct {
|
|
*fakeGuard
|
|
}
|
|
|
|
func (g methodGuard) MCPAllowsMethod(_ context.Context, subject pact.MCPSubject, req pact.MCPRequirements, perms []string, method string) (bool, error) {
|
|
return g.decide(subject, req, perms, method), nil
|
|
}
|
|
|
|
// grant builds one verifier answer valid for an hour.
|
|
func grant(user uint, client string, token uint, scopes ...string) pact.MCPGrant {
|
|
return pact.MCPGrant{UserID: user, ClientID: client, TokenID: token, Scopes: scopes, ExpiresAt: time.Now().Add(time.Hour)}
|
|
}
|
|
|
|
// fakeVerifier answers the grants it holds; revoke drops one. Its default
|
|
// table is the acme verifier's: oas_at_test (user 7, c1, read write),
|
|
// oas_at_other (user 8, c2, read write), oas_at_read (user 9, c3, read).
|
|
type fakeVerifier struct {
|
|
mu sync.Mutex
|
|
grants map[string]pact.MCPGrant
|
|
resource string
|
|
}
|
|
|
|
func newFakeVerifier(extra map[string]pact.MCPGrant) *fakeVerifier {
|
|
v := &fakeVerifier{grants: map[string]pact.MCPGrant{
|
|
"oas_at_test": grant(7, "c1", 1, "read", "write"),
|
|
"oas_at_other": grant(8, "c2", 2, "read", "write"),
|
|
"oas_at_read": grant(9, "c3", 3, "read"),
|
|
}, resource: "http://acme.test/mcp"}
|
|
for k, g := range extra {
|
|
v.grants[k] = g
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (v *fakeVerifier) MCPVerifyToken(_ context.Context, raw string) (pact.MCPGrant, error) {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
g, ok := v.grants[raw]
|
|
if !ok {
|
|
return pact.MCPGrant{}, pact.ErrMCPTokenInvalid
|
|
}
|
|
return g, nil
|
|
}
|
|
|
|
func (v *fakeVerifier) MCPResource() string { return v.resource }
|
|
|
|
func (v *fakeVerifier) revoke(raw string) {
|
|
v.mu.Lock()
|
|
delete(v.grants, raw)
|
|
v.mu.Unlock()
|
|
}
|
|
|
|
// fakeLinkResolver links frontend users to backend users in memory and
|
|
// counts its lookups.
|
|
type fakeLinkResolver struct {
|
|
mu sync.Mutex
|
|
links map[uint]uint
|
|
calls atomic.Int64
|
|
}
|
|
|
|
func newFakeLinks() *fakeLinkResolver { return &fakeLinkResolver{links: map[uint]uint{}} }
|
|
|
|
func (l *fakeLinkResolver) set(user, admin uint) {
|
|
l.mu.Lock()
|
|
l.links[user] = admin
|
|
l.mu.Unlock()
|
|
}
|
|
|
|
func (l *fakeLinkResolver) remove(user uint) {
|
|
l.mu.Lock()
|
|
delete(l.links, user)
|
|
l.mu.Unlock()
|
|
}
|
|
|
|
func (l *fakeLinkResolver) LinkedBackendUser(_ context.Context, user uint) (pact.LinkedBackendUser, bool, error) {
|
|
l.calls.Add(1)
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
admin, ok := l.links[user]
|
|
if !ok {
|
|
return pact.LinkedBackendUser{}, false, nil
|
|
}
|
|
return pact.LinkedBackendUser{ID: admin, Login: "admin" + strconv.FormatUint(uint64(admin), 10)}, true, nil
|
|
}
|
|
|
|
// fakePersonalResolver resolves the personal tokens it holds; any other
|
|
// usr_ token is invalid and any other prefix is not its.
|
|
type fakePersonalResolver struct {
|
|
tokens map[string]pact.MCPSubject
|
|
}
|
|
|
|
func (r fakePersonalResolver) MCPResolvePersonalToken(_ context.Context, raw string) (pact.MCPSubject, bool, error) {
|
|
if !strings.HasPrefix(raw, "usr_") {
|
|
return pact.MCPSubject{}, false, nil
|
|
}
|
|
s, ok := r.tokens[raw]
|
|
if !ok {
|
|
return pact.MCPSubject{}, true, pact.ErrMCPTokenInvalid
|
|
}
|
|
return s, true, nil
|
|
}
|
|
|
|
// testRoute is one route of a routePlugin.
|
|
type testRoute struct {
|
|
method string
|
|
path string
|
|
mw []string
|
|
h http.HandlerFunc
|
|
}
|
|
|
|
// routePlugin registers a bouncer guard (default "acme" as middleware
|
|
// "acme.auth" through refusingGuard, so only the dispatch marker passes),
|
|
// its routes with their own middleware and the MCP tools and prompts it is
|
|
// given.
|
|
type routePlugin struct {
|
|
id string
|
|
guard string
|
|
middleware string
|
|
routes []testRoute
|
|
tools []pact.MCPTool
|
|
prompts []pact.MCPPrompt
|
|
// factory, when set, registers a pass-through middleware factory under
|
|
// that name (for <factory>:<param> requirement middleware).
|
|
factory string
|
|
}
|
|
|
|
func (p routePlugin) MiddlewareFactories() map[string]func(string) pact.Middleware {
|
|
if p.factory == "" {
|
|
return nil
|
|
}
|
|
return map[string]func(string) pact.Middleware{p.factory: func(string) pact.Middleware {
|
|
return func(next http.Handler) http.Handler { return next }
|
|
}}
|
|
}
|
|
|
|
func (p routePlugin) ID() string { return p.id }
|
|
func (routePlugin) Requires() []string { return nil }
|
|
func (routePlugin) Register(*backpack.App) error { return nil }
|
|
func (routePlugin) Boot(*backpack.App) error { return nil }
|
|
func (p routePlugin) MCP() pact.MCP { return pact.MCP{Tools: p.tools, Prompts: p.prompts} }
|
|
func (p routePlugin) guardNames() (string, string) {
|
|
guard, mw := p.guard, p.middleware
|
|
if guard == "" {
|
|
guard = "acme"
|
|
}
|
|
if mw == "" {
|
|
mw = guard + ".auth"
|
|
}
|
|
return guard, mw
|
|
}
|
|
|
|
func (p routePlugin) Middlewares() map[string]pact.Middleware {
|
|
guard, name := p.guardNames()
|
|
reg := bouncer.NewRegistry()
|
|
if err := reg.Register(p.ID(), guard, refusingGuard{}); err != nil {
|
|
panic(err)
|
|
}
|
|
m, err := reg.Middleware(guard)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return map[string]pact.Middleware{name: m}
|
|
}
|
|
|
|
func (p routePlugin) Routes(r pact.Router) error {
|
|
for _, rt := range p.routes {
|
|
rt := rt
|
|
r.Group("", rt.mw, func(g pact.Router) {
|
|
switch rt.method {
|
|
case http.MethodGet:
|
|
g.Get(rt.path, rt.h)
|
|
case http.MethodPost:
|
|
g.Post(rt.path, rt.h)
|
|
case http.MethodPut:
|
|
g.Put(rt.path, rt.h)
|
|
case http.MethodPatch:
|
|
g.Patch(rt.path, rt.h)
|
|
case http.MethodDelete:
|
|
g.Delete(rt.path, rt.h)
|
|
}
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// jsonRoute answers body with status as JSON.
|
|
func jsonRoute(status int, body string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_, _ = io.WriteString(w, body)
|
|
}
|
|
}
|
|
|
|
// appSpec describes one test application: the mcp.yaml body (the flag is
|
|
// switched on by mountPier), extra config files, the guard providers, the
|
|
// verifier (default newFakeVerifier(nil)), the link resolver and the
|
|
// personal token resolver.
|
|
type appSpec struct {
|
|
mcp string
|
|
files map[string]string
|
|
guards []pact.MCPGuard
|
|
verifier pact.MCPTokenVerifier
|
|
links pact.MCPLinkedBackendResolver
|
|
personal pact.MCPPersonalTokenResolver
|
|
}
|
|
|
|
// newApp builds the application of spec.
|
|
func newApp(t *testing.T, spec appSpec) *backpack.App {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
files := map[string]string{
|
|
"app.yaml": "name: acme\nurl: http://acme.test\n",
|
|
"http.yaml": "body_limits:\n default_bytes: 1048576\n upload_bytes: 1048576\n",
|
|
"mcp.yaml": "enabled: false\n",
|
|
}
|
|
if spec.mcp != "" {
|
|
files["mcp.yaml"] = spec.mcp
|
|
}
|
|
for name, body := range spec.files {
|
|
files[name] = body
|
|
}
|
|
for name, body := range files {
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
app := backpack.New(cfg)
|
|
verifier := spec.verifier
|
|
if verifier == nil {
|
|
verifier = newFakeVerifier(nil)
|
|
}
|
|
if err := app.Publish[pact.MCPTokenVerifier](verifier); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if spec.links != nil {
|
|
if err := app.Publish[pact.MCPLinkedBackendResolver](spec.links); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if spec.personal != nil {
|
|
if err := app.Publish[pact.MCPPersonalTokenResolver](spec.personal); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if len(spec.guards) > 0 {
|
|
reg := pact.NewMCPGuards()
|
|
for _, g := range spec.guards {
|
|
if err := reg.Register(g); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := app.Publish(reg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return app
|
|
}
|
|
|
|
// startInstance mounts pier for plugins on app and serves it.
|
|
func startInstance(t *testing.T, app *backpack.App, plugins ...party.Plugin) *instance {
|
|
t.Helper()
|
|
host, err := mountPier(t, app, plugins)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
srv := httptest.NewServer(host)
|
|
t.Cleanup(srv.Close)
|
|
return &instance{app: app, host: host, srv: srv}
|
|
}
|
|
|
|
// twoInstances mounts two pier instances on separate apps built from spec
|
|
// (sharing its guard, verifier and resolver objects) whose Redis session
|
|
// store is one miniredis.
|
|
func twoInstances(t *testing.T, spec appSpec, plugins ...party.Plugin) (*instance, *instance, *miniredis.Miniredis) {
|
|
t.Helper()
|
|
mr := miniredis.RunT(t)
|
|
files := map[string]string{"redis.yaml": "url: redis://" + mr.Addr() + "/0\n"}
|
|
for k, v := range spec.files {
|
|
files[k] = v
|
|
}
|
|
spec.files = files
|
|
if spec.mcp == "" {
|
|
spec.mcp = "enabled: false\nsessions:\n store: redis\n"
|
|
}
|
|
a := startInstance(t, newApp(t, spec), plugins...)
|
|
b := startInstance(t, newApp(t, spec), plugins...)
|
|
return a, b, mr
|
|
}
|
|
|
|
// url is the instance's MCP endpoint.
|
|
func (in *instance) url() string { return in.srv.URL + "/mcp" }
|
|
|
|
// pier returns the mounted endpoint.
|
|
func (in *instance) pier() *pier.Pier { return in.host.pier }
|
|
|
|
// clientOpts tunes sdkClient: the protocol version ("" pins 2025-11-25,
|
|
// the stateful path; "newest" leaves the SDK's default, the stateless
|
|
// path), a channel that receives every tools/list_changed and extra
|
|
// headers on every request.
|
|
type clientOpts struct {
|
|
version string
|
|
changed chan struct{}
|
|
headers map[string]string
|
|
}
|
|
|
|
// headerTransport adds the bearer and the extra headers to every request.
|
|
type headerTransport struct {
|
|
token string
|
|
headers map[string]string
|
|
}
|
|
|
|
func (h headerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
|
r = r.Clone(r.Context())
|
|
r.Header.Set("Authorization", "Bearer "+h.token)
|
|
for k, v := range h.headers {
|
|
r.Header.Set(k, v)
|
|
}
|
|
return http.DefaultTransport.RoundTrip(r)
|
|
}
|
|
|
|
// sdkClient connects an SDK client to url with bearer.
|
|
func sdkClient(t *testing.T, ctx context.Context, url, bearer string, opts clientOpts) *mcp.ClientSession {
|
|
t.Helper()
|
|
var copts *mcp.ClientOptions
|
|
if opts.changed != nil {
|
|
ch := opts.changed
|
|
copts = &mcp.ClientOptions{ToolListChangedHandler: func(context.Context, *mcp.ToolListChangedRequest) {
|
|
select {
|
|
case ch <- struct{}{}:
|
|
default:
|
|
}
|
|
}}
|
|
}
|
|
client := mcp.NewClient(&mcp.Implementation{Name: "pier-test", Version: "1"}, copts)
|
|
transport := &mcp.StreamableClientTransport{Endpoint: url, HTTPClient: &http.Client{Transport: headerTransport{token: bearer, headers: opts.headers}}}
|
|
var sopts *mcp.ClientSessionOptions
|
|
switch opts.version {
|
|
case "":
|
|
sopts = &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"}
|
|
case "newest":
|
|
default:
|
|
sopts = &mcp.ClientSessionOptions{ProtocolVersion: opts.version}
|
|
}
|
|
cs, err := client.Connect(ctx, transport, sopts)
|
|
if err != nil {
|
|
t.Fatalf("connect with %s: %v", bearer, err)
|
|
}
|
|
t.Cleanup(func() { _ = cs.Close() })
|
|
return cs
|
|
}
|
|
|
|
// listNames lists the tool names a session sees, sorted.
|
|
func listNames(t *testing.T, ctx context.Context, cs *mcp.ClientSession) []string {
|
|
t.Helper()
|
|
list, err := cs.ListTools(ctx, nil)
|
|
if err != nil {
|
|
t.Fatalf("tools/list: %v", err)
|
|
}
|
|
return toolNamesOf(list)
|
|
}
|
|
|
|
// waitChanged waits for one tools/list_changed.
|
|
func waitChanged(t *testing.T, ch <-chan struct{}) {
|
|
t.Helper()
|
|
select {
|
|
case <-ch:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("no tools/list_changed within 10 s")
|
|
}
|
|
}
|
|
|
|
// fireSubjectChanged fires one subject event on app, as a plugin does after
|
|
// its transaction commits.
|
|
func fireSubjectChanged(t *testing.T, app *backpack.App, guard string, id uint, kind string) {
|
|
t.Helper()
|
|
if err := app.Events.Fire(context.Background(), &pact.MCPSubjectChanged{Guard: guard, UserID: id, Kind: kind}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// structured decodes a tool result's structured content as an object.
|
|
func structured(t *testing.T, res *mcp.CallToolResult) map[string]any {
|
|
t.Helper()
|
|
raw, err := json.Marshal(res.StructuredContent)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal(raw, &out)
|
|
return out
|
|
}
|
|
|
|
// resultText is the text of a tool result's single content item.
|
|
func resultText(t *testing.T, res *mcp.CallToolResult) string {
|
|
t.Helper()
|
|
if len(res.Content) != 1 {
|
|
t.Fatalf("content = %+v, want one item", res.Content)
|
|
}
|
|
text, ok := res.Content[0].(*mcp.TextContent)
|
|
if !ok {
|
|
t.Fatalf("content = %T", res.Content[0])
|
|
}
|
|
return text.Text
|
|
}
|