- pact.MCPPrompt.Writes (additive; the zero value keeps today's listing) - pier's allowsPrompt hides a prompt declared with Writes from an identity without write, as it hides a tool that is not read-only (C-IN-01 framework part, P-28)
14 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. |
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. |
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.blogdeclaringcreate_postexposesblog_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.
MethodandPathname 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) orbackend(the administrator, built in). WhenGuardis empty the route must carry the guard's token-accepting middleware (for exampleuser.api_tokenor cabana'sbackend); a tool whose route authenticates inside its handler names its guard explicitly, ascreate_postdoes. 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);
Permissionsadds codes that filter the listing on top. The route still enforces on every call; the declaration only decides who sees the tool. - Input.
Inputis the zero value of a struct. Apath:"id"field fills the{id}segment, aquery:"q"field goes to the query string, every other field forms the JSON body; ajsonschema:"..."tag is the field's description and a field withoutomitemptyis 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. AGETorDELETEtool may carry only path and query fields, and an embedded struct on one fails boot (its promoted fields would be body fields). Apath:field must be required: one withomitemptyoromitzerofails 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.
GETis read-only and idempotent,PUTidempotent,DELETEdestructive;ReadOnly,Destructive,IdempotentandOpenWorldoverride. 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 errorpier: the tool could not be run for this caller, logged server-side. The dispatched request carries the MCP request'sHost,X-Forwarded-ProtoandX-Forwarded-Host, so a route that builds absolute links builds them as over HTTP. - Prompts. A
pact.MCPPromptis prefixed and filtered like a tool, names its guard, declares its arguments, and renders its messages inRender; required arguments are checked first. A prompt never dispatches a route. SetWriteson 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.
TitleandDescriptionare 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. 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
AuthorizationorCookieheader; the identity travels as thebouncer.Dispatchmarker 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
Originis not inmcp.allowed_originsis refused before its body is read (DNS rebinding); requests withoutOriginpass. The endpoint sits behindmcp.throttle, a request body cap,mcp.call_timeoutandmcp.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.