- 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
419 lines
14 KiB
Go
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)
|
|
}
|