Warden plan
tier: needs-server requires: [needs-postgres]
Connecting an AI client to Robin stops being "paste a URL with a live-forever bearer token in the query string" and starts looking like an actual OAuth sign-in, without breaking the clients already relying on the old URL:
/mcp request gets the RFC 9728 challenge. Today a request with no Authorization header and no ?token= gets a bare 401 {"error":"Missing token"} — no WWW-Authenticate header — because the OAuth guard is only invoked once a bearer header is already present (server/src/routes/mcp.ts:159). The fix makes the challenge-less path challenge too: any unauthenticated request to /mcp carries WWW-Authenticate: Bearer ... resource_metadata="<PRM url>", so a client that has never heard of Robin's OAuth can discover it cold.app/src/components/screens/settings/McpConnect.tsx) today displays mcpEndpointUrl, which — per the module's own comment ("it already carries a working token") — bakes a token into the URL. The fix stops minting/showing that token: the endpoint shown is the plain /mcp URL, and connecting happens through the OAuth handshake (Claude's "Add custom connector" consent screen), not by copy-pasting a secret.?token= keeps working server-side. The legacy JWT path in routes/mcp.ts (verifyMcpToken / mcpRevoked) is explicitly UNCHANGED — existing connections must not break. Only minting new ones on the profile page stops.serverInfo advertise "Robin", and /favicon.ico serves Robin's icon — what a client (e.g. Claude's connector list) renders for the connection.mcp:read/mcp:write scope split, and the #303 revocation hardening (mcpOAuthRevoked on the OAuth path) all keep holding.greenlight-pg :5433 with vector, greenlight-redis :6380) plus the app ($APP_URL, default http://localhost:8080) and server ($SERVER_URL, default http://localhost:3000) started via bash ~/.studio/master.withrobin.ai/scripts/dev-server.sh.PROJECT_ROOT = a tree carrying the #187 connector-UX fix. Invoke via that tree's .warden symlink.curl, jq, grep, psql on PATH; npx agent-browser (0.26.x) for the rendered-profile step. The env file is provisioned automatically by .warden/run.sh from .warden/env/ci-env.template.sh plus ~/.config/robin/warden-secrets.sh.andrew@robin.ai / robin2026, via bash ~/.studio/master.withrobin.ai/scripts/seed-andrew.sh) with an existing MCP endpoint/token, so the legacy-URL regression check has a real pre-issue token to exercise.set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# MC-1: the guard is reachable on the no-credential path, not gated behind an
# already-present bearer header. This is a loose source guard (the live check
# in step 2 is the real assertion) — it just fails fast on an obviously
# unchanged mcp.ts.
RT=server/src/routes/mcp.ts
# Match the returned code literal (error: 'Missing token'), not prose in
# comments explaining the legacy scheme.
if grep -qE "error: *'Missing token'" "$RT"; then
warden_fail "MC-1: routes/mcp.ts still returns the bare 'Missing token' 401 literal — credential-less /mcp still looks unpatched (verify live in step 2 regardless)"
else
warden_pass "MC-1: the old bare 'Missing token' 401 literal is gone from routes/mcp.ts"
fi
# MC-2: the displayed endpoint is built credential-free SERVER-side — the fix
# landed in the users module (buildMcpEndpointUrl returns a bare /mcp URL);
# the app card still renders profile.mcpEndpointUrl verbatim, so the field
# name alone proves nothing either way. Assert the builder shape instead.
UR=server/src/modules/users/routes.ts
if grep -qF '${appUrl}/mcp`' "$UR" && ! grep -qE 'mcpEndpointUrl.*token=' "$UR"; then
warden_pass "MC-2: the profile endpoint is built credential-free server-side (bare /mcp, no token= interpolation) — verify rendered in step 5"
else
warden_fail "MC-2: users/routes.ts does not build a bare credential-free /mcp endpoint — the profile card would still hand out a secret-bearing URL (verify live in step 5)"
fi
/mcp emits the RFC 9728 challenge (the blocker, live)set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
SERVER_BASE="${SERVER_URL:-http://localhost:3000}"
# The exact repro from the issue: POST /mcp with NO Authorization header and
# NO ?token= query param.
HDRS=/tmp/warden-mcp187-headers.txt
BODY=/tmp/warden-mcp187-body.json
CODE=$(curl -s -D "$HDRS" -o "$BODY" -w '%{http_code}' -X POST \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
"$SERVER_BASE/mcp")
[ "$CODE" = "401" ] \
&& warden_pass "credential-less POST /mcp returns 401" \
|| warden_fail "credential-less POST /mcp returned $CODE, expected 401"
if grep -qi '^www-authenticate:' "$HDRS"; then
warden_pass "credential-less 401 carries a WWW-Authenticate header (the blocker is fixed)"
else
warden_fail "credential-less 401 carries NO WWW-Authenticate header — same blocker as the issue: a client with no OAuth support cannot discover OAuth"
fi
if grep -qi '^www-authenticate:.*Bearer' "$HDRS" && grep -qi '^www-authenticate:.*resource_metadata=' "$HDRS"; then
warden_pass "the challenge names Bearer + resource_metadata (RFC 9728 shape)"
else
warden_fail "the WWW-Authenticate header is present but not RFC-9728-shaped (missing Bearer or resource_metadata): $(grep -i '^www-authenticate:' "$HDRS")"
fi
# NEGATIVE: the error body must not leak into silence — still informative,
# but must NOT be the old bare, challenge-less shape.
if grep -q '"error":"Missing token"' "$BODY" && ! grep -qi '^www-authenticate:' "$HDRS"; then
warden_fail "regression: response is the old bare 'Missing token' 401 with no challenge"
else
warden_pass "response is not the old challenge-less 'Missing token' shape"
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
SERVER_BASE="${SERVER_URL:-http://localhost:3000}"
PRM=/tmp/warden-mcp187-prm.json
PRM_CODE=$(curl -s -o "$PRM" -w '%{http_code}' "$SERVER_BASE/.well-known/oauth-protected-resource")
[ "$PRM_CODE" = "200" ] \
&& warden_pass "GET /.well-known/oauth-protected-resource returns 200" \
|| warden_fail "PRM document returned $PRM_CODE"
if jq -e '.scopes_supported | index("mcp:read") and index("mcp:write")' "$PRM" >/dev/null 2>&1; then
warden_pass "PRM document advertises both mcp:read and mcp:write scopes"
else
warden_fail "PRM document does not advertise both mcp:read and mcp:write: $(cat "$PRM")"
fi
AS=/tmp/warden-mcp187-as.json
AS_CODE=$(curl -s -o "$AS" -w '%{http_code}' "$SERVER_BASE/.well-known/oauth-authorization-server")
[ "$AS_CODE" = "200" ] \
&& warden_pass "GET /.well-known/oauth-authorization-server returns 200" \
|| warden_fail "AS metadata document returned $AS_CODE"
# Branding: favicon serves Robin's icon (non-empty image), not a default/blank.
FAVI=/tmp/warden-mcp187-favicon.ico
FAVI_CODE=$(curl -s -o "$FAVI" -w '%{http_code}' "$SERVER_BASE/favicon.ico")
FAVI_SIZE=$(wc -c < "$FAVI" | tr -d ' ')
[ "$FAVI_CODE" = "200" ] && [ "${FAVI_SIZE:-0}" -gt 0 ] \
&& warden_pass "GET /favicon.ico returns 200 with a non-empty body ($FAVI_SIZE bytes)" \
|| warden_fail "GET /favicon.ico returned $FAVI_CODE / $FAVI_SIZE bytes — connector clients would show no/blank branding"
# Branding: the MCP server identifies itself as Robin. A credential-less
# initialize can no longer succeed (step 2 REQUIRES it to 401 with the
# challenge), so the live authenticated initialize assertion lives in step
# 6's committed suite ("the initialize handshake reports serverInfo.name =
# 'Robin'"). Here: assert the one branding constant is wired into McpServer
# and that the live test exists to be run.
if grep -qF "MCP_SERVER_NAME = 'Robin'" server/src/lib/oauth-config.ts \
&& grep -q 'name: MCP_SERVER_NAME' server/src/mcp/server.ts \
&& grep -qF "serverInfo.name = 'Robin'" server/src/__tests__/oauth-mcp.test.ts; then
warden_pass "MCP serverInfo branding: MCP_SERVER_NAME='Robin' is wired into McpServer, with the authenticated initialize live-asserted by step 6's suite"
else
warden_fail "MCP serverInfo branding is not wired (constant, McpServer name, or the live initialize test is missing) — connector UI would show a generic/placeholder name"
fi
?token= connections keep working (regression floor, live)set -uo pipefail
source "$WARDEN_LIB/assert.sh"
source "$WARDEN_LIB/db.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# shellcheck disable=SC1091
source "${WARDEN_ENV_FILE:?WARDEN_ENV_FILE not set — run this plan via .warden/run.sh}"
SERVER_BASE="${SERVER_URL:-http://localhost:3000}"
APP_BASE="${APP_URL:-http://localhost:8080}"
# The seeded andrew@robin.ai account already has an MCP endpoint (a live
# ?token=-bearing URL) — sign in and pull it from the profile API so this
# step exercises a REAL pre-existing token, not a freshly minted one.
JAR="$(mktemp /tmp/warden-mcp187-cookies-XXXXXX.txt)"
curl -s -o /dev/null -c "$JAR" -X POST -H 'Content-Type: application/json' -H "Origin: $APP_BASE" \
-d '{"email":"andrew@robin.ai","password":"robin2026"}' \
"$APP_BASE/api/auth/sign-in/email"
PROFILE=/tmp/warden-mcp187-profile.json
curl -s -b "$JAR" -H "Origin: $APP_BASE" "$APP_BASE/api/users/profile" -o "$PROFILE" 2>/dev/null
jq -e . "$PROFILE" >/dev/null 2>&1 || \
curl -s -b "$JAR" -H "Origin: $APP_BASE" "$SERVER_BASE/users/profile" -o "$PROFILE" 2>/dev/null
LEGACY_URL=$(jq -r '.mcpEndpointUrl // empty' "$PROFILE" 2>/dev/null)
if [ -z "$LEGACY_URL" ]; then
warden_skip "legacy ?token= regression check" "could not read andrew@robin.ai's existing mcpEndpointUrl from the profile response"
elif ! echo "$LEGACY_URL" | grep -q 'token='; then
# Post-fix the profile hands out a bare /mcp URL; on a fresh DB no pre-fix
# token-bearing URL exists to exercise. The legacy ?token= ACCEPTANCE path
# is live-asserted by step 6's committed suite instead.
warden_skip "legacy ?token= regression check" "profile hands out a credential-free URL (post-fix, correct) and no pre-fix token exists in this DB — legacy acceptance covered by step 6's oauth-mcp suite"
else
LEGACY_CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' "$LEGACY_URL")
[ "$LEGACY_CODE" = "200" ] \
&& warden_pass "an existing ?token= MCP URL still authenticates (200 on tools/list) — legacy connections are not broken" \
|| warden_fail "an existing ?token= MCP URL returned $LEGACY_CODE — legacy connections would break (server must keep ACCEPTING old tokens, only stop MINTING new ones)"
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
APP_BASE="${APP_URL:-http://localhost:8080}"
if ! npx agent-browser session >/dev/null 2>&1; then
warden_skip "rendered profile MCP endpoint check" "npx agent-browser is unavailable on this box"
else
npx agent-browser cookies clear >/dev/null 2>&1
npx agent-browser open "$APP_BASE/login" >/dev/null
npx agent-browser wait "input" >/dev/null
npx agent-browser fill "input[type='email'], input[name='email']" "andrew@robin.ai" >/dev/null
npx agent-browser fill "input[type='password'], input[name='password']" "robin2026" >/dev/null
npx agent-browser click "button[type='submit']" >/dev/null
sleep 2
# The MCP connect card moved to Settings → Connected apps (/settings/connections).
npx agent-browser open "$APP_BASE/settings/connections" >/dev/null
npx agent-browser wait "text=Connect Claude via MCP" >/dev/null 2>&1
sleep 2
ENDPOINT_TEXT=$(npx agent-browser get text "body" 2>/dev/null | grep -Eo 'https?://[^"[:space:]]*/mcp[^"[:space:]]*' | head -1)
if echo "$ENDPOINT_TEXT" | grep -q 'token='; then
warden_fail "the profile page's displayed MCP endpoint still carries a ?token= query param: $ENDPOINT_TEXT"
elif echo "$ENDPOINT_TEXT" | grep -qE '^https?://.*/mcp'; then
warden_pass "the profile page shows a plain /mcp endpoint with no embedded token: $ENDPOINT_TEXT"
else
warden_fail "could not read a plain /mcp endpoint string off the profile page (selector drift or the field is empty): '$ENDPOINT_TEXT'"
fi
fi
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# shellcheck disable=SC1091
source "${WARDEN_ENV_FILE:?WARDEN_ENV_FILE not set — run this plan via .warden/run.sh}"
# Re-runs the committed OAuth/MCP suite as the durable floor: valid-bearer
# read/write, mcp:read-only refused on a write tool, the legacy ?token= path,
# the per-member kill-switch, and the RFC 9728 challenge on an INVALID
# bearer (already correct — must not regress while fixing the credential-less
# path in step 2).
if pnpm --filter @robin/server exec vitest run \
src/__tests__/oauth-mcp.test.ts \
>/tmp/warden-mcp187-suite.log 2>&1; then
warden_pass "oauth-mcp.test.ts suite passes (scope split, legacy token, kill-switch, invalid-bearer challenge)"
else
tail -30 /tmp/warden-mcp187-suite.log
warden_fail "oauth-mcp.test.ts FAILED — see /tmp/warden-mcp187-suite.log"
fi
Step 1 is a loose static guard — the "Missing token" literal and the McpConnect.tsx grep are both easy to satisfy without actually fixing the bug (e.g. reordering the same logic), so step 2's live curl against a real credential-less request is the load-bearing assertion, not step 1. Step 4 deliberately pulls a pre-existing seeded token rather than minting one, because minting one through the (post-fix) profile page is exactly the thing that should no longer happen — the regression floor has to be a token that predates the fix. Step 5 is best-effort (agent-browser selector drift is expected as the profile page is redesigned; keep the assertion pinned to "no token= substring" + "looks like a bare /mcp URL" rather than an exact DOM path).
Stays manual / not asserted here:
?token= deprecation decision. The issue leaves open whether ?token= stays long-term or gets a deadline; this plan only asserts it is not broken today. No predicate here enforces a removal timeline.SERVER_PUBLIC_URL-driven branding is config, not code the local stack can prove — step 3's serverInfo/favicon checks are the only branding predicates in scope for this plan; the wildcard-DNS/subdomain provisioning and the invite-email/admin-dashboard Railway-URL sweep are operator/deploy-time checks, not warden predicates.The tier vocabulary above (needs-server, requires: [needs-postgres]) is the real front-matter contract .warden/run.sh/check-plan-tiers.mjs enforce (.warden/TIERS.md's seven-label set: hermetic / needs-redis / needs-postgres / needs-server / needs-model / destructive / volatile). v11-batch is not a member of that vocabulary and run.sh tier:v11-batch would hard-exit ("unknown tier") — it reads as an external batch/wave label for this design sweep, not a warden tier. If the orchestrator wants this plan selectable as part of a "v11" wave, that needs a real selection mechanism (e.g. phase:v11-batch via WARDEN_PHASES/a plans/v11-batch/ subdirectory) rather than a tier: value — flagging rather than guessing.