Warden plan

19 - issue #187 MCP connector UX: credential-less discovery, plain endpoint, branded metadata

← eval suite index


tier: needs-server requires: [needs-postgres]


19 - issue #187 MCP connector UX: credential-less discovery, plain endpoint, branded metadata

What it proves

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:

What is genuinely still open, and what this plan fixes:

  1. Branding gap (real, unfixed): 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.
  2. Test-coverage gaps the warden gate's unit-test list calls for that the current suite does not yet have, even though the underlying code already behaves correctly for most of them (this plan pins the behavior, it is not fixing new bugs for these):
  3. GET/HEAD (not just POST) credential-less → challenge.
  4. Disallowed-Origin + no-credential together → 403, not swapped for 401.
  5. resource_metadata= value in the challenge is byte-equal to PRM_URL (not a coincidentally-matching hardcoded string).
  6. A revoked user's valid OAuth bearer 401 is distinguishable from the credential-less 401 (no stray WWW-Authenticate on the revoked path).
  7. Concurrent credential-less requests are independent (no shared-state leak in challenge construction).
  8. 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.
  9. No idempotency test that two GET /users/profile calls return the same mcpEndpointUrl (trivially true since buildMcpEndpointUrl is pure, but unpinned).
  10. No test pinning serverInfo.name to a single named constant (currently an inline string literal — the state-is-the-seam gap the gate is naming).

Prerequisites

Tasks

Task 1 — Brand the MCP server name behind one constant

Files:

Steps:

  1. In 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.
  2. In 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.
  3. Add/extend a unit test asserting 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.

Task 2 — Pin credential-less challenge behavior across verbs and Origin ordering

Files:

Steps:

  1. Add a test alongside the existing '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.
  2. Add a test that sends a disallowed 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.
  3. Add a test asserting the credential-less challenge's 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.
  4. Add a test that flips 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).
  5. Add a test firing two concurrent (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).

Task 3 — Strengthen the McpConnect frontend test for token-freedom

Files:

Steps:

  1. Replace the single hardcoded-fixture assertion (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.
  2. Add a regenerate-flow test: mock 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.

Task 4 — Pin profile-endpoint idempotency

Files:

Steps:

  1. New file 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).
  2. Seed a test user + member row with mcpEnabled: true.
  3. Call 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.

Task 5 — Live/browser verification pass (warden predicates, end to end)

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:

  1. Start the dev server via bash ~/.studio/master.withrobin.ai/scripts/dev-server.sh; seed via bash ~/.studio/master.withrobin.ai/scripts/seed-andrew.sh.
  2. curl -sD - -X POST http://localhost:3000/mcp -H 'content-type: application/json' -d '{}' → confirm 401 + WWW-Authenticate header naming Bearer and resource_metadata=.
  3. curl -s http://localhost:3000/.well-known/oauth-protected-resource | jq .scopes_supported → confirm mcp:read and mcp:write both present.
  4. curl -s http://localhost:3000/.well-known/oauth-authorization-server -o /dev/null -w '%{http_code}\n'200.
  5. curl -s http://localhost:3000/favicon.ico -o /dev/null -w '%{http_code} %{size_download}\n'200 and non-zero size.
  6. Send a real MCP initialize JSON-RPC call (bearer or a seeded ?token= URL) and confirm serverInfo.name === 'Robin' in the response.
  7. Using a pre-existing seeded ?token= URL (from seed-andrew.sh or a manually-signed legacy token predating this change), confirm tools/list still returns 200.
  8. Log in as 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.

Stays manual / explicitly out of scope

Risks