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:
- The MCP 401 response is missing the
WWW-Authenticateheader required by RFC 6750 and MCP spec 2025-11-25. connect.tshands out the wrong MCP endpoint URL.- Default OAuth scope is read+write (too permissive for analysis-only tools).
OAUTH_JWT_SECRETfalls back toOAUTH_STATE_SECRET(rotation coupling).- No
client_idin 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-Authenticateheader, correct MCP_ENDPOINT, dedicatedOAUTH_JWT_SECRET - Phase 2 (security): read-only default scope,
POST /admin/tokens/issuefor partner provisioning - Phase 3 (observability + UX):
client_idin audit trail,/connectweb portal - Phase 4 (ops): reconnect ChatGPT, verify Cursor/Gemini/Perplexity
Key design choices
Read-only scope as default — DEFAULT_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_SECRETandOAUTH_STATE_SECREThave 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.