Files
summercms/modules/pier/README.md
Jakub Zych 240457291f fix(pier): widen returning identities, warm the Redis store, end stateless listen streams, refuse personal tokens without a TokenID
- Identity.fingerprint covers scopes, both subjects, the ceiling and the
  token kind; a stateful lookup or stateless hit whose fingerprint changed
  re-filters the server with the event diff under its own mutex (A-WR-04)
- Mount warms a Redis store in the background with a 1 s to 30 s backoff
  and every request kicks a pending open without waiting, so stateless-only
  instances subscribe to fan-out (A-WR-05)
- a stateless subscriptions/listen POST re-verifies its bearer every
  recheck interval; an LRU-evicted stateless server closes its sessions
  (A-WR-06)
- resolvePersonalToken refuses a subject with TokenID 0, and the
  pact.MCPPersonalTokenResolver contract says one resolver per application,
  false means unauthorized and TokenID keys the binding (A-WR-09, A-IN-03)
2026-10-23 15:59:54 +02:00

147 lines
20 KiB
Markdown

# 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 shares sessions across instances and fans subject events out to every instance, so a permission or link change pushes `notifications/tools/list_changed` to the sessions it concerns and a revocation ends them everywhere. Clients of protocol 2026-07-28 and later, which have no sessions, are served statelessly from the same path by the SDK's stateless handler over per-identity servers, sharing identity, dispatch and filtering with the stateful path.
## 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:<client_id>:<user_id>`, `pat:user:<token_id>` or `pat:backend:<token_id>` survives access-token rotation. One `pact.MCPPersonalTokenResolver` serves the application; a subject it answers without a `TokenID` is refused, so personal-token users never share a binding.
- 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 (D-25, D-27): `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`. A standalone GET stream re-verifies its bearer every minute and ends when it no longer resolves to the session's binding or the session's record is gone.
- Session reaper (A-CR-01): most clients exit without DELETE, so a janitor started at mount checks every local session against the store every `min(mcp.sessions.idle_ttl/2, 1m)` and closes each local copy whose record has expired (its SDK server session ends and it leaves the fan-out set), logging the `expired` step. The store's TTL decides on every instance: a session another instance keeps alive still has its record, so no instance closes it. No configuration key; the reaper runs for the life of the process.
- Session stores: `memory` (one instance; records in the process) or `redis` (records `<prefix>mcp:s:<id>` with `PX` TTLs, per-binding sets `<prefix>mcp:b:<binding>`, events on channel `<prefix>mcp:events`, under `cooler.KeyPrefix`); Mount never waits for Redis: a background warm-up opens the client from `redis.url` with a backoff (one second doubling to thirty, each failure logged) and subscribes to the event channel, so an instance that serves only stateless clients still receives fan-out, and the first request on any path also starts the open without waiting for it; while the store is down stateful requests answer 503 and stateless requests are served.
- Fan-out (D-26): every `pact.MCPSubjectChanged` fired on the application is published through the store and applied on every instance: a permission or link change recomputes the tools and prompts of the subject's sessions and stateless servers (the SDK then sends `notifications/tools/list_changed` and `notifications/prompts/list_changed`), a revocation or deletion closes those sessions, drops their records and evicts the stateless servers; a link event also drops the link cache at once.
- Hybrid protocol (D-32): a request whose `Mcp-Protocol-Version` is 2026-07-28 or later, or whose first JSON-RPC method is `server/discover`, goes to the SDK's stateless handler over an in-process LRU of per-identity servers keyed by binding (capacity `mcp.sessions.max_per_binding` times 16); every other request goes to the stateful router. Both run behind the same bearer verification. A server evicted from the LRU closes its sessions, and a stateless `subscriptions/listen` stream re-verifies its bearer every minute and ends when it no longer resolves to its binding, as the stateful GET stream does.
- Widening on return (A-WR-04): when a binding comes back with a different identity (more or fewer scopes, a backend subject added or dropped), its stateful session or stateless server is re-filtered on that request, so newly allowed tools and prompts appear at once (with `list_changed`) instead of waiting for an event.
- Protected resource metadata (RFC 9728, `pier.PRMScopes`): `GET /.well-known/oauth-protected-resource` and `GET /.well-known/oauth-protected-resource<mcp.path>` answer the resource, the authorization server (the published verifier's resource origin, else `app.url`), the supported scopes and the bearer method; a `mcp.resource` that differs from the verifier's resource is a boot warning.
- 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="<app.url>/.well-known/oauth-protected-resource<mcp.path>", 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`: `open`, `rehydrate`, `end`, `binding_refused`, `revoked`, `stream_closed`, `expired`), never arguments, bodies or tokens. A tool call served on the stateful path without a session logs the session `ephemeral`, one on the stateless path `stateless`.
## 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:<mcp.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. |
| `pier.PRMScopes` | The `scopes_supported` list of the protected resource metadata (`read`, `write`, `ai`, `offline_access`, `backend`). |
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 and events reach only its own sessions) or `redis` (sessions and events shared by every instance through `redis.url`, opened on the first request). |
| `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 `<max>,<minutes>` 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.
Operational notes: a single instance (the demo deployment) runs on the memory store; several instances behind one load balancer need `mcp.sessions.store: redis` so a session opened on one instance is served by another and a permission change reaches every session. A reverse proxy must route `mcp.path` and both `/.well-known/oauth-protected-resource` paths to the binary and must not buffer the endpoint's responses (server-sent events). Every tool call leaves one `pier: tool call` line and every session step one `pier: session` line under the `pier` component.
## 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, a real 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; `TestPierSessionsSmoke` runs two instances on one miniredis (rehydration, the binding refusal, DELETE, the idle TTL, the per-binding cap); `TestPierFanOutSmoke` receives `list_changed` on one instance for an event fired on the other and sees a revocation end the session; `TestPierStatelessSmoke` connects the newest SDK client statelessly next to a 2025-11-25 client with a session. `TestPierReaperSmoke` lets a session expire on the memory store and sees one reaper pass close it (404 next) while a session kept active survives. The tracer's subtests cover the read/write ceiling (a read-only grant lists the GET tool only), prompts (`prompts/list`, `prompts/get`, the required-argument refusal), the audit line (tool, user and status present, arguments and token absent) and personal tokens (401 without the flag and no lookup; with the flag an injected admin token resolver yields a backend-only identity bound to `pat:backend:<id>`). `TestExampleBlogPlugin` keeps the `acme.blog` example of `docs/services/mcp.md` mounting. `go test -short -tags summercms_mcp ./modules/pier/... ./modules/surf/...` runs the same tests with the SDK linked into surf.