From cfdbaf0e471bdd716a3f8174a7ae696462bd51f2 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 22 Oct 2026 18:15:59 +0200 Subject: [PATCH] feat(19.7-02): pier MCP module tracer with in-process dispatch and sessions - modules/pier: Host contract, mcp.* config, catalog compiled from pact.HasMCP (prefixing, route binding, guard resolution, schema reflection, annotations), per-request identity through the published verifier and resolvers, built-in backend guard provider, in-process dispatch with the bouncer marker, per-identity SDK server with the per-request re-filter, stateful session router with rehydration over a memory store, audit lines - modules/surf/pier_on.go: the summercms_mcp hook adapting the router to pier.Host; the untagged hello binary carries no SDK - go.mod: go-sdk v1.8.0 and jsonschema-go v0.4.3 (decision note committed before this change) - TestPierTracerSmoke: SDK client lists and calls acme_echo end to end through surf's compiled guard chain; README and root table row --- README.md | 1 + go.mod | 7 + go.sum | 12 + modules/pier/README.md | 137 +++++++ modules/pier/audit.go | 71 ++++ modules/pier/backend_guard.go | 113 ++++++ modules/pier/catalog.go | 455 +++++++++++++++++++++++ modules/pier/config.go | 262 ++++++++++++++ modules/pier/dispatch.go | 351 ++++++++++++++++++ modules/pier/filter.go | 67 ++++ modules/pier/guards.go | 43 +++ modules/pier/host.go | 33 ++ modules/pier/identity.go | 279 ++++++++++++++ modules/pier/pier.go | 275 ++++++++++++++ modules/pier/session.go | 582 ++++++++++++++++++++++++++++++ modules/pier/store.go | 46 +++ modules/pier/store_memory.go | 103 ++++++ modules/pier/tracer_smoke_test.go | 380 +++++++++++++++++++ modules/surf/pier_on.go | 68 ++++ 19 files changed, 3285 insertions(+) create mode 100644 modules/pier/README.md create mode 100644 modules/pier/audit.go create mode 100644 modules/pier/backend_guard.go create mode 100644 modules/pier/catalog.go create mode 100644 modules/pier/config.go create mode 100644 modules/pier/dispatch.go create mode 100644 modules/pier/filter.go create mode 100644 modules/pier/guards.go create mode 100644 modules/pier/host.go create mode 100644 modules/pier/identity.go create mode 100644 modules/pier/pier.go create mode 100644 modules/pier/session.go create mode 100644 modules/pier/store.go create mode 100644 modules/pier/store_memory.go create mode 100644 modules/pier/tracer_smoke_test.go create mode 100644 modules/surf/pier_on.go diff --git a/README.md b/README.md index 8b922b43..659902a7 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ The `migrate`, `migrate:status`, `migrate:rollback`, `serve` and admin commands | [pact](modules/pact/README.md) | Capability interfaces that compiled plugins implement to contribute routes, config, migrations, middleware, commands, admin screens, translations, mail templates and jobs. | An agreement both sides keep; it holds the interfaces a plugin agrees to implement. | | [party](modules/party/README.md) | Compiled plugin registry that orders plugins by their dependencies and runs their Register and Boot lifecycle. | The guest list and the order people arrive in; it registers plugins and boots them in dependency order. | | [phrasebook](modules/phrasebook/README.md) | Namespaced translation catalogs loaded from plugin YAML, with locale fallback, placeholder interpolation and CLDR pluralization. | What you pack to speak the local language on holiday; it holds the translations. | +| [pier](modules/pier/README.md) | Opt-in MCP endpoint that lists the tools and prompts plugins declare per caller and runs every tool call in-process through the plugin's own token-guarded route as the caller's pre-resolved principal. | The pier where outside boats dock to reach the beach; agents connect here to reach the application's plugins. | | [postcard](modules/postcard/README.md) | Transactional mail from plugin-owned Markdown templates and layouts, delivered through a memory, log or SMTP driver. | What you send home in summer; it delivers the application's mail. | | [seaglass](modules/seaglass/README.md) | HTML sanitizer for stored rich text: a bluemonday allowlist policy that returns clean HTML byte for byte and reports what it removed from the rest. | Glass the sea has worn smooth; rough HTML goes in and only the safe, smooth part comes out. | | [sieve](modules/sieve/README.md) | Generic, dependency-free collection helpers that the standard library slices and maps packages lack: mapping, filtering, reducing, keying, grouping, deduplicating, partitioning and finding, plus a small set type. | A sand sieve: you shake a collection through it and keep what you need; map, filter and reduce for slices and maps. | diff --git a/go.mod b/go.mod index 647e090a..4bb18a04 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/go-playground/validator/v10 v10.30.4 github.com/goccy/go-yaml v1.19.2 github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/jsonschema-go v0.4.3 github.com/jackc/pgx/v5 v5.10.0 github.com/knadh/koanf/parsers/yaml v1.1.1 github.com/knadh/koanf/providers/confmap v1.0.1 @@ -29,6 +30,7 @@ require ( github.com/knadh/koanf/v2 v2.3.6 github.com/maypok86/otter/v2 v2.3.0 github.com/microcosm-cc/bluemonday v1.0.27 + github.com/modelcontextprotocol/go-sdk v1.8.0 github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/redis/go-redis/v9 v9.23.0 github.com/riverqueue/river v0.47.0 @@ -149,6 +151,8 @@ require ( github.com/riverqueue/river/riverdriver/riverpgxv5 v0.47.0 // indirect github.com/riverqueue/river/rivershared v0.47.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/shirou/gopsutil/v4 v4.26.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/stretchr/testify v1.12.1 // indirect @@ -159,6 +163,7 @@ require ( github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect github.com/xo/terminfo v1.0.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -170,8 +175,10 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/atomic v1.12.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect + golang.org/x/time v0.15.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/api v0.272.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect diff --git a/go.sum b/go.sum index 1a73a1e6..e4557185 100644 --- a/go.sum +++ b/go.sum @@ -211,6 +211,8 @@ github.com/google/go-replayers/grpcreplay v1.3.0 h1:1Keyy0m1sIpqstQmgz307zhiJ1pV github.com/google/go-replayers/grpcreplay v1.3.0/go.mod h1:v6NgKtkijC0d3e3RW8il6Sy5sqRVUwoQa4mHOGEy8DI= github.com/google/go-replayers/httpreplay v1.2.0 h1:VM1wEyyjaoU53BwrOnaf9VhAyQQEEioJvFYxYcLRKzk= github.com/google/go-replayers/httpreplay v1.2.0/go.mod h1:WahEFFZZ7a1P4VM1qEeHy+tME4bwyqPcwWbNlUI1Mcg= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc= github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= @@ -303,6 +305,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= @@ -343,6 +347,10 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shirou/gopsutil/v4 v4.26.6 h1:Mzr/npDtQC/xpeEuQKHZt8Zo9CmPvhTj8nkR8w5TLDs= github.com/shirou/gopsutil/v4 v4.26.6/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -383,6 +391,8 @@ github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pc github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w= github.com/xo/terminfo v1.0.0 h1:2ZpYzqWzyyytjk3TP6aJVDhkMAkc99/1xKQdA3TDTBY= github.com/xo/terminfo v1.0.0/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4= github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= @@ -446,6 +456,8 @@ golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= diff --git a/modules/pier/README.md b/modules/pier/README.md new file mode 100644 index 00000000..c38860a3 --- /dev/null +++ b/modules/pier/README.md @@ -0,0 +1,137 @@ +# pier + +Opt-in MCP endpoint that lists the tools and prompts plugins declare per caller and runs every tool call in-process through the plugin's own token-guarded route as the caller's pre-resolved principal. + +`import "git.golem15.com/golem15/summercms/modules/pier"` + +**Name:** The pier where outside boats dock to reach the beach; agents connect here to reach the application's plugins. + +## Overview + +pier mounts a streamable HTTP MCP endpoint (POST, GET and DELETE at `mcp.path`, default `/mcp`) inside the application binary, on the official Go SDK. A plugin takes part by implementing `pact.HasMCP`: each `pact.MCPTool` names one of the plugin's own routes by method and path, an input struct whose tags say which fields fill the path, the query string and the body, and the guard the tool runs as. At boot pier compiles every declaration against the route table (an unknown route, a duplicate or invalid name, a route that is not token-guarded, a non-object input all fail boot naming the plugin and the tool), reflects the input schema from the struct and prefixes the exposed name with the plugin's short code (`acme.blog` declaring `create_post` exposes `blog_create_post`). + +Every request to the endpoint is authorized afresh from its bearer: an OAuth access token through the `pact.MCPTokenVerifier` the OAuth server plugin publishes, or, behind `mcp.allow_personal_tokens`, a personal API token (an admin `g15_` token through cabana, a frontend token through the published `pact.MCPPersonalTokenResolver`). The resulting `pier.Identity` names a frontend subject and, when the grant carries the `backend` scope and the published `pact.MCPLinkedBackendResolver` confirms a link, a backend subject. `tools/list` is computed for that identity: a tool needs a subject under its guard, a grant without `write` sees only read-only tools, and the guard provider's own check (the same logic its route middleware enforces) decides the rest. A `tools/call` runs the bound route's compiled middleware chain in-process with the `bouncer.Dispatch` marker carrying the principal the guard provider resolved; the inner request never carries an `Authorization` or `Cookie` header. A 2xx answer becomes text plus structured content, anything else a tool error carrying the route's own body. + +Sessions (`Mcp-Session-Id`) are bound to the identity that opened them (client and user, or the personal token), live in a store with a sliding idle TTL, and are served by a per-session SDK server on whichever instance receives the request, rehydrated from the stored session state when it is not local. The memory store serves one instance; the Redis store (next task) shares sessions and events across instances. + +## Role in the framework + +A service module on `backpack`, `pact`, `bouncer`, `cabana` and `cooler`, mounted by `surf` through the `pier.Host` interface: surf imports pier in a binary built with the `summercms_mcp` tag (`modules/surf/pier_on.go`), never the reverse. Only pier imports the MCP SDK and the schema library; `pact` holds every plugin-facing type, so plugins compile with and without the tag. Without the tag the binary carries no SDK and `mcp.enabled: true` fails boot naming the tag; with the tag the flag still defaults to off. + +Use cases: + +- Let an editor connect one MCP client to the application once, through the application's own OAuth server, and reach the tools of every plugin they may use. +- Expose a plugin's management routes to agents without a second server: the route's throttles, scopes and permissions run unchanged on every call. +- Run backend tools as the linked administrator, with the administrator's real permissions capped by the grant's read or write scope. + +## Features + +- `pact.HasMCP` declarations compiled at boot: prefix from the plugin id (last dot-segment, lower-cased, runs of other characters as `_`), exposed names matching `[a-z0-9_]{1,128}`, duplicate names refused within and across plugins, descriptions capped at `pier.MaxDescriptionRunes`. +- Route binding by exact method and pattern (D-10); a tool targets a route that carries its guard's token-accepting middleware or names its guard explicitly (D-15); the requirements a route's middleware implies come from the guard provider's `MCPRequirements` and are checked together with the tool's `Permissions` (D-16). +- Input schema reflected from the struct with `jsonschema.ForType` (`jsonschema:"..."` tags describe fields, fields without `omitempty` are required, no additional properties); every call's arguments are validated against it, then split by the `path:"x"` and `query:"q"` tags into path values, query string and the raw JSON body minus those keys (D-11); a `GET` or `DELETE` tool may carry only path and query fields. An optional `Output` struct produces the output schema. +- Annotations from the method (D-14): `GET` read-only and idempotent, `PUT` idempotent, `DELETE` destructive; `ReadOnly`, `Destructive`, `Idempotent` and `OpenWorld` override; the destructive and open-world hints are always explicit. +- In-process dispatch through `pier.Host.Handler`, the route's compiled chain, with `bouncer.WithDispatch` carrying the guard provider's `MCPDispatch` principal and credential, path values set as the mux would, `RemoteAddr`, `X-Forwarded-For`, `X-Real-IP` and `Accept-Language` copied from the MCP request, `mcp.call_timeout` on the context and the answer captured up to `mcp.max_result_bytes` (D-17). +- Results (D-13): 2xx is text plus structured content (an object as is, any other JSON wrapped as `{"data": …}`); a 4xx or 5xx is a tool error whose text is the route's body; a too-large answer is a tool error with a fixed text. +- Built-in `backend` guard provider (`pier.BackendGuard`): routes behind cabana's `backend` middleware, listing through `cabana.Allows` on the administrator's principal from `cabana.BackendPrincipal`, an admin personal token's scope limit computed per dispatched route method with `cabana.ScopeLimitFor`; a plugin that registers its own `backend` provider wins. +- Identity per request (`pier.Identity`): `oas_at_` tokens through the published verifier, personal tokens only with `mcp.allow_personal_tokens`, the backend subject through the published link resolver with an in-process cache of at most `pier.LinkCacheTTL`; the binding key `oauth::`, `pat:user:` or `pat:backend:` survives access-token rotation. +- Per-identity SDK server plus a receiving middleware that re-filters `tools/list` and `prompts/list` and refuses `tools/call` and `prompts/get` for the identity of the current request, so a permission change takes effect on the next request (D-08, D-25). +- Stateful sessions: `initialize` opens a session (429 above `mcp.sessions.max_per_binding`), a request with an unknown id answers 404 (the client re-initialises), another identity presenting the id answers 403 `session_binding`, DELETE ends the session everywhere, the record's TTL slides on every request, a session initialised on one instance is rehydrated on another from the stored `ServerSessionState`. +- Prompts (D-28): prefixed and filtered like tools, required arguments checked before `Render` runs; a prompt never dispatches a route. +- A 401 carries `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-protected-resource", scope="read"`; a browser request whose `Origin` is not in `mcp.allowed_origins` is refused with 403 before any body is read. +- One audit line per tool call (`pier: tool call`: tool, guard, user id, admin id, client id, token kind, session prefix, status, duration) and one per session step (`pier: session`), never arguments, bodies or tokens. + +## Usage + +Declare a tool in a plugin (the `pact` types only; no SDK import): + +```go +type createPostInput struct { + Title string `json:"title" jsonschema:"the post title"` + Body string `json:"body" jsonschema:"the Markdown body"` + Published bool `json:"published,omitempty" jsonschema:"publish at once"` +} + +type showPostInput struct { + ID int `json:"id" path:"id" jsonschema:"the post id"` +} + +func (p Plugin) MCP() pact.MCP { + return pact.MCP{Tools: []pact.MCPTool{ + {Name: "create_post", Title: "Create post", Description: "Creates a draft post.", + Guard: "backend", Method: "POST", Path: "/_blog/api/v1/posts", Input: createPostInput{}, + Permissions: []string{"acme.blog.access_posts"}}, + {Name: "show_post", Title: "Show post", Description: "Shows one post.", + Method: "GET", Path: "/_blog/api/v1/posts/{id}", Input: showPostInput{}}, + }} +} +``` + +Build the application with the tag and enable the endpoint: + +```yaml +# summer.yaml +build: + tags: [summercms_mcp] +``` + +```yaml +# config/mcp.yaml +enabled: true +path: /mcp +``` + +An MCP client then connects to `https://example.com/mcp` with an OAuth access token of the application's authorization server. surf mounts pier itself; an application never calls `pier.Mount`. + +## API reference + +| Identifier | Description | +|------------|-------------| +| `pier.Mount` | Reads the `mcp.*` keys and, with `mcp.enabled` true, compiles every plugin's declaration, registers the built-in backend guard provider and mounts POST, GET and DELETE at `mcp.path` through the host's streaming route kind behind `throttle:`; a bad declaration or a route already at the path fails with an error naming the plugin. Called by surf's tagged mount hook. | +| `pier.Host` | What pier needs from the router: `Routes`, `Handler` (the compiled chain of one route, resolved lazily), `MountStream`, `MountJSON` and `Owner`. surf passes an adapter. | +| `pier.RouteInfo` | One registered route: method, pattern, plugin id and middleware names. | +| `pier.PluginID` | `summercms.pier`, the pseudo-plugin id of pier's routes in the route table. | +| `pier.Config` | The loaded `mcp.*` configuration (see Configuration). | +| `pier.LoadConfig` | Reads and validates the `mcp.*` keys; an invalid value fails with an error naming the key. | +| `pier.Pier` | The mounted endpoint; `pier.Pier.Handler` is its handler (Origin check, body limit, bearer verification, session router), `pier.Pier.Config` the loaded configuration and `pier.Pier.ToolNames` the exposed tool names. | +| `pier.Identity` | Who one request acts as: `Frontend` and `Backend` subjects (`pact.MCPSubject`), `Scopes`, the session `Binding`, `ExpiresAt` and `TokenKind` (`pier.TokenKindOAuth` or `pier.TokenKindPersonal`). | +| `pier.OAuthTokenPrefix` | `oas_at_`, the prefix of the tokens the published `pact.MCPTokenVerifier` verifies. | +| `pier.BackendGuard` | `backend`, the name of the built-in guard provider for routes behind cabana's backend guard. | +| `pier.ErrUnauthorized` | What identity resolution answers for a bearer it does not accept. | +| `pier.LinkCacheTTL` | How long a resolved backend link is reused on one instance (5 s); a link event drops it at once. | +| `pier.MaxDescriptionRunes` | The cap on a tool or prompt description (1024). | +| `pier.Store` | The session registry and event channel: `Put`, `Get`, `Touch`, `Delete`, `Count`, `Publish`, `Subscribe`. | +| `pier.Record` | One stored session: binding, session state JSON and creation time. | +| `pier.Event` | One instance-to-instance event: a kind (`pier.EventEnded` or a `pact.MCPSubjectChanged` kind), guard, user id, session id and publishing instance. | +| `pier.LogComponent` | `pier`, the diary component of the audit lines. | + +Configuration keys are exported as constants: `pier.EnabledKey`, `pier.PathKey`, `pier.AllowPersonalTokensKey`, `pier.SessionStoreKey`, `pier.SessionIdleTTLKey`, `pier.SessionMaxPerBindingKey`, `pier.ResourceKey`, `pier.ThrottleKey`, `pier.AllowedOriginsKey`, `pier.CallTimeoutKey`, `pier.MaxResultBytesKey`, with defaults `pier.DefaultPath`, `pier.DefaultSessionStore`, `pier.DefaultIdleTTL`, `pier.DefaultMaxPerBinding`, `pier.DefaultThrottle`, `pier.DefaultCallTimeout` and `pier.DefaultMaxResultBytes`, and store names `pier.StoreMemory` and `pier.StoreRedis`. + +## Configuration + +| Key | Default | Description | +|-----|---------|-------------| +| `mcp.enabled` | `false` | Mounts the endpoint. `true` in a binary built without the `summercms_mcp` tag fails boot naming the tag. | +| `mcp.path` | `/mcp` | The endpoint path: an absolute URL path without scheme, host, query, wildcard or dot segments. A route a plugin registered there fails boot naming the plugin. | +| `mcp.allow_personal_tokens` | `false` | Lets personal API tokens open sessions: an admin `g15_` token yields backend tools only, a frontend token (through the published resolver) frontend tools only, each capped by the token's own scope. OAuth tokens are always accepted. | +| `mcp.sessions.store` | `memory` | `memory` (one instance; sessions end with the process) or `redis` (shared through `redis.url`; the Redis store arrives with the next task). | +| `mcp.sessions.idle_ttl` | `30m` | Sliding idle TTL of a session record. | +| `mcp.sessions.max_per_binding` | `20` | Open sessions one binding (client and user, or personal token) may hold; the next `initialize` answers 429. | +| `mcp.resource` | `app.url` + `mcp.path` | The resource indicator the endpoint identifies itself by; must equal the OAuth server's MCP resource. Required (or `app.url`) when enabled. | +| `mcp.throttle` | `120,1` | The endpoint's inline throttle, surf's `,` spelling. | +| `mcp.allowed_origins` | the origin of `app.url` | Browser origins (`scheme://host[:port]`) allowed to call the endpoint; a request with another `Origin` is refused with 403 before its body is read. Requests without `Origin` pass. | +| `mcp.call_timeout` | `30s` | Bound on one in-process tool call. | +| `mcp.max_result_bytes` | `1048576` | Cap on the route answer captured for a tool result; a larger answer is a tool error. | + +Environment: `SUMMER_MCP__ENABLED`, `SUMMER_MCP__PATH`, `SUMMER_MCP__SESSIONS__STORE` and so on. + +## Dependencies + +- `github.com/modelcontextprotocol/go-sdk` v1.8.0 (`mcp`, `auth`, `oauthex`, `jsonrpc`): the server, sessions, streamable HTTP transport, bearer middleware and metadata types; decided in `.planning/notes/2026-10-22-mcp-go-sdk.md`. +- `github.com/google/jsonschema-go` v0.4.3: input and output schema reflection and argument validation. +- Framework: `backpack`, `pact`, `party`, `bouncer`, `cabana`, `compass`, `cooler`, `diary`. + +Only this module (and surf's tagged `pier_on.go`) imports the SDK; `pact` stays free of it. + +## Testing + +`go test -short ./modules/pier/...` runs without Docker, Redis or a build tag: `TestPierTracerSmoke` builds a real surf router with a fake plugin, mounts pier through an in-test `pier.Host` adapter, connects the SDK client over `httptest` and proves `tools/list`, `tools/call` through the compiled guard chain with the dispatch marker (no `Authorization` or `Cookie` reaches the route), the 401 challenge, and the boot errors for a duplicate name and an unknown route. `go test -short -tags summercms_mcp ./modules/pier/... ./modules/surf/...` runs the same tests with the SDK linked into surf. diff --git a/modules/pier/audit.go b/modules/pier/audit.go new file mode 100644 index 00000000..f09e6c82 --- /dev/null +++ b/modules/pier/audit.go @@ -0,0 +1,71 @@ +package pier + +import ( + "log/slog" + "time" + + "git.golem15.com/golem15/summercms/modules/diary" +) + +// LogComponent is the diary component pier logs under. +const LogComponent = "pier" + +// sessionPrefixLen is how much of a session id the audit lines show. +const sessionPrefixLen = 8 + +// stateless is the session value of a call served on the stateless path. +const stateless = "stateless" + +// logger is the module's logger (component pier). +func logger() *slog.Logger { + return diary.For(LogComponent) +} + +// sessionPrefix shortens a session id for logging; a stateless call logs +// "stateless". +func sessionPrefix(id string) string { + if id == "" { + return stateless + } + if len(id) > sessionPrefixLen { + return id[:sessionPrefixLen] + } + return id +} + +// logToolCall writes the one audit line per tool call (D-23): the tool, +// its guard, the frontend user id (0 when none), the backend admin id (0 +// when none), the OAuth client id, the token kind, the session prefix, the +// route's status and the duration. Never the arguments, the body or a token. +func logToolCall(log *slog.Logger, id Identity, t *tool, session string, status int, took time.Duration) { + var userID, adminID uint + var clientID string + if id.Frontend != nil { + userID = id.Frontend.UserID + clientID = id.Frontend.ClientID + } + if id.Backend != nil { + adminID = id.Backend.UserID + if clientID == "" { + clientID = id.Backend.ClientID + } + } + log.Info("pier: tool call", + "tool", t.name, + "guard", t.guardName, + "user_id", userID, + "admin_id", adminID, + "client_id", clientID, + "token_kind", id.TokenKind, + "session", sessionPrefix(session), + "status", status, + "duration_ms", took.Milliseconds(), + ) +} + +// logSession writes one line per session lifecycle step (open, rehydrate, +// end, binding_refused, expired) with the session prefix and the binding, +// never the token. +func logSession(log *slog.Logger, step, session, binding string) { + log.Info("pier: session", "step", step, "session", sessionPrefix(session), "binding", binding) +} diff --git a/modules/pier/backend_guard.go b/modules/pier/backend_guard.go new file mode 100644 index 00000000..eeaa82b8 --- /dev/null +++ b/modules/pier/backend_guard.go @@ -0,0 +1,113 @@ +package pier + +import ( + "context" + "fmt" + "net/http" + "slices" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// BackendGuard is the name of the built-in guard provider for routes behind +// cabana's backend token guard (middleware "backend"): a backend tool runs +// as the admin the subject names, with the admin's real cabana permissions +// (superuser included) and, for an admin personal token, the token's scope +// limit for the dispatched route's method. +const BackendGuard = "backend" + +// backendMiddleware is cabana's backend guard middleware name. +const backendMiddleware = "backend" + +// methodAllower is the method-aware listing check pier uses for the backend +// provider: an admin personal token's scope limit depends on the method of +// the route a tool is bound to (Pitfall 7). +type methodAllower interface { + MCPAllowsMethod(ctx context.Context, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string, method string) (bool, error) +} + +// backendGuard is the built-in provider registered as BackendGuard unless a +// plugin already owns the name. +type backendGuard struct { + app *backpack.App +} + +func (backendGuard) MCPGuardName() string { return BackendGuard } + +// MCPTokenGuarded reports whether the route carries cabana's backend guard. +func (backendGuard) MCPTokenGuarded(middleware []string) bool { + return slices.Contains(middleware, backendMiddleware) +} + +// MCPRequirements is empty: the backend guard's middleware implies no +// scope or permission; routes check cabana.Allows themselves and tools +// declare Permissions for the listing. +func (backendGuard) MCPRequirements([]string) (pact.MCPRequirements, error) { + return pact.MCPRequirements{}, nil +} + +// MCPAllows answers with cabana.Allows for the admin's principal. For an +// admin personal token the scope limit is evaluated as for a GET request; +// pier itself calls MCPAllowsMethod with the tool's method. +func (g backendGuard) MCPAllows(ctx context.Context, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string) (bool, error) { + return g.MCPAllowsMethod(ctx, subject, req, permissions, http.MethodGet) +} + +// MCPAllowsMethod answers cabana.Allows for the admin's principal with the +// personal token's scope limit computed for method. +func (g backendGuard) MCPAllowsMethod(ctx context.Context, subject pact.MCPSubject, _ pact.MCPRequirements, permissions []string, method string) (bool, error) { + principal, err := g.principal(ctx, subject, method) + if err != nil { + return false, err + } + if principal == nil { + return false, nil + } + return cabana.Allows(principal, permissions), nil +} + +// MCPDispatch builds the admin principal for one in-process call. +func (g backendGuard) MCPDispatch(ctx context.Context, subject pact.MCPSubject, method string) (pact.MCPDispatch, error) { + principal, err := g.principal(ctx, subject, method) + if err != nil { + return pact.MCPDispatch{}, err + } + if principal == nil { + return pact.MCPDispatch{}, fmt.Errorf("pier: backend user %d is unknown or deactivated", subject.UserID) + } + return pact.MCPDispatch{Principal: principal, Guards: []string{BackendGuard}}, nil +} + +// principal loads the admin through cabana.BackendPrincipal (nil when the +// admin does not exist or is deactivated) and, for a personal-token subject +// (no OAuth client), sets the scope limit for method; a token whose stored +// scopes are all unknown answers nil, as the HTTP guard refuses it. +func (g backendGuard) principal(ctx context.Context, subject pact.MCPSubject, method string) (*bouncer.Principal, error) { + principal, err := cabana.BackendPrincipal(ctx, g.app, subject.UserID) + if err != nil || principal == nil { + return nil, err + } + if subject.ClientID == "" && subject.TokenID != 0 { + limit, known, _ := cabana.ScopeLimitFor(g.app, subject.Scopes, method) + if known == 0 { + return nil, nil + } + if limit == nil { + limit = &bouncer.ScopeLimit{} + } + principal.Scope = limit + } + return principal, nil +} + +// registerBackendGuard adds the built-in provider unless a plugin already +// owns the backend name. +func registerBackendGuard(app *backpack.App, reg *pact.MCPGuards) error { + if _, taken := reg.Lookup(BackendGuard); taken { + return nil + } + return reg.Register(backendGuard{app: app}) +} diff --git a/modules/pier/catalog.go b/modules/pier/catalog.go new file mode 100644 index 00000000..c876e105 --- /dev/null +++ b/modules/pier/catalog.go @@ -0,0 +1,455 @@ +package pier + +import ( + "fmt" + "net/http" + "reflect" + "regexp" + "sort" + "strings" + "sync" + "unicode/utf8" + + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// MaxDescriptionRunes caps a tool or prompt description; a longer one fails +// boot (descriptions are static text, never built from data). +const MaxDescriptionRunes = 1024 + +// exposedName is what an exposed tool or prompt name must match: the SDK +// only logs an invalid name, pier refuses it at boot (Pitfall 11). +var exposedName = regexp.MustCompile(`^[a-z0-9_]{1,128}$`) + +// argKind says where one input field goes on the dispatched request. +type argKind int + +const ( + argBody argKind = iota + argPath + argQuery +) + +// argField is one top-level field of a tool's input struct. +type argField struct { + name string // JSON property name + kind argKind // body, path or query + key string // the {segment} or query key for path and query fields +} + +// tool is one compiled tool: the plugin's declaration bound to a route, the +// guard that runs it, the requirements the route's middleware implies, the +// reflected and resolved input schema, the SDK descriptor and the lazily +// resolved compiled handler. +type tool struct { + name string + pluginID string + decl pact.MCPTool + method string + pattern string + guardName string + guard pact.MCPGuard + req pact.MCPRequirements + schema *jsonschema.Schema + resolved *jsonschema.Resolved + fields []argField + segments []string + readOnly bool + desc *mcp.Tool + + once sync.Once + handler http.Handler + hasH bool +} + +// prompt is one compiled prompt: the declaration, its guard and the SDK +// descriptor. +type prompt struct { + name string + pluginID string + decl pact.MCPPrompt + guardName string + guard pact.MCPGuard + desc *mcp.Prompt +} + +// catalog is every tool and prompt the activated plugins declare, keyed by +// exposed name, in declaration order. +type catalog struct { + tools []*tool + byName map[string]*tool + prompts []*prompt + promptByName map[string]*prompt + owners map[string]string // exposed name -> plugin id, tools and prompts +} + +// compileCatalog validates every pact.HasMCP declaration against the host's +// route table and the guard registry. Zero declaring plugins is fine. Every +// failure is "pier: plugin tool|prompt : ..." and fails boot. +func compileCatalog(host Host, guards *pact.MCPGuards, plugins []party.Plugin) (*catalog, error) { + c := &catalog{ + byName: map[string]*tool{}, + promptByName: map[string]*prompt{}, + owners: map[string]string{}, + } + routes := map[string]RouteInfo{} + for _, ri := range host.Routes() { + routes[ri.Method+" "+ri.Pattern] = ri + } + for _, p := range plugins { + hm, ok := p.(pact.HasMCP) + if !ok { + continue + } + decl := hm.MCP() + prefix := prefixFor(p.ID()) + for _, t := range decl.Tools { + ct, err := compileTool(p.ID(), prefix, t, routes, guards) + if err != nil { + return nil, err + } + if owner, taken := c.owners[ct.name]; taken { + return nil, fmt.Errorf("pier: plugin %s tool %q: exposed name %q is already taken by plugin %s", p.ID(), t.Name, ct.name, owner) + } + c.owners[ct.name] = p.ID() + c.byName[ct.name] = ct + c.tools = append(c.tools, ct) + } + for _, pr := range decl.Prompts { + cp, err := compilePrompt(p.ID(), prefix, pr, guards) + if err != nil { + return nil, err + } + if owner, taken := c.owners[cp.name]; taken { + return nil, fmt.Errorf("pier: plugin %s prompt %q: exposed name %q is already taken by plugin %s", p.ID(), pr.Name, cp.name, owner) + } + c.owners[cp.name] = p.ID() + c.promptByName[cp.name] = cp + c.prompts = append(c.prompts, cp) + } + } + return c, nil +} + +// prefixFor derives the tool prefix from a plugin id: the last dot-segment, +// lower-cased, every run of characters outside [a-z0-9] replaced by "_", +// plus a trailing "_" (acme.blog -> blog_). +func prefixFor(pluginID string) string { + code := pluginID + if i := strings.LastIndex(code, "."); i >= 0 { + code = code[i+1:] + } + code = strings.ToLower(code) + var b strings.Builder + run := false + for _, r := range code { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + run = false + continue + } + if !run { + b.WriteByte('_') + run = true + } + } + return strings.Trim(b.String(), "_") + "_" +} + +func compileTool(pluginID, prefix string, t pact.MCPTool, routes map[string]RouteInfo, guards *pact.MCPGuards) (*tool, error) { + errPrefix := fmt.Sprintf("pier: plugin %s tool %q", pluginID, t.Name) + if strings.TrimSpace(t.Name) == "" { + return nil, fmt.Errorf("%s: the name is empty", errPrefix) + } + name := prefix + t.Name + if !exposedName.MatchString(name) { + return nil, fmt.Errorf("%s: exposed name %q must match %s", errPrefix, name, exposedName.String()) + } + if utf8.RuneCountInString(t.Description) > MaxDescriptionRunes { + return nil, fmt.Errorf("%s: the description is longer than %d characters", errPrefix, MaxDescriptionRunes) + } + method := strings.ToUpper(strings.TrimSpace(t.Method)) + pattern := strings.TrimSpace(t.Path) + route, ok := routes[method+" "+pattern] + if !ok { + return nil, fmt.Errorf("%s: binds to %s %s, which no plugin registers", errPrefix, method, pattern) + } + guardName, guard, err := resolveGuard(t.Guard, route.Middleware, guards) + if err != nil { + return nil, fmt.Errorf("%s: %w", errPrefix, err) + } + req, err := guard.MCPRequirements(route.Middleware) + if err != nil { + return nil, fmt.Errorf("%s: guard %s rejects the route's middleware: %w", errPrefix, guardName, err) + } + if t.Input == nil { + return nil, fmt.Errorf("%s: Input is nil (the zero value of the input struct is required)", errPrefix) + } + rt := reflect.TypeOf(t.Input) + for rt.Kind() == reflect.Pointer { + rt = rt.Elem() + } + if rt.Kind() != reflect.Struct { + return nil, fmt.Errorf("%s: Input must be a struct or a pointer to one, got %s", errPrefix, rt) + } + schema, err := jsonschema.ForType(rt, nil) + if err != nil { + return nil, fmt.Errorf("%s: input schema: %w", errPrefix, err) + } + if schema.Type != "object" { + return nil, fmt.Errorf("%s: input schema must be an object, got %q", errPrefix, schema.Type) + } + resolved, err := schema.Resolve(nil) + if err != nil { + return nil, fmt.Errorf("%s: input schema does not resolve: %w", errPrefix, err) + } + fields, err := inputFields(rt) + if err != nil { + return nil, fmt.Errorf("%s: %w", errPrefix, err) + } + segments := patternSegments(pattern) + tagged := map[string]bool{} + for _, f := range fields { + if f.kind != argPath { + continue + } + found := false + for _, s := range segments { + if s == f.key { + found = true + break + } + } + if !found { + return nil, fmt.Errorf("%s: field %q has tag path:%q but %s has no {%s} segment", errPrefix, f.name, f.key, pattern, f.key) + } + tagged[f.key] = true + } + for _, s := range segments { + if !tagged[s] { + return nil, fmt.Errorf("%s: segment {%s} of %s has no input field with tag path:%q", errPrefix, s, pattern, s) + } + } + if method == http.MethodGet || method == http.MethodDelete { + for _, f := range fields { + if f.kind == argBody { + return nil, fmt.Errorf("%s: field %q would be a request body, but a %s route takes only path and query fields", errPrefix, f.name, method) + } + } + } + var output *jsonschema.Schema + if t.Output != nil { + ot := reflect.TypeOf(t.Output) + for ot.Kind() == reflect.Pointer { + ot = ot.Elem() + } + output, err = jsonschema.ForType(ot, nil) + if err != nil { + return nil, fmt.Errorf("%s: output schema: %w", errPrefix, err) + } + } + ann := annotationsFor(method, t) + desc := &mcp.Tool{ + Name: name, + Title: t.Title, + Description: t.Description, + InputSchema: schema, + Annotations: ann, + } + if output != nil { + desc.OutputSchema = output + } + return &tool{ + name: name, + pluginID: pluginID, + decl: t, + method: method, + pattern: pattern, + guardName: guardName, + guard: guard, + req: req, + schema: schema, + resolved: resolved, + fields: fields, + segments: segments, + readOnly: ann.ReadOnlyHint, + desc: desc, + }, nil +} + +func compilePrompt(pluginID, prefix string, p pact.MCPPrompt, guards *pact.MCPGuards) (*prompt, error) { + errPrefix := fmt.Sprintf("pier: plugin %s prompt %q", pluginID, p.Name) + if strings.TrimSpace(p.Name) == "" { + return nil, fmt.Errorf("%s: the name is empty", errPrefix) + } + name := prefix + p.Name + if !exposedName.MatchString(name) { + return nil, fmt.Errorf("%s: exposed name %q must match %s", errPrefix, name, exposedName.String()) + } + if utf8.RuneCountInString(p.Description) > MaxDescriptionRunes { + return nil, fmt.Errorf("%s: the description is longer than %d characters", errPrefix, MaxDescriptionRunes) + } + if p.Render == nil { + return nil, fmt.Errorf("%s: Render is nil", errPrefix) + } + if strings.TrimSpace(p.Guard) == "" { + return nil, fmt.Errorf("%s: a prompt must name its Guard (%s)", errPrefix, strings.Join(guards.Names(), ", ")) + } + guard, ok := guards.Lookup(p.Guard) + if !ok { + return nil, fmt.Errorf("%s: unknown guard %q (registered: %s)", errPrefix, p.Guard, strings.Join(guards.Names(), ", ")) + } + seen := map[string]bool{} + desc := &mcp.Prompt{Name: name, Title: p.Title, Description: p.Description} + for _, a := range p.Arguments { + if strings.TrimSpace(a.Name) == "" { + return nil, fmt.Errorf("%s: an argument has an empty name", errPrefix) + } + if seen[a.Name] { + return nil, fmt.Errorf("%s: argument %q is declared twice", errPrefix, a.Name) + } + seen[a.Name] = true + desc.Arguments = append(desc.Arguments, &mcp.PromptArgument{Name: a.Name, Description: a.Description, Required: a.Required}) + } + return &prompt{name: name, pluginID: pluginID, decl: p, guardName: p.Guard, guard: guard, desc: desc}, nil +} + +// resolveGuard picks the guard a tool runs as: the declared one, which must +// be registered, or else the registered provider whose token-accepting +// middleware the route carries (D-15). +func resolveGuard(declared string, middleware []string, guards *pact.MCPGuards) (string, pact.MCPGuard, error) { + if declared != "" { + g, ok := guards.Lookup(declared) + if !ok { + return "", nil, fmt.Errorf("unknown guard %q (registered: %s)", declared, strings.Join(guards.Names(), ", ")) + } + return declared, g, nil + } + for _, name := range guards.Names() { + g, _ := guards.Lookup(name) + if g != nil && g.MCPTokenGuarded(middleware) { + return name, g, nil + } + } + return "", nil, fmt.Errorf("the route is not token-guarded (middleware: %s) and the tool declares no Guard", strings.Join(middleware, ", ")) +} + +// inputFields classifies the exported top-level fields of the input struct +// by their path, query and json tags. +func inputFields(rt reflect.Type) ([]argField, error) { + var out []argField + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + if !f.IsExported() { + continue + } + name := f.Name + if tag, ok := f.Tag.Lookup("json"); ok { + n, _, _ := strings.Cut(tag, ",") + if n == "-" { + continue + } + if n != "" { + name = n + } + } + pathKey, hasPath := f.Tag.Lookup("path") + queryKey, hasQuery := f.Tag.Lookup("query") + switch { + case hasPath && hasQuery: + return nil, fmt.Errorf("field %q has both a path and a query tag", f.Name) + case hasPath: + if pathKey == "" { + return nil, fmt.Errorf("field %q has an empty path tag", f.Name) + } + if f.Anonymous { + return nil, fmt.Errorf("field %q is embedded and cannot carry a path tag", f.Name) + } + out = append(out, argField{name: name, kind: argPath, key: pathKey}) + case hasQuery: + if queryKey == "" { + return nil, fmt.Errorf("field %q has an empty query tag", f.Name) + } + if f.Anonymous { + return nil, fmt.Errorf("field %q is embedded and cannot carry a query tag", f.Name) + } + out = append(out, argField{name: name, kind: argQuery, key: queryKey}) + default: + if f.Anonymous { + // jsonschema promotes an embedded struct's fields; they are + // body fields under their own names. + continue + } + out = append(out, argField{name: name, kind: argBody}) + } + } + return out, nil +} + +// patternSegments lists the {name} and {name...} segments of a route +// pattern in order; {$} is not a parameter. +func patternSegments(pattern string) []string { + var out []string + for _, seg := range strings.Split(pattern, "/") { + if len(seg) < 3 || seg[0] != '{' || seg[len(seg)-1] != '}' { + continue + } + name := strings.TrimSuffix(seg[1:len(seg)-1], "...") + if name == "$" || name == "" { + continue + } + out = append(out, name) + } + return out +} + +// annotationsFor derives the annotations from the HTTP method (D-14) with +// the declaration's overrides; the destructive and open-world hints are +// always explicit. +func annotationsFor(method string, t pact.MCPTool) *mcp.ToolAnnotations { + readOnly := method == http.MethodGet + if t.ReadOnly != nil { + readOnly = *t.ReadOnly + } + idempotent := method == http.MethodGet || method == http.MethodPut + if t.Idempotent != nil { + idempotent = *t.Idempotent + } + destructive := method == http.MethodDelete + if t.Destructive != nil { + destructive = *t.Destructive + } + openWorld := false + if t.OpenWorld != nil { + openWorld = *t.OpenWorld + } + return &mcp.ToolAnnotations{ + Title: t.Title, + ReadOnlyHint: readOnly, + IdempotentHint: idempotent, + DestructiveHint: &destructive, + OpenWorldHint: &openWorld, + } +} + +// compiledHandler resolves the route's compiled chain once, after the host +// has compiled its routes. +func (t *tool) compiledHandler(host Host) (http.Handler, bool) { + t.once.Do(func() { + t.handler, t.hasH = host.Handler(t.method, t.pattern) + }) + return t.handler, t.hasH +} + +// toolNames lists exposed tool names, sorted. +func toolNames(tools []*tool) []string { + out := make([]string, 0, len(tools)) + for _, t := range tools { + out = append(out, t.name) + } + sort.Strings(out) + return out +} diff --git a/modules/pier/config.go b/modules/pier/config.go new file mode 100644 index 00000000..cce6b27f --- /dev/null +++ b/modules/pier/config.go @@ -0,0 +1,262 @@ +package pier + +import ( + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "git.golem15.com/golem15/summercms/modules/compass" +) + +// Configuration keys of the MCP module, all under mcp.* (config/mcp.yaml or +// SUMMER_MCP__* environment variables). +const ( + // EnabledKey mounts the endpoint when true; false (the default) mounts + // nothing. surf reads it to call the mount hook. + EnabledKey = "mcp.enabled" + // PathKey is the endpoint path (POST, GET and DELETE), default /mcp. + PathKey = "mcp.path" + // AllowPersonalTokensKey lets personal API tokens (a frontend user token + // through the published resolver, an admin g15_ token through cabana) + // open sessions; false (the default) accepts OAuth access tokens only. + AllowPersonalTokensKey = "mcp.allow_personal_tokens" + // SessionStoreKey selects where session records live: memory (the + // default, one instance) or redis (shared through redis.url). + SessionStoreKey = "mcp.sessions.store" + // SessionIdleTTLKey is the sliding idle TTL of a session, default 30m. + SessionIdleTTLKey = "mcp.sessions.idle_ttl" + // SessionMaxPerBindingKey caps the open sessions of one binding (one + // client and user, or one personal token), default 20. + SessionMaxPerBindingKey = "mcp.sessions.max_per_binding" + // ResourceKey is the resource indicator the endpoint identifies itself + // by in the protected resource metadata; default app.url plus mcp.path. + ResourceKey = "mcp.resource" + // ThrottleKey is the inline throttle of the endpoint, surf's + // , spelling, default 120,1. + ThrottleKey = "mcp.throttle" + // AllowedOriginsKey lists the browser origins allowed to call the + // endpoint; default the origin of app.url. A request without an Origin + // header is never refused by it. + AllowedOriginsKey = "mcp.allowed_origins" + // CallTimeoutKey bounds one in-process tool call, default 30s. + CallTimeoutKey = "mcp.call_timeout" + // MaxResultBytesKey caps the route answer captured for a tool result, + // default 1048576 (1 MiB); a larger answer is a tool error. + MaxResultBytesKey = "mcp.max_result_bytes" +) + +// Defaults of the mcp.* keys. +const ( + DefaultPath = "/mcp" + DefaultSessionStore = "memory" + DefaultIdleTTL = 30 * time.Minute + DefaultMaxPerBinding = 20 + DefaultThrottle = "120,1" + DefaultCallTimeout = 30 * time.Second + DefaultMaxResultBytes = int64(1 << 20) +) + +// Session store names. +const ( + StoreMemory = "memory" + StoreRedis = "redis" +) + +// Config is the MCP module's configuration as LoadConfig reads it from the +// mcp.* keys, with defaults applied and every value validated. +type Config struct { + Enabled bool + Path string + AllowPersonalTokens bool + SessionStore string + IdleTTL time.Duration + MaxPerBinding int + Resource string + Throttle string + AllowedOrigins []string + CallTimeout time.Duration + MaxResultBytes int64 + // AppURL is app.url trimmed of its trailing slash, the base of the + // default Resource and of the protected resource metadata URL. + AppURL string +} + +// LoadConfig reads the mcp.* keys. A nil config is every default with the +// endpoint disabled. An invalid value fails with an error naming the key. +func LoadConfig(cfg *compass.Config) (Config, error) { + out := Config{ + Path: DefaultPath, + SessionStore: DefaultSessionStore, + IdleTTL: DefaultIdleTTL, + MaxPerBinding: DefaultMaxPerBinding, + Throttle: DefaultThrottle, + CallTimeout: DefaultCallTimeout, + MaxResultBytes: DefaultMaxResultBytes, + } + if cfg == nil { + return out, nil + } + out.Enabled = cfg.Bool(EnabledKey) + out.AllowPersonalTokens = cfg.Bool(AllowPersonalTokensKey) + out.AppURL = strings.TrimRight(strings.TrimSpace(cfg.String("app.url")), "/") + + path, err := endpointPath(cfg.String(PathKey)) + if err != nil { + return out, err + } + out.Path = path + + store := strings.ToLower(strings.TrimSpace(cfg.String(SessionStoreKey))) + switch store { + case "": + case StoreMemory, StoreRedis: + out.SessionStore = store + default: + return out, fmt.Errorf("pier: %s must be %s or %s, got %q", SessionStoreKey, StoreMemory, StoreRedis, store) + } + + if out.IdleTTL, err = durationKey(cfg, SessionIdleTTLKey, DefaultIdleTTL); err != nil { + return out, err + } + if out.CallTimeout, err = durationKey(cfg, CallTimeoutKey, DefaultCallTimeout); err != nil { + return out, err + } + if cfg.Has(SessionMaxPerBindingKey) { + n := cfg.Int(SessionMaxPerBindingKey) + if n < 1 { + return out, fmt.Errorf("pier: %s must be a whole number of at least 1", SessionMaxPerBindingKey) + } + out.MaxPerBinding = n + } + if cfg.Has(MaxResultBytesKey) { + n := cfg.Int(MaxResultBytesKey) + if n < 1 { + return out, fmt.Errorf("pier: %s must be a whole number of bytes of at least 1", MaxResultBytesKey) + } + out.MaxResultBytes = int64(n) + } + if raw := strings.TrimSpace(cfg.String(ThrottleKey)); raw != "" { + if err := validateThrottle(raw); err != nil { + return out, fmt.Errorf("pier: %s must be , with both at least 1 (for example 120,1), got %q", ThrottleKey, raw) + } + out.Throttle = raw + } + + if raw := strings.TrimSpace(cfg.String(ResourceKey)); raw != "" { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Fragment != "" || u.RawQuery != "" { + return out, fmt.Errorf("pier: %s must be an http or https URL without query or fragment, got %q", ResourceKey, raw) + } + out.Resource = strings.TrimRight(raw, "/") + } else if out.AppURL != "" { + out.Resource = out.AppURL + out.Path + } else if out.Enabled { + return out, fmt.Errorf("pier: %s or app.url is required when %s is true", ResourceKey, EnabledKey) + } + + origins, err := originsKey(cfg, AllowedOriginsKey) + if err != nil { + return out, err + } + if origins == nil && out.AppURL != "" { + if u, err := url.Parse(out.AppURL); err == nil && u.Scheme != "" && u.Host != "" { + origins = []string{u.Scheme + "://" + u.Host} + } + } + out.AllowedOrigins = origins + return out, nil +} + +// endpointPath validates mcp.path: an absolute URL path without scheme, +// host, query, wildcard or dot segments, with no trailing slash. +func endpointPath(raw string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return DefaultPath, nil + } + path := strings.TrimSuffix(value, "/") + bad := !strings.HasPrefix(path, "/") || strings.ContainsAny(path, "{}?# \t\r\n\\") || path == "" + if !bad { + for _, seg := range strings.Split(path[1:], "/") { + if seg == "" || seg == "." || seg == ".." { + bad = true + break + } + } + } + if bad { + return "", fmt.Errorf("pier: %s must be an absolute URL path such as /mcp (no scheme, host, query or wildcard), got %q", PathKey, raw) + } + return path, nil +} + +// durationKey reads a Go duration (30m, 45s) or a whole number of seconds. +func durationKey(cfg *compass.Config, key string, fallback time.Duration) (time.Duration, error) { + raw := strings.TrimSpace(cfg.String(key)) + if raw == "" { + return fallback, nil + } + d, err := time.ParseDuration(raw) + if err != nil { + n, nerr := strconv.Atoi(raw) + if nerr != nil { + return 0, fmt.Errorf("pier: %s must be a duration such as 30m or 45s, got %q", key, raw) + } + d = time.Duration(n) * time.Second + } + if d <= 0 { + return 0, fmt.Errorf("pier: %s must be a positive duration, got %q", key, raw) + } + return d, nil +} + +// validateThrottle checks surf's inline throttle spelling ,. +func validateThrottle(raw string) error { + nStr, mStr, ok := strings.Cut(raw, ",") + if !ok { + return fmt.Errorf("not ,") + } + n, errN := strconv.Atoi(strings.TrimSpace(nStr)) + m, errM := strconv.Atoi(strings.TrimSpace(mStr)) + if errN != nil || errM != nil || n < 1 || m < 1 { + return fmt.Errorf("not ,") + } + return nil +} + +// originsKey reads a YAML list or a comma-separated string of origins +// (scheme://host[:port]); nil when the key is absent or empty. +func originsKey(cfg *compass.Config, key string) ([]string, error) { + raw, ok := cfg.Lookup(key) + if !ok || raw == nil { + return nil, nil + } + var items []string + switch v := raw.(type) { + case []any: + for _, item := range v { + items = append(items, fmt.Sprint(item)) + } + case []string: + items = append(items, v...) + case string: + items = strings.Split(v, ",") + default: + return nil, fmt.Errorf("pier: %s must be a list of origins such as https://example.com", key) + } + var out []string + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + u, err := url.Parse(item) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" { + return nil, fmt.Errorf("pier: %s entries must be origins such as https://example.com, got %q", key, item) + } + out = append(out, strings.ToLower(u.Scheme+"://"+u.Host)) + } + return out, nil +} diff --git a/modules/pier/dispatch.go b/modules/pier/dispatch.go new file mode 100644 index 00000000..6353eb6d --- /dev/null +++ b/modules/pier/dispatch.go @@ -0,0 +1,351 @@ +package pier + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "time" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// resultTooLarge is the text of the tool error for a route answer above +// mcp.max_result_bytes. +const resultTooLarge = "pier: the route's answer exceeds mcp.max_result_bytes" + +// callInfo is what a dispatched call copies from the outer MCP request: the +// client address and the forwarding, real-ip and language headers, plus +// the session the call belongs to, for the audit line. +type callInfo struct { + remoteAddr string + forwardedFor string + realIP string + acceptLanguage string + session string +} + +// callInfoFrom reads callInfo off an HTTP request. +func callInfoFrom(r *http.Request) callInfo { + if r == nil { + return callInfo{} + } + return callInfo{ + remoteAddr: r.RemoteAddr, + forwardedFor: r.Header.Get("X-Forwarded-For"), + realIP: r.Header.Get("X-Real-IP"), + acceptLanguage: r.Header.Get("Accept-Language"), + } +} + +// dispatcher runs tool calls in-process through the host's compiled route +// chains (D-10, D-11, D-13, D-17). +type dispatcher struct { + host Host + app *backpack.App + cfg Config +} + +// call validates the arguments against the tool's resolved schema, splits +// them into path values, query string and body, builds the inner request +// without any Authorization or Cookie header, attaches the bouncer dispatch +// marker with the guard's pre-resolved principal, serves the compiled chain +// into a size-capped recorder and maps the answer: 2xx to text plus +// structured content, anything else to a tool error carrying the route's +// own body. Protocol-level failures (a tool the identity may not call, an +// unknown route) are tool errors too, so the agent can read them. +func (d *dispatcher) call(ctx context.Context, id Identity, info callInfo, t *tool, raw json.RawMessage) *mcp.CallToolResult { + start := time.Now() + res, status := d.run(ctx, id, info, t, raw) + logToolCall(logger(), id, t, info.session, status, time.Since(start)) + return res +} + +func (d *dispatcher) run(ctx context.Context, id Identity, info callInfo, t *tool, raw json.RawMessage) (*mcp.CallToolResult, int) { + if len(bytes.TrimSpace(raw)) == 0 { + raw = json.RawMessage("{}") + } + var parts map[string]json.RawMessage + if err := json.Unmarshal(raw, &parts); err != nil { + return toolError("pier: arguments must be a JSON object: " + err.Error()), 0 + } + var instance any + if err := json.Unmarshal(raw, &instance); err != nil { + return toolError("pier: arguments must be a JSON object: " + err.Error()), 0 + } + if err := t.resolved.Validate(instance); err != nil { + return toolError("pier: invalid arguments: " + err.Error()), 0 + } + pathValues := map[string]string{} + query := url.Values{} + for _, f := range t.fields { + v, ok := parts[f.name] + if !ok { + continue + } + switch f.kind { + case argPath: + s, err := scalarString(v) + if err != nil { + return toolError(fmt.Sprintf("pier: invalid arguments: %q must be a string or a number", f.name)), 0 + } + if s == "" || (strings.Contains(s, "/") && !strings.Contains(t.pattern, "{"+f.key+"...}")) { + return toolError(fmt.Sprintf("pier: invalid arguments: %q must be one non-empty path segment", f.name)), 0 + } + pathValues[f.key] = s + delete(parts, f.name) + case argQuery: + values, err := queryStrings(v) + if err != nil { + return toolError(fmt.Sprintf("pier: invalid arguments: %q must be a scalar or a list of scalars", f.name)), 0 + } + for _, s := range values { + query.Add(f.key, s) + } + delete(parts, f.name) + } + } + path := t.pattern + for key, value := range pathValues { + if strings.Contains(path, "{"+key+"...}") { + path = strings.ReplaceAll(path, "{"+key+"...}", escapePathSegments(value)) + } else { + path = strings.ReplaceAll(path, "{"+key+"}", url.PathEscape(value)) + } + } + target := path + if enc := query.Encode(); enc != "" { + target += "?" + enc + } + var body *bytes.Reader + if t.method != http.MethodGet && t.method != http.MethodDelete { + encoded, err := json.Marshal(parts) + if err != nil { + return toolError("pier: arguments could not be encoded: " + err.Error()), 0 + } + body = bytes.NewReader(encoded) + } + subject := id.subjectFor(t.guardName) + if subject == nil { + return toolError(fmt.Sprintf("pier: tool %q is not available to this caller", t.name)), 0 + } + handler, ok := t.compiledHandler(d.host) + if !ok { + return toolError(fmt.Sprintf("pier: tool %q is bound to %s %s, which is not compiled", t.name, t.method, t.pattern)), 0 + } + callCtx, cancel := context.WithTimeout(ctx, d.cfg.CallTimeout) + defer cancel() + disp, err := t.guard.MCPDispatch(callCtx, *subject, t.method) + if err != nil { + return toolError("pier: " + err.Error()), 0 + } + principal, ok := disp.Principal.(*bouncer.Principal) + if !ok || principal == nil { + return toolError(fmt.Sprintf("pier: guard %s did not resolve a principal", t.guardName)), 0 + } + credential := disp.Credential + if credential == nil && t.guardName == BackendGuard { + credential = id.credential + } + callCtx = bouncer.WithDispatch(callCtx, &bouncer.Dispatch{Guards: disp.Guards, Principal: principal, Credential: credential}) + + var req *http.Request + if body != nil { + req = httptest.NewRequestWithContext(callCtx, t.method, target, body) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequestWithContext(callCtx, t.method, target, nil) + } + req.Header.Set("Accept", "application/json") + if info.remoteAddr != "" { + req.RemoteAddr = info.remoteAddr + } + if info.forwardedFor != "" { + req.Header.Set("X-Forwarded-For", info.forwardedFor) + } + if info.realIP != "" { + req.Header.Set("X-Real-IP", info.realIP) + } + if info.acceptLanguage != "" { + req.Header.Set("Accept-Language", info.acceptLanguage) + } + for _, seg := range t.segments { + req.SetPathValue(seg, pathValues[seg]) + } + req.Pattern = t.method + " " + t.pattern + + rec := newCappedRecorder(d.cfg.MaxResultBytes) + handler.ServeHTTP(rec, req) + if rec.overflow { + return toolError(resultTooLarge), rec.rec.Code + } + return mapResult(rec.rec.Code, rec.rec.Body.Bytes()), rec.rec.Code +} + +// mapResult turns the route's status and body into the tool result (D-13): +// 2xx is text plus structured content (an object as is, any other JSON +// wrapped as {"data": v}, a non-JSON body text only); anything else is a +// tool error whose text is the route's body (or the status text when the +// body is empty) and whose structured content is the body when it is an +// object. +func mapResult(status int, body []byte) *mcp.CallToolResult { + text := string(body) + structured := structuredOf(body) + if status >= 200 && status < 300 { + res := &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}} + if structured != nil { + res.StructuredContent = structured + } else if v, ok := anyJSON(body); ok { + res.StructuredContent = map[string]any{"data": v} + } + return res + } + if strings.TrimSpace(text) == "" { + text = http.StatusText(status) + if text == "" { + text = "status " + strconv.Itoa(status) + } + } + res := toolError(text) + if structured != nil { + res.StructuredContent = structured + } + return res +} + +// toolError is a tool result with IsError set and one text content. +func toolError(text string) *mcp.CallToolResult { + return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: text}}} +} + +// structuredOf decodes body when it is a JSON object, else nil. +func structuredOf(body []byte) map[string]any { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 || trimmed[0] != '{' { + return nil + } + var m map[string]any + if err := json.Unmarshal(trimmed, &m); err != nil { + return nil + } + return m +} + +// anyJSON decodes body as any JSON value. +func anyJSON(body []byte) (any, bool) { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { + return nil, false + } + var v any + if err := json.Unmarshal(trimmed, &v); err != nil { + return nil, false + } + return v, true +} + +// scalarString renders a JSON string or number as a path value. +func scalarString(raw json.RawMessage) (string, error) { + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return "", err + } + switch x := v.(type) { + case string: + return x, nil + case float64: + return strconv.FormatFloat(x, 'f', -1, 64), nil + case bool: + return strconv.FormatBool(x), nil + } + return "", fmt.Errorf("not a scalar") +} + +// queryStrings renders a JSON scalar or list of scalars as query values. +func queryStrings(raw json.RawMessage) ([]string, error) { + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return nil, err + } + switch x := v.(type) { + case nil: + return nil, nil + case []any: + var out []string + for _, item := range x { + s, err := scalarAny(item) + if err != nil { + return nil, err + } + out = append(out, s) + } + return out, nil + default: + s, err := scalarAny(x) + if err != nil { + return nil, err + } + return []string{s}, nil + } +} + +func scalarAny(v any) (string, error) { + switch x := v.(type) { + case string: + return x, nil + case float64: + return strconv.FormatFloat(x, 'f', -1, 64), nil + case bool: + return strconv.FormatBool(x), nil + } + return "", fmt.Errorf("not a scalar") +} + +// escapePathSegments escapes a multi-segment value segment by segment. +func escapePathSegments(value string) string { + parts := strings.Split(value, "/") + for i, p := range parts { + parts[i] = url.PathEscape(p) + } + return strings.Join(parts, "/") +} + +// cappedRecorder records a route answer up to a byte limit; beyond it the +// rest is dropped and overflow is set, so a tool result is bounded. +type cappedRecorder struct { + rec *httptest.ResponseRecorder + limit int64 + written int64 + overflow bool +} + +func newCappedRecorder(limit int64) *cappedRecorder { + return &cappedRecorder{rec: httptest.NewRecorder(), limit: limit} +} + +func (c *cappedRecorder) Header() http.Header { return c.rec.Header() } + +func (c *cappedRecorder) WriteHeader(status int) { c.rec.WriteHeader(status) } + +func (c *cappedRecorder) Write(p []byte) (int, error) { + if c.overflow { + return len(p), nil + } + if c.written+int64(len(p)) > c.limit { + c.overflow = true + c.rec.Body.Reset() + return len(p), nil + } + c.written += int64(len(p)) + return c.rec.Write(p) +} + +// Flush is a no-op: the answer is read once the handler returns. +func (c *cappedRecorder) Flush() {} diff --git a/modules/pier/filter.go b/modules/pier/filter.go new file mode 100644 index 00000000..7dba696c --- /dev/null +++ b/modules/pier/filter.go @@ -0,0 +1,67 @@ +package pier + +import ( + "context" + + "git.golem15.com/golem15/summercms/modules/pact" +) + +// allowed computes the tools and prompts an identity may see and call: a +// tool needs a subject under its guard, the read/write ceiling hides +// non-read-only tools from an identity without write (D-20), and the +// guard provider's own check decides the rest (D-16, D-23). A provider +// error hides the item and is logged. The same function builds the +// per-identity server and re-checks every list and call for the current +// request's identity (D-08, D-25). +func (c *catalog) allowed(ctx context.Context, id Identity) ([]*tool, []*prompt) { + var tools []*tool + for _, t := range c.tools { + if c.allowsTool(ctx, id, t) { + tools = append(tools, t) + } + } + var prompts []*prompt + for _, p := range c.prompts { + if c.allowsPrompt(ctx, id, p) { + prompts = append(prompts, p) + } + } + return tools, prompts +} + +func (c *catalog) allowsTool(ctx context.Context, id Identity, t *tool) bool { + subject := id.subjectFor(t.guardName) + if subject == nil { + return false + } + if !t.readOnly && !id.writes() { + return false + } + ok, err := allows(ctx, t.guard, *subject, t.req, t.decl.Permissions, t.method) + if err != nil { + logger().Warn("pier: guard check failed", "tool", t.name, "guard", t.guardName, "error", err) + return false + } + return ok +} + +func (c *catalog) allowsPrompt(ctx context.Context, id Identity, p *prompt) bool { + subject := id.subjectFor(p.guardName) + if subject == nil { + return false + } + ok, err := allows(ctx, p.guard, *subject, pact.MCPRequirements{}, p.decl.Permissions, "") + if err != nil { + logger().Warn("pier: guard check failed", "prompt", p.name, "guard", p.guardName, "error", err) + return false + } + return ok +} + +// allows asks the provider, method-aware when it supports it. +func allows(ctx context.Context, g pact.MCPGuard, subject pact.MCPSubject, req pact.MCPRequirements, permissions []string, method string) (bool, error) { + if ma, ok := g.(methodAllower); ok && method != "" { + return ma.MCPAllowsMethod(ctx, subject, req, permissions, method) + } + return g.MCPAllows(ctx, subject, req, permissions) +} diff --git a/modules/pier/guards.go b/modules/pier/guards.go new file mode 100644 index 00000000..817dac0b --- /dev/null +++ b/modules/pier/guards.go @@ -0,0 +1,43 @@ +package pier + +import ( + "fmt" + "strings" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// guardRegistry returns the application's pact.MCPGuards registry, +// publishing an empty one when no plugin did (find-or-publish). +func guardRegistry(app *backpack.App) (*pact.MCPGuards, error) { + if reg, ok := app.Lookup[*pact.MCPGuards](); ok && reg != nil { + return reg, nil + } + reg := pact.NewMCPGuards() + if err := app.Publish(reg); err != nil { + return nil, fmt.Errorf("pier: publishing the guard registry: %w", err) + } + return reg, nil +} + +// frontendGuardName picks the guard provider an OAuth grant's user acts +// under: the one registered provider other than backend. None means OAuth +// identities have no frontend subject (backend tools only, through the +// link); more than one is a boot error, because a grant names no guard. +func frontendGuardName(reg *pact.MCPGuards) (string, error) { + var names []string + for _, name := range reg.Names() { + if name != BackendGuard { + names = append(names, name) + } + } + switch len(names) { + case 0: + return "", nil + case 1: + return names[0], nil + default: + return "", fmt.Errorf("pier: several frontend guard providers are registered (%s); an OAuth grant can act under exactly one", strings.Join(names, ", ")) + } +} diff --git a/modules/pier/host.go b/modules/pier/host.go new file mode 100644 index 00000000..d87f8b5f --- /dev/null +++ b/modules/pier/host.go @@ -0,0 +1,33 @@ +package pier + +import "net/http" + +// Host is what pier needs from the HTTP router that mounts it, so that pier +// never imports surf (surf imports pier in a binary built with the +// summercms_mcp tag and passes an adapter). Routes lists every registered +// route, Handler returns the compiled middleware chain of one route for an +// in-process dispatch (resolved lazily, after the router has compiled), +// MountStream registers one unbuffered route under the pier pseudo-plugin, +// MountJSON registers one plain GET route for a static JSON document and +// Owner reports the plugin that registered a route, for collision checks. +type Host interface { + Routes() []RouteInfo + Handler(method, pattern string) (http.Handler, bool) + MountStream(method, path string, h http.Handler, middleware ...string) error + MountJSON(method, path string, h http.Handler) error + Owner(method, path string) (string, bool) +} + +// RouteInfo is one registered route as the Host reports it: the HTTP method, +// the pattern as registered (with {param} segments), the plugin that +// registered it and its middleware names in order. +type RouteInfo struct { + Method string + Pattern string + PluginID string + Middleware []string +} + +// PluginID is the pseudo-plugin id pier's own routes are registered under in +// the route table. +const PluginID = "summercms.pier" diff --git a/modules/pier/identity.go b/modules/pier/identity.go new file mode 100644 index 00000000..fadb0f5a --- /dev/null +++ b/modules/pier/identity.go @@ -0,0 +1,279 @@ +package pier + +import ( + "context" + "errors" + "slices" + "strconv" + "strings" + "sync" + "time" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/cabana" + "git.golem15.com/golem15/summercms/modules/pact" +) + +// OAuthTokenPrefix is the prefix of the OAuth access tokens the published +// pact.MCPTokenVerifier verifies; every other bearer is a personal token. +const OAuthTokenPrefix = "oas_at_" + +// Token kinds of an Identity. +const ( + TokenKindOAuth = "oauth" + TokenKindPersonal = "pat" +) + +// LinkCacheTTL bounds how long a resolved backend link is reused on one +// instance before it is read again (P-07); a link event drops it at once. +const LinkCacheTTL = 5 * time.Second + +// ErrUnauthorized is what identity resolution answers for a missing, +// unknown, expired or revoked bearer; the endpoint turns it into a 401 with +// the protected resource metadata challenge. +var ErrUnauthorized = errors.New("pier: unauthorized") + +// Identity is who one MCP request acts as: the frontend subject (the OAuth +// grant's user or a frontend personal token), the backend subject (the +// linked admin when the grant carries the backend scope, or an admin +// personal token), the scopes of the credential, the session binding key +// (D-35: oauth::, pat:user: or +// pat:backend:), the credential's expiry and its kind. Every +// request resolves its own Identity from its bearer; a session never +// replaces it. +type Identity struct { + Frontend *pact.MCPSubject + Backend *pact.MCPSubject + Scopes []string + Binding string + ExpiresAt time.Time + TokenKind string + + // ceiling says the read/write ceiling (D-20) applies: OAuth grants and + // frontend personal tokens carry read and write; an admin personal + // token is capped through its cabana scope limit instead. + ceiling bool + // credential is the admin personal token row stored next to the + // backend principal on a dispatched call. + credential any +} + +// subjectFor returns the subject acting under guard, nil when the identity +// has none for it. +func (id Identity) subjectFor(guard string) *pact.MCPSubject { + if id.Backend != nil && id.Backend.Guard == guard { + return id.Backend + } + if id.Frontend != nil && id.Frontend.Guard == guard { + return id.Frontend + } + return nil +} + +// writes reports whether the identity may call tools that are not +// read-only: no ceiling, or the write scope granted. +func (id Identity) writes() bool { + return !id.ceiling || slices.Contains(id.Scopes, "write") +} + +// subjects lists the identity's subjects (frontend first). +func (id Identity) subjects() []pact.MCPSubject { + var out []pact.MCPSubject + if id.Frontend != nil { + out = append(out, *id.Frontend) + } + if id.Backend != nil { + out = append(out, *id.Backend) + } + return out +} + +// has reports whether the identity includes the subject (guard and user id). +func (id Identity) has(guard string, userID uint) bool { + for _, s := range id.subjects() { + if s.Guard == guard && s.UserID == userID { + return true + } + } + return false +} + +// backendTokenFunc resolves an admin personal token; tests inject one in +// place of cabana.ResolveBackendToken, which needs a database. +type backendTokenFunc func(ctx context.Context, app *backpack.App, raw string) (*bouncer.Principal, *cabana.BackendAPIToken, []string, error) + +// identityResolver turns a bearer into an Identity through the published +// verifier and resolvers, with the per-instance link cache. +type identityResolver struct { + app *backpack.App + cfg Config + frontendGuard string + backendTokens backendTokenFunc + + mu sync.Mutex + links map[uint]linkEntry + now func() time.Time +} + +type linkEntry struct { + link pact.LinkedBackendUser + ok bool + at time.Time +} + +func newIdentityResolver(app *backpack.App, cfg Config, frontendGuard string) *identityResolver { + return &identityResolver{ + app: app, + cfg: cfg, + frontendGuard: frontendGuard, + backendTokens: cabana.ResolveBackendToken, + links: map[uint]linkEntry{}, + now: time.Now, + } +} + +// resolve verifies raw afresh: an OAuth access token through the published +// pact.MCPTokenVerifier (missing verifier: unauthorized); with +// mcp.allow_personal_tokens, an admin g15_ token through cabana and any +// other prefix through the published pact.MCPPersonalTokenResolver. A +// backend subject for an OAuth grant exists only with the backend scope and +// a link the published pact.MCPLinkedBackendResolver confirms (D-08). +func (r *identityResolver) resolve(ctx context.Context, raw string) (Identity, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return Identity{}, ErrUnauthorized + } + if strings.HasPrefix(raw, OAuthTokenPrefix) { + return r.resolveOAuth(ctx, raw) + } + if !r.cfg.AllowPersonalTokens { + return Identity{}, ErrUnauthorized + } + if strings.HasPrefix(raw, cabana.APITokenPrefix) { + return r.resolveBackendToken(ctx, raw) + } + return r.resolvePersonalToken(ctx, raw) +} + +func (r *identityResolver) resolveOAuth(ctx context.Context, raw string) (Identity, error) { + verifier, ok := r.app.Lookup[pact.MCPTokenVerifier]() + if !ok || verifier == nil { + return Identity{}, ErrUnauthorized + } + grant, err := verifier.MCPVerifyToken(ctx, raw) + if err != nil || grant.UserID == 0 { + return Identity{}, ErrUnauthorized + } + scopes := append([]string(nil), grant.Scopes...) + id := Identity{ + Scopes: scopes, + Binding: "oauth:" + grant.ClientID + ":" + strconv.FormatUint(uint64(grant.UserID), 10), + ExpiresAt: grant.ExpiresAt, + TokenKind: TokenKindOAuth, + ceiling: true, + } + if r.frontendGuard != "" { + id.Frontend = &pact.MCPSubject{ + Guard: r.frontendGuard, + UserID: grant.UserID, + Scopes: scopes, + ClientID: grant.ClientID, + TokenID: grant.TokenID, + } + } + if slices.Contains(scopes, "backend") { + if link, ok := r.linkedBackend(ctx, grant.UserID); ok { + id.Backend = &pact.MCPSubject{ + Guard: BackendGuard, + UserID: link.ID, + Scopes: scopes, + ClientID: grant.ClientID, + TokenID: grant.TokenID, + } + } + } + return id, nil +} + +func (r *identityResolver) resolveBackendToken(ctx context.Context, raw string) (Identity, error) { + principal, row, scopes, err := r.backendTokens(ctx, r.app, raw) + if err != nil || principal == nil || row == nil { + return Identity{}, ErrUnauthorized + } + id := Identity{ + Scopes: append([]string(nil), scopes...), + Binding: "pat:backend:" + strconv.FormatUint(uint64(row.ID), 10), + TokenKind: TokenKindPersonal, + Backend: &pact.MCPSubject{ + Guard: BackendGuard, + UserID: principal.ID, + Scopes: append([]string(nil), scopes...), + TokenID: row.ID, + }, + credential: row, + } + if row.ExpiresAt != nil { + id.ExpiresAt = *row.ExpiresAt + } + return id, nil +} + +func (r *identityResolver) resolvePersonalToken(ctx context.Context, raw string) (Identity, error) { + resolver, ok := r.app.Lookup[pact.MCPPersonalTokenResolver]() + if !ok || resolver == nil { + return Identity{}, ErrUnauthorized + } + subject, ok, err := resolver.MCPResolvePersonalToken(ctx, raw) + if err != nil || !ok || subject.UserID == 0 { + return Identity{}, ErrUnauthorized + } + if subject.Guard == "" { + subject.Guard = r.frontendGuard + } + if subject.Guard == "" || subject.Guard == BackendGuard { + return Identity{}, ErrUnauthorized + } + return Identity{ + Frontend: &subject, + Scopes: append([]string(nil), subject.Scopes...), + Binding: "pat:user:" + strconv.FormatUint(uint64(subject.TokenID), 10), + TokenKind: TokenKindPersonal, + ceiling: true, + }, nil +} + +// linkedBackend answers the backend account linked to a frontend user +// through the published resolver, reusing an answer younger than +// LinkCacheTTL on this instance (P-07). +func (r *identityResolver) linkedBackend(ctx context.Context, userID uint) (pact.LinkedBackendUser, bool) { + resolver, ok := r.app.Lookup[pact.MCPLinkedBackendResolver]() + if !ok || resolver == nil { + return pact.LinkedBackendUser{}, false + } + now := r.now() + r.mu.Lock() + if e, ok := r.links[userID]; ok && now.Sub(e.at) < LinkCacheTTL { + r.mu.Unlock() + return e.link, e.ok + } + r.mu.Unlock() + link, found, err := resolver.LinkedBackendUser(ctx, userID) + if err != nil { + return pact.LinkedBackendUser{}, false + } + if link.ID == 0 { + found = false + } + r.mu.Lock() + r.links[userID] = linkEntry{link: link, ok: found, at: now} + r.mu.Unlock() + return link, found +} + +// forgetLink drops the cached link of one frontend user (a link event). +func (r *identityResolver) forgetLink(userID uint) { + r.mu.Lock() + delete(r.links, userID) + r.mu.Unlock() +} diff --git a/modules/pier/pier.go b/modules/pier/pier.go new file mode 100644 index 00000000..12f15d45 --- /dev/null +++ b/modules/pier/pier.go @@ -0,0 +1,275 @@ +// Package pier is the opt-in MCP endpoint of a SummerCMS application: the +// place where outside agents dock to reach the application's plugins. +// Plugins declare tools and prompts through pact.HasMCP, bound to their own +// token-guarded routes; pier lists them per caller and runs every call +// in-process through the route's compiled middleware chain as the caller's +// pre-resolved principal. It is linked only in binaries built with the +// summercms_mcp tag and mounted only with mcp.enabled true. +package pier + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "net/http" + "net/url" + "runtime/debug" + "strings" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/party" + "github.com/modelcontextprotocol/go-sdk/auth" +) + +// frameworkModule is the module path whose version names the server. +const frameworkModule = "git.golem15.com/golem15/summercms" + +// maxRequestBody bounds one MCP request body, as the SDK's default does. +const maxRequestBody = 4 << 20 + +// wellKnownPRM is the RFC 9728 well-known path. +const wellKnownPRM = "/.well-known/oauth-protected-resource" + +// Pier is the mounted MCP endpoint: its configuration, the compiled +// catalog, the identity resolver, the dispatcher, the session store and the +// stateful session router. +type Pier struct { + cfg Config + app *backpack.App + host Host + catalog *catalog + identities *identityResolver + dispatch *dispatcher + store Store + sessions *sessionRouter + handler http.Handler + prmURL string + appName string + version string + instance string +} + +// Mount reads the mcp.* keys and, when mcp.enabled is true, compiles every +// plugin's MCP declaration against the host's route table, registers the +// built-in backend guard provider, builds the session store and mounts +// POST, GET and DELETE at mcp.path through the host's streaming route kind +// behind the inline throttle. Nothing is mounted when the flag is off. A +// bad binding, a duplicate name, an unknown guard or a route already +// registered at mcp.path fails with an error naming the plugin, so boot +// (and route:list) fail. Nothing dials Redis here. +func Mount(host Host, app *backpack.App, plugins []party.Plugin) error { + if host == nil || app == nil { + return errors.New("pier: a host and an application are required") + } + cfg, err := LoadConfig(app.Config) + if err != nil { + return err + } + if !cfg.Enabled { + return nil + } + reg, err := guardRegistry(app) + if err != nil { + return err + } + if err := registerBackendGuard(app, reg); err != nil { + return err + } + cat, err := compileCatalog(host, reg, plugins) + if err != nil { + return err + } + frontend, err := frontendGuardName(reg) + if err != nil { + return err + } + p := &Pier{ + cfg: cfg, + app: app, + host: host, + catalog: cat, + store: newMemoryStore(), + appName: appName(app), + version: frameworkVersion(), + instance: rand.Text(), + } + p.identities = newIdentityResolver(app, cfg, frontend) + p.dispatch = &dispatcher{host: host, app: app, cfg: cfg} + p.sessions = newSessionRouter(p) + p.prmURL = p.metadataURL() + p.handler = p.originCheck(p.bodyLimit(p.bearer(p.sessions))) + + methods := []string{http.MethodPost, http.MethodGet, http.MethodDelete} + for _, m := range methods { + if owner, ok := host.Owner(m, cfg.Path); ok { + return fmt.Errorf("pier: route %s %s (plugin %q) collides with the MCP endpoint %s mounts", m, cfg.Path, owner, PathKey) + } + } + for _, m := range methods { + if err := host.MountStream(m, cfg.Path, p.handler, "throttle:"+cfg.Throttle); err != nil { + return err + } + } + return nil +} + +// Handler returns the endpoint's handler (the Origin check, the body limit, +// the bearer verification and the session router), for tests and hosts +// that serve it outside the route table. +func (p *Pier) Handler() http.Handler { + return p.handler +} + +// Config returns the loaded configuration. +func (p *Pier) Config() Config { + return p.cfg +} + +// ToolNames lists the exposed tool names of the compiled catalog, sorted. +func (p *Pier) ToolNames() []string { + return toolNames(p.catalog.tools) +} + +// metadataURL is the protected resource metadata URL named in the 401 +// challenge: /.well-known/oauth-protected-resource, or +// the resource's origin when app.url is unset. +func (p *Pier) metadataURL() string { + base := p.cfg.AppURL + if base == "" { + if u, err := url.Parse(p.cfg.Resource); err == nil && u.Scheme != "" && u.Host != "" { + base = u.Scheme + "://" + u.Host + } + } + return base + wellKnownPRM + p.cfg.Path +} + +// originCheck refuses a browser request whose Origin is not allowed (403 +// before any body is read); a request without Origin passes. +func (p *Pier) originCheck(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if origin := strings.TrimSpace(r.Header.Get("Origin")); origin != "" { + allowed := false + lower := strings.ToLower(origin) + for _, o := range p.cfg.AllowedOrigins { + if o == lower { + allowed = true + break + } + } + if !allowed { + http.Error(w, "Forbidden: origin not allowed", http.StatusForbidden) + return + } + } + next.ServeHTTP(w, r) + }) +} + +// bodyLimit caps the request body at maxRequestBody. +func (p *Pier) bodyLimit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody) + } + next.ServeHTTP(w, r) + }) +} + +// bearer verifies the bearer of every request afresh and stores the +// resolved Identity on the request for the session router and the SDK +// handlers; a failure is a 401 carrying WWW-Authenticate with the +// protected resource metadata URL and the read scope. +func (p *Pier) bearer(next http.Handler) http.Handler { + verify := func(ctx context.Context, token string, r *http.Request) (*auth.TokenInfo, error) { + id, err := p.identities.resolve(ctx, token) + if err != nil { + return nil, fmt.Errorf("%w: %v", auth.ErrInvalidToken, err) + } + return &auth.TokenInfo{ + Scopes: id.Scopes, + Expiration: id.ExpiresAt, + UserID: id.Binding, + Extra: map[string]any{extraIdentity: id, extraCall: callInfoFrom(r)}, + }, nil + } + h := auth.RequireBearerToken(verify, &auth.RequireBearerTokenOptions{ + ResourceMetadataURL: p.prmURL, + AllowMissingExpiration: true, + })(next) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(&challengeWriter{ResponseWriter: w}, r) + }) +} + +// challengeWriter completes the 401 challenge with scope="read", which the +// SDK middleware would otherwise enforce on every request. +type challengeWriter struct { + http.ResponseWriter +} + +func (c *challengeWriter) WriteHeader(status int) { + if status == http.StatusUnauthorized { + h := c.Header() + if v := h.Get("WWW-Authenticate"); v != "" && !strings.Contains(v, "scope=") { + h.Set("WWW-Authenticate", v+`, scope="read"`) + } + } + c.ResponseWriter.WriteHeader(status) +} + +func (c *challengeWriter) Flush() { + if f, ok := c.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +func (c *challengeWriter) Unwrap() http.ResponseWriter { return c.ResponseWriter } + +// identityFromRequest reads the Identity the bearer middleware stored. +func identityFromRequest(r *http.Request) (Identity, bool) { + ti := auth.TokenInfoFromContext(r.Context()) + if ti == nil || ti.Extra == nil { + return Identity{}, false + } + id, ok := ti.Extra[extraIdentity].(Identity) + return id, ok && id.Binding != "" +} + +// storeUnavailable answers 503 for a session store failure, logged once per +// call without the store's address. +func (p *Pier) storeUnavailable(w http.ResponseWriter, err error) { + logger().Error("pier: session store unavailable", "error", err) + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "session_store_unavailable"}) +} + +// appName is app.name, or "summercms" when unset. +func appName(app *backpack.App) string { + if app != nil && app.Config != nil { + if name := strings.TrimSpace(app.Config.String("app.name")); name != "" { + return name + } + } + return "summercms" +} + +// frameworkVersion is the framework module's version in the binary's build +// info, or "devel" for a replace or a checkout build. +func frameworkVersion() string { + info, ok := debug.ReadBuildInfo() + if !ok { + return "devel" + } + if info.Main.Path == frameworkModule && info.Main.Version != "" && info.Main.Version != "(devel)" { + return info.Main.Version + } + for _, dep := range info.Deps { + if dep.Path == frameworkModule { + if dep.Replace != nil || dep.Version == "" || dep.Version == "(devel)" { + return "devel" + } + return dep.Version + } + } + return "devel" +} diff --git a/modules/pier/session.go b/modules/pier/session.go new file mode 100644 index 00000000..200a5484 --- /dev/null +++ b/modules/pier/session.go @@ -0,0 +1,582 @@ +package pier + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "slices" + "sync" + "sync/atomic" + "time" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Protocol constants the router reads. +const ( + sessionIDHeader = "Mcp-Session-Id" + protocolVersionHeader = "Mcp-Protocol-Version" + protocolVersion202607 = "2026-07-28" + protocolVersion202511 = "2025-11-25" + protocolVersion202503 = "2025-03-26" + methodInitialize = "initialize" + methodListTools = "tools/list" + methodCallTool = "tools/call" + methodListPrompts = "prompts/list" + methodGetPrompt = "prompts/get" + methodDiscover = "server/discover" +) + +// Keys of auth.TokenInfo.Extra set by the bearer middleware. +const ( + extraIdentity = "pier.identity" + extraCall = "pier.call" +) + +// sessionRouter is the stateful streamable HTTP path (D-25): sessions live +// in the Store, bound to the identity that opened them, and are served by +// a per-session SDK server on whichever instance receives the request, +// rehydrated from the stored session state when it is not local. +type sessionRouter struct { + p *Pier + mu sync.Mutex + local map[string]*localSession +} + +// localSession is one session connected on this instance. +type localSession struct { + id string + binding string + created time.Time + server *mcp.Server + transport *mcp.StreamableServerTransport + ss *mcp.ServerSession + + mu sync.Mutex + identity Identity + tools map[string]bool + prompts map[string]bool + + initialized atomic.Bool + persisted atomic.Bool +} + +func newSessionRouter(p *Pier) *sessionRouter { + return &sessionRouter{p: p, local: map[string]*localSession{}} +} + +func (sr *sessionRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { + id, ok := identityFromRequest(r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + sid := r.Header.Get(sessionIDHeader) + switch r.Method { + case http.MethodDelete: + sr.serveDelete(w, r, id, sid) + case http.MethodGet: + if sid == "" { + http.Error(w, "Bad Request: GET requires an Mcp-Session-Id header", http.StatusBadRequest) + return + } + ls, ok := sr.lookup(w, r, id, sid) + if !ok { + return + } + sr.serveStream(w, r, ls) + case http.MethodPost: + if sid == "" { + sr.serveNew(w, r, id) + return + } + ls, ok := sr.lookup(w, r, id, sid) + if !ok { + return + } + ls.transport.ServeHTTP(w, r) + sr.persist(r.Context(), ls) + default: + w.Header().Set("Allow", "GET, POST, DELETE") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + } +} + +// serveNew handles a POST without a session id: an initialize opens a +// session (refused with 429 above mcp.sessions.max_per_binding); any other +// request is served by a throwaway connection and closed, as the SDK does, +// so a server/discover probe or a ping before initialize gets an answer. +func (sr *sessionRouter) serveNew(w http.ResponseWriter, r *http.Request, id Identity) { + body, err := io.ReadAll(r.Body) + if err != nil { + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + http.Error(w, fmt.Sprintf("request body exceeds %d bytes", mbe.Limit), http.StatusRequestEntityTooLarge) + return + } + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + _ = r.Body.Close() + r.Body = io.NopCloser(bytes.NewReader(body)) + if !slices.Contains(peekMethods(body), methodInitialize) { + sr.serveEphemeral(w, r, id) + return + } + ctx := r.Context() + n, err := sr.p.store.Count(ctx, id.Binding) + if err != nil { + sr.p.storeUnavailable(w, err) + return + } + if n >= sr.p.cfg.MaxPerBinding { + writeJSON(w, http.StatusTooManyRequests, map[string]any{"error": "too_many_sessions", "max_per_binding": sr.p.cfg.MaxPerBinding}) + return + } + sid := rand.Text() + sr.mu.Lock() + ls, err := sr.connect(ctx, sid, id, nil) + if err == nil { + sr.local[sid] = ls + } + sr.mu.Unlock() + if err != nil { + logger().Error("pier: session connect failed", "error", err) + http.Error(w, "failed connection", http.StatusInternalServerError) + return + } + if err := sr.p.store.Put(ctx, sid, Record{Binding: id.Binding, Created: ls.created}, sr.p.cfg.IdleTTL); err != nil { + sr.closeLocal(sid) + sr.p.storeUnavailable(w, err) + return + } + logSession(logger(), "open", sid, id.Binding) + ls.transport.ServeHTTP(w, r) + if ls.ss.InitializeParams() == nil { + // initialize failed: nothing to keep (the SDK cleans up the same way). + _ = sr.p.store.Delete(ctx, sid) + sr.closeLocal(sid) + return + } + sr.persist(ctx, ls) +} + +// serveEphemeral answers one request outside any session with the state +// the SDK synthesises for a pre-initialize request. +func (sr *sessionRouter) serveEphemeral(w http.ResponseWriter, r *http.Request, id Identity) { + pv := r.Header.Get(protocolVersionHeader) + if pv == "" || pv >= protocolVersion202607 { + pv = protocolVersion202503 + } + srv, _, _ := sr.p.newServer(r.Context(), id, nil) + tr := &mcp.StreamableServerTransport{} + state := &mcp.ServerSessionState{ + InitializeParams: &mcp.InitializeParams{ProtocolVersion: pv}, + InitializedParams: &mcp.InitializedParams{}, + LogLevel: mcp.LoggingLevel("info"), + } + ss, err := srv.Connect(r.Context(), tr, &mcp.ServerSessionOptions{State: state}) + if err != nil { + http.Error(w, "failed connection", http.StatusInternalServerError) + return + } + defer ss.Close() + tr.ServeHTTP(w, r) +} + +// serveDelete ends a session everywhere: the record is removed, every +// instance is told and the local copy is closed. +func (sr *sessionRouter) serveDelete(w http.ResponseWriter, r *http.Request, id Identity, sid string) { + if sid == "" { + http.Error(w, "Bad Request: DELETE requires an Mcp-Session-Id header", http.StatusBadRequest) + return + } + ctx := r.Context() + rec, ok, err := sr.p.store.Get(ctx, sid) + if err != nil { + sr.p.storeUnavailable(w, err) + return + } + if !ok { + http.Error(w, "session not found", http.StatusNotFound) + return + } + if rec.Binding != id.Binding { + logSession(logger(), "binding_refused", sid, id.Binding) + writeJSON(w, http.StatusForbidden, map[string]any{"error": "session_binding"}) + return + } + if err := sr.p.store.Delete(ctx, sid); err != nil { + sr.p.storeUnavailable(w, err) + return + } + _ = sr.p.store.Publish(ctx, Event{Kind: EventEnded, SessionID: sid, Instance: sr.p.instance}) + sr.closeLocal(sid) + logSession(logger(), "end", sid, id.Binding) + w.WriteHeader(http.StatusNoContent) +} + +// lookup finds the session of a request: 404 when the record is gone (the +// client re-initialises), 403 when the binding differs, else the local +// copy, rehydrated from the stored state when this instance has none; the +// record's TTL slides on every request. +func (sr *sessionRouter) lookup(w http.ResponseWriter, r *http.Request, id Identity, sid string) (*localSession, bool) { + ctx := r.Context() + rec, ok, err := sr.p.store.Get(ctx, sid) + if err != nil { + sr.p.storeUnavailable(w, err) + return nil, false + } + if !ok { + sr.closeLocal(sid) + http.Error(w, "session not found", http.StatusNotFound) + return nil, false + } + if rec.Binding != id.Binding { + logSession(logger(), "binding_refused", sid, id.Binding) + writeJSON(w, http.StatusForbidden, map[string]any{"error": "session_binding"}) + return nil, false + } + sr.mu.Lock() + ls := sr.local[sid] + if ls == nil { + if len(rec.State) == 0 { + sr.mu.Unlock() + http.Error(w, "session not found", http.StatusNotFound) + return nil, false + } + var state mcp.ServerSessionState + if err := json.Unmarshal(rec.State, &state); err != nil || state.InitializeParams == nil { + sr.mu.Unlock() + http.Error(w, "session not found", http.StatusNotFound) + return nil, false + } + ls, err = sr.connect(ctx, sid, id, &state) + if err != nil { + sr.mu.Unlock() + logger().Error("pier: session rehydrate failed", "error", err) + http.Error(w, "failed connection", http.StatusInternalServerError) + return nil, false + } + ls.created = rec.Created + if state.InitializedParams != nil { + ls.persisted.Store(true) + } + sr.local[sid] = ls + logSession(logger(), "rehydrate", sid, id.Binding) + } + sr.mu.Unlock() + ls.mu.Lock() + ls.identity = id + ls.mu.Unlock() + if err := sr.p.store.Touch(ctx, sid, sr.p.cfg.IdleTTL); err != nil { + sr.p.storeUnavailable(w, err) + return nil, false + } + return ls, true +} + +// connect builds the per-identity server and connects it over a transport +// with the session id, from the stored state when rehydrating. A goroutine +// drops the local entry when the session closes. +func (sr *sessionRouter) connect(ctx context.Context, sid string, id Identity, state *mcp.ServerSessionState) (*localSession, error) { + ls := &localSession{id: sid, binding: id.Binding, created: time.Now().UTC(), identity: id} + srv, tools, prompts := sr.p.newServer(ctx, id, func() { ls.initialized.Store(true) }) + ls.server, ls.tools, ls.prompts = srv, tools, prompts + ls.transport = &mcp.StreamableServerTransport{SessionID: sid} + var opts *mcp.ServerSessionOptions + if state != nil { + opts = &mcp.ServerSessionOptions{State: state} + if state.InitializedParams != nil { + ls.initialized.Store(true) + } + } + ss, err := srv.Connect(context.WithoutCancel(ctx), ls.transport, opts) + if err != nil { + return nil, err + } + ls.ss = ss + go func() { + _ = ss.Wait() + sr.mu.Lock() + if sr.local[sid] == ls { + delete(sr.local, sid) + } + sr.mu.Unlock() + }() + return ls, nil +} + +// persist writes the session state once initialize answered and again once +// the client's initialized notification arrived, so another instance can +// rehydrate a usable session. +func (sr *sessionRouter) persist(ctx context.Context, ls *localSession) { + if ls.persisted.Load() { + return + } + params := ls.ss.InitializeParams() + if params == nil { + return + } + state := mcp.ServerSessionState{ + InitializeParams: params, + NegotiatedProtocolVersion: negotiatedVersion(params.ProtocolVersion), + LogLevel: mcp.LoggingLevel("info"), + } + complete := ls.initialized.Load() + if complete { + state.InitializedParams = &mcp.InitializedParams{} + } + raw, err := json.Marshal(state) + if err != nil { + return + } + if err := sr.p.store.Put(ctx, ls.id, Record{Binding: ls.binding, State: raw, Created: ls.created}, sr.p.cfg.IdleTTL); err != nil { + logger().Warn("pier: session state not stored", "session", sessionPrefix(ls.id), "error", err) + return + } + if complete { + ls.persisted.Store(true) + } +} + +// serveStream serves the standalone GET stream. +func (sr *sessionRouter) serveStream(w http.ResponseWriter, r *http.Request, ls *localSession) { + ls.transport.ServeHTTP(w, r) +} + +// closeLocal closes and forgets this instance's copy of a session. +func (sr *sessionRouter) closeLocal(sid string) { + sr.mu.Lock() + ls := sr.local[sid] + delete(sr.local, sid) + sr.mu.Unlock() + if ls != nil && ls.ss != nil { + _ = ls.ss.Close() + } +} + +// negotiatedVersion mirrors the SDK's handshake rule: the client's version +// when the SDK supports it and it predates 2026-07-28, else the newest +// handshake-era version. +func negotiatedVersion(client string) string { + supported := mcp.SupportedProtocolVersions() + if slices.Contains(supported, client) && client < protocolVersion202607 { + return client + } + for _, v := range supported { + if v < protocolVersion202607 { + return v + } + } + return protocolVersion202511 +} + +// peekMethods lists the JSON-RPC methods of a request body (one message or +// a batch) without consuming it; a malformed body lists none. +func peekMethods(body []byte) []string { + type msg struct { + Method string `json:"method"` + } + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { + return nil + } + if trimmed[0] == '[' { + var batch []msg + if err := json.Unmarshal(trimmed, &batch); err != nil { + return nil + } + out := make([]string, 0, len(batch)) + for _, m := range batch { + out = append(out, m.Method) + } + return out + } + var m msg + if err := json.Unmarshal(trimmed, &m); err != nil { + return nil + } + return []string{m.Method} +} + +// newServer builds one SDK server for an identity: the tools and prompts +// allowed(...) grants it, the receiving middleware that re-checks every +// list and call for the current request's identity, and the handlers that +// dispatch calls and render prompts. It returns the server and the names +// it holds, for later diffs. +func (p *Pier) newServer(ctx context.Context, id Identity, onInitialized func()) (*mcp.Server, map[string]bool, map[string]bool) { + opts := &mcp.ServerOptions{ + Capabilities: &mcp.ServerCapabilities{ + Tools: &mcp.ToolCapabilities{ListChanged: true}, + Prompts: &mcp.PromptCapabilities{ListChanged: true}, + }, + } + if onInitialized != nil { + opts.InitializedHandler = func(context.Context, *mcp.InitializedRequest) { onInitialized() } + } + srv := mcp.NewServer(&mcp.Implementation{Name: p.appName, Version: p.version}, opts) + srv.AddReceivingMiddleware(p.receiving(id)) + tools, prompts := p.catalog.allowed(ctx, id) + toolSet := map[string]bool{} + for _, t := range tools { + srv.AddTool(t.desc, p.toolHandler(t, id)) + toolSet[t.name] = true + } + promptSet := map[string]bool{} + for _, pr := range prompts { + srv.AddPrompt(pr.desc, p.promptHandler(pr)) + promptSet[pr.name] = true + } + return srv, toolSet, promptSet +} + +// receiving is the per-request re-check (D-08, D-25): tools/list and +// prompts/list are filtered for the identity of the request that carried +// them, and tools/call or prompts/get for an item outside that list is a +// JSON-RPC invalid-params error with a fixed text. +func (p *Pier) receiving(session Identity) mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch method { + case methodListTools, methodListPrompts, methodCallTool, methodGetPrompt: + default: + return next(ctx, method, req) + } + id, _ := identityFromExtra(req.GetExtra(), session) + tools, prompts := p.catalog.allowed(ctx, id) + switch method { + case methodCallTool: + params, _ := req.GetParams().(*mcp.CallToolParamsRaw) + if params == nil || !containsTool(tools, params.Name) { + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "pier: tool is not available to this caller"} + } + return next(ctx, method, req) + case methodGetPrompt: + params, _ := req.GetParams().(*mcp.GetPromptParams) + if params == nil || !containsPrompt(prompts, params.Name) { + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "pier: prompt is not available to this caller"} + } + return next(ctx, method, req) + case methodListTools: + res, err := next(ctx, method, req) + if err != nil { + return nil, err + } + if lr, ok := res.(*mcp.ListToolsResult); ok { + kept := make([]*mcp.Tool, 0, len(lr.Tools)) + for _, t := range lr.Tools { + if containsTool(tools, t.Name) { + kept = append(kept, t) + } + } + lr.Tools = kept + } + return res, nil + default: + res, err := next(ctx, method, req) + if err != nil { + return nil, err + } + if lr, ok := res.(*mcp.ListPromptsResult); ok { + kept := make([]*mcp.Prompt, 0, len(lr.Prompts)) + for _, pr := range lr.Prompts { + if containsPrompt(prompts, pr.Name) { + kept = append(kept, pr) + } + } + lr.Prompts = kept + } + return res, nil + } + } + } +} + +func containsTool(tools []*tool, name string) bool { + for _, t := range tools { + if t.name == name { + return true + } + } + return false +} + +func containsPrompt(prompts []*prompt, name string) bool { + for _, p := range prompts { + if p.name == name { + return true + } + } + return false +} + +// toolHandler dispatches one call as the current request's identity. +func (p *Pier) toolHandler(t *tool, session Identity) mcp.ToolHandler { + return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id, info := identityFromExtra(req.Extra, session) + if req.Session != nil { + info.session = req.Session.ID() + } + return p.dispatch.call(ctx, id, info, t, req.Params.Arguments), nil + } +} + +// promptHandler checks the required arguments and renders the prompt. +func (p *Pier) promptHandler(pr *prompt) mcp.PromptHandler { + return func(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + args := map[string]string{} + if req.Params != nil { + for k, v := range req.Params.Arguments { + args[k] = v + } + } + for _, a := range pr.decl.Arguments { + if a.Required && args[a.Name] == "" { + return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: fmt.Sprintf("pier: prompt argument %q is required", a.Name)} + } + } + messages, err := pr.decl.Render(ctx, args) + if err != nil { + return nil, err + } + res := &mcp.GetPromptResult{Description: pr.decl.Description, Messages: []*mcp.PromptMessage{}} + for _, m := range messages { + role := mcp.Role(m.Role) + if role != "assistant" { + role = "user" + } + res.Messages = append(res.Messages, &mcp.PromptMessage{Role: role, Content: &mcp.TextContent{Text: m.Text}}) + } + return res, nil + } +} + +// identityFromExtra reads the request's identity and call info off the +// token info the bearer middleware stored; the session's identity is the +// fallback for a request that carries none. +func identityFromExtra(extra *mcp.RequestExtra, fallback Identity) (Identity, callInfo) { + if extra == nil || extra.TokenInfo == nil || extra.TokenInfo.Extra == nil { + return fallback, callInfo{} + } + id, _ := extra.TokenInfo.Extra[extraIdentity].(Identity) + info, _ := extra.TokenInfo.Extra[extraCall].(callInfo) + if id.Binding == "" { + return fallback, info + } + return id, info +} + +// writeJSON writes a small JSON body with a status. +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/modules/pier/store.go b/modules/pier/store.go new file mode 100644 index 00000000..1a166389 --- /dev/null +++ b/modules/pier/store.go @@ -0,0 +1,46 @@ +package pier + +import ( + "context" + "encoding/json" + "time" +) + +// Record is one stored session: the binding key of the identity that opened +// it, the JSON of the SDK's ServerSessionState once initialised (nil until +// then) and the creation time. +type Record struct { + Binding string `json:"binding"` + State json.RawMessage `json:"state,omitempty"` + Created time.Time `json:"created"` +} + +// Event is what instances publish to each other: a session ended by DELETE +// (EventEnded, with SessionID) or a subject change (the pact +// MCPSubjectChanged kinds, with Guard and UserID). Instance names the +// publisher so it can skip its own echo when it has already acted. +type Event struct { + Kind string `json:"kind"` + Guard string `json:"guard,omitempty"` + UserID uint `json:"user_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + Instance string `json:"instance,omitempty"` +} + +// EventEnded is the kind of a session-ended event. +const EventEnded = "ended" + +// Store is the session registry and the event channel shared by every +// instance of the application: memory for one instance (and tests), Redis +// for several. Put writes a record with a TTL, Touch slides it, Count +// answers the live sessions of one binding, Publish and Subscribe carry +// Events. +type Store interface { + Put(ctx context.Context, id string, rec Record, ttl time.Duration) error + Get(ctx context.Context, id string) (Record, bool, error) + Touch(ctx context.Context, id string, ttl time.Duration) error + Delete(ctx context.Context, id string) error + Count(ctx context.Context, binding string) (int, error) + Publish(ctx context.Context, ev Event) error + Subscribe(ctx context.Context, fn func(Event)) error +} diff --git a/modules/pier/store_memory.go b/modules/pier/store_memory.go new file mode 100644 index 00000000..39425e34 --- /dev/null +++ b/modules/pier/store_memory.go @@ -0,0 +1,103 @@ +package pier + +import ( + "context" + "sync" + "time" +) + +// memoryStore keeps records in the process with lazy expiry and fans events +// out to in-process subscribers; the default store, for one instance. +type memoryStore struct { + mu sync.Mutex + records map[string]memoryRecord + subs []func(Event) + now func() time.Time +} + +type memoryRecord struct { + rec Record + expires time.Time +} + +func newMemoryStore() *memoryStore { + return &memoryStore{records: map[string]memoryRecord{}, now: time.Now} +} + +func (m *memoryStore) sweepLocked() { + now := m.now() + for id, r := range m.records { + if !r.expires.After(now) { + delete(m.records, id) + } + } +} + +func (m *memoryStore) Put(_ context.Context, id string, rec Record, ttl time.Duration) error { + m.mu.Lock() + defer m.mu.Unlock() + m.sweepLocked() + m.records[id] = memoryRecord{rec: rec, expires: m.now().Add(ttl)} + return nil +} + +func (m *memoryStore) Get(_ context.Context, id string) (Record, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + r, ok := m.records[id] + if !ok || !r.expires.After(m.now()) { + delete(m.records, id) + return Record{}, false, nil + } + return r.rec, true, nil +} + +func (m *memoryStore) Touch(_ context.Context, id string, ttl time.Duration) error { + m.mu.Lock() + defer m.mu.Unlock() + r, ok := m.records[id] + if !ok || !r.expires.After(m.now()) { + delete(m.records, id) + return nil + } + r.expires = m.now().Add(ttl) + m.records[id] = r + return nil +} + +func (m *memoryStore) Delete(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.records, id) + return nil +} + +func (m *memoryStore) Count(_ context.Context, binding string) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.sweepLocked() + n := 0 + for _, r := range m.records { + if r.rec.Binding == binding { + n++ + } + } + return n, nil +} + +func (m *memoryStore) Publish(_ context.Context, ev Event) error { + m.mu.Lock() + subs := append([]func(Event){}, m.subs...) + m.mu.Unlock() + for _, fn := range subs { + go fn(ev) + } + return nil +} + +func (m *memoryStore) Subscribe(_ context.Context, fn func(Event)) error { + m.mu.Lock() + defer m.mu.Unlock() + m.subs = append(m.subs, fn) + return nil +} diff --git a/modules/pier/tracer_smoke_test.go b/modules/pier/tracer_smoke_test.go new file mode 100644 index 00000000..aabd8777 --- /dev/null +++ b/modules/pier/tracer_smoke_test.go @@ -0,0 +1,380 @@ +package pier_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "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 +} + +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.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} +} + +// acmeGuard is the fake guard provider for "acme": token-guarded by +// acme.auth, allows everything, dispatches as the subject's user id. +type acmeGuard struct{} + +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 (acmeGuard) MCPAllows(context.Context, pact.MCPSubject, pact.MCPRequirements, []string) (bool, error) { + return true, 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. +type acmeVerifier struct{} + +func (acmeVerifier) MCPVerifyToken(_ context.Context, raw string) (pact.MCPGrant, error) { + if raw != "oas_at_test" { + return pact.MCPGrant{}, pact.ErrMCPTokenInvalid + } + return pact.MCPGrant{UserID: 7, ClientID: "c1", TokenID: 1, Scopes: []string{"read", "write"}, ExpiresAt: time.Now().Add(time.Hour)}, nil +} + +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 +} + +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, publishes the +// fake verifier and guard registry, builds the router with the MCP flag +// off (so no tag is needed) and then enables it for pier.Mount. +func pierApp(t *testing.T, mcpYAML string) *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": mcpYAML, + } + 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) + } + reg := pact.NewMCPGuards() + if err := reg.Register(acmeGuard{}); 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{}} + return host, pier.Mount(host, app, plugins) +} + +// 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"}}, + } + cs, err := client.Connect(ctx, transport, nil) + 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)) + } +} diff --git a/modules/surf/pier_on.go b/modules/surf/pier_on.go new file mode 100644 index 00000000..c160f27e --- /dev/null +++ b/modules/surf/pier_on.go @@ -0,0 +1,68 @@ +//go:build summercms_mcp + +package surf + +import ( + "fmt" + "net/http" + "strings" + + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/pier" +) + +// Binaries built with the summercms_mcp tag (MCPBuildTag) link the MCP +// module: this file sets the mount hook BuildRouter calls when mcp.enabled +// is true, with an adapter that gives pier the route table, the compiled +// handler lookup and the two mount kinds without pier importing surf. +func init() { + mountPier = func(r *Router, app *backpack.App, plugins []party.Plugin) error { + return pier.Mount(pierHost{r: r}, app, plugins) + } +} + +// pierHost adapts a Router to pier.Host. +type pierHost struct { + r *Router +} + +func (h pierHost) Routes() []pier.RouteInfo { + routes := h.r.Routes() + out := make([]pier.RouteInfo, len(routes)) + for i, ri := range routes { + out[i] = pier.RouteInfo{Method: ri.Method, Pattern: ri.Pattern, PluginID: ri.PluginID, Middleware: ri.Middleware} + } + return out +} + +func (h pierHost) Handler(method, pattern string) (http.Handler, bool) { + return h.r.Handler(method, pattern) +} + +func (h pierHost) MountStream(method, path string, handler http.Handler, middleware ...string) error { + h.r.BindPlugin(pier.PluginID) + return h.r.MountStream(method, path, handler, middleware...) +} + +// MountJSON registers a plain GET route (no middleware) under the pier +// pseudo-plugin; only GET is supported. +func (h pierHost) MountJSON(method, path string, handler http.Handler) error { + if strings.ToUpper(strings.TrimSpace(method)) != http.MethodGet { + return fmt.Errorf("surf: pier may mount JSON routes with GET only, got %q", method) + } + if owner, ok := h.Owner(http.MethodGet, path); ok { + return fmt.Errorf("surf: route GET %s (plugin %q) collides with a route the MCP module mounts", path, owner) + } + h.r.BindPlugin(pier.PluginID) + h.r.GroupRaw("", nil, func(g pact.Router) { + g.Get(path, handler.ServeHTTP) + }) + return nil +} + +func (h pierHost) Owner(method, path string) (string, bool) { + owner, ok := h.r.seen[strings.ToUpper(strings.TrimSpace(method))+" "+path] + return owner, ok +}