From 1468344460cf317cc63016f4f218a5f158537ccb Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 28 Oct 2026 17:05:19 +0100 Subject: [PATCH] docs(phase-32.1): complete phase execution --- .planning/ROADMAP.md | 2 +- .../32.1-REVIEW.md | 185 +++++++++++++++++ .../32.1-VERIFICATION.md | 195 ++++++++++++++++++ 3 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-REVIEW.md create mode 100644 .planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-VERIFICATION.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 2d1d889f..cee97ef6 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:** 5/5 plans executed +**Plans:** 5/5 plans complete **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`). diff --git a/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-REVIEW.md b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-REVIEW.md new file mode 100644 index 00000000..12e4b63e --- /dev/null +++ b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-REVIEW.md @@ -0,0 +1,185 @@ +--- +phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con +reviewed: 2026-10-28T15:58:24Z +depth: standard +files_reviewed: 53 +files_reviewed_list: + - modules/pact/capabilities.go + - modules/pact/mcp.go + - modules/pact/mcp_call_test.go + - modules/pact/mcp_call_negative_test.go + - modules/pact/README.md + - modules/pier/dispatch.go + - modules/pier/prm.go + - modules/pier/session.go + - modules/pier/stateless.go + - modules/pier/mcp_seam_test.go + - modules/pier/dispatch_mcpcall_test.go + - modules/pier/prm_scopes_test.go + - modules/pier/README.md + - modules/wristband/authorize.go + - modules/wristband/server.go + - modules/wristband/consent.go + - modules/wristband/mcp_scopes_smoke_test.go + - modules/wristband/README.md + - docs/services/authentication.md + - docs/services/mcp.md + - docs/services/oauth-server.md + - 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/mcp_implies_test.go + - sm-user-plugin/README.md + - sm-oauthserver-plugin/classes/mcp_scopes.go + - sm-oauthserver-plugin/classes/mcp_verifier.go + - sm-oauthserver-plugin/classes/mcp_backend_binding.go + - sm-oauthserver-plugin/classes/mcp_store.go + - sm-oauthserver-plugin/controllers/mcp_consent_api.go + - sm-oauthserver-plugin/controllers/authorizations.go + - sm-oauthserver-plugin/config/config.yaml + - sm-oauthserver-plugin/plugin.go + - sm-oauthserver-plugin/servers.go + - sm-oauthserver-plugin/lang/en/lang.yaml + - sm-oauthserver-plugin/lang/pl/lang.yaml + - sm-oauthserver-plugin/README.md + - sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go + - sm-oauthserver-plugin/mcp_scopes_test.go + - sm-oauthserver-plugin/mcp_consent_test.go + - sm-oauthserver-plugin/mcp_consent_smoke_test.go + - sm-oauthserver-plugin/mcp_backfill_test.go + - sm-oauthserver-plugin/authorizations_smoke_test.go + - sm-oauthserver-plugin/authorizations_kind_test.go + - sm-oauthserver-plugin/go.mod + - vue-oauth-layer/app/pages/connect.vue + - vue-oauth-layer/app/utils/connectConsent.ts + - vue-oauth-layer/tests/connect.test.ts + - vue-oauth-layer/nuxt.config.ts + - vue-oauth-layer/package.json + - vue-oauth-layer/README.md +findings: + critical: 1 + warning: 6 + info: 0 + total: 7 +status: issues_found +--- + +# Phase 32.1: Code Review Report + +**Reviewed:** 2026-10-28T15:58:24Z +**Depth:** standard +**Files Reviewed:** 53 +**Status:** issues_found + +## Summary + +Reviewed the phase 32.1 commits only: summercms.go `63d05e8`, `9e29d6f`, `876f0f0`, `9094bf9`; sm-user-plugin `25fd981`, `193a381`, `756797c`; sm-oauthserver-plugin `644641a`, `52529c6`, `a409074`, `4f8618e`, `a7743e7`; vue-oauth-layer `02c466e`, `37c1e12`, `7f805b3`. Lockfiles, the vue skeleton, and phase 33 planning commits were not treated as product changes. Advisory only; no product file was modified. + +The scope list, `MCPCall` placement, PRM copy, and the OAuth-only `Implies` expansion match the phase decisions. The consent decision does not. `IssueCode` commits the authorization code, then a later transaction writes the consent row, and neither takes `LockUserGrants`. A revoke that lands in that gap deletes the code and the old consent, and the allow puts the consent back. Separately, clearing a declared scope on `/connect` does not stop the user guard from implying it back from locked `read`/`write`, and a replaced consent does not revoke refresh tokens that still carry the removed scopes. + +## Narrative Findings (AI reviewer) + +## Critical Issues + +### CR-01: An in-flight MCP allow recreates a consent the user just revoked + +**File:** `sm-oauthserver-plugin/controllers/mcp_consent_api.go:221-241`, `sm-oauthserver-plugin/controllers/authorizations.go:138-164`, `sm-oauthserver-plugin/classes/mcp_store.go:256-279` + +**Issue:** `MCPConsentDecide` calls `srv.IssueCode`, which commits `MarkIssued` in its own transaction, and only then calls `recordMCPConsent`. `MarkIssued` does not take `LockUserGrants`. Revoke does: it locks, deletes the consent, and deletes MCP codes, access tokens, and refresh tokens for that user and client. + +This interleaving is possible whenever a consent row already exists (a re-consent, including the silent `consent_required: false` path): + +1. Allow's `IssueCode` commits. The new MCP code exists. The consent row is still the old one, or not yet replaced. +2. Revoke commits. It deletes that consent and the new code, then returns `{"revoked": true}`. +3. Allow's `recordMCPConsent` runs. `Take` now returns not found, so `replaceMCPConsent` inserts a new row. +4. Allow returns 200 and the redirect for a code revoke already deleted. + +The token exchange fails, but the consent row is back. The next authorize for scopes that row covers sets `consent_required` false, and `vue-oauth-layer` posts allow immediately. Revoke did not stick. The same split leaves a live code and a 500 if the consent write fails after `IssueCode` succeeds, because the redirect is withheld while the code hash is already stored. + +**Fix:** Issue the code and upsert the consent in one transaction that holds `LockUserGrants` for the whole unit, the same exclusive lock revoke takes. Revoke then either waits and deletes both rows, or commits first and the allow sees that result inside the lock. Do not write the consent after `IssueCode` returns. + +```go +err = s.backend.WithinTx(ctx, func(tx Tx) error { + if err := classes.LockUserGrants(ctx, tx.DB(), []uint{userID}); err != nil { + return err + } + if err := tx.MarkIssued(ctx, pending.ID, sha256Hex(code), userID, grantedScopes, collectionIDs, expiresAt); err != nil { + return err + } + if err := tx.MarkConsented(ctx, client.ClientID); err != nil { + return err + } + return replaceMCPConsent(ctx, tx.DB(), userID, client.ClientID, stored, now) +}) +``` + +`replaceMCPConsent` has to use that same `tx`. A consent failure must roll the code back. + +## Warnings + +### WR-01: Clearing a declared scope does not stop `read` or `write` from implying it + +**File:** `sm-user-plugin/classes/mcp_guard.go:160-174`, `sm-oauthserver-plugin/controllers/mcp_consent_api.go:408-418`, `vue-oauth-layer/app/pages/connect.vue:147-160` + +**Issue:** `read` is locked whenever it was requested. Declared scopes are checkboxes the user can clear, and `grantedScopes` then omits them from the token and, on a real replace, from the consent row. `mcpEffectiveScopes` adds every catalog name whose `Implies` is `read` or `write` whenever the OAuth grant still holds that stock scope. A grant of only `read` is also what a client receives when the user cleared `servers:read` and left locked `read`. `MCPAllows` and the in-memory `user.scope` credential both see the name again, so the tool still runs. The checkbox is not a denial for any scope implied by a stock scope that remains on the grant. + +**Fix:** Store an explicit denial, or stop presenting an implied name as optional while its stock scope is on the request. If the name stays optional, `mcpEffectiveScopes` must not append a declared name the user cleared on this grant. Absence cannot mean both "never requested" and "user denied" without a separate denial list. + +### WR-02: `mcp_scopes` does not limit what an OAuth `read` or `write` grant can call + +**File:** `sm-user-plugin/classes/mcp_guard.go:165-173`, `sm-oauthserver-plugin/classes/mcp_scopes.go:25-57`, `sm-oauthserver-plugin/config/config.yaml:77-84` + +**Issue:** `AdvertisedMCPScopes` drops declared names that are not in `golem15.oauthserver.mcp_scopes`. An empty list is documented as the five fixed names only, and authorize rejects anything outside the advertised slice. `mcpEffectiveScopes` never reads that slice. It walks the whole member-token catalog. A client that was only allowed to request `read` still satisfies every `Implies: "read"` name, including names the operator excluded and that `/connect` never shows. + +**Fix:** Intersect the expansion with the advertised MCP scope list (the same slice `NewServers` stored on the verifier). An unset filter still implies every declared name. A set or empty filter then matches what metadata, authorize, and the consent page show. + +### WR-03: The consent backfill treats unusable tokens as live grants + +**File:** `sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go:151-182` + +**Issue:** `loadLiveMCPTokens` keeps a row when `revoked_at` is null and `expires_at` is null or in the future. It does not require an active, unrevoked MCP client, a non-null access-token expiry, or `rotated_to_id IS NULL`. `MCPTokenVerifier` rejects an access token with a nil expiry, a revoked client, or an inactive client (`classes/mcp_verifier.go:103-115`). A refresh token with `rotated_to_id` set is a replay, not a usable grant (`modules/wristband/token.go:332-340`). Those rows still seed `golem15_oauthserver_consents`. The inserted consent outlives the token, so a later authorize for those scopes skips the prompt even though this server would not have honored the source token. + +**Fix:** Match the verifier. Require `c.revoked_at IS NULL AND c.is_active`, `a.expires_at IS NOT NULL AND a.expires_at > NOW()` for access tokens, and `r.rotated_to_id IS NULL` for refresh tokens. + +### WR-04: Replacing a consent does not revoke refresh tokens that still carry the removed scopes + +**File:** `sm-oauthserver-plugin/controllers/mcp_consent_api.go:348-364`, `modules/wristband/token.go:361-370` + +**Issue:** A real allow replaces `golem15_oauthserver_consents.scopes` with the new grant. It does not revoke that client's access or refresh tokens. Refresh rotation copies `rec.Scopes` and `rec.OfflineAccess` from the existing refresh row, not from the consent row. After the user clears `write` or `offline_access` on a later allow, the old refresh token keeps minting access tokens with the old scope set until it expires or the user deletes the whole authorization. + +**Fix:** In the same locked transaction as the consent replace, revoke this client’s access tokens and refresh-token lineages whose scopes are not a subset of the new grant (and whose `offline_access` column is set when the new grant omits it). Leave other clients alone. + +### WR-05: sm-user-plugin v0.1.16 does not require the framework version it uses + +**File:** `sm-user-plugin/go.mod:6`, `sm-user-plugin/go.mod:183` + +**Issue:** The tagged module calls `pact.MemberTokenScopeCatalog` and `FrontendTokenScope.Implies`, which shipped in summercms v0.5.2. `go.mod` still requires `git.golem15.com/golem15/summercms v0.5.0` and replaces it with `../summercms.go`. Downstream modules ignore that replace. An app that selects v0.1.16 without already selecting summercms v0.5.2 does not compile. sm-oauthserver-plugin v0.1.4 happens to require v0.5.2, so the full stack builds only because of that other module. + +**Fix:** Require `git.golem15.com/golem15/summercms v0.5.2` in sm-user-plugin and drop the sibling replace from the release commit. A local replace can stay uncommitted in a development checkout. + +### WR-06: `/connect` can allow a stale request after `?request=` changes + +**File:** `vue-oauth-layer/app/pages/connect.vue:52-68` + +**Issue:** `load` has no generation token. `watch(requestId)` starts another `load` while the previous `$fetch` is in flight, and `consent_required === false` posts allow before render. Whichever response arrives last assigns `show` and, if that body does not require consent, calls `submit('allow')` for the request it loaded, which may no longer be `requestId`. Both requests can be approved, and the browser can be sent to the redirect of the one the user is no longer on. + +**Fix:** Capture a sequence number (or the request id) before the fetch and ignore the response, including the auto-allow, when it is no longer current. + +```ts +let loadSeq = 0 +async function load(fromRefetch = false) { + const seq = ++loadSeq + const id = requestId.value + // ... + const body = await $fetch(consentUrl(), { credentials: 'include' }) + if (seq !== loadSeq || id !== requestId.value) return + // assign show and maybe submit +} +``` + +--- + +_Reviewed: 2026-10-28T15:58:24Z_ +_Reviewer: Claude (msd-code-reviewer)_ +_Depth: standard_ diff --git a/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-VERIFICATION.md b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-VERIFICATION.md new file mode 100644 index 00000000..2bf5db51 --- /dev/null +++ b/.planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-VERIFICATION.md @@ -0,0 +1,195 @@ +--- +phase: 32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con +verified: 2026-10-28T15:54:38Z +status: passed +score: 8/8 must-haves verified +covered_files: + - ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-01-PLAN.md" + - ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-01-SUMMARY.md" + - ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-PLAN.md" + - ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-02-SUMMARY.md" + - ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-PLAN.md" + - ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-03-SUMMARY.md" + - ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-04-PLAN.md" + - ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-04-SUMMARY.md" + - ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-PLAN.md" + - ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-05-SUMMARY.md" + - ".planning/phases/32.1-mcp-oauth-for-plugin-apps-declared-scopes-mcp-subject-on-con/32.1-CONTEXT.md" + - "docs/services/authentication.md" + - "docs/services/mcp.md" + - "docs/services/oauth-server.md" + - "modules/pact/README.md" + - "modules/pact/capabilities.go" + - "modules/pact/mcp.go" + - "modules/pact/mcp_call_negative_test.go" + - "modules/pact/mcp_call_test.go" + - "modules/pier/README.md" + - "modules/pier/dispatch.go" + - "modules/pier/dispatch_mcpcall_test.go" + - "modules/pier/events.go" + - "modules/pier/filter.go" + - "modules/pier/identity.go" + - "modules/pier/mcp_seam_test.go" + - "modules/pier/prm.go" + - "modules/pier/prm_scopes_test.go" + - "modules/pier/session.go" + - "modules/pier/stateless.go" + - "modules/wristband/README.md" + - "modules/wristband/authorize.go" + - "modules/wristband/consent.go" + - "modules/wristband/mcp_scopes_smoke_test.go" + - "modules/wristband/server.go" +covered_digest: "v2:sha256:647c82bb902ca0f7439d83e8c93465a4d4a63e02d02ec0ed4838ce50b4489262" +behavior_unverified: 0 +overrides_applied: 0 +decision_coverage: + honored: 18 + total: 18 + not_honored: [] +--- + +# Phase 32.1: MCP OAuth for plugin apps Verification Report + +**Phase 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. Three gaps ship as patch releases of the framework (0.5.x) and `sm-oauthserver-plugin`. +**Verified:** 2026-10-28T15:54:38Z +**Status:** passed +**Re-verification:** No — initial verification + +MVP mode does not apply. Phase 32.1 has no `Mode: mvp` in ROADMAP.md. The goal is user-story shaped but does not match the validator (`I want an agent`, not `I want to`). + +Sibling implementations were read and tested in their own checkouts. They cannot be listed in `covered_files` because the fingerprint refuses paths outside this repository. The released commits are `summercms` `v0.5.2` (`9094bf9`, tag object `35c1f0f`), `sm-user-plugin` `v0.1.16` (`756797c`), `sm-oauthserver-plugin` `v0.1.4` (`a7743e7`), and local `vue-oauth-layer` `v0.1.0` (`7f805b3`). Framework `HEAD` (`bba6539`) is only the phase-05 planning commit; `v0.5.2..HEAD` does not change product code. + +## Goal Achievement + +### Observable Truths + +| # | Truth | Status | Evidence | +| --- | ------- | ---------- | ---------- | +| 1 | Scopes a plugin declares through `pact.HasFrontendTokenScopes` can be requested, consented to and granted over MCP OAuth. Wristband metadata and the authorize allow-list, and pier protected-resource scopes, include them, so a tool behind `user.scope:` lists and runs for an OAuth agent granted that scope (including a stock scope the declaration `Implies`). | ✓ VERIFIED | `AdvertisedMCPScopes` appends declared catalog names after the five fixed names. `NewServers` assigns that one slice to `mcpOpts.ScopesSupported` and `NewMCPTokenVerifier`. Metadata and `allowedAuthorizeScopes` both call `resolvedScopes`. pier copies `MCPScopesSupported` when non-empty. `MCPAllows` / `MCPDispatch` share `mcpEffectiveScopes`, which keeps names already on the grant and, only when `ClientID` is set, appends catalog names whose `Implies` is held. `tools/list` reaches that check through `filter.go` `allows`. `user.scope` reads the dispatch credential via `HasScope`. `TestMCPAdvertisedScopes` (metadata equals the verifier, including `acme:read`) and `TestMCPImplies*` passed. | +| 2 | A route run by an MCP tool call can read the MCP subject (guard, user, client ID, token id) from its request context. A personal-token call's credential keeps the token id. | ✓ VERIFIED | `dispatch.go` sets `WithMCPCall` with `id.subjectFor` immediately before `bouncer.WithDispatch`. OAuth identity copies `ClientID` and `TokenID` from the grant onto both frontend and backend subjects. `MCPCallFrom` on a bare context is false. `MCPDispatch` sets `credential.ID = subject.TokenID` only when `ClientID` is empty. `TestMCPCallFromBareContext`, `TestMCPCallRoundTrip`, `TestDispatchMCPCallBothGuards`, and `TestMCPImpliesPersonalUnchanged` passed. | +| 3 | `sm-oauthserver-plugin` records a consent for MCP grants, so per-client revoke ends one agent's grant and its MCP sessions without touching the user's other agents. | ✓ VERIFIED | `MCPConsentDecide` calls `recordMCPConsent` after `IssueCode`. `replaceMCPConsent` is `ON CONFLICT` assignment, not `SaveConsent`. `revoke` takes `LockUserGrants` and deletes the consent, OIDC codes, MCP codes, access tokens and refresh tokens for that user and client, then fires `MCPSubjectRevoked` with `ClientID` and a zero `TokenID`. pier `revokedMatch` closes only that client's sessions when `TokenID` is 0. `TestMCPConsentReplace`, `TestMCPRevokeDeletesCode` (other client's token still verifies; event shape matches), and `TestMCPConsentBackfillIdempotent` passed. Rollback returns nil and does not delete. | +| 4 | Module READMEs and `docs/` pages describe the three changes. The framework and `sm-oauthserver-plugin` have patch tags. | ✓ VERIFIED | pact, pier, wristband, user-plugin and oauthserver READMEs plus `docs/services/mcp.md`, `oauth-server.md` and `authentication.md` name `MCPCallFrom`, `MCPAdvertisedScopes`, `MemberTokenScopeCatalog`, `Implies`, `mcp_scopes`, consent replace and the per-client revoke. No consuming application is named. `go test ./cmd/summer -run TestDocsTree` passed. Remote tags: `v0.5.2`, `v0.1.16`, `v0.1.4`. Changelog `https://summercms.io/changelog/summercms-0-5-2` returned HTTP 200 with title `SummerCMS 0.5.2`. | +| 5 | An empty MCP `Options.ScopesSupported` advertises and accepts `read`, `write`, `ai`, `offline_access` and `backend`. A non-empty list is used as given, so `DefaultOptions` still refuses `backend`. OIDC discovery stays `openid`, `profile`, `email`. `PendingRequestView.OfflineAccess` is the pending flag. `IssueCode`'s signature is unchanged. | ✓ VERIFIED | `resolvedScopes` in `modules/wristband/server.go`. `PendingRequest` copies `pending.OfflineAccess`. `IssueCode(ctx, requestID, userID, grantedScopes, collectionIDs)`. `TestMCPScopesSmoke`, `TestDiscoveryFields` and `TestMCPOfflineAccessUntick` passed. | +| 6 | Implies expansion is OAuth-only. `write` does not satisfy `Implies` `read`. The compiled catalog is published as `pact.MemberTokenScopeCatalog`. Implied names are not written onto the subject, the token row, or a fingerprint. The demo app user submodule was not edited. | ✓ VERIFIED | `bootAPITokenScopes` publishes the concrete catalog and `pact.MemberTokenScopeCatalog`. `mcpEffectiveScopes` returns `subject.Scopes` unchanged when `ClientID` is empty and never writes the clone back. `TestMCPImpliesOAuth`, `TestMCPImpliesPersonalUnchanged`, `TestMCPImpliesWriteDoesNotGrantRead`, `TestMCPImpliesNotPersisted`, `TestMCPImpliesAlreadyPresent` and `TestMCPImpliesCompileRejects` passed. `sm-demo-app/plugins/golem15/user` is still tag `v0.1.13`. | +| 7 | `golem15.oauthserver.mcp_scopes` unset advertises every declared member scope; a set list keeps only those names; an empty list is the five fixed names; an unknown name fails boot and names the key. Fixed names are always present. pier copies the verifier list or `PRMScopes` and does not read the member-token catalog. | ✓ VERIFIED | `classes/mcp_scopes.go` and `servers.go`. `mountPRM` type-asserts `MCPAdvertisedScopes` only. `TestMCPAdvertisedScopes`, `TestPRMUsesAdvertisedScopes` and `TestPRMFallsBackToPRMScopes` passed. | +| 8 | Authorization entries carry `kind` `oidc` or `mcp`. An allow with no scopes field grants the whole request. `offline_access` stays a column flag. The `/connect` layer locks `read` in the UI, does not ask the server to force `read`, renders client and scope text rather than raw HTML, and refetches on `409 backend_link_changed`. | ✓ VERIFIED | `TestAuthorizationsKind` and `TestMCPOfflineAccessUntick` passed. `grantedScopes` returns the request when `submitted` is nil. `allowBody` keeps locked names and omits `scopes` when consent is already stored. `connect.vue` interpolates `{{ }}` and calls `shouldRefetch` then `load(true)`. `node --test tests/connect.test.ts` passed (8 tests), including the source scan that rejects `v-html`. | + +**Score:** 8/8 truths verified (0 present, behavior-unverified) + +### Decision Coverage + +All trackable CONTEXT.md decisions are honored by shipped artifacts (18/18). Non-blocking. + +### Required Artifacts + +| Artifact | Expected | Status | Details | +| -------- | ----------- | ------ | ------- | +| `modules/pact/mcp.go` | Catalog, advertised scopes, `MCPCall` / `WithMCPCall` / `MCPCallFrom` | ✓ VERIFIED | Interfaces and accessor are exported and used by pier and both plugins. On `v0.5.2`. Absent from `v0.5.0`. | +| `modules/pact/capabilities.go` | `FrontendTokenScope.Implies` | ✓ VERIFIED | Field present. User plugin rejects any value other than empty, `read`, or `write`. | +| `modules/pier/dispatch.go` | `MCPCall` beside `WithDispatch` | ✓ VERIFIED | Lines 215–216. Both guards' tool calls observed it. | +| `modules/pier/prm.go` | PRM scopes from the verifier, else `PRMScopes` | ✓ VERIFIED | Does not look up `MemberTokenScopeCatalog`. | +| `modules/wristband/server.go` | One `resolvedScopes` slice | ✓ VERIFIED | Metadata uses it. Authorize builds its allow-list from the same method. | +| `modules/wristband/consent.go` | `PendingRequestView.OfflineAccess`; `IssueCode` signature unchanged | ✓ VERIFIED | Direct copy from the pending row. | +| `sm-user-plugin/classes/mcp_guard.go` | OAuth-only Implies shared by `MCPAllows` and `MCPDispatch` | ✓ VERIFIED | On tag `v0.1.16`. | +| `sm-user-plugin/plugin.go` | Publish `MemberTokenScopeCatalog` | ✓ VERIFIED | `bootAPITokenScopes`. | +| `sm-oauthserver-plugin/classes/mcp_scopes.go` | `golem15.oauthserver.mcp_scopes` | ✓ VERIFIED | Key documented in `config/config.yaml` (commented default). | +| `sm-oauthserver-plugin/classes/mcp_verifier.go` | `MCPScopesSupported` | ✓ VERIFIED | Same slice stored at `NewServers`. | +| `sm-oauthserver-plugin/controllers/mcp_consent_api.go` | `scope_details` and consent replace | ✓ VERIFIED | `replaceMCPConsent` uses `ON CONFLICT`. | +| `sm-oauthserver-plugin/controllers/authorizations.go` | Revoke under `lockUserGrants`, then the client-scoped event | ✓ VERIFIED | Deletes `MCPCode` with the other pair rows. | +| `sm-oauthserver-plugin/updates/202610280001_backfill_golem15_oauthserver_mcp_consents.go` | Idempotent backfill, no-delete rollback | ✓ VERIFIED | `WHERE NOT EXISTS`. `Rollback` returns nil. | +| `vue-oauth-layer/app/pages/connect.vue` | `/connect` checkboxes | ✓ VERIFIED | Imports `allowBody`. Text interpolations only. Local tag `v0.1.0`. | +| `vue-oauth-layer/app/utils/connectConsent.ts` | Show JSON to POST body | ✓ VERIFIED | Locked `read` stays. No server-side force-read field. | + +### Key Link Verification + +| From | To | Via | Status | Details | +| ---- | --- | --- | ------ | ------- | +| `wristband.Server.Metadata` | `allowedAuthorizeScopes` | `resolvedScopes` | ✓ WIRED | Both call the same method. A non-empty host list is cloned as given. | +| `NewServers` | `MCPTokenVerifier.MCPScopesSupported` | the slice assigned to `mcpOpts.ScopesSupported` | ✓ WIRED | `TestMCPAdvertisedScopes` compared metadata JSON to the verifier. | +| `pier.mountPRM` | `pact.MCPAdvertisedScopes` | lookup of `MCPTokenVerifier`, then type assert | ✓ WIRED | Empty or unimplemented falls back to `PRMScopes`. | +| `pier.dispatch` | `pact.MCPCallFrom` | `WithMCPCall` immediately before `WithDispatch` | ✓ WIRED | Frontend and backend tool handlers observed the call. | +| `MCPAllows` | `MCPDispatch` | `mcpEffectiveScopes` | ✓ WIRED | One helper. Personal tokens return the same slice. | +| `plugin.bootAPITokenScopes` | `pact.MemberTokenScopeCatalog` | `app.Publish` | ✓ WIRED | `TestMCPImpliesOAuth` looks the interface up. | +| `MCPConsentDecide` | `golem15_oauthserver_consents` | replace upsert after `IssueCode`, not `SaveConsent` | ✓ WIRED | Second allow of a smaller set stored only `read`. | +| `authorizations.revoke` | MCP sessions | `LockUserGrants`, delete `MCPCode`, `MCPSubjectRevoked` with `ClientID` | ✓ WIRED | pier `revokedMatch` selects that client when `TokenID` is 0. | +| `connect.vue` | `POST /oauth/mcp/requests/{request}` | `allowBody` (`decision`, `scopes`, `backend_admin`) | ✓ WIRED | Page and tests import the same functions. | + +### Data-Flow Trace (Level 4) + +| Artifact | Data Variable | Source | Produces Real Data | Status | +| -------- | ------------- | ------ | ------------------ | ------ | +| Wristband MCP metadata `scopes_supported` | `resolvedScopes()` | `Options.ScopesSupported` set from `AdvertisedMCPScopes` (catalog plus fixed names) | Yes | ✓ FLOWING | +| pier protected-resource `scopes_supported` | `advertised.MCPScopesSupported()` | the verifier slice, else `PRMScopes` | Yes | ✓ FLOWING | +| MCP consent row `scopes` | `stored` in `MCPConsentDecide` | granted intersection of the pending request | Yes | ✓ FLOWING | +| Route `pact.MCPCall.Subject` | `id.subjectFor` | OAuth grant or personal-token resolver (`ClientID`, `TokenID`, scopes) | Yes | ✓ FLOWING | +| `user.scope` check during a tool call | dispatch `*models.APIToken.Scopes` | `mcpEffectiveScopes` (grant, plus OAuth implied names) | Yes | ✓ FLOWING | +| `/connect` checkboxes | `scope_details` from consent show | catalog labels, not a hardcoded tool list | Yes | ✓ FLOWING | + +### Behavioral Spot-Checks + +| Behavior | Command | Result | Status | +| -------- | ------- | ------ | ------ | +| Bare context has no MCP call; round trip keeps the subject | `go test -short -count=1 ./modules/pact -run 'TestMCPCallFromBareContext\|TestMCPCallRoundTrip\|TestFrontendTokenScopeLiteralWithoutImplies'` | ok | ✓ PASS | +| Both guards record `MCPCall`; PRM uses the advertised list or falls back | `go test -short -count=1 ./modules/pier -run 'TestDispatchMCPCallBothGuards\|TestPRMUsesAdvertisedScopes\|TestPRMFallsBackToPRMScopes\|TestMCPSeam'` | ok | ✓ PASS | +| Empty MCP scope list includes `backend`; OIDC discovery stays the three names | `go test -short -count=1 ./modules/wristband -run 'TestMCPScopesSmoke\|TestDiscoveryFields'` | ok | ✓ PASS | +| OAuth Implies, personal token unchanged, write does not grant read | `go test -short -count=1 . -run TestMCPImplies` in `sm-user-plugin` | ok | ✓ PASS | +| Declared name `acme:read` is on MCP metadata and the verifier; unknown `mcp_scopes` fails boot | `GOWORK=off` not required; `go test -short -count=1 . -run TestMCPAdvertisedScopes` in `sm-oauthserver-plugin` | ok | ✓ PASS | +| Consent replace, offline flag, revoke leaves the other client, kind, idempotent backfill | `GOWORK=off go test -count=1 -timeout 180s -run 'TestMCPConsentReplace\|TestMCPRevokeDeletesCode\|TestMCPConsentBackfillIdempotent\|TestAuthorizationsKind\|TestMCPOfflineAccessUntick'` | ok (6.951s) | ✓ PASS | +| `/connect` mapping, locked read, no `v-html` | `node --test tests/connect.test.ts` | 8 pass | ✓ PASS | +| Docs identifiers | `go test ./cmd/summer -count=1 -run TestDocsTree` | ok | ✓ PASS | +| Patch tags on the remotes | `git ls-remote --tags` for `v0.5.2`, `v0.1.16`, `v0.1.4` | each tag object matches the local tag | ✓ PASS | +| Changelog post | `curl -fsS https://summercms.io/changelog/summercms-0-5-2` | HTTP 200, title `SummerCMS 0.5.2` | ✓ PASS | + +`TestMCPAdvertisedScopes` was run in the oauth plugin directory, whose uncommitted `go.work` points `sm-user-plugin` at `sm-demo-app` `v0.1.13`. That test publishes its own catalog and does not import the phase user plugin. The consent tests were run with `GOWORK=off`, so they used `go.mod`'s `sm-user-plugin v0.1.16`. + +### Probe Execution + +No phase plan or success criterion declares a `scripts/*/tests/probe-*.sh` probe. None were required. + +### Test Quality Audit + +| Test File | Linked Req | Active | Skipped | Circular | Assertion Level | Verdict | +|-----------|-----------|--------|---------|----------|-----------------|---------| +| `modules/pact/mcp_call_negative_test.go` | SC-2 | 2 | 0 | no | value (`ok` false; empty `Implies`) | sufficient | +| `modules/pier/dispatch_mcpcall_test.go` | SC-2 | 1 | 0 | no | behavioral (real `tools/call` on both guards) | sufficient | +| `modules/pier/prm_scopes_test.go` | SC-1 | 2 | 0 | no | value (scope list includes `acme:read`, fallback includes `backend`) | sufficient | +| `sm-user-plugin/mcp_implies_test.go` | SC-1, SC-2 | 6 matched by `TestMCPImplies` | 0 | no | value (credential id, scopes, slice identity) | sufficient | +| `sm-oauthserver-plugin/mcp_consent_test.go` | SC-3 | replace, offline, revoke | 0 in the run | no | behavioral (stored row, other token still verifies, event equality) | sufficient | +| `sm-oauthserver-plugin/mcp_backfill_test.go` | SC-3 | idempotent backfill | 0 in the run | no | behavioral | sufficient | +| `vue-oauth-layer/tests/connect.test.ts` | layer must-haves | 8 | 0 | no | value, plus a source scan for `v-html` | sufficient | + +**Disabled tests on requirements:** 0 +**Circular patterns detected:** 0 +**Insufficient assertions:** 0 + +`TestDispatchMCPCallBothGuards` checks guard, tool and session id. It does not echo `ClientID` or `TokenID`. Those fields are copied with the whole `MCPSubject` in `identity.go` (`ClientID: grant.ClientID`, `TokenID: grant.TokenID`) and stored by `WithMCPCall`. The personal-token id rule is asserted directly in `TestMCPImpliesPersonalUnchanged`. + +### Requirements Coverage + +| Requirement | Source Plan | Description | Status | Evidence | +| ----------- | ---------- | ----------- | ------ | -------- | +| none | all five plans `requirements: []` | ROADMAP says Requirements: TBD. `REQUIREMENTS.md` has no Phase 32.1 IDs. | ✓ SATISFIED | No ID to satisfy and none orphaned. | + +### Anti-Patterns Found + +| File | Line | Pattern | Severity | Impact | +| ---- | ---- | ------- | -------- | ------ | +| `sm-user-plugin/go.mod` | require | `summercms v0.5.0` while the tagged source calls `pact.MemberTokenScopeCatalog`, which does not exist on `v0.5.0` | ⚠️ Warning | A module graph that selects only `v0.5.0` does not compile `v0.1.16`. `sm-oauthserver-plugin` `v0.1.4` requires `summercms v0.5.2` and `sm-user-plugin v0.1.16`, so the graph that turns MCP OAuth on selects `v0.5.2`. The local `replace` is ignored by dependents. Not a failed success criterion. | +| `vue-oauth-layer` | tag `v0.1.0` | No `origin`. README inside the tag still says the tag does not exist. | ⚠️ Warning | SC-4 does not require this tag. Apps cannot pin the layer until a remote exists. The local tag was not moved. | +| `sm-oauthserver-plugin/go.work` | uncommitted | `replace` of `sm-user-plugin` points at `sm-demo-app/plugins/golem15/user` (`v0.1.13`), not the phase branch | ℹ️ Info | Not on tag `v0.1.4`. Dependents do not use `go.work`. A local `go test` without `GOWORK=off` would compile the wrong user plugin. | +| `sm-user-plugin/classes/device_auth.go` | comments | The letters `XXXX` match a `XXX` debt scan | ℹ️ Info | Short-code format, not a `XXX` marker. No `TBD` / `FIXME` / `XXX` debt markers in the phase files. | + +### Human Verification Required + +None. The state transitions (Implies expansion, consent replace, revoke isolation, backfill idempotence, both guards' `MCPCall`) were exercised by the named tests above. The `/connect` contract is covered by `node --test`, including the no-raw-HTML source check. No live browser OAuth session was required: the phase ships the framework and plugins, and it does not turn `mcp.enabled` on in an application. + +### Gaps Summary + +No gap blocks the phase goal. Declared member scopes are on the MCP authorization server and the protected-resource metadata. An OAuth subject can list and run a `user.scope:` tool either because the grant holds that name or because the catalog marks it implied by `read` or `write`. The route can read the caller through `pact.MCPCallFrom`, and a personal token's credential id is still the token id. An MCP allow writes a consent row that a later smaller allow replaces, and deleting that authorization revokes only that client. Docs describe the three changes. `v0.5.2` and `sm-oauthserver-plugin` `v0.1.4` are on their remotes, and the framework changelog post is live. + +The two warnings (user-plugin minimum version, unpublished vue layer) do not stop an application that takes `sm-oauthserver-plugin` `v0.1.4` from compiling the OAuth path. They should be fixed in a later patch if the user plugin is built on its own, or when `vue-oauth-layer` gets a remote. + +--- + +_Verified: 2026-10-28T15:54:38Z_ +_Verifier: Claude (msd-verifier)_