Files
summercms/modules/pier
Jakub Zych 0b8e027b81 test(19.7-05): security review pins for the MCP endpoint's outer layers
- TestEndpointGuards: a foreign browser Origin is 403 before the bearer is
  read, a body over the endpoint cap is 413, a long GET stream ends once
  its grant stops verifying (T-19.7-18, T-19.7-19)
- TestDispatch: an argument key the input struct does not declare is
  refused before dispatch (T-19.7-17)
- the gate requires TestEndpointGuards (untagged and tagged) and runs the
  demo's deploy check (T-19.7-37)
2026-10-23 14:26:26 +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 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.
  • 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.
  • 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); the Redis client is opened on the first request from redis.url, never at mount, so route:list and tests never dial, and a missing URL is a 503 logged once.
  • 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.
  • 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), never arguments, bodies or tokens.

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 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. 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.