- check-phase19.7.sh: workspace_check (framework to this checkout, the OAuth server plugin's user plugin to USER_PLUGIN) in the plugins, consumers and docker stages; consumer_with_user_plugin builds fonoteka.go and sm-bm-app against USER_PLUGIN through a scratch go.work; demo_pins accepts unreleased commits its canonical checkout holds and refuses any drift with PHASE197_RELEASED=1; self-test cases for each, one PASS line each - TestSessionReaper pins A-CR-01, A-IN-01 and A-IN-06 (memory and Redis stores, store errors, the GET stream re-check, the ephemeral label) - TestAuthTime pins auth_time at mint and through every refresh path - GO_TESTS and GO_TAGGED_TESTS require them with the plan 06/07 smokes
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, a path: field the schema does not require, an embedded struct on a GET or DELETE tool 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.HasMCPdeclarations 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 atpier.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
MCPRequirementsand are checked together with the tool'sPermissions(D-16). - Input schema reflected from the struct with
jsonschema.ForType(jsonschema:"..."tags describe fields, fields withoutomitemptyare required, no additional properties); every call's arguments are validated against it, then split by thepath:"x"andquery:"q"tags into path values, query string and the raw JSON body minus those keys (D-11); aGETorDELETEtool may carry only path and query fields, and an embedded struct (whose promoted fields would be body fields) fails boot there. Apath:field must be required (noomitemptyoromitzero), and at call time pier delivers only path values the HTTP mux could: a missing value, an empty,.or..segment, or a/inside a single-segment value is a tool error before dispatch. Numbers in path and query values keep their literal digits, so integer ids above 2^53 stay exact. An optionalOutputstruct produces the output schema. - Annotations from the method (D-14):
GETread-only and idempotent,PUTidempotent,DELETEdestructive;ReadOnly,Destructive,IdempotentandOpenWorldoverride; the destructive and open-world hints are always explicit. - In-process dispatch through
pier.Host.Handler, the route's compiled chain, withbouncer.WithDispatchcarrying the guard provider'sMCPDispatchprincipal and credential, path values set as the mux would,RemoteAddr, theHost,X-Forwarded-For,X-Forwarded-Proto,X-Forwarded-Host,X-Real-IPandAccept-Languagecopied from the MCP request (neverAuthorizationorCookie),mcp.call_timeouton the context and the answer captured up tomcp.max_result_bytes(D-17). A failed guard dispatch answers the fixed tool errorpier: the tool could not be run for this callerand logs the cause server-side, so no provider or database error reaches the agent (an invalid token or a deactivated account keep their own fixed texts); a dispatch whose guard list namesbackendfor a frontend tool, or whose principal kind contradicts the tool's guard, is refused the same way. - 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
backendguard provider (pier.BackendGuard): routes behind cabana'sbackendmiddleware, listing throughcabana.Allowson the administrator's principal fromcabana.BackendPrincipal, an admin personal token's scope limit computed per dispatched route method withcabana.ScopeLimitFor; a plugin that registers its ownbackendprovider wins. - Identity per request (
pier.Identity):oas_at_tokens through the published verifier, personal tokens only withmcp.allow_personal_tokens, the backend subject through the published link resolver with an in-process cache of at mostpier.LinkCacheTTL; the binding keyoauth:<client_id>:<user_id>,pat:user:<token_id>orpat:backend:<token_id>survives access-token rotation. Onepact.MCPPersonalTokenResolverserves the application; a subject it answers without aTokenIDis refused, so personal-token users never share a binding. - Per-identity SDK server plus a receiving middleware that re-filters
tools/listandprompts/listand refusestools/callandprompts/getfor 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):
initializeopens a session (429 abovemcp.sessions.max_per_binding), a request with an unknown id answers 404 (the client re-initialises), another identity presenting the id answers 403session_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 storedServerSessionState. 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 theexpiredstep. 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) orredis(records<prefix>mcp:s:<id>withPXTTLs, per-binding sets<prefix>mcp:b:<binding>, events on channel<prefix>mcp:events, undercooler.KeyPrefix); Mount never waits for Redis: a background warm-up opens the client fromredis.urlwith 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.MCPSubjectChangedfired 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 sendsnotifications/tools/list_changedandnotifications/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. A revocation namingTokenIDends only the sessions whose subject under the event's guard is that user with that token id and no client id (one personal token); one namingClientIDends only that OAuth client's sessions of the user; one naming neither, and every deletion, ends all sessions of the subject. - Client address: before resolving a bearer, pier puts the client address on the resolvers' context with
towel.WithClientIP, taken from the host when it offers one (surf applieshttp.trusted_proxies) or from the request'sRemoteAddr, so a resolver can log a failed attempt with it. - Hybrid protocol (D-32): a request whose
Mcp-Protocol-Versionis 2026-07-28 or later, or whose first JSON-RPC method isserver/discover, goes to the SDK's stateless handler over an in-process LRU of per-identity servers keyed by binding (capacitymcp.sessions.max_per_bindingtimes 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 statelesssubscriptions/listenstream 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-resourceandGET /.well-known/oauth-protected-resource<mcp.path>answer the resource, the authorization server (the published verifier's resource origin, elseapp.url), the supported scopes and the bearer method; amcp.resourcethat differs from the verifier's resource is a boot warning. - Prompts (D-28): prefixed and filtered like tools, required arguments checked before
Renderruns; a prompt never dispatches a route. A prompt declared withWrites(it directs tools that are not read-only) sits under the read/write ceiling and is hidden from an identity withoutwrite. - A 401 carries
WWW-Authenticate: Bearer resource_metadata="<app.url>/.well-known/oauth-protected-resource<mcp.path>", scope="read"; a browser request whoseOriginis not inmcp.allowed_originsis 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 sessionephemeral, one on the stateless pathstateless.
Usage
Declare a tool in a plugin (the pact types only; no SDK import):
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:
# summer.yaml
build:
tags: [summercms_mcp]
# 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, publishing instance, and the optional token id and client id that narrow a revocation. |
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-sdkv1.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-gov0.4.3: input and output schema reflection and argument validation.- Framework:
backpack,pact,party,bouncer,cabana,compass,cooler,diary,towel.
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.