Files
summercms/modules/pier/tracer_smoke_test.go
Jakub Zych 8c6c0a3654 feat(19.7-02): identity kinds, ceilings, prompts and the MCP services page
- personal tokens only behind mcp.allow_personal_tokens (401 without a
  lookup otherwise); an admin token yields a backend-only identity with
  its scope limit per dispatched method, a user token a frontend-only one
- read/write ceiling and prompts proven by the tracer's subtests; the
  audit line asserted to carry the tool and never the arguments or token
- the mounted *pier.Pier is published on the application (test seams in
  export_test.go)
- docs/services/mcp.md with the tested acme.blog example, site.yaml note,
  cross-links from authentication.md, registration.md and the surf README
2026-10-22 18:34:39 +02:00

419 lines
14 KiB
Go

package pier_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"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/modelcontextprotocol/go-sdk/mcp"
)
// Tracer smoke test of phase 19.7 plan 02 (Task 2): one tool declared by a
// fake plugin is listed and called end to end by the SDK client through
// pier's stateful path, dispatching in-process through surf's compiled
// guard chain with the bouncer dispatch marker.
//
// The test lives in package pier_test and builds a real surf router with
// surf.BuildRouter, then mounts pier through a tiny in-test Host adapter
// that records the endpoint handler instead of compiling a route, so the
// pier package tests need no build tag (with the tag, surf imports pier,
// which an external test package may still import).
// 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{},
}
}
func TestPierTracerSmoke(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
app := pierApp(t, "enabled: false\n")
plugins := []party.Plugin{acmePlugin{tools: []pact.MCPTool{echoTool()}}}
host, err := mountPier(t, app, plugins)
if err != nil {
t.Fatal(err)
}
for _, m := range []string{"POST", "GET", "DELETE"} {
if host.handlers[m] == nil {
t.Fatalf("pier did not mount %s %s", m, host.path)
}
}
srv := httptest.NewServer(host)
defer srv.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "acme-test", Version: "1"}, nil)
transport := &mcp.StreamableClientTransport{
Endpoint: srv.URL + "/mcp",
HTTPClient: &http.Client{Transport: bearerTransport{token: "oas_at_test"}},
}
// Pinned to 2025-11-25: the stateful path with sessions (the newest
// protocol is served statelessly, see TestPierStatelessSmoke).
cs, err := client.Connect(ctx, transport, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"})
if err != nil {
t.Fatalf("connect: %v", err)
}
defer cs.Close()
if cs.ID() == "" {
t.Fatal("the stateful path answered no Mcp-Session-Id")
}
list, err := cs.ListTools(ctx, nil)
if err != nil {
t.Fatalf("tools/list: %v", err)
}
if len(list.Tools) != 1 || list.Tools[0].Name != "acme_echo" {
t.Fatalf("tools/list = %+v, want exactly acme_echo", list.Tools)
}
ann := list.Tools[0].Annotations
if ann == nil || ann.ReadOnlyHint || ann.DestructiveHint == nil || *ann.DestructiveHint {
t.Fatalf("annotations = %+v, want readOnlyHint false and destructiveHint false", ann)
}
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "acme_echo", Arguments: map[string]any{"id": 5, "text": "hi"}})
if err != nil {
t.Fatalf("tools/call: %v", err)
}
if res.IsError {
t.Fatalf("tools/call isError: %+v", res.Content)
}
structured, _ := res.StructuredContent.(map[string]any)
if structured["principal"] != 7.0 || structured["id"] != 5.0 || structured["auth"] != false {
t.Fatalf("structuredContent = %+v, want principal 7, id 5, auth false", structured)
}
body, _ := structured["body"].(map[string]any)
if len(body) != 1 || body["text"] != "hi" {
t.Fatalf("body = %+v, want {text: hi} without id", body)
}
if len(res.Content) != 1 {
t.Fatalf("content = %+v, want one text item", res.Content)
}
if text, ok := res.Content[0].(*mcp.TextContent); !ok || !strings.Contains(text.Text, `"principal":7`) {
t.Fatalf("text content = %+v", res.Content[0])
}
// A bad bearer is a 401 carrying the protected resource metadata challenge.
init := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"x","version":"1"}}}`
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/mcp", bytes.NewBufferString(init))
req.Header.Set("Authorization", "Bearer oas_at_bad")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
challenge := resp.Header.Get("WWW-Authenticate")
if resp.StatusCode != http.StatusUnauthorized || !strings.Contains(challenge, `resource_metadata="http://acme.test/.well-known/oauth-protected-resource/mcp"`) || !strings.Contains(challenge, `scope="read"`) {
t.Fatalf("bad bearer = %d %q", resp.StatusCode, challenge)
}
// The same tool declared twice fails Mount naming the exposed name.
twice := []party.Plugin{acmePlugin{tools: []pact.MCPTool{echoTool(), echoTool()}}}
if _, err := mountPier(t, pierApp(t, "enabled: false\n"), twice); err == nil || !strings.Contains(err.Error(), "acme_echo") {
t.Fatalf("duplicate tool: err = %v, want one naming acme_echo", err)
}
// A tool bound to a route nobody registers fails Mount naming the route.
nope := echoTool()
nope.Name, nope.Method, nope.Path = "nope", "GET", "/nope"
nope.Input = struct{}{}
missing := []party.Plugin{acmePlugin{tools: []pact.MCPTool{nope}}}
if _, err := mountPier(t, pierApp(t, "enabled: false\n"), missing); err == nil || !strings.Contains(err.Error(), "/nope") {
t.Fatalf("missing route: err = %v, want one naming /nope", err)
}
// A plugin that declares nothing exposes nothing, and mcp.enabled false mounts nothing.
none, err := mountPier(t, pierApp(t, "enabled: false\n"), []party.Plugin{acmePlugin{}})
if err != nil || none.handlers["POST"] == nil {
t.Fatalf("plugin without tools: err = %v, mounted = %v", err, none.handlers["POST"] != nil)
}
off := pierApp(t, "enabled: false\n")
router, err := surf.BuildRouter(off, plugins)
if err != nil {
t.Fatal(err)
}
offHost := &testHost{r: router, handlers: map[string]http.Handler{}}
if err := pier.Mount(offHost, off, plugins); err != nil || len(offHost.handlers) != 0 {
t.Fatalf("mcp.enabled false: err = %v, handlers = %d", err, len(offHost.handlers))
}
tracerIdentityCases(t, ctx)
}