feat(19.7-02): identity kinds, ceilings, prompts and the MCP services page

- 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
This commit is contained in:
Jakub Zych
2026-10-22 18:34:39 +02:00
parent 806e87c510
commit 8c6c0a3654
12 changed files with 589 additions and 13 deletions

View File

@@ -87,7 +87,7 @@ A WinterCMS plugin overrides `register*` methods of `PluginBase`. A SummerCMS pl
| `pact.HasAdminControllers` | Admin controllers built from `fields.yaml` and `columns.yaml`. |
| `pact.HasAdminActionExtensions` | Record actions added to any admin controller by ID (`pact.AdminActionExtension`); a `pact.AdminRecordAction` with a `Prompt` asks the administrator for one text value first and receives it as `pact.AdminRecordActionInput.Value`. |
| `pact.HasAccountContributions` | Sections with a status line and buttons on the administrator's My account page (`pact.AccountContribution`, `pact.AccountAction`), run for the signed-in administrator only. |
| `pact.HasMCP` | MCP tools and prompts (`pact.MCP`, `pact.MCPTool`, `pact.MCPPrompt`) bound to the plugin's own token-guarded routes, served by the framework's opt-in MCP endpoint. |
| `pact.HasMCP` | MCP tools and prompts (`pact.MCP`, `pact.MCPTool`, `pact.MCPPrompt`) bound to the plugin's own token-guarded routes, served by the framework's opt-in [MCP endpoint](../services/mcp.md). |
| `pact.HasModels` | The plugin's GORM models. No framework package reads it yet. |
## Embedded files

View File

@@ -137,7 +137,7 @@ To protect routes, return that middleware from `pact.HasMiddleware` under a name
A guard that resolves more than a user, such as an API token record, implements `bouncer.CredentialGuard`; the middleware stores that record too, and handlers read it with `bouncer.Credential`.
A framework dispatcher that calls a route in-process on behalf of a caller it has already authenticated (the MCP module running a tool through the plugin's own route) does not mint a token for the call. It attaches a `bouncer.Dispatch` marker to the request context with `bouncer.WithDispatch`: the pre-resolved principal, its optional credential and the exact guard names that may honour it. The marker is context-only. Nothing reads a header, cookie or query string for it, so an HTTP request can never carry one, and only a dispatcher inside the framework sets it; plugins never do. `bouncer.Registry.Middleware` honours a marker only for a guard name the marker lists (`bouncer.Dispatched`) and then skips the guard, which covers every guard registered through the registry, such as a plugin's API token guard. `cabana.ResolveBackend` honours a marker for the `backend` guard when its principal is a backend one; a frontend marker is refused as unauthenticated, and a scoped principal is still checked against the cover codes. A marker for one guard has no effect on a route behind another.
A framework dispatcher that calls a route in-process on behalf of a caller it has already authenticated (the MCP module running a tool through the plugin's own route) does not mint a token for the call. It attaches a `bouncer.Dispatch` marker to the request context with `bouncer.WithDispatch`: the pre-resolved principal, its optional credential and the exact guard names that may honour it. The marker is context-only. Nothing reads a header, cookie or query string for it, so an HTTP request can never carry one, and only a dispatcher inside the framework sets it; plugins never do. `bouncer.Registry.Middleware` honours a marker only for a guard name the marker lists (`bouncer.Dispatched`) and then skips the guard, which covers every guard registered through the registry, such as a plugin's API token guard. `cabana.ResolveBackend` honours a marker for the `backend` guard when its principal is a backend one; a frontend marker is refused as unauthenticated, and a scoped principal is still checked against the cover codes. A marker for one guard has no effect on a route behind another. The one dispatcher in the framework is the [MCP endpoint](mcp.md): pier resolves the caller from the bearer of every MCP request, asks the tool's guard provider for the principal and sets the marker on the in-process call, so the route sees the same principal an HTTP request with that user's token would carry, while the bearer itself never reaches the route.
### Accepting admin tokens on plugin routes

152
docs/services/mcp.md Normal file
View File

@@ -0,0 +1,152 @@
---
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.

View File

@@ -11,6 +11,7 @@ llms_notes:
- "SummerCMS is headless: it serves a JSON API and an admin SPA, with no frontend themes."
- "It targets Go 1.27."
- "A new application starts with `curl -fsSL https://summercms.io/new | bash -s -- <name>`, or with `summer new <name>` next to a framework checkout."
- "The opt-in MCP endpoint (module pier, build tag summercms_mcp, config mcp.enabled) exposes plugin-declared tools and prompts to AI agents, filtered per caller and run through the plugin's own routes."
sections:
- name: setup
title: Setup

View File

@@ -141,4 +141,4 @@ Only this module (and surf's tagged `pier_on.go`) imports the SDK; `pact` stays
## 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. `go test -short -tags summercms_mcp ./modules/pier/... ./modules/surf/...` runs the same tests with the SDK linked into surf.
`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.

View File

@@ -0,0 +1,174 @@
package pier_test
import (
"context"
"errors"
"net/http"
"slices"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// The neutral acme.blog example docs/services/mcp.md shows: a plugin that
// declares two tools and one prompt bound to its own token-guarded routes.
// TestExampleBlogPlugin keeps it compiling and mounting.
// 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
},
},
},
}
}
// userTokenGuard stands in for a user plugin's API token guard: it refuses
// every HTTP request, so only an in-process dispatch reaches the routes.
type userTokenGuard struct{}
func (userTokenGuard) AuthenticateCredential(*http.Request) (*bouncer.Principal, any, error) {
return nil, nil, errors.New("blog: no token")
}
func (userTokenGuard) WriteUnauthorized(w http.ResponseWriter, _ error) {
http.Error(w, `{"error":"Invalid token"}`, http.StatusUnauthorized)
}
// userGuardProvider is the "user" guard provider such a plugin publishes.
type userGuardProvider struct{}
func (userGuardProvider) MCPGuardName() string { return "user" }
func (userGuardProvider) MCPTokenGuarded(mw []string) bool {
return slices.Contains(mw, "user.api_token")
}
func (userGuardProvider) MCPRequirements([]string) (pact.MCPRequirements, error) {
return pact.MCPRequirements{}, nil
}
func (userGuardProvider) MCPAllows(context.Context, pact.MCPSubject, pact.MCPRequirements, []string) (bool, error) {
return true, nil
}
func (userGuardProvider) MCPDispatch(_ context.Context, subject pact.MCPSubject, _ string) (pact.MCPDispatch, error) {
return pact.MCPDispatch{Principal: &bouncer.Principal{ID: subject.UserID}, Guards: []string{"api_token"}}, nil
}
// blogPlugin is the acme.blog plugin of the example.
type blogPlugin struct{}
func (blogPlugin) ID() string { return "acme.blog" }
func (blogPlugin) Requires() []string { return nil }
func (blogPlugin) Register(*backpack.App) error { return nil }
func (blogPlugin) MCP() pact.MCP { return blogTools() }
func (blogPlugin) Boot(app *backpack.App) error {
reg := pact.NewMCPGuards()
if err := reg.Register(userGuardProvider{}); err != nil {
return err
}
return app.Publish(reg)
}
func (p blogPlugin) Middlewares() map[string]pact.Middleware {
guards := bouncer.NewRegistry()
if err := guards.Register(p.ID(), "api_token", userTokenGuard{}); err != nil {
panic(err)
}
auth, err := guards.Middleware("api_token")
if err != nil {
panic(err)
}
return map[string]pact.Middleware{"user.api_token": auth, "backend": auth}
}
func (blogPlugin) Routes(r pact.Router) error {
ok := func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[]}`))
}
r.Group("/_blog/api/v1", []string{"user.api_token"}, func(g pact.Router) {
g.Get("/posts", ok)
g.Get("/posts/{id}", ok)
})
r.Group("/_blog/api/v1", []string{"backend"}, func(g pact.Router) {
g.Post("/posts", ok)
})
return nil
}
func TestExampleBlogPlugin(t *testing.T) {
if decl := blogTools(); len(decl.Tools) != 3 || len(decl.Prompts) != 1 {
t.Fatalf("declaration = %d tools, %d prompts", len(decl.Tools), len(decl.Prompts))
}
app := pierAppWith(t, map[string]string{"mcp.yaml": "enabled: false\n"}, nil)
plugin := blogPlugin{}
if err := plugin.Boot(app); err != nil {
t.Fatal(err)
}
host, err := mountPier(t, app, []party.Plugin{plugin})
if err != nil {
t.Fatal(err)
}
names := host.pier.ToolNames()
want := []string{"blog_create_post", "blog_list_posts", "blog_show_post"}
if !slices.Equal(names, want) {
t.Fatalf("tool names = %v, want %v", names, want)
}
}

View File

@@ -0,0 +1,25 @@
package pier
import (
"context"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
)
// Test seams (package pier, compiled into the test binary only).
// SetBackendTokens replaces the admin personal token resolver, which needs
// a database in production (cabana.ResolveBackendToken).
func (p *Pier) SetBackendTokens(fn func(ctx context.Context, app *backpack.App, raw string) (*bouncer.Principal, *cabana.BackendAPIToken, []string, error)) {
p.identities.backendTokens = fn
}
// Resolve resolves a bearer into an Identity as the endpoint does.
func (p *Pier) Resolve(ctx context.Context, raw string) (Identity, error) {
return p.identities.resolve(ctx, raw)
}
// Ceiling reports whether the read/write ceiling applies to the identity.
func (id Identity) Ceiling() bool { return id.ceiling }

View File

@@ -135,8 +135,10 @@ func newIdentityResolver(app *backpack.App, cfg Config, frontendGuard string) *i
// resolve verifies raw afresh: an OAuth access token through the published
// pact.MCPTokenVerifier (missing verifier: unauthorized); with
// mcp.allow_personal_tokens, an admin g15_ token through cabana and any
// other prefix through the published pact.MCPPersonalTokenResolver. A
// mcp.allow_personal_tokens true, an admin g15_ token through cabana and
// any other prefix through the published pact.MCPPersonalTokenResolver;
// with mcp.allow_personal_tokens false (the default) any bearer that is
// not an OAuth token is unauthorized without a lookup (D-19). A
// backend subject for an OAuth grant exists only with the backend scope and
// a link the published pact.MCPLinkedBackendResolver confirms (D-08).
func (r *identityResolver) resolve(ctx context.Context, raw string) (Identity, error) {

View File

@@ -0,0 +1,200 @@
package pier_test
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Task 4 cases of the tracer: identity kinds behind the flag, the
// read/write ceiling, prompts and the audit line.
type showInput struct {
ID int `json:"id" path:"id" jsonschema:"the item id"`
}
func showTool() pact.MCPTool {
return pact.MCPTool{Name: "show", Title: "Show item", Description: "Shows one item.", Method: "GET", Path: "/acme/items/{id}", Input: showInput{}}
}
func summarizePrompt() pact.MCPPrompt {
return pact.MCPPrompt{
Name: "summarize", Title: "Summarize", Description: "Asks for a summary.", Guard: "acme",
Arguments: []pact.MCPPromptArgument{{Name: "topic", Description: "what to summarize", Required: true}},
Render: func(_ context.Context, args map[string]string) ([]pact.MCPPromptMessage, error) {
return []pact.MCPPromptMessage{{Role: "user", Text: "Summarize " + args["topic"] + "."}}, nil
},
}
}
func connectPinned(t *testing.T, ctx context.Context, url, token string) *mcp.ClientSession {
t.Helper()
client := mcp.NewClient(&mcp.Implementation{Name: "acme-test", Version: "1"}, nil)
transport := &mcp.StreamableClientTransport{Endpoint: url, HTTPClient: &http.Client{Transport: bearerTransport{token: token}}}
cs, err := client.Connect(ctx, transport, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"})
if err != nil {
t.Fatalf("connect with %s: %v", token, err)
}
return cs
}
func toolNamesOf(list *mcp.ListToolsResult) []string {
var out []string
for _, tl := range list.Tools {
out = append(out, tl.Name)
}
slices.Sort(out)
return out
}
func tracerIdentityCases(t *testing.T, ctx context.Context) {
plugins := []party.Plugin{acmePlugin{tools: []pact.MCPTool{echoTool(), showTool()}, prompts: []pact.MCPPrompt{summarizePrompt()}}}
t.Run("read-write ceiling and prompts", func(t *testing.T) {
app := pierApp(t, "enabled: false\n")
host, err := mountPier(t, app, plugins)
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(host)
defer srv.Close()
readOnly := connectPinned(t, ctx, srv.URL+"/mcp", "oas_at_read")
defer readOnly.Close()
list, err := readOnly.ListTools(ctx, nil)
if err != nil {
t.Fatal(err)
}
if got := toolNamesOf(list); !slices.Equal(got, []string{"acme_show"}) {
t.Fatalf("read-only grant lists %v, want [acme_show]", got)
}
if res, err := readOnly.CallTool(ctx, &mcp.CallToolParams{Name: "acme_echo", Arguments: map[string]any{"id": 1, "text": "x"}}); err == nil {
t.Fatalf("read-only grant called a write tool: %+v", res)
}
writer := connectPinned(t, ctx, srv.URL+"/mcp", "oas_at_test")
defer writer.Close()
list, err = writer.ListTools(ctx, nil)
if err != nil {
t.Fatal(err)
}
if got := toolNamesOf(list); !slices.Equal(got, []string{"acme_echo", "acme_show"}) {
t.Fatalf("write grant lists %v, want [acme_echo acme_show]", got)
}
show, err := writer.CallTool(ctx, &mcp.CallToolParams{Name: "acme_show", Arguments: map[string]any{"id": 12}})
if err != nil || show.IsError {
t.Fatalf("acme_show = %+v, %v", show, err)
}
if structured, _ := show.StructuredContent.(map[string]any); structured["item"] != "12" {
t.Fatalf("acme_show structuredContent = %+v", show.StructuredContent)
}
prompts, err := writer.ListPrompts(ctx, nil)
if err != nil || len(prompts.Prompts) != 1 || prompts.Prompts[0].Name != "acme_summarize" {
t.Fatalf("prompts/list = %+v, %v", prompts, err)
}
got, err := writer.GetPrompt(ctx, &mcp.GetPromptParams{Name: "acme_summarize", Arguments: map[string]string{"topic": "the pier"}})
if err != nil || len(got.Messages) != 1 {
t.Fatalf("prompts/get = %+v, %v", got, err)
}
if text, ok := got.Messages[0].Content.(*mcp.TextContent); !ok || text.Text != "Summarize the pier." || got.Messages[0].Role != "user" {
t.Fatalf("prompt message = %+v", got.Messages[0])
}
if _, err := writer.GetPrompt(ctx, &mcp.GetPromptParams{Name: "acme_summarize"}); err == nil || !strings.Contains(err.Error(), "topic") {
t.Fatalf("prompts/get without the required argument: err = %v", err)
}
})
t.Run("audit line", func(t *testing.T) {
app := pierApp(t, "enabled: false\n")
host, err := mountPier(t, app, plugins)
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(host)
defer srv.Close()
var buf bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
defer slog.SetDefault(previous)
cs := connectPinned(t, ctx, srv.URL+"/mcp", "oas_at_test")
defer cs.Close()
if _, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "acme_echo", Arguments: map[string]any{"id": 4, "text": "secret-argument-text"}}); err != nil {
t.Fatal(err)
}
logged := buf.String()
if !strings.Contains(logged, "pier: tool call") || !strings.Contains(logged, "tool=acme_echo") || !strings.Contains(logged, "user_id=7") || !strings.Contains(logged, "status=200") {
t.Fatalf("audit line missing: %s", logged)
}
if strings.Contains(logged, "secret-argument-text") || strings.Contains(logged, "oas_at_test") {
t.Fatalf("audit line leaks arguments or the token: %s", logged)
}
})
t.Run("personal tokens behind the flag", func(t *testing.T) {
called := false
resolver := func(_ context.Context, _ *backpack.App, raw string) (*bouncer.Principal, *cabana.BackendAPIToken, []string, error) {
called = true
if raw != "g15_admin" {
return nil, nil, nil, cabana.ErrUnauthenticated
}
return &bouncer.Principal{ID: 42, Backend: true}, &cabana.BackendAPIToken{ID: 5, BackendUserID: 42}, []string{"admin:read"}, nil
}
off := pierApp(t, "enabled: false\n")
offHost, err := mountPier(t, off, plugins)
if err != nil {
t.Fatal(err)
}
offHost.pier.SetBackendTokens(resolver)
offSrv := httptest.NewServer(offHost)
defer offSrv.Close()
if reply := rpc(t, ctx, offSrv.URL+"/mcp", "g15_admin", "", initializeBody); reply.status != http.StatusUnauthorized {
t.Fatalf("g15_ bearer with the flag off = %d %s", reply.status, reply.body)
}
if called {
t.Fatal("the flag off still looked the personal token up")
}
if _, err := offHost.pier.Resolve(ctx, "usr_token"); err == nil {
t.Fatal("a frontend personal token resolved with the flag off")
}
on := pierApp(t, "enabled: false\nallow_personal_tokens: true\n")
onHost, err := mountPier(t, on, plugins)
if err != nil {
t.Fatal(err)
}
onHost.pier.SetBackendTokens(resolver)
id, err := onHost.pier.Resolve(ctx, "g15_admin")
if err != nil {
t.Fatal(err)
}
if id.Backend == nil || id.Backend.Guard != "backend" || id.Backend.UserID != 42 || id.Backend.TokenID != 5 || id.Frontend != nil || id.Binding != "pat:backend:5" || id.TokenKind != "pat" || id.Ceiling() {
t.Fatalf("g15_ identity = %+v", id)
}
if _, err := onHost.pier.Resolve(ctx, "g15_wrong"); err == nil {
t.Fatal("an unknown g15_ token resolved")
}
onSrv := httptest.NewServer(onHost)
defer onSrv.Close()
cs := connectPinned(t, ctx, onSrv.URL+"/mcp", "g15_admin")
defer cs.Close()
list, err := cs.ListTools(ctx, nil)
if err != nil || len(list.Tools) != 0 {
t.Fatalf("an admin token lists %+v, want no frontend tools (%v)", list, err)
}
if _, err := onHost.pier.Resolve(ctx, "usr_token"); err == nil {
t.Fatal("a frontend personal token resolved without a published resolver")
}
})
}

View File

@@ -66,7 +66,8 @@ type Pier struct {
// behind the inline throttle. Nothing is mounted when the flag is off. A
// bad binding, a duplicate name, an unknown guard or a route already
// registered at mcp.path fails with an error naming the plugin, so boot
// (and route:list) fail. Nothing dials Redis here.
// (and route:list) fail. Nothing dials Redis here. The mounted *Pier is
// published on the application.
func Mount(host Host, app *backpack.App, plugins []party.Plugin) error {
if host == nil || app == nil {
return errors.New("pier: a host and an application are required")
@@ -126,6 +127,9 @@ func Mount(host Host, app *backpack.App, plugins []party.Plugin) error {
return err
}
p.listenEvents()
if err := app.Publish(p); err != nil {
return fmt.Errorf("pier: publishing the endpoint: %w", err)
}
return nil
}

View File

@@ -61,7 +61,8 @@ type echoInput struct {
// acmePlugin registers bouncer guard "acme" as middleware "acme.auth", one
// guarded route and the MCP tools it is given.
type acmePlugin struct {
tools []pact.MCPTool
tools []pact.MCPTool
prompts []pact.MCPPrompt
}
func (acmePlugin) ID() string { return "test.acme" }
@@ -83,6 +84,10 @@ func (p acmePlugin) Middlewares() map[string]pact.Middleware {
func (acmePlugin) Routes(r pact.Router) error {
r.Group("/acme", []string{"acme.auth"}, func(g pact.Router) {
g.Get("/items/{id}", func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"item":"` + req.PathValue("id") + `"}`))
})
g.Post("/echo/{id}", func(w http.ResponseWriter, req *http.Request) {
user, _ := bouncer.User(req.Context())
var body map[string]any
@@ -103,7 +108,7 @@ func (acmePlugin) Routes(r pact.Router) error {
}
func (p acmePlugin) MCP() pact.MCP {
return pact.MCP{Tools: p.tools}
return pact.MCP{Tools: p.tools, Prompts: p.prompts}
}
// acmeGuard is the fake guard provider for "acme": token-guarded by
@@ -142,6 +147,8 @@ func (acmeVerifier) MCPVerifyToken(_ context.Context, raw string) (pact.MCPGrant
return pact.MCPGrant{UserID: 7, ClientID: "c1", TokenID: 1, Scopes: []string{"read", "write"}, ExpiresAt: time.Now().Add(time.Hour)}, nil
case "oas_at_other":
return pact.MCPGrant{UserID: 8, ClientID: "c2", TokenID: 2, Scopes: []string{"read", "write"}, ExpiresAt: time.Now().Add(time.Hour)}, nil
case "oas_at_read":
return pact.MCPGrant{UserID: 9, ClientID: "c3", TokenID: 3, Scopes: []string{"read"}, ExpiresAt: time.Now().Add(time.Hour)}, nil
}
return pact.MCPGrant{}, pact.ErrMCPTokenInvalid
}
@@ -154,6 +161,7 @@ type testHost struct {
r *surf.Router
handlers map[string]http.Handler
path string
pier *pier.Pier
}
func (h *testHost) Routes() []pier.RouteInfo {
@@ -212,9 +220,10 @@ func pierApp(t *testing.T, mcpYAML string) *backpack.App {
}
// pierAppWith builds an app from extra config files (app.yaml and
// http.yaml have defaults) and publishes the fake verifier and the given
// guard provider; the router is built with the MCP flag off (so no tag is
// needed) and the flag is enabled for pier.Mount afterwards.
// http.yaml have defaults) and publishes the fake verifier and, unless
// guard is nil, a guard registry with that provider; the router is built
// with the MCP flag off (so no tag is needed) and the flag is enabled for
// pier.Mount afterwards.
func pierAppWith(t *testing.T, extra map[string]string, guard *acmeGuard) *backpack.App {
t.Helper()
dir := t.TempDir()
@@ -238,6 +247,9 @@ func pierAppWith(t *testing.T, extra map[string]string, guard *acmeGuard) *backp
if err := app.Publish[pact.MCPTokenVerifier](acmeVerifier{}); err != nil {
t.Fatal(err)
}
if guard == nil {
return app
}
reg := pact.NewMCPGuards()
if err := reg.Register(guard); err != nil {
t.Fatal(err)
@@ -260,7 +272,11 @@ func mountPier(t *testing.T, app *backpack.App, plugins []party.Plugin) (*testHo
t.Fatal(err)
}
host := &testHost{r: router, handlers: map[string]http.Handler{}}
return host, pier.Mount(host, app, plugins)
err = pier.Mount(host, app, plugins)
if err == nil {
host.pier, _ = app.Lookup[*pier.Pier]()
}
return host, err
}
// bearerTransport adds the bearer to every request of the SDK client.
@@ -397,4 +413,6 @@ func TestPierTracerSmoke(t *testing.T) {
if err := pier.Mount(offHost, off, plugins); err != nil || len(offHost.handlers) != 0 {
t.Fatalf("mcp.enabled false: err = %v, handlers = %d", err, len(offHost.handlers))
}
tracerIdentityCases(t, ctx)
}

View File

@@ -41,7 +41,7 @@ Use cases:
- Request log, on by default: every request writes one Info record `http request` from the [diary](../diary/README.md) component `http` after it is served, with `method`, `pattern` (the matched route, such as `GET /posts/{id}`), `status`, `duration_ms` and `request_id` (the `X-Request-Id` header, trimmed and cut to 128 bytes, left out when empty). A request whose client went away adds `client_gone=true`; its 5xx is logged as 499 (client closed request) only when the client had already gone when the status was written and the handler did not panic, so a 5xx written before the cancellation and a recovered panic keep their real status. An unmatched request (a 404 or 405 from the mux, or a CORS preflight answered before routing) has an empty `pattern`. The URL path, the query string, headers and the client address are never logged, for an unmatched request too: a token route requested with another method answers 405 before any route handler runs. Records go to the app's published `*slog.Logger`, else the `http` component logger, whose channels all redact through [sunscreen](../sunscreen/README.md). Silence it with `log.components.http: warn` or turn it off with `http.access_log: false`.
- Startup summary and shutdown report for `serve`: once it listens, a block on a terminal (version, environment, URL and admin URL, database host and name, pending migrations, plugins, queue, scheduler, mail, cache, realtime and search drivers; `-v` adds one line per plugin) and, off a terminal, the same facts as a `startup summary` record and a `listening on <addr>` record of component `serve`; after SIGINT or SIGTERM, a report of requests served, drained and aborted, worker jobs, uptime and drain errors. Never a DSN user or password.
- Streaming routes: `surf.Router.MountStream` registers a raw route whose response is not buffered, so the handler's `http.Flusher` reaches the client while it is still running (server-sent events, long-lived streams). Named middleware, `body.limit`, locale, the CORS profile and the access-log pattern apply as on a raw route; a panic before the first byte answers an opaque 500 and after it only cuts the body short. Used by the MCP module; plugin route groups stay buffered.
- MCP module hook: with `mcp.enabled` true, `surf.BuildRouter` calls the mount hook the MCP module sets in a binary built with the `surf.MCPBuildTag` tag (`summercms_mcp` in `summer.yaml` `build.tags`), after every plugin route and the admin exist. Off (the default) mounts nothing and changes nothing; on without the tag fails boot with an error naming the tag.
- MCP module hook: with `mcp.enabled` true, `surf.BuildRouter` calls the mount hook the MCP module sets in a binary built with the `surf.MCPBuildTag` tag (`summercms_mcp` in `summer.yaml` `build.tags`), after every plugin route and the admin exist. Off (the default) mounts nothing and changes nothing; on without the tag fails boot with an error naming the tag. The tagged `pier_on.go` sets the hook with an adapter that gives [pier](../pier/README.md) the route table, `surf.Router.Handler`, `surf.Router.MountStream` and a plain GET mount for its metadata documents, so pier never imports surf; its routes are owned by `summercms.pier`.
- A read-only route table (`surf.Router.Routes`), one route's compiled chain (`surf.Router.Handler`), the admin mount status (`surf.Router.AdminPrefix`) and the `serve` and `route:list` commands.
## Usage