Files
summercms/docs/services/mcp.md
Jakub Zych 58408aaa83 fix(19.7): RA-IN-03 document that the Redis session store needs a standalone Redis
reserveScript reads the record keys of the binding set's members, which it
cannot declare in KEYS, and its declared keys hash to different slots, so
Redis Cluster refuses it and a key-pattern ACL must allow the whole
<prefix>mcp: range. The script's comment, the pier README's
mcp.sessions.store row (which also no longer says the store opens on the
first request) and docs/services/mcp.md say so.
2026-10-23 20:17:17 +02:00

15 KiB

title, description, section, order
title description section order
MCP endpoint Expose plugin-declared tools and prompts to AI agents through the opt-in pier module, as the signed-in user or the linked administrator. services 65

MCP endpoint

pier mounts a Model Context Protocol endpoint inside the application binary. WinterCMS has no counterpart; the nearest Golem15 pattern is a separate MCP server process that calls the site's token API. pier replaces that process: a plugin declares tools bound to its own routes, pier lists them per caller and runs every call in-process through the route's middleware, so an agent gets exactly what the route's guard, scopes and permissions allow and nothing else.

The design is one connector per person. An editor adds the site's MCP URL to their client once and signs in through the application's own OAuth server as their frontend user; with a verified link to a backend account and the backend scope, the backend-guarded tools run as that administrator.

Enabling it

pier is linked only into binaries built with the summercms_mcp tag, so an application that does not use it carries no MCP code, and it mounts only when mcp.enabled is true:

# summer.yaml
build:
  tags: [summercms_mcp]
# config/mcp.yaml
enabled: true
path: /mcp

mcp.enabled: true in a binary built without the tag fails start-up with an error naming the tag. The keys, all under mcp.* (environment SUMMER_MCP__*):

Key Default Description
mcp.enabled false Mounts POST, GET and DELETE at mcp.path.
mcp.path /mcp The endpoint path. A route a plugin already registered there fails boot.
mcp.allow_personal_tokens false Also accepts personal API tokens (see Identity).
mcp.sessions.store memory memory for one instance, redis to share sessions and events across instances through redis.url (a standalone Redis; Redis Cluster is not supported).
mcp.sessions.idle_ttl 30m Sliding idle TTL of a session.
mcp.sessions.max_per_binding 20 Open sessions one client-and-user pair (or one personal token) may hold.
mcp.resource app.url + mcp.path The resource indicator; must equal the OAuth server's MCP resource.
mcp.throttle 120,1 The endpoint's rate limit, <max>,<minutes>.
mcp.allowed_origins the origin of app.url Browser origins allowed to call the endpoint; another Origin is refused with 403.
mcp.call_timeout 30s Bound on one tool call, and on the guard checks that re-filter a session when its grant or permissions change.
mcp.max_result_bytes 1048576 Cap on a route answer returned as a tool result.

Declaring tools and prompts

A plugin implements pact.HasMCP and returns a pact.MCP value. It compiles against pact only; no plugin imports the MCP SDK, so the plugin builds the same with and without the tag. This is a blog plugin, acme.blog, declaring three tools and one prompt:

// blogTools is the MCP declaration of the acme.blog plugin. A field of an
// input struct is a body field unless its path or query tag says
// otherwise; fields without omitempty are required in the schema.
func blogTools() pact.MCP {
	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 instead of saving a draft"`
	}
	type showPostInput struct {
		ID int `json:"id" path:"id" jsonschema:"the post id"`
	}
	type listPostsInput struct {
		Query string `json:"q,omitempty" query:"q" jsonschema:"a phrase the title or body contains"`
	}
	return pact.MCP{
		Tools: []pact.MCPTool{
			{
				// Exposed as blog_create_post; the backend guard runs it as
				// the linked administrator, listed only with access_posts.
				Name:        "create_post",
				Title:       "Create post",
				Description: "Creates a post; a draft unless published is true.",
				Guard:       "backend",
				Method:      "POST",
				Path:        "/_blog/api/v1/posts",
				Input:       createPostInput{},
				Permissions: []string{"acme.blog.access_posts"},
			},
			{
				// Exposed as blog_show_post; the route's user.api_token
				// middleware names the guard, GET makes it read-only.
				Name:        "show_post",
				Title:       "Show post",
				Description: "Shows one post by id.",
				Method:      "GET",
				Path:        "/_blog/api/v1/posts/{id}",
				Input:       showPostInput{},
			},
			{
				Name:        "list_posts",
				Title:       "List posts",
				Description: "Lists posts, optionally those matching a phrase.",
				Method:      "GET",
				Path:        "/_blog/api/v1/posts",
				Input:       listPostsInput{},
			},
		},
		Prompts: []pact.MCPPrompt{
			{
				// Exposed as blog_summarize; prompts name their guard.
				Name:        "summarize",
				Title:       "Summarize a post",
				Description: "Asks for a three-sentence summary of a post.",
				Guard:       "user",
				Arguments:   []pact.MCPPromptArgument{{Name: "slug", Description: "the post slug", Required: true}},
				Render: func(_ context.Context, args map[string]string) ([]pact.MCPPromptMessage, error) {
					return []pact.MCPPromptMessage{{Role: "user", Text: "Summarize the post " + args["slug"] + " in three sentences."}}, nil
				},
			},
		},
	}
}

What pier does with it at boot:

  • Names. The exposed name is the plugin's short code (the last dot-segment of the plugin id, lower-cased) plus _ plus the declared name: acme.blog declaring create_post exposes blog_create_post. A duplicate within or across plugins, an empty name or one that does not match [a-z0-9_]{1,128} after prefixing fails boot. Clients and saved agent configurations reference these names, so treat them as a published API.
  • Routes. Method and Path name one of the plugin's own routes exactly as registered, {id} segments included. A route no plugin registers fails boot. The call runs the route's full compiled chain: throttles, the guard, scope and permission middleware, body limits and the handler, the same code path as an HTTP request.
  • Guards. A tool runs as exactly one guard: user (the frontend user, when a user plugin publishes that provider) or backend (the administrator, built in). When Guard is empty the route must carry the guard's token-accepting middleware (for example user.api_token or cabana's backend); a tool whose route authenticates inside its handler names its guard explicitly, as create_post does. A cookie-only or public route cannot become a tool by accident.
  • Permissions. The route's middleware implies requirements the guard provider parses (scopes, permission codes); Permissions adds codes that filter the listing on top. The route still enforces on every call; the declaration only decides who sees the tool.
  • Input. Input is the zero value of a struct. A path:"id" field fills the {id} segment, a query:"q" field goes to the query string, every other field forms the JSON body; a jsonschema:"..." tag is the field's description and a field without omitempty is required. pier reflects the input schema from the struct, validates every call's arguments against it, and forwards the body as the agent sent it minus the path and query keys, so an update route that checks key presence sees exactly what the agent set. A GET or DELETE tool may carry only path and query fields, and an embedded struct on one fails boot (its promoted fields would be body fields). A path: field must be required: one with omitempty or omitzero fails boot. At call time pier passes a route only path values the HTTP mux could produce: a missing value, an empty, . or .. segment, or a / inside a single-segment value is a tool error before the route runs. Numbers keep their literal digits in path and query values, so integer ids above 2^53 stay exact. Output, when set, is the struct the 2xx body matches and becomes the output schema.
  • Annotations. GET is read-only and idempotent, PUT idempotent, DELETE destructive; ReadOnly, Destructive, Idempotent and OpenWorld override. The read-only hint also decides what a read-only grant sees (below).
  • Results. A 2xx body is returned as text plus structured content (an object as is, any other JSON wrapped as {"data": …}); a 4xx or 5xx is a tool error carrying the route's own body, so the agent reads the real validation errors. A failure of the guard provider itself (for example a database error while resolving the principal) is the fixed tool error pier: the tool could not be run for this caller, logged server-side. The dispatched request carries the MCP request's Host, X-Forwarded-Proto and X-Forwarded-Host, so a route that builds absolute links builds them as over HTTP.
  • Prompts. A pact.MCPPrompt is prefixed and filtered like a tool, names its guard, declares its arguments, and renders its messages in Render; required arguments are checked first. A prompt never dispatches a route. An error Render returns is logged, and the agent gets only a fixed "the prompt could not be rendered" error. Set Writes on a prompt whose text directs tools that are not read-only (for example "draft a post, then create it"): it then sits under the same read/write ceiling as those tools, so a read-only grant is not offered a prompt it cannot follow.
  • Descriptions. Title and Description are static text, at most 1024 characters, passed through unchanged. Keep them factual one-liners and never build them from data: tool text is read by the model, so data-derived text would be a prompt-injection surface.

Identity and sessions

Every request is authorized afresh from its bearer; a session never replaces the bearer. An OAuth access token of the application's authorization server (oas_at_…) is verified through the pact.MCPTokenVerifier the OAuth server plugin publishes: the grant's user is the frontend subject, the grant's scopes are the ceiling. With the backend scope and a link the published pact.MCPLinkedBackendResolver confirms, the linked administrator is the backend subject, resolved on every request (cached for at most five seconds on one instance and dropped at once by a link event), so unlinking takes effect on the next call with no token surgery. The backend principal keeps the administrator's real permissions, superuser included, under the grant's ceiling.

The read and write scopes are a ceiling: a grant with only read sees tools whose read-only hint is true, write unlocks the rest, and permissions always filter on top. The ai and offline_access scopes play no part in it.

With mcp.allow_personal_tokens true, personal API tokens also open sessions, each capped by the token's own scope exactly as over HTTP: an administrator's g15_ token yields backend tools only, with the token's scope limit computed per dispatched route method; a frontend user's token, resolved by the user plugin's published pact.MCPPersonalTokenResolver, yields frontend tools only. An application has one such resolver; a subject it answers must carry the token's TokenID, which keys the session binding, and a subject without one is refused. With the flag off (the default), any bearer that is not an OAuth token is refused without a lookup.

Sessions follow the streamable HTTP transport: initialize opens one, Mcp-Session-Id names it, DELETE ends it. A session is bound to the client and user that opened it (or to the personal token), so it survives access-token rotation, and another identity presenting its id is refused with 403. Sessions carry a sliding idle TTL and a cap per binding; a session whose client went away without DELETE is closed on every instance once its idle TTL passes. When the same client and user come back with a wider grant (more scopes, the backend scope with a link), the session gains the newly allowed tools on that request. With mcp.sessions.store: redis several instances share them: a session opened on one instance is served by another, which rehydrates it from the stored session state, and a permission change, a link change or a revocation fired on any instance reaches every session it concerns, as notifications/tools/list_changed or as the end of the session. A revocation can be narrowed: a pact.MCPSubjectChanged with TokenID ends only the sessions that personal token opened (revoking one admin token on My account does this), one with ClientID only the sessions of that OAuth client, and one with neither every session of the user. Clients of protocol version 2026-07-28 and later, which have no sessions, are served statelessly from the same path with the same identity, dispatch and filtering.

Protected resource metadata

pier serves the OAuth protected resource metadata (RFC 9728) at /.well-known/oauth-protected-resource and at /.well-known/oauth-protected-resource<mcp.path>: the resource, the authorization server (the verifier's resource origin, else app.url), the supported scopes (read, write, ai, offline_access, backend) and the bearer method. A 401 carries WWW-Authenticate: Bearer resource_metadata="…", scope="read", which is how a client finds the authorization server. mcp.resource must equal the resource the OAuth server issues tokens for; a difference is logged as a warning at boot.

Security notes

  • The bearer is never forwarded. The inner request of a tool call carries no Authorization or Cookie header; the identity travels as the bouncer.Dispatch marker on the request context, which only a framework dispatcher can set and which a guard honours only when the marker lists its name. See Authentication.
  • Arguments are validated against the reflected schema (no additional properties); a single-segment path value may not contain /; the route's own constraints, body limit and middleware still run.
  • A browser request whose Origin is not in mcp.allowed_origins is refused before its body is read (DNS rebinding); requests without Origin pass. The endpoint sits behind mcp.throttle, a request body cap, mcp.call_timeout and mcp.max_result_bytes.
  • A long-lived event stream re-verifies its bearer every minute and ends when the bearer no longer resolves to the session's binding.

Operations

A reverse proxy routes mcp.path and both /.well-known/oauth-protected-resource paths to the binary and does not buffer the endpoint's responses (they are server-sent events). The single-instance default is the memory store; several instances need mcp.sessions.store: redis and redis.url.

Every tool call writes one pier: tool call record of the pier log component with the tool, the guard, the frontend user id, the administrator id, the OAuth client id, the token kind, the session prefix, the route's status and the duration; every session step writes one pier: session record. Arguments, bodies and tokens are never logged.