Warden plan

17 - issue #187 MCP connector UX (credential-less discovery, plain endpoint, branded metadata)

← eval suite index


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


17 - issue #187 MCP connector UX (credential-less discovery, plain endpoint, branded metadata)

What it proves

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:

Prerequisites

Step 1: the challenge-less blocker is fixed (static, repo tree)

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

Step 2: credential-less /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

Step 3: discovery documents resolve and are branded (live)

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

Step 4: legacy ?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

Step 5: rendered profile page — plain endpoint, no token, real browser

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

Step 6: scope split + #303 revocation regression floor (live vitest)

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

Shape (note for the next author)

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:

Tier-label note

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.