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