From 3f52b1ab495b487b03b8e534bdfc6b6676104b00 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 28 Oct 2026 15:06:32 +0100 Subject: [PATCH] docs(32.1-02): complete OAuth implied scopes plan --- .planning/ROADMAP.md | 4 +- .../32.1-02-SUMMARY.md | 194 ++++++++++++++++++ 2 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-SUMMARY.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 05d16ced..0244e1ad 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1621,7 +1621,7 @@ Plans: **Goal:** As an application whose plugins declare their own member token scopes, I want an agent connected to `/mcp` over OAuth to get the same tools, attribution and revocation as one using a personal token, so that OAuth sign-in can be switched on in production. A consuming app's agent-access work found three gaps (its D-36, D-37 and D-38); all three are additive and ship as patch releases of the framework (0.5.x) and `sm-oauthserver-plugin`. **Requirements**: TBD **Depends on:** Phase 19.4 (OIDC provider on wristband), Phase 19.7 (generic MCP module) -**Plans:** 1/5 plans executed +**Plans:** 2/5 plans executed **Success Criteria** (what must be TRUE): 1. Scopes a plugin declares through `pact.HasFrontendTokenScopes` can be requested, consented to and granted over MCP OAuth: the wristband metadata and authorize allow-list and pier's protected-resource scopes include them, so a tool route behind `user.scope:` lists and runs for an OAuth agent granted that scope. Today the list is fixed to `read`, `write`, `ai`, `offline_access` (and `backend`). @@ -1634,7 +1634,7 @@ Plans: - [x] 32.1-01-PLAN.md — Framework seam: catalog, MCPCall, one advertised scope list **Wave 2** *(blocked on Wave 1 completion)* -- [ ] 32.1-02-PLAN.md — sm-user-plugin from v0.1.14: publish the catalog and Implies for OAuth subjects +- [x] 32.1-02-PLAN.md — sm-user-plugin from v0.1.14: publish the catalog and Implies for OAuth subjects **Wave 3** *(blocked on Wave 2 completion)* - [ ] 32.1-03-PLAN.md — sm-oauthserver-plugin: consent replace, revoke, backfill diff --git a/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-SUMMARY.md b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-SUMMARY.md new file mode 100644 index 00000000..b4530ce5 --- /dev/null +++ b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-SUMMARY.md @@ -0,0 +1,194 @@ +--- +phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con +plan: 02 +subsystem: auth +tags: [mcp, oauth, scopes, implies, sm-user-plugin] + +requires: + - phase: 32.1-01 + provides: pact.MemberTokenScopeCatalog and FrontendTokenScope.Implies +provides: + - "sm-user-plugin on phase-32.1-mcp-implied-scopes publishes the compiled catalog as pact.MemberTokenScopeCatalog" + - "OAuth MCP subjects expand declared scopes whose Implies is read or write inside MCPAllows and MCPDispatch" + - "Personal tokens are not expanded and keep credential ID equal to TokenID" + - "CompileAPITokenScopes rejects a bad Implies and the reserved names backend and offline_access" +affects: [32.1-03, 32.1-05] + +actuals: + tokens: 10492 + tasks: 2 + commits: 0 +plan_head_before: 415a09333bf9bb2cc93ac8324aac794cc74ff818 +plan_head_after: 415a09333bf9bb2cc93ac8324aac794cc74ff818 + +tech-stack: + added: [] + patterns: + - "One unexported helper, mcpEffectiveScopes, feeds MCPAllows and MCPDispatch" + - "Expansion clones the grant only when ClientID is non-empty and never writes that clone back" + - "The catalog is published twice: the concrete *APITokenScopeCatalog and pact.MemberTokenScopeCatalog" + +key-files: + created: + - ../sm-user-plugin/mcp_implies_test.go + modified: + - ../sm-user-plugin/classes/api_token_scopes.go + - ../sm-user-plugin/classes/mcp_guard.go + - ../sm-user-plugin/plugin.go + - ../sm-user-plugin/go.mod + - ../sm-user-plugin/README.md + +key-decisions: + - "Implies tests live in the plugin root package so go test . runs them; classes/mcp_guard_test.go would report no tests to run" + - "The tracer loads a principal from an in-memory database/sql driver so go test -short proves MCPAllows and MCPDispatch without Docker" + - "The phase branch replace points at ../summercms.go, the sibling of this checkout, not the old submodule-relative path" + +patterns-established: + - "Pattern: OAuth effective scopes are a clone plus catalog names whose Implies is exactly the stock scope the grant holds" + - "Pattern: a personal token returns subject.Scopes unchanged, including slice identity, and credential.ID stays TokenID" + +requirements-completed: [] + +coverage: + - id: D1 + description: "An OAuth subject with ClientID set and scopes read passes MCPAllows for servers:read when Implies is read, and the MCPDispatch credential HasScope servers:read with ID 0. The grant slice is not rewritten. The catalog is published as pact.MemberTokenScopeCatalog." + verification: + - kind: unit + ref: mcp_implies_test.go#TestMCPImpliesOAuth + status: pass + human_judgment: false + - id: D2 + description: "A personal token (ClientID empty) with only read does not gain servers:read, and the dispatch credential ID equals TokenID" + verification: + - kind: unit + ref: mcp_implies_test.go#TestMCPImpliesPersonalUnchanged + status: pass + human_judgment: false + - id: D3 + description: "A grant of write does not satisfy Implies read, and a declared name already on the grant is not appended a second time. A catalog entry with empty Implies is not appended." + verification: + - kind: unit + ref: mcp_implies_test.go#TestMCPImpliesWriteDoesNotGrantRead + status: pass + - kind: unit + ref: mcp_implies_test.go#TestMCPImpliesAlreadyPresent + status: pass + human_judgment: false + - id: D4 + description: "CompileAPITokenScopes fails for Implies other than empty, read, or write, and for declared names backend and offline_access. The error names the plugin and the bad value." + verification: + - kind: unit + ref: mcp_implies_test.go#TestMCPImpliesCompileRejects + status: pass + human_judgment: false + - id: D5 + description: "The plugin README names FrontendTokenScope.Implies, the OAuth-only expansion, unchanged personal tokens, and pact.MemberTokenScopeCatalog, and names no consuming application" + verification: + - kind: other + ref: README.md MemberTokenScopeCatalog and Implies; rg found no consuming-application name + status: pass + human_judgment: false + +duration: 17min +completed: 2026-10-28 +status: complete +--- + +# Phase 32.1 Plan 02: OAuth implied scopes Summary + +**On a branch from sm-user-plugin v0.1.14, an OAuth MCP grant of read satisfies declared scopes marked Implies read, and a personal token keeps its scopes and credential id** + +## Performance + +- **Duration:** 17 min +- **Started:** 2026-10-28T13:48:50Z +- **Completed:** 2026-10-28T14:06:00Z +- **Tasks:** 2 +- **Files modified:** 6 + +## Accomplishments + +- The checkout `/media/nvme/dev/golem15/summercms.io/summercms/sm-user-plugin` is branch `phase-32.1-mcp-implied-scopes` from tag `v0.1.14` (`a1b78b8`). `MCPDispatch` still sets `credential.ID` only when `ClientID` is empty. +- `*APITokenScopeCatalog.MemberTokenScopes` returns the compiled list. Boot publishes that value as `pact.MemberTokenScopeCatalog` beside the concrete catalog and still publishes `pact.MCPPersonalTokenResolver`. +- `mcpEffectiveScopes` is the only expansion. It returns `subject.Scopes` unchanged when `ClientID` is empty. Otherwise it clones the grant and appends a catalog name only when `Implies` is `read` or `write`, the clone holds that stock scope, and the name is not already present. `MCPAllows` and `MCPDispatch` both use it. The clone is not written onto `subject.Scopes`, a token row, or a fingerprint. `APIToken.OAuthClientID` is not set. +- `CompileAPITokenScopes` rejects `Implies` other than empty, `read`, or `write`, and rejects declared names `backend` and `offline_access`. Built-ins keep `Implies` empty. The summercms replace on this branch is `../summercms.go`. + +## Task Commits + +Plugin repo `sm-user-plugin` only. summercms.go had no code commits in this plan (`commits: 0` from `415a093..HEAD` at summary time). + +1. **Task 1: Tracer — v0.1.14 worktree publishes the catalog and expands one implied scope** - `25fd981` (feat) +2. **Task 2: Reject bad Implies and keep personal tokens unexpanded** - `193a381` (test) + +## Files Created/Modified + +- `../sm-user-plugin/classes/api_token_scopes.go` - `MemberTokenScopes`, `Implies` on the compiled scope, rejection of bad `Implies` and of `backend` / `offline_access` +- `../sm-user-plugin/classes/mcp_guard.go` - `mcpEffectiveScopes` shared by `MCPAllows` and `MCPDispatch` +- `../sm-user-plugin/plugin.go` - second publish as `pact.MemberTokenScopeCatalog` +- `../sm-user-plugin/go.mod` - replace `../summercms.go` so the branch compiles against plan 01 +- `../sm-user-plugin/mcp_implies_test.go` - OAuth, personal-token, write, duplicate, and compile-rejection tests +- `../sm-user-plugin/README.md` - `Implies`, the OAuth-only expansion, and `pact.MemberTokenScopeCatalog` + +## Decisions Made + +- The tests are in the plugin root package (`mcp_implies_test.go`), not `classes/mcp_guard_test.go`. The plan's verify is `go test .`, which does not compile `classes/`. +- `MCPAllows` and `MCPDispatch` need a principal before they can return success. Docker-backed tests skip under `-short` (CLAUDE.md), and the verify command is `go test -short`, so the tests answer one user and no group grants from an in-memory `database/sql` driver. No new module dependency. +- The existing `go.mod` replace pointed at `../../../../summercms.go` (a submodule layout). This checkout is a sibling of `summercms.go`, so the replace is `../summercms.go`, as the plan required. Do not tag that path as a release for app submodules. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Tests live in the plugin root package** +- **Found during:** Task 1 (tracer) +- **Issue:** The plan named `classes/mcp_guard_test.go`, but `go -C test .` runs package `user`. A test under `classes/` makes that command print `[no tests to run]`. +- **Fix:** `TestMCPImpliesOAuth` and the task 2 cases are in `mcp_implies_test.go` at the plugin root. +- **Files modified:** `mcp_implies_test.go` +- **Verification:** `go test -short -count=1 -run 'TestMCP' .` exits 0; `-v` shows `--- PASS: TestMCPImpliesOAuth` +- **Committed in:** `25fd981` (task 1), `193a381` (task 2) + +**2. [Rule 3 - Blocking] In-memory principal instead of testcontainers** +- **Found during:** Task 1 (tracer) +- **Issue:** A passing `MCPAllows` / `MCPDispatch` loads the user. The admin harness skips under `-short`, so a Docker test would not produce a PASS on the mandated command. CLAUDE.md says Docker-backed tests skip under `-short`. +- **Fix:** The tests publish a `*gorm.DB` over a tiny in-memory driver that returns one user row and no group grants. Scope expansion does not read the database. +- **Files modified:** `mcp_implies_test.go` +- **Verification:** `TestMCPImpliesOAuth` PASS under `-short` with no container +- **Committed in:** `25fd981` + +--- + +**Total deviations:** 2 auto-fixed (2 blocking) +**Impact on plan:** Both keep the mandated `go test -short` command able to prove the behavior. No production path changed beyond the plan. + +## Issues Encountered + +`state.advance-plan` was not run. It has no phase argument and advances whatever Current Position names. That position is phase 33, from another session. Advancing it would move the wrong plan counter. Roadmap progress for 32.1 was updated with `roadmap.update-plan-progress`. STATE.md was updated with phase-scoped handlers where they exist, and was not committed. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +Plan 03 can `Lookup` `pact.MemberTokenScopeCatalog` from an app that boots this branch. The catalog includes built-ins (empty `Implies`) then declared scopes. + +Plan 05 should extend `mcp_implies_test.go` in the plugin root, not add `classes/mcp_guard_test.go`, or its `go test .` verify will not see the new tests. These tests already exist and must not be redeclared in package `user`: `TestMCPImpliesOAuth`, `TestMCPImpliesPersonalUnchanged`, `TestMCPImpliesWriteDoesNotGrantRead`, `TestMCPImpliesAlreadyPresent`, `TestMCPImpliesCompileRejects`. `TestMCPImpliesNotPersisted` is not a separate test yet; `TestMCPImpliesOAuth` already checks that `subject.Scopes` keeps its original slice header. + +The app submodule `sm-demo-app/plugins/golem15/user` is still `07196a4` with no diff. This branch was not pushed or tagged. The `../summercms.go` replace is for this sibling checkout only. + +## Self-Check: PASSED + +- FOUND: ../sm-user-plugin/classes/mcp_guard.go +- FOUND: ../sm-user-plugin/classes/api_token_scopes.go +- FOUND: ../sm-user-plugin/plugin.go +- FOUND: ../sm-user-plugin/mcp_implies_test.go +- FOUND: ../sm-user-plugin/README.md +- FOUND: ../sm-user-plugin/go.mod +- FOUND: 25fd981 +- FOUND: 193a381 +- Demo submodule HEAD 07196a4, status clean +- `go test -short -count=1 -run 'TestMCP' .` in the plugin: exit 0 + +--- +*Phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con* +*Completed: 2026-10-28*