From 14979e7009250d750efd70165a839e33920c14c4 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 28 Oct 2026 13:02:54 +0100 Subject: [PATCH] docs(32.1): capture phase context --- .../32.1-CONTEXT.md | 124 +++++++++++++++++ .../32.1-DISCUSSION-LOG.md | 126 ++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md create mode 100644 .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-DISCUSSION-LOG.md diff --git a/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md new file mode 100644 index 00000000..e6648091 --- /dev/null +++ b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md @@ -0,0 +1,124 @@ +# Phase 32.1: MCP OAuth for plugin apps: declared scopes, MCP subject on context, per-client MCP consent - Context + +**Gathered:** 2026-10-28 +**Status:** Ready for planning + + +## Phase Boundary + +An agent connected to `/mcp` over OAuth gets the same tools, attribution and revocation as one using a member personal API token, so that a consuming application can switch OAuth sign-in on in production. Three additive gaps are closed: + +1. **Declared scopes over MCP OAuth.** Scopes a plugin declares through `pact.HasFrontendTokenScopes` can be requested, consented to and granted on the MCP OAuth server, and appear in pier's protected-resource metadata. Today the list is fixed to `read`, `write`, `ai`, `offline_access` (+ `backend` on the MCP server). +2. **MCP call context.** A route run by an MCP tool call can read who called it (guard, user, client ID, token id, scopes) and the MCP session through a `pact` accessor. +3. **MCP consent row.** `sm-oauthserver-plugin` records a consent for MCP grants, so the existing per-client revoke ends one agent's grant and its MCP sessions. + +Everything ships as patch releases: framework 0.5.x, `sm-oauthserver-plugin`, and `sm-user-plugin` (it publishes the scope catalog). Module READMEs and `docs/` pages describe the changes. + +The source of the three gaps is a consuming app's agent-access work (its D-36, D-37, D-38). Those notes were not found in the local sibling repos; the ROADMAP entry is the authoritative statement. + + + + +## Implementation Decisions + +### Declared scopes and the read/write ceiling +- **D-01:** **The 19.7 D-20 ceiling stays.** OAuth `read`/`write` remain the ceiling: a non-read-only tool still needs `write`, and a route's `user.scope:` still needs the declared scope in the grant. A write tool behind `user.scope:bm:write` needs both `write` and `bm:write`, as a personal token with read/write plus declared scopes does today. No pier ceiling change. +- **D-02:** **Declared scopes are offered on the MCP OAuth server only**, like `backend` (19.7 D-21). The OIDC server keeps its PHP-parity scope list. +- **D-03:** **Advertised scopes = fixed scopes + declared scopes, filtered by an optional opt-in list.** When the config list is set, only the declared scopes it names are advertised and accepted. When it is unset, every declared scope is. The fixed scopes (`read`, `write`, `ai`, `offline_access`, `backend`) are always present. The same set drives the wristband MCP server's metadata `scopes_supported`, its authorize allow-list (`allowedAuthorizeScopes`) and pier's PRM `scopes_supported`. This rule must be documented clearly in the oauthserver README, the pier README and `docs/services/oauth-server.md` / `docs/services/mcp.md`. A name in the list that no plugin declares should be a clear boot error or warning; the planner decides which. — **Reversibility:** costly — scope names published in metadata and stored in grants. +- **D-04:** **The config key lives in sm-oauthserver-plugin** (proposed `golem15.oauthserver.mcp_scopes`), next to where `NewServers` builds `mcpOpts.ScopesSupported`. **pier reads the list from the published verifier** (`pact.MCPTokenVerifier`, extended additively) instead of its own `PRMScopes` constant, so the PRM and the authorization server cannot drift. `pier.PRMScopes` stays as the fallback when no verifier publishes a list. +- **D-05:** **Plumbing: a published pact catalog.** The plugin that owns member tokens (sm-user-plugin, which already compiles and validates `FrontendTokenScopes()` at boot) publishes the compiled list on the app (`app.Lookup[...]`) through a new additive pact interface. sm-oauthserver-plugin and pier read it at boot. Labels and descriptions travel with the names so the consent page can show them. + +### Consent page +- **D-06:** **Per-scope checkboxes.** This changes the consent behaviour from 19.4's allow/deny-the-whole-request. Every requested scope starts ticked. `read`, when requested, is locked (cannot be unticked). `offline_access`, `backend` and declared scopes can be unticked. A submit that leaves no data-bearing scope keeps the existing 422 `no_grantable_scopes`. `MCPConsentDecide` already intersects submitted ∩ requested (`grantedScopes`), so the server side mostly exists. +- **D-07:** **Both surfaces.** The MCP consent JSON (show answer of `controllers/mcp_consent_api.go`) carries a label and description per requested scope: the FrontendTokenScope label/description resolved through phrasebook for declared scopes, existing text for fixed scopes. It also says whether each scope is locked, so redirect-mode app pages can render checkboxes or keep all-or-nothing (as the consuming app chose in its D-07). The built-in consent page renders the checkboxes. *Research must confirm which built-in page the MCP flow uses today* (`views/consent.html.tmpl` vs redirect-only to an app `/connect` page). If the MCP flow has no built-in page, the checkboxes go wherever the plugin ships its MCP consent rendering, and the planner raises it. +- **D-08:** **Re-consent on scope growth.** When a client that already holds a consent requests any scope the recorded consent does not cover, the user goes through the consent page again. After the decision, the stored consent's scopes become the newly granted set. + +### MCP call context accessor +- **D-09:** **The accessor lives in `pact`:** `pact.MCPCallFrom(ctx) (MCPCall, bool)` with a matching setter that pier calls next to `bouncer.WithDispatch` in `dispatch.go`. Plugins already import pact, so they need no pier or bouncer import. +- **D-10:** **New `pact.MCPCall` wrapper:** `MCPCall{Subject MCPSubject, SessionID string, Tool string}`. `MCPSubject` (the guard-facing type) stays unchanged. `SessionID` is the MCP session id and is empty in pier's stateless mode. `Tool` is the tool name. Exact field names are the planner's call within this shape. — **Reversibility:** costly — exported pact API that consuming apps will read. +- **D-11:** **Every MCP tool call carries it:** OAuth and personal-token, frontend and backend guards. The subject is the one pier resolved for that tool's guard (`id.subjectFor(t.guardName)`). Plain HTTP requests have none (`ok == false`). A personal-token call's credential keeps carrying the token id as today (sm-user-plugin v0.1.14); the accessor is additive to it, not a replacement. + +### MCP consent row and revoke +- **D-12:** **The same `golem15_oauthserver_consents` table.** When an MCP code is issued (`MCPConsentDecide` → `IssueCode`, and any direct grant path through the MCP store), upsert the (user_id, client_id) row with the granted scopes. MCP clients are DCR clients distinct from OIDC clients, so the unique (user_id, client_id) constraint does not collide. The existing `revoke` (deletes the consent, plus codes, access and refresh tokens of the pair, then emits `MCPSubjectRevoked{ClientID}`, which pier uses to evict sessions) then works unchanged for MCP clients. +- **D-13:** **MCP clients appear in `GET /oauth/authorizations`** with an additive kind marker (e.g. `"kind":"mcp"`; OIDC entries get `"oidc"` or omit it, per the planner). Existing fields and order are unchanged. +- **D-14:** **A data migration backfills consents** for grants that predate this release: one row per (user, client) with live MCP access or refresh tokens, scopes = union of the tokens' scopes, `created_at` = earliest token. It must be idempotent and skip pairs that already have a consent. — **Reversibility:** one-way — writes rows into a shared table on every consuming install. +- **D-15:** **One revoke ends everything for that client:** frontend and backend (`backend_user_id`) access and every MCP session of the client. No separate backend revoke. + +### Release +- **D-16:** Patch releases only: framework 0.5.x (never hardcode the version), `sm-user-plugin` (catalog publication), `sm-oauthserver-plugin` (scopes, consent JSON/page, consent row, backfill, listing marker). Commit and push each plugin before bumping pointers in apps. Each pushed framework tag gets a changelog post. + +### Claude's Discretion +- The pact interface name and shape for the published scope catalog (D-05), within "names + label + description, additive". +- Whether an opt-in name nobody declares fails boot or warns (D-03). +- Field names of `MCPCall` and the setter name (D-10). +- How the kind marker is spelled in the authorizations listing (D-13). + + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Phase scope +- `.planning/ROADMAP.md` § "Phase 32.1" — goal and the four success criteria. + +### Prior decisions this phase builds on +- `.planning/phases/19.7-generic-mcp-module/19.7-CONTEXT.md` — pier design; D-10 (tool binds to route, in-process dispatch), D-16 (permissions from route middleware), D-20 (read/write ceiling, kept by D-01), D-21 (`backend` scope on the MCP server only). +- `.planning/phases/19.4-oidc-provider-on-wristband/19.4-CONTEXT.md` — sm-oauthserver-plugin's two-Server design, client kinds, consent model, additive wristband interfaces. +- `../sm-bm-app/.planning/phases/03.3-mcp-access-for-bm/03.3-CONTEXT.md` — a consuming app's MCP access decisions (D-07 consent page all-or-nothing in that app; personal tokens alongside OAuth). + +### Docs to update +- `modules/pier/README.md`, `modules/wristband/README.md`, `modules/pact/README.md` +- `docs/services/mcp.md`, `docs/services/oauth-server.md`, `docs/services/authentication.md` +- `../sm-oauthserver-plugin/README.md`, the sm-user-plugin README + + + + +## Existing Code Insights + +### Reusable Assets +- `modules/pact/capabilities.go:821-835`: `FrontendTokenScope{Name, Label, Description}` and `HasFrontendTokenScopes`; `ValidTokenScopeName`. +- `modules/pact/mcp.go:134`: `MCPSubject{Guard, UserID, Scopes, ClientID, TokenID}`, wrapped by the new `MCPCall`. +- `modules/pact/mcp.go:289-303`: `MCPSubjectChanged` with `MCPSubjectRevoked` + `ClientID`, which pier already uses to evict one client's sessions (`modules/pier/events.go:75-90`). +- `../sm-oauthserver-plugin/controllers/mcp_consent_api.go`: `MCPConsentDecide` and `grantedScopes` (submitted ∩ requested) already support partial grants. +- `../sm-oauthserver-plugin/controllers/authorizations.go`: `AuthorizationsIndex` and `revoke(userID, clientID)` (consent + codes + access/refresh tokens in one tx, then the revoked event). + +### Established Patterns +- Scope lists are hardcoded today: `modules/wristband/server.go:99` (`ScopesSupported`), `modules/wristband/authorize.go:24` (`authorizeAllowedScopes`, used only when `Options.ScopesSupported` is empty), `modules/pier/prm.go:14` (`PRMScopes`). +- `../sm-oauthserver-plugin/servers.go:158` builds the MCP server's scopes as OIDC scopes + `backend`; the declared/opt-in set goes in here. +- `modules/pier/filter.go:60-70`: the read/write ceiling (`id.writes()`) before the guard's `MCPAllows`; unchanged by D-01. +- sm-user-plugin `classes/mcp_guard.go`: `MCPAllows` requires the route's `user.scope` names in `subject.Scopes`; the personal-token `MCPDispatch` builds an in-memory credential carrying the token. +- Find-or-publish of shared registries on `backpack.App` (`pact.MCPGuards`) is the model for the published scope catalog. + +### Integration Points +- `modules/pier/dispatch.go:177-200`: where `bouncer.WithDispatch` is set; the `MCPCall` setter goes next to it (session id from the call info, tool from `t.name`). +- `modules/pier/prm.go:mountPRM`: already looks up `pact.MCPTokenVerifier`; extend it to read the scope list from there. +- `../sm-oauthserver-plugin/classes/mcp_store.go`: the MCP grant/token store; the consent upsert hooks in at code issue (and at direct grant minting). +- `../sm-oauthserver-plugin/updates/`: new migration for the backfill (D-14). + + + + +## Specific Ideas + +- The opt-in-or-all rule (D-03) must be documented with examples: unset = all declared scopes, set = only those listed, fixed scopes always. +- A redirect-mode app may keep an all-or-nothing consent page; the JSON must give it enough (label, description, locked) to render either way. + + + + +## Deferred Ideas + +- Offering declared scopes on the OIDC server (non-MCP OAuth clients). Rejected for this phase (D-02). +- Release/rollout order across framework, sm-user-plugin, sm-oauthserver-plugin and the consuming app was not discussed in detail; the planner follows the standard "plugin first, pointer bump second" order. + +### Reviewed Todos (not folded) +- 27 pending todos matched on keywords only (e.g. "Record PHP OIDC parity against WavePath", user-plugin logout cookie, activate-by-code second factor). None touch MCP scopes, call context or MCP consent; none were folded. + + + +--- + +*Phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con* diff --git a/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-DISCUSSION-LOG.md b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-DISCUSSION-LOG.md new file mode 100644 index 00000000..1d63f5b2 --- /dev/null +++ b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-DISCUSSION-LOG.md @@ -0,0 +1,126 @@ +# Phase 32.1: MCP OAuth for plugin apps - 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-28 +**Phase:** 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con +**Areas discussed:** Declared scopes vs ceiling, Consent page for scopes, MCP subject accessor, MCP consent row & revoke + +--- + +## Declared scopes vs ceiling + +| Option | Description | Selected | +|--------|-------------|----------| +| Keep both | D-20 read/write ceiling stays; declared scopes are extra gates | ✓ | +| Declared scope stands alone | Tools behind a declared scope skip the read/write ceiling | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Fixed + all declared | Every FrontendTokenScope is advertised | | +| Fixed + config opt-in list | Only listed declared scopes advertised | | +| Other | | ✓ | + +**User's choice:** Opt-in config list if set; if unset, all declared scopes. "Document this properly." + +| Option | Description | Selected | +|--------|-------------|----------| +| MCP server only | Same as `backend` (D-21) | ✓ | +| Both servers | OIDC clients could request plugin scopes too | | + +| Option | Description | Selected | +|--------|-------------|----------| +| You decide | Researcher/planner pick the mechanism | | +| Published pact catalog | User plugin publishes the compiled list; oauthserver and pier read it | ✓ | + +--- + +## Consent page for scopes + +| Option | Description | Selected | +|--------|-------------|----------| +| oauthserver, pier reads it | `golem15.oauthserver.mcp_scopes`; pier reads from the verifier | ✓ | +| pier (mcp.scopes) | MCP module owns the list | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Label+description, all-or-nothing | As in 19.4 | | +| Per-scope checkboxes | User can untick individual scopes | ✓ | + +| Option | Description | Selected | +|--------|-------------|----------| +| Both: JSON + built-in page | Consent JSON carries label/description; built-in page renders them | ✓ | +| JSON only | Built-in page shows raw names | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Show consent again | Any uncovered requested scope sends the user through consent | ✓ | +| You decide | Follow the OIDC path | | + +| Option | Description | Selected | +|--------|-------------|----------| +| All ticked, read locked | `read` can't be unticked; others can | ✓ | +| All ticked, none locked | Anything can be unticked | | + +--- + +## MCP subject accessor + +| Option | Description | Selected | +|--------|-------------|----------| +| pact | `pact.MCPSubjectFrom(ctx)` | ✓ | +| pier | `pier.SubjectFrom(ctx)` | | +| bouncer | Subject field on `bouncer.Dispatch` | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Every MCP tool call | OAuth + PAT, frontend + backend | ✓ | +| OAuth calls only | | | + +| Option | Description | Selected | +|--------|-------------|----------| +| MCPSubject as is | Guard, UserID, ClientID, TokenID, Scopes | | +| Plus the MCP session id | Also Mcp-Session-Id | ✓ | + +| Option | Description | Selected | +|--------|-------------|----------| +| New pact.MCPCall wrapper | `MCPCall{Subject, SessionID, Tool}`; MCPSubject unchanged | ✓ | +| SessionID field on MCPSubject | Guards see the field too | | + +--- + +## MCP consent row & revoke + +| Option | Description | Selected | +|--------|-------------|----------| +| Same consents table | Upsert (user, client) at MCP code issue | ✓ | +| Separate MCP consents table | New table, revoke/listing look in both | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Yes, with a kind marker | Additive `"kind":"mcp"` | ✓ | +| Yes, no new field | | | +| Separate MCP list endpoint | | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Migration backfills rows | From live MCP access/refresh tokens | ✓ | +| Lazy: write on next refresh | | | +| Nothing | | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Yes, one revoke ends all | Frontend + backend + sessions | ✓ | +| You decide | | | + +--- + +## Claude's Discretion + +- Pact catalog interface name/shape; boot error vs warning for unknown opt-in names; `MCPCall` field and setter names; kind marker spelling. + +## Deferred Ideas + +- Declared scopes on the OIDC server. +- Detailed rollout order across repos (planner uses the standard plugin-first order).