Skip to content

Universal MCP Access for External Tools

ADR-030 — Universal MCP Access for External Tools

Status: Accepted
Date: 2026-04-25
Deciders: Mishaal Murawala, Claude Code
Supersedes: n/a
Related: ADR-024 (OAuth 2.1 + Streamable HTTP), ADR-025 (Secrets Store)


Context

V5 has a complete OAuth 2.1 Authorization Server (ADR-024) but external tools cannot connect because:

  1. The MCP 401 response is missing the WWW-Authenticate header required by RFC 6750 and MCP spec 2025-11-25.
  2. connect.ts hands out the wrong MCP endpoint URL.
  3. Default OAuth scope is read+write (too permissive for analysis-only tools).
  4. OAUTH_JWT_SECRET falls back to OAUTH_STATE_SECRET (rotation coupling).
  5. No client_id in audit trail (can’t attribute actions to tools).

The goal is: any MCP-capable tool (ChatGPT, Gemini, Cursor, Perplexity, partner clients) can connect to V5 in under 5 minutes using standard OAuth 2.1 + PKCE, zero manual token setup.


Decision

Ship a 4-phase project per docs/plans/UNIVERSAL-MCP-ACCESS.md:

  • Phase 1 (bugs + secret separation): WWW-Authenticate header, correct MCP_ENDPOINT, dedicated OAUTH_JWT_SECRET
  • Phase 2 (security): read-only default scope, POST /admin/tokens/issue for partner provisioning
  • Phase 3 (observability + UX): client_id in audit trail, /connect web portal
  • Phase 4 (ops): reconnect ChatGPT, verify Cursor/Gemini/Perplexity

Key design choices

Read-only scope as defaultDEFAULT_MCP_SCOPES changes from ['mcp:read', 'mcp:write'] to ['mcp:read']. Write access requires explicit scope=mcp:read mcp:write in the auth URL and explicit user approval on the consent page.

ALLOW_LEGACY_BEARER = true preserved — existing static bearer token integrations are unaffected throughout all phases. Sunset trigger: 14 consecutive days with zero bearer-auth writes in kv_audit after Phase 3 ships.

client_id from JWT, not request param — the client_id is extracted from the JWT payload (set at token issuance time, tamper-proof). It is never accepted as an input parameter to any tool.

OAUTH_JWT_SECRET as standalone secret — remove the ?? OAUTH_STATE_SECRET fallback. Phase 1 deploy requires wrangler secret put OAUTH_JWT_SECRET before deploying. All existing OAuth sessions will re-auth once (acceptable; documented in deploy plan).


Consequences

Positive

  • ChatGPT, Gemini CLI, Cursor, Perplexity can connect using their native OAuth flows — zero custom integration code
  • External tools get read-only access by default — no accidental writes from analysis tools
  • Audit trail now attributes every action to a specific tool (client_id)
  • OAUTH_JWT_SECRET and OAUTH_STATE_SECRET have independent rotation cycles

Negative

  • Phase 1 deploy invalidates all existing OAuth sessions (one-time re-auth cost)
  • Default scope change to read-only may surprise existing OAuth users who expected full access — documented in deploy checklist

Neutral

  • Static bearer token users (including Mishaal’s Claude Code sessions) are completely unaffected
  • No new infrastructure — all changes within the existing V5 Worker

Alternatives considered

Grant write access by default, let users opt down — rejected. Default-open is indefensible when the platform handles ad spend writes, email sends, and CRM mutations. Analysis tools should not be able to accidentally trigger mutations.

Separate Worker for external OAuth clients — rejected. Invariant #1 (two-plane architecture) prohibits a third Worker without an ADR. There’s no reason to split — the existing Worker handles this cleanly.

Rate limiting per client_id in Phase 1 — deferred to Phase 4+. Needs 2+ weeks of client_id telemetry to set meaningful limits. Premature rate limits create false friction.