Warden plan
tier: needs-server requires: [needs-postgres]
Issue #187 describes three blockers: (1) /mcp with no credential returns a challenge-less 401, (2) the profile page mints and displays a bearer token in the endpoint URL, (3) the MCP connection is unbranded (Railway name/icon).
Correction to the designer notes that seeded this plan. Those notes were grounded against a stale checkout — not origin/canary tip. As of origin/canary (fetched during this research pass, tip commit 33ea40fb), blockers (1) and (2) are already shipped:
a2aab3c4 fix(mcp): tell a credential-less client where to authenticate already changes server/src/routes/mcp.ts's credential-less branch to c.header('WWW-Authenticate', buildWwwAuthenticateChallenge(PRM_URL)) before the 401, replacing the old bare {"error":"Missing token"}. The exact test the warden gate asks for already exists at server/src/__tests__/oauth-mcp.test.ts:214 ('no credential → 401 + the challenge...'), asserting www-authenticate contains Bearer, resource_metadata=, and /.well-known/oauth-protected-resource.server/src/modules/users/routes.ts:156 (buildMcpEndpointUrl) already returns a bare ${SERVER_PUBLIC_URL}/mcp with no token, and app/src/components/screens/settings/McpConnect.tsx already only renders that plain URL plus a regenerate action. POST /users/regenerate-mcp (routes.ts:571) still rotates the legacy ?token= credential server-side (non-breaking) but returns the same plain URL.server/src/index.ts:240), and the mcpEnabled kill-switch / #303 revocation guards (mcpRevoked / mcpOAuthRevoked, routes/mcp.ts:66-95) are all live and covered.What is genuinely still open, and what this plan fixes:
server/src/mcp/server.ts:1834 sets new McpServer({ name: 'robin-mcp', version: '1.0.0' }) — the MCP initialize response reports serverInfo.name = 'robin-mcp', not the warden-required 'Robin'. No logo_uri in the PRM document (server/src/core/oauth/index.ts:63, buildProtectedResourceMetadata) — out of scope per warden predicates (none require it), left as a documented non-goal.resource_metadata= value in the challenge is byte-equal to PRM_URL (not a coincidentally-matching hardcoded string).WWW-Authenticate on the revoked path).mcp-connect.test.tsx currently only renders one hardcoded fixture string and asserts it appears verbatim — it does not actually assert token-freedom "regardless of shape", nor cover the regenerate flow's result, nor the loading state.GET /users/profile calls return the same mcpEndpointUrl (trivially true since buildMcpEndpointUrl is pure, but unpinned).serverInfo.name to a single named constant (currently an inline string literal — the state-is-the-seam gap the gate is naming).greenlight-pg :5433 with the vector extension, or the disposable integrate-batch-pg :5466 test DB per repo convention — oauth-mcp.test.ts is DB-backed (ensureTestDatabase / pushTestSchema).PROJECT_ROOT = a checkout of origin/canary at or after a2aab3c4. Any checkout predating that commit will show the old challenge-less 401 and the gate's first two predicates fail for reasons unrelated to this plan's tasks — re-fetch before running.bash ~/.studio/master.withrobin.ai/scripts/dev-server.sh for the live curl/browser verification steps; seed via bash ~/.studio/master.withrobin.ai/scripts/seed-andrew.sh.Files:
server/src/lib/oauth-config.ts (add constant)server/src/mcp/server.ts (consume it)server/src/mcp/server.test.ts (new assertion; check if a server.ts test file already exists first — extend it if so)Steps:
server/src/lib/oauth-config.ts, add export const MCP_SERVER_NAME = 'Robin' next to the existing RS_CANONICAL_URI / AS_ISSUER / PRM_URL constants — same file, same "single source of truth" rationale already documented at the top of that file. Do not source this from CAPTURE_OAUTH_CLIENT_NAME (server/src/bootstrap/env.ts:194) — that env var is the mobile capture app's OAuth client id (server/src/modules/capture/ oauth-client.ts:31, default 'robin-capture-mobile'), a different feature; reusing it here would couple two unrelated brandable strings.server/src/mcp/server.ts:1833, change new McpServer({ name: 'robin-mcp', version: '1.0.0' }) to new McpServer({ name: MCP_SERVER_NAME, version: '1.0.0' }), importing MCP_SERVER_NAME from ../lib/oauth-config.js.createMcpServer(...)'s underlying server.server.getServerVersion()?.name (or equivalent public accessor — check what @modelcontextprotocol/sdk's McpServer exposes; if nothing is public, assert via the initialize response in the existing MCP transport test harness) equals MCP_SERVER_NAME, and a second assertion that the literal 'Robin' appears in exactly one place in the diff (the constant file) — i.e. grep-based, not a runtime check, if the SDK has no read-back accessor.Verify: pnpm --filter @robin/server vitest run server/src/mcp/server.test.ts passes; grep -rn "'robin-mcp'" server/src returns nothing.
Files:
server/src/__tests__/oauth-mcp.test.tsSteps:
'no credential → 401 + the challenge' (line 214) that issues GET and HEAD (instead of POST) to mcp.request('/') with no credential and no body, asserting the same 401 + www-authenticate shape.Origin header (https://evil.example, matching the existing origin test's fixture at line 289) together with NO authorization header and no ?token=, asserting res.status === 403 (the origin gate) and that no www-authenticate header is set — proving origin-before-auth ordering held even when both conditions are true simultaneously.resource_metadata= value is byte-equal to PRM_URL imported from ../lib/oauth-config.js (not a re-derived or hardcoded string) — parse the header value the same way packages/oauth/src/challenge.test.ts does, or regex-extract the URL between resource_metadata=" and the closing quote.mcpEnabled to false for TEST_USER_ID (mirroring the existing kill-switch test at line 458), issues a request with a VALID mint()'d bearer token, asserts res.status === 401, and asserts res.headers.get('www-authenticate') is falsy — distinguishing "revoked, valid signature" (no challenge, already authenticated) from "no credential at all" (challenge).Promise.all) credential-less requests and asserting both come back 401 with the same well-formed www-authenticate header (no interleaving/undefined value from shared state).Verify: pnpm --filter @robin/server vitest run server/src/__tests__/oauth-mcp.test.ts — all existing + new it() blocks pass; confirm no regression in the existing scope-split, revocation, DCR/PKCE, and legacy-?token= tests already in this file (they must stay green untouched).
Files:
app/src/components/screens/settings/mcp-connect.test.tsxSteps:
profileHook.state = { data: { mcpEndpointUrl: 'https://robin.example/mcp?token=abc123' }, ... }) with fixtures that reflect the real post-fix shape (mcpEndpointUrl: 'https://robin.example/mcp', no query string) for the primary render test, and add a loop/parameterized case over { data: undefined, isLoading: true }, { data: undefined, isLoading: false }, and { data: { mcpEndpointUrl: '' }, isLoading: false } asserting the rendered DOM text passed to the copy handler never contains the substring 'token=' in any of these states.regenerateMcpEndpoint (already imported from @/lib/api) to resolve { data: { mcpEndpointUrl: 'https://robin.example/mcp' } }, drive the existing confirm-regen UI flow, and assert the displayed post-regen URL string also excludes 'token='.Verify: pnpm --filter @robin/app vitest run src/components/screens/settings/mcp-connect.test.tsx.
Files:
server/src/modules/users/users.settings.test.ts (or a new users.mcp-endpoint.test.ts beside it if users.settings.test.ts is scoped to the unrelated /settings routes only — check its describe blocks first, per the file listing already gathered: it currently only covers GET/PUT /settings)Steps:
server/src/modules/users/users.mcp-endpoint.test.ts (keeps users.settings.test.ts scoped to its existing subject). Reuse this module's existing DB test-setup helpers (createTestUser, ensureTestDatabase, etc., same imports as oauth-mcp.test.ts).mcpEnabled: true.GET /users/profile twice in sequence through the users router test harness, asserting both responses' mcpEndpointUrl are identical strings and neither contains 'token='.Verify: pnpm --filter @robin/server vitest run server/src/modules/users/users.mcp-endpoint.test.ts.
No file changes — this task runs the actual gate predicates against the dev server to catch anything a unit test can't (real HTTP layer, real browser render).
Steps:
bash ~/.studio/master.withrobin.ai/scripts/dev-server.sh; seed via bash ~/.studio/master.withrobin.ai/scripts/seed-andrew.sh.curl -sD - -X POST http://localhost:3000/mcp -H 'content-type: application/json' -d '{}' → confirm 401 + WWW-Authenticate header naming Bearer and resource_metadata=.curl -s http://localhost:3000/.well-known/oauth-protected-resource | jq .scopes_supported → confirm mcp:read and mcp:write both present.curl -s http://localhost:3000/.well-known/oauth-authorization-server -o /dev/null -w '%{http_code}\n' → 200.curl -s http://localhost:3000/favicon.ico -o /dev/null -w '%{http_code} %{size_download}\n' → 200 and non-zero size.initialize JSON-RPC call (bearer or a seeded ?token= URL) and confirm serverInfo.name === 'Robin' in the response.?token= URL (from seed-andrew.sh or a manually-signed legacy token predating this change), confirm tools/list still returns 200.andrew@robin.ai / robin2026 in the browser, open Settings → the MCP connect panel, confirm the displayed endpoint string has no token= substring.Verify: all 7 checks above pass manually; paste terminal output into the PR description's Validation section.
logo_uri in the PRM/AS metadata — no warden predicate requires it; adding it without a confirmed client-rendering behavior (issue's own "verify which source Claude actually renders" caveat) risks dead code. Flagged, not built.?token= deprecation deadline — issue leaves this open; this plan keeps accepting it server-side indefinitely, per the issue's own fix step 3.<client>.withrobin.ai, SERVER_PUBLIC_URL sweep, Railway-URL leak audit across invite emails/admin dashboard) — consolidated into #187's scope by the issue's own edit, but those are ops/config tasks (env var changes + a copy sweep), not code the warden predicates above touch. Separate follow-up plan if wanted.origin/canary at/after a2aab3c4, Tasks 2/5 will appear to fail for reasons already fixed upstream — re-fetch and diff before concluding a regression.server/src/mcp/server.ts and server/src/__tests__/oauth-mcp.test.ts are both large, actively-touched files (recent history shows near-daily MCP-tool commits). Rebase before landing; keep Task 1's diff to the two lines described, Task 2's diff to additive it() blocks only.McpServer read-back for Task 1's unit test is unconfirmed — the SDK may not expose a post-construction getter for name. If it doesn't, fall back to asserting via a live initialize RPC round-trip in oauth-mcp.test.ts itself (add one more it() there instead of a new server.test.ts) rather than inventing a new SDK-internal reflection hack.