- modules/pier: Host contract, mcp.* config, catalog compiled from pact.HasMCP (prefixing, route binding, guard resolution, schema reflection, annotations), per-request identity through the published verifier and resolvers, built-in backend guard provider, in-process dispatch with the bouncer marker, per-identity SDK server with the per-request re-filter, stateful session router with rehydration over a memory store, audit lines - modules/surf/pier_on.go: the summercms_mcp hook adapting the router to pier.Host; the untagged hello binary carries no SDK - go.mod: go-sdk v1.8.0 and jsonschema-go v0.4.3 (decision note committed before this change) - TestPierTracerSmoke: SDK client lists and calls acme_echo end to end through surf's compiled guard chain; README and root table row
138 lines
15 KiB
Markdown
138 lines
15 KiB
Markdown
# 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 (next task) shares sessions and events across instances.
|
|
|
|
## 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: `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`.
|
|
- 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):
|
|
|
|
```go
|
|
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:
|
|
|
|
```yaml
|
|
# summer.yaml
|
|
build:
|
|
tags: [summercms_mcp]
|
|
```
|
|
|
|
```yaml
|
|
# 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. |
|
|
|
|
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) or `redis` (shared through `redis.url`; the Redis store arrives with the next task). |
|
|
| `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.
|
|
|
|
## 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, 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. `go test -short -tags summercms_mcp ./modules/pier/... ./modules/surf/...` runs the same tests with the SDK linked into surf.
|