- personal tokens only behind mcp.allow_personal_tokens (401 without a lookup otherwise); an admin token yields a backend-only identity with its scope limit per dispatched method, a user token a frontend-only one - read/write ceiling and prompts proven by the tracer's subtests; the audit line asserted to carry the tool and never the arguments or token - the mounted *pier.Pier is published on the application (test seams in export_test.go) - docs/services/mcp.md with the tested acme.blog example, site.yaml note, cross-links from authentication.md, registration.md and the surf README
153 lines
13 KiB
Markdown
153 lines
13 KiB
Markdown
---
|
|
title: MCP endpoint
|
|
description: Expose plugin-declared tools and prompts to AI agents through the opt-in pier module, as the signed-in user or the linked administrator.
|
|
section: services
|
|
order: 65
|
|
---
|
|
# MCP endpoint
|
|
|
|
[pier](../../modules/pier/README.md) mounts a [Model Context Protocol](https://modelcontextprotocol.io) 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](oauth-server.md) 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:
|
|
|
|
```yaml
|
|
# summer.yaml
|
|
build:
|
|
tags: [summercms_mcp]
|
|
```
|
|
|
|
```yaml
|
|
# 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:
|
|
|
|
```go src=modules/pier/example_mcp_test.go#blogTools
|
|
// 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. `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.
|
|
- **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.
|
|
- **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. 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. 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 `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](authentication.md).
|
|
- 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](logging.md) 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.
|