Files
summercms/modules/pier
Jakub Zych cccf4025ea fix(19.7): RA-WR-01 never wait on a session lock under the router lock
Fan-out matching snapshots the sessions (and stateless entries) under the
router-wide lock, releases it, and reads each identity through a small
identity box that is never held across I/O, so an event no longer waits on
a session that is re-filtering while it holds sr.mu or sp.mu.

Widening and the fan-out recomputes run their guard checks with
guardContext: not cancelled with the triggering request, but bounded by
mcp.call_timeout, so a provider that never answers cannot hold a session
lock forever. README and docs/services/mcp.md describe the wider meaning of
mcp.call_timeout.

TestLockScopeUnderWidening blocks a widening guard check, fires a
permission event for that session and asserts that another session's call
still finishes, and that a never-answering check ends at call_timeout.
2026-10-23 20:02:13 +02:00
..

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.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, and an embedded struct (whose promoted fields would be body fields) fails boot there. A path: field must be required (no omitempty or omitzero), 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 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, the Host, X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host, X-Real-IP and Accept-Language copied from the MCP request (never Authorization or Cookie), mcp.call_timeout on the context and the answer captured up to mcp.max_result_bytes (D-17). A failed guard dispatch answers the fixed tool error pier: the tool could not be run for this caller and 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 names backend for 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 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. A revocation naming TokenID ends 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 naming ClientID ends 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 applies http.trusted_proxies) or from the request's RemoteAddr, so a resolver can log a failed attempt with it.
  • 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 prompt declared with Writes (it directs tools that are not read-only) sits under the read/write ceiling and is hidden from an identity without write.
  • 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):

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, and on the guard checks pier runs while it re-filters a session or stateless server (widening on return, a fan-out recompute).
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, 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.