docs(19.7): capture phase context

This commit is contained in:
Jakub Zych
2026-10-18 12:41:37 +02:00
parent bdc4107215
commit b8f90749c7
2 changed files with 298 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
# Phase 19.7: Generic MCP module - Context
**Gathered:** 2026-10-18
**Status:** Ready for planning
<domain>
## Phase Boundary
A new opt-in framework module, **`pier`**, mounts a stateful streamable-HTTP MCP endpoint inside a SummerCMS binary. Plugins declare MCP tools (and prompts) in Go through an optional `pact` capability. Each tool binds to one of the plugin's own token-guarded routes and is called in-process through that route's middleware. `tools/list` is computed per principal from the existing permission systems. An editor connects one MCP client once, through the OAuth sign-in of 19.4's `sm-oauthserver-plugin` as their frontend user. With a verified link to a backend user and a granted `backend` scope, the backend-guarded tools (Journal) run as that admin.
The phase also delivers its two prerequisite todos, both in `sm-user-plugin` and additive only:
- plugin-registered frontend permission codes;
- the verified frontend ↔ backend account link, including the cabana seam it needs.
The first real tools ship in Journal (backend) and sm-user-plugin (frontend), and sm-demo-app mounts the module.
Out of scope: MCP resources (the capability is designed for them, but they ship later), resource subscriptions, and generating tools from `fields.yaml`/cabana CRUD.
</domain>
<decisions>
## Implementation Decisions
### Prerequisites and sequencing
- **D-01:** **Both prerequisite todos are folded into 19.7**, as early plans before the MCP module itself: `2026-10-17-plugin-registered-frontend-permissions` and `2026-10-17-frontend-backend-account-link`. One sm-user-plugin release, one pin bump per app, and one security review cover the whole identity chain.
- **D-02:** **19.7 runs after 19.4 is complete.** OAuth for MCP comes from `sm-oauthserver-plugin`'s MCP `wristband.Server` (`/oauth/mcp/*`, DCR, 19.4 D-01/D-02/D-06). 19.7 adds no second OAuth adapter. It adds the `backend` scope (D-21) and resolves the backend principal next to the frontend one.
- **D-03:** **The account link lives in sm-user-plugin.** That covers:
- the table, for example `golem15_user_backend_links` (name is the planner's call);
- the proof round-trip;
- a new backend permission (for example `golem15.user.manage_backend_links`);
- the admin set/remove action;
- an `app.Lookup` service the MCP module calls, for example `LinkedBackendUser(ctx, frontendUserID)` (name is the planner's call).
Only sm-user-plugin can verify a frontend sign-in (password, sociallogin, the mfa gate), and sm-oauthserver-plugin already `Requires()` it. The framework never imports the user plugin. — **Reversibility:** costly — the table and service become part of a core plugin's contract consumed by apps and pier.
- **D-04:** **cabana gains an additive, generic seam** so that a plugin can contribute:
- a section or action to **My Profile** (`cabana/account.go` and the admin SPA account page), which sm-user-plugin uses for "Link frontend account";
- a record action on the **backend Users** screen (set/remove the link).
The seam is generic and names neither the user plugin nor MCP. Existing cabana payloads are unchanged. The researcher checks whether the 19.6 D-15 "Reset 2FA" action needs the same seam, and reuses it if so.
- **D-05:** **The link is proven by a site sign-in round-trip.** "Link frontend account" creates a one-shot, signed, short-lived link request bound to the admin. The admin is sent to the site login with it. The frontend user signs in by any method the site supports (password, social login, 2FA), and the request is confirmed. Only then is the link saved. Linking by matching email, or by picking a user from a list without proof, is never allowed. — **Reversibility:** one-way — a link created without proof lets an admin act as that frontend user through MCP; unproven links cannot be told apart afterwards.
- **D-06:** **Cardinality 1:1**, unique on both sides: a frontend user has at most one backend link, and a backend user has at most one frontend link.
- **D-07:** **Admin management behind the new backend permission.** A holder can remove any link and can set one for editor onboarding. A link set by an admin records `linked_by` and fires an event. The self-service My Profile path always needs the D-05 proof.
- **D-08:** **The link is resolved on every request, not stored in the OAuth grant.** Unlinking, deleting either user, or revoking the grant takes effect on the next MCP call with no token surgery. One indexed query per request, which may be cached briefly; the planner decides and states the staleness bound.
### Frontend permission codes (todo)
- **D-09:** A new optional `pact` capability is the frontend counterpart of `pact.HasPermissions`. A plugin returns codes with label, tab and comment. sm-user-plugin offers them in the user and group permission editors next to the rows seeded in `golem15_user_frontend_permissions`. Additive only: existing seeded rows, the table, the codes and the payloads stay as they are (core-plugin rule).
### Tool declaration and dispatch
- **D-10:** **A tool binds to a route by method + path** (for example `POST /_journal/api/v1/posts`), resolved at boot in surf's route table. **Boot fails** if the route does not exist. The call goes **in-process through that route's full middleware chain** (throttles, scope, permission), so it runs the same code path as HTTP. A tool carries no handler func.
- **D-11:** **Arguments map through one input struct with tags.** `path:"id"` fills `{id}`, `query:"q"` goes to the query string, and the remaining `json` fields form the body. The input JSON schema is reflected from the same struct, with `jsonschema` tags supplying descriptions.
- **D-12:** **Names are auto-prefixed with the plugin short code.** A plugin declares `create_post`, and pier exposes `journal_create_post`. A duplicate within one plugin is a boot error. Prompts follow the same rule. — **Reversibility:** costly — clients and saved agent configs reference tool names once shipped.
- **D-13:** **Results pass the route's JSON through.**
- A 2xx body is returned as text content plus `structuredContent`.
- An optional Output struct produces an `outputSchema`.
- A 4xx/5xx becomes `isError: true` carrying the route's own error body, so the agent sees the real validation errors.
- **D-14:** **Annotations are derived from the HTTP method and can be overridden.** GET gives `readOnlyHint`, DELETE gives `destructiveHint`, and PUT gives `idempotentHint`. A tool may override any hint.
- **D-15:** **Only token-guarded routes can be targets.** At boot the route must either carry the guard's token-accepting middleware (`user.api_token` for `user`, the cabana backend token guard for `backend`), or its tool must explicitly declare a guard. The second case covers routes that authenticate inside the handler. A cookie/CSRF-only or public route can't become a tool by accident.
- **D-16:** **Permissions = route middleware ∪ tool declaration.** pier derives the requirements from route middleware where present (`user.scope:*`, `user.permission:<code>`, cabana scope/permission). A tool may also declare `Permissions`, which `tools/list` checks too. Journal is the case that needs this: `POST /_journal/api/v1/posts` has only `throttle:journal-api` and checks `cabana.Allows` inside `requireBackendPrincipal`. The declaration only filters the list, and the route still enforces on every call.
- **D-17:** **Identity reaches the handler as a pre-resolved principal on the context.** pier sets the resolved principal (`bouncer.WithUser` / the user plugin's equivalent) behind a framework-owned marker that only an in-process pier dispatch can set; it must not be settable from an HTTP request. The following honour that marker before parsing the Authorization header, additively:
- bouncer;
- sm-user-plugin's `user.api_token` middleware;
- Journal's `requireBackendPrincipal`.
No credential is minted per call. Security review covers the marker. — **Reversibility:** costly — three auth paths in two core plugins learn the marker.
- **D-18:** **The first tools ship in this phase.** Journal declares backend post tools (list/show/create/update, plus whatever research finds useful), and sm-user-plugin declares a few frontend tools (for example own profile). sm-demo-app enables pier and mounts both, which proves both guards end to end.
### Session credentials and filtering
- **D-19:** **Personal tokens can open an MCP session behind a config flag**, off by default (spelling is the planner's call, for example `pier.allow_personal_tokens`).
- A `user_api_tokens` bearer yields frontend tools only.
- A cabana `g15_…` token yields backend tools only.
- Each is capped by that token's own scope, exactly as over HTTP.
OAuth is always available.
- **D-20:** **OAuth `read`/`write` are a ceiling.** A grant with only `read` offers only tools with `readOnlyHint`, and `write` unlocks the rest. Permissions always filter on top: a scope never grants anything.
- **D-21:** **Backend tools need a separate `backend` OAuth scope.** A client must request `backend`. The consent screen shows a line such as "also act as admin <name>" only when the user has a link. A user can therefore connect a client to frontend tools only, and the scope is a visible, revocable ceiling. This adds the scope to sm-oauthserver-plugin's MCP Server (additive to 19.4's `read`/`write`/`ai`/`offline_access`). — **Reversibility:** costly — published scope name in the MCP metadata and in existing grants.
- **D-22:** **The backend principal keeps the linked admin's real cabana permissions**, superuser included, capped by the grant's read/write scope (D-20). The security review covers superuser reach through MCP.
- **D-23:** **A call runs as the tool's declared guard.** Each tool has exactly one guard: a `backend` tool runs as the linked admin, and a `user` tool runs as the frontend user. `tools/list` is the union of what each identity may call. Audit and actor attribution name the real actor.
### Transport, SDK and features
- **D-24:** **The SDK is the official `github.com/modelcontextprotocol/go-sdk`.** A decision note under `.planning/notes/` names it and its pinned version before code lands, and `.planning/research/STACK.md` is updated (CLAUDE.md dependency rule). Research also:
- checks the transitive dependencies, including `google/jsonschema-go` for schema reflection;
- confirms that the streamable HTTP handler supports the session-store and auth hooks needed for D-25.
- **D-25:** **Stateful streamable HTTP with Redis-backed sessions** (`Mcp-Session-Id`, SSE streams), shared across instances on 19.3's Redis.
- A session never replaces the bearer: **every request is authorized fresh**.
- A session is **bound to the principal(s) that opened it**. A different token presenting the same session ID is refused.
- DELETE on the endpoint ends a session.
— **Reversibility:** costly — session storage and the multi-instance fan-out shape the module's internals.
- **D-26:** **`list_changed` is pushed.** These events fan out over Redis pub/sub to the instance holding the session, which sends `notifications/tools/list_changed` (and `prompts/list_changed`):
- frontend user or group permission changes;
- backend permission changes;
- link set or removed;
- grant or token revoked.
Every request still re-checks (D-08), so the push only shortens how long a client shows a stale list.
- **D-27:** **Sessions use a sliding idle TTL** (configurable, for example 30 min) in Redis. Revoking the grant or token, deleting the user or removing the link ends or downgrades the session.
- **D-28:** **The capability is designed for tools, prompts and resources; 19.7 ships tools and prompts.** A prompt is a template with arguments, filtered by the same guard and permissions and prefixed like tools (D-12). Resources (URI template → GET route) are deferred, but the `pact` capability's shape leaves room for them without a breaking change. — **Reversibility:** costly — the pact capability is a published framework API.
- **D-29:** **Module name `pier`**, where outside boats (agents) dock to reach the beach. It gets a README in the standard structure with a `**Name:**` mnemonic and a row in the root `README.md` table.
- **D-30:** **Off by default.** With pier disabled nothing is mounted and nothing changes, the same pattern as `http.api_docs.enabled` mounting `/api-docs` (`modules/surf/apidocs.go`).
### Quality gates (from the roadmap)
- **D-31:** The phase gets a **security review**: link proof, the D-17 context marker, session binding, scope ceilings and superuser reach. **Unit tests are the last plan.** Commit order follows the existing pattern:
1. framework release (pier, the cabana seam, the bouncer marker);
2. sm-user-plugin;
3. sm-oauthserver-plugin (`backend` scope, consent line);
4. sm-journal-plugin (tools, marker);
5. app pointer bumps as separate commits.
fonoteka.go and sm-bm-app must still build and pass at the bumped pins. Push, tag and repo work need the user's go-ahead.
### Claude's Discretion
- Exact Go identifiers: the pact capability and tool/prompt struct names, the link service, the context marker, the table name and the permission code.
- Config key spellings (enable flag, endpoint path with default `/mcp`, the personal-token flag, the session TTL).
- The link-request token format and TTL, within "one-shot, signed, short-lived, bound to the admin".
- The cache bound for the per-request link lookup.
- Which Journal and user tools ship beyond the minimum that proves both guards.
- How the cabana seam is shaped (contribution interface vs extending `AdminController`), as long as it is generic and additive.
### Folded Todos
- **Plugins register their frontend permission codes in Go** (`.planning/todos/pending/2026-10-17-plugin-registered-frontend-permissions.md`, g15office `hmguritm`). Today a plugin guarding a route with `user.permission:<code>` cannot declare that code, so every app must seed it. `tools/list` filters by these codes, so they are needed here. This becomes D-09.
- **Verified link between a frontend user and a backend user** (`.planning/todos/pending/2026-10-17-frontend-backend-account-link.md`, g15office `tqyvnzxx`). One person holds a frontend and a backend account linked together, so an MCP grant can run backend tools as the linked admin. This becomes D-03 to D-08.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### MCP module design
- `.planning/notes/2026-10-17-generic-mcp-module.md`: the agreed shape (opt-in module, plugin-declared tools, per-principal `tools/list`, one connector per person, Journal stays on `backend_users`, link proof rule) and the open questions this context resolves.
- `.planning/ROADMAP.md` § "Phase 19.7: Generic MCP module": goal and success criteria.
### Prerequisite todos (folded)
- `.planning/todos/pending/2026-10-17-plugin-registered-frontend-permissions.md`
- `.planning/todos/pending/2026-10-17-frontend-backend-account-link.md`
### OAuth dependency
- `.planning/phases/19.4-oidc-provider-on-wristband/19.4-CONTEXT.md`: sm-oauthserver-plugin, the two-Server design (D-06), client kinds (D-08), additive wristband interfaces (D-09), MCP scopes.
- `.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-CONTEXT.md`: the login-gate seam (D-02) the link proof's site sign-in passes through, and the Users-screen admin action (D-15) that may share the D-04 cabana seam.
- `.planning/phases/19.5-social-login-in-sm-user-plugin/19.5-CONTEXT.md`: sociallogin as a site sign-in method the link proof must accept.
### Project rules
- `.planning/notes/core-plugins-own-repos.md`: plugin repos, submodule mounting, commit order.
- `.planning/research/STACK.md`: the decided dependency set; the go-sdk is added through a decision note.
- `CLAUDE.md`: dependency rule, docs rule (module README and docs/ pages), core-plugin additive rule.
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `modules/surf/apidocs.go` (`mountAPIDocs`, `http.api_docs.enabled`): the pattern for a config-gated, off-by-default mount.
- `modules/pact/capabilities.go`: `HasPermissions`, `HasTokenScopes` and `TokenScope{Permissions, Methods}` are the models for the new frontend-permission and MCP capabilities. `Router` has no route names, so tools bind by method + path (D-10).
- `modules/bouncer/context.go`: `Principal{Backend, IsSuperuser, PermissionGrants, Scope}` and `WithUser` carry the pre-resolved principal (D-17).
- `modules/cabana/contracts.go:197`: `cabana.Allows(principal, required)` is the backend filter for `tools/list`.
- sm-user-plugin `classes/permissions.go`: `MergedPermissions`, `HasPermission` and `UserHasPermission` (tri-state allow/inherit/deny) are the frontend filter.
- sm-user-plugin `plugin.go:244-252`: the `user.api_token`, `user.scope` and `user.permission` middleware names that pier parses from the route table.
- `modules/wristband/server.go`: the MCP OAuth Server; metadata and `ScopesSupported` (where `backend` is added through sm-oauthserver-plugin's options).
- `modules/cooler` (Redis client, `rediscache.go`): Redis for session storage and pub/sub (D-25/D-26), on 19.3's infrastructure.
- `modules/cabana/account.go` and `account_tokens.go`: the My Profile endpoints, next to which the D-04 seam lands.
### Established Patterns
- Journal (`sm-journal-plugin/routes.go:25-30`, `controllers/api/auth.go:66-94`): its management routes carry only `throttle:journal-api` and authenticate in the handler (`requireBackendPrincipal`, kept for PHP-parity error bodies). Hence D-15/D-16/D-17.
- Shared plugins depend on the framework, never the reverse. A framework module reaches plugin services through `app.Lookup`.
- The two prior pattern references: `fonoteka-mcp` (Node, `@modelcontextprotocol/sdk`, in `/media/nvme/dev/golem15/fonoteka/fonoteka-mcp`) is today's separate MCP server calling the token API, and WinterCMS `registerMcpTools()` is the declaration counterpart.
### Integration Points
- surf route table: boot-time lookup of method + path to the compiled handler chain, and in-process dispatch.
- sm-oauthserver-plugin (19.4): bearer → frontend user plus granted scopes; `backend` scope and consent line.
- sm-user-plugin: link table and service, frontend permission capability, `user.api_token` marker support, frontend tools.
- cabana: My Profile and backend Users contribution seam; backend token guard marker support.
- sm-demo-app: enables pier and mounts Journal and user tools.
</code_context>
<specifics>
## Specific Ideas
- The user asked for a rationale against each placement before choosing the link's home. The deciding facts were:
- only sm-user-plugin can verify a frontend sign-in;
- the framework cannot import the plugin;
- both affected screens are cabana's, so a generic cabana seam is needed whichever side owns the link.
- "One person, one connector" is the core UX: an editor adds the site's MCP URL once and sees both sets of tools.
</specifics>
<deferred>
## Deferred Ideas
- **MCP resources** (URI template → GET route, guard-filtered) and `resources/subscribe`. The capability is shaped for them (D-28), so a follow-up phase adds them.
- **Generated admin CRUD tools from `fields.yaml`.** Ruled out in the design note: agents use plugin management routes.
### Reviewed Todos (not folded)
The other `todo.match-phase` hits matched keywords only and are unrelated:
- `journal-author-profile-fields`
- `user-plugin-commands-open-database` (already folded into 19.5)
- `translate-messages-as-nuxt-i18n-json-editor`
- `reload-open-apps-when-a-new-version-is-deployed`
- `uploads-keep-client-extension-stored-xss`
</deferred>
---
*Phase: 19.7-generic-mcp-module*
*Context gathered: 2026-10-18*

View File

@@ -0,0 +1,90 @@
# Phase 19.7: Generic MCP module - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
**Date:** 2026-10-18
**Phase:** 19.7-generic-mcp-module
**Areas discussed:** Prerequisites & sequencing, Tool declaration & dispatch, Session credentials & filtering, Transport & SDK
---
## Prerequisites & sequencing
| Question | Options | Selected |
|----------|---------|----------|
| Where are the two prerequisite todos built? | Fold into 19.7 / Separate decimal phase first / Permissions in, link deferred | Fold into 19.7 |
| Dependency on 19.4 sm-oauthserver-plugin | Requires 19.4 done / Pluggable authenticator | Requires 19.4 done |
| Where does the account link live? (first pass) | sm-user-plugin / cabana / inside the MCP module | User asked: "sm-user-plugin really recommended, rethink this and give me better rationale against each" |
| Where does the account link live? (after rationale) | sm-user-plugin + cabana seam / cabana generic identities / inside the MCP module | sm-user-plugin + cabana seam |
| How is the link proven? | Site sign-in round-trip / Enter frontend credentials in admin / You decide | Site sign-in round-trip |
| When is the link resolved? | Per request / Stored in grant | Per request |
| Link cardinality | 1 frontend ↔ 1 backend / 1 backend ↔ many frontend | 1 ↔ 1 |
| Admin link management | Permission-gated, unlink + link / Superuser only, unlink only | Permission-gated, unlink + link |
**Notes:** The rationale given for the second pass:
- Only sm-user-plugin can verify a frontend sign-in, and the framework cannot import it.
- My Profile and backend Users are both cabana screens, so a generic cabana seam is needed whichever side owns the link.
- A cabana-owned table would need an identity-prover registry, and it would leave orphan rows on frontend-user deletion.
- An MCP-owned link has every downside of the cabana option and none of its upsides.
---
## Tool declaration & dispatch
| Question | Options | Selected |
|----------|---------|----------|
| Binding | Route by method+path / Own handler func / Both | Route by method+path |
| Argument mapping | Struct tags / Separate structs | Struct tags |
| Naming | Auto-prefix plugin code / Plugin picks full name / App-configurable prefix | Auto-prefix plugin code |
| Result | Route JSON passthrough / Typed output required | Route JSON passthrough |
| Annotations | Derived, overridable / Explicit only | Derived, overridable |
| Targets | Token-guarded only / Any route | Token-guarded only |
| First tools | Journal + user plugin tools / Framework + example only | Journal + user plugin tools |
| Handler-auth routes (Journal) | Declared on tool, middleware merged / Move Journal to middleware | Declared on tool, middleware merged |
| Identity into handler | Pre-resolved principal in context / Mint short-lived JWT | Pre-resolved principal in context |
**Notes:** Two follow-up questions came from a scouting finding. Journal's management routes carry only `throttle:journal-api` and authenticate inside the handler (`requireBackendPrincipal`).
---
## Session credentials & filtering
| Question | Options | Selected |
|----------|---------|----------|
| Personal tokens for MCP sessions | Yes, single identity / Yes, behind a config flag / OAuth only | Yes, behind a config flag |
| OAuth read/write | read = read-only tools / Scopes ignored | read = read-only tools |
| Backend reach | Separate `backend` scope + consent line / Automatic with link | Separate `backend` scope + consent line |
| Admin permissions via MCP | Full admin perms, read/write capped / Exclude superuser | Full admin perms, read/write capped |
| Which identity runs a call | The tool's declared guard / Prefer backend | The tool's declared guard |
| list_changed (asked assuming stateless) | No, recompute per request / Yes, via event bus | No (later revised, see Transport) |
---
## Transport & SDK
| Question | Options | Selected |
|----------|---------|----------|
| SDK | Official modelcontextprotocol/go-sdk / mark3labs/mcp-go / Hand-written JSON-RPC | Official go-sdk |
| Session model | Stateless / Stateful with Redis | Stateful with Redis |
| Features (first pass) | Tools only / Tools + prompts / Tools + resources | "Tools + prompts + resources ?" |
| Features (after explanation) | Design for all, ship tools + prompts / All three / Tools only, design for all | Design for all, ship tools + prompts |
| list_changed with sessions | Yes, via event bus + Redis / No, re-check only | Yes, via event bus + Redis |
| Session lifetime | Sliding idle TTL, ends on revoke / You decide | Sliding idle TTL, ends on revoke |
| Module name | conch / pier / buoy | pier |
**Notes:** Choosing stateful sessions reopened the earlier list_changed answer, which was revised to push notifications. Authorization stays per request, and sessions are bound to their principal(s).
---
## Claude's Discretion
- Go identifiers, the table and permission names, and the config key spellings.
- The link-request token format and TTL, and the link-lookup cache bound.
- Which Journal and user tools ship beyond the minimum.
- The shape of the cabana seam.
## Deferred Ideas
- MCP resources and subscriptions (follow-up phase).
- Tools generated from `fields.yaml` (ruled out).