Wire protocol: replace @modelcontextprotocol/sdk 1.30 with @modelcontextprotocol/server + /node 2.0.0 (zod 4). One per-request factory (mcp-server-factory.ts, extracted from index.ts) serves both eras through mcp-http.ts: 2026-07-28 clients via createMcpHandler (legacy:'reject', JSON response mode, server/discover, Mcp-Method routing), 2025 clients through the existing stateless transport with enableJsonResponse — routed on the SDK's own isLegacyRequest predicate, because the built-in stateless fallback would answer every current client with SSE and lose the gzip win. Capabilities now declare listChanged:false explicitly; cacheHints stamp ttlMs/cacheScope:'private' on discover/list results (5 min) and resource reads (60 min) — the catalog is role-dependent, so never public. Authorization: advertise client_id_metadata_document_supported; an https client_id is a Client ID Metadata Document resolved by cimd.ts (URL-shape checks, SSRF gate on every resolved address before any request, no redirects, 5 s / 16 KB caps, exact client_id match, bounded redirect_uris, 10-60 min cache). The requested redirect_uri still has to pass the dynamic-registration allowlist; HMAC-signed DCR client_ids remain the fallback. Field limits moved to oauth-limits.ts (no import cycle). Tests: mcp-http.test.ts drives the real v2 client over loopback HTTP in both eras and pins role parity; cimd.test.ts / oauth-cimd.test.ts cover recognition, SSRF, validation, cache and the router paths. Package 1.5 -> 1.6. OKF doc docs/mcp/protocol-eras-and-cimd.md; mcp-oauth-flow.md updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8.2 KiB
type, title, description, resource, tags, timestamp
| type | title | description | resource | tags | timestamp | ||||
|---|---|---|---|---|---|---|---|---|---|
| Reference | MCP OAuth Flow — Who Authenticates Where | How the two unrelated identities (AI-product account vs. Entra ID work account) and three parties interact when connecting an AI client to the Autopilot Monitor MCP server. | /src/McpServer/autopilot-monitor-mcp/src/oauth.ts |
|
2026-08-29T08:45:00+02:00 |
MCP OAuth Flow — Who Authenticates Where
Status: Reference · Owner: MCP Server · Code: src/McpServer/autopilot-monitor-mcp/src/oauth.ts
Why this document exists
Connecting an AI client (Claude Desktop, claude.ai, VS Code, ChatGPT) to the
Autopilot Monitor MCP server involves two unrelated identities and three
parties plus Entra ID. This regularly confuses users during setup, because
the account signed in to the AI product and the account used for Autopilot
Monitor are usually different people-in-one: e.g. Luke uses his normal
account luke for his Claude subscription, but his admin account adm.luke
for Autopilot Monitor.
The two identities never mix:
luke(AI-product account) — only authenticates Luke against Claude itself. Anthropic needs it to associate the OAuth callback with the right Claude user and connector. The MCP server never sees this identity.adm.luke(Entra ID work account) — the identity inside the token. Every MCP tool call is authorized asadm.luke; roles and tenant scope derive from it. Anthropic never sees this password, only the issued tokens.
The parties
- AI client + its backend (e.g. Claude Desktop + claude.ai) — the OAuth client. For hosted surfaces the token exchange and token storage happen in the vendor's backend, not on the user's machine.
- MCP server (Container App) — acts as a stateless OAuth proxy /
authorization-server façade in front of Entra ID. It is not an IdP:
it establishes the client's identity statelessly — a Client ID Metadata
Document (MCP spec 2026-07-28: the
client_idis an HTTPS URL the server fetches and validates) or, for older clients, dynamic registration (RFC 7591, HMAC-signed statelessclient_id) — enforces the redirect-URI allowlist and PKCE, and exchanges codes at Entra using its confidential client secret. See Protocol Eras & CIMD. - Entra ID — the real identity provider. This is where
adm.lukelives.
Full sequence
Claude Desktop Browser (user's MCP server Entra ID
+ claude.ai backend DEFAULT browser!) (OAuth proxy)
│ │ │ │
1. │─ POST /mcp ──────────┼───────────────────────▶│ 401 + WWW-Auth │
2. │─ Discovery ──────────┼───────────────────────▶│ RFC 9728/8414 docs │
3. │─ client identity ────┼───────────────────────▶│ CIMD: client_id is a │
│ (CIMD, or legacy │ │ URL, doc fetched at │
│ POST /oauth/ │ │ /authorize; DCR: │
│ register) │ │ HMAC-signed client_id│
│ │ │ │
4. │─ opens browser ─────▶│─ GET /oauth/authorize ▶│─ 302 to Entra ──────▶│
│ │ (PKCE challenge) │ app-reg client_id, │
│ │ │ prompt=select_acct │
│ │ │ │
5. │ │◀── Microsoft sign-in: account picker ────────│
│ │ ➜ user picks adm.luke HERE │
│ │ │ │
6. │ │◀─ 302 /oauth/callback?code=... ──────────────│
│ │─ code ────────────────▶│ verifies HMAC state, │
│ │ │ re-checks allowlist │
│ │◀─ 302 claude.ai/api/mcp/auth_callback?code=..│
│ │ │ │
│◀═ browser hits claude.ai ═════╗ │ │
│ ➜ the browser MUST have an │ │ │
│ active claude.ai session │ │ │
│ as luke — otherwise │ │ │
│ Anthropic cannot map the │ │ │
│ code to a user/connector │ │ │
│ and the connect fails ════╝ │ │
│ │ │ │
7. │─ Anthropic backend: POST /oauth/token ───────▶│─ + client_secret ───▶│
│ (code + PKCE verifier) │◀─ access + refresh ──│
│◀─ tokens (stored server-side at Anthropic, per connector) │
│ │ │ │
8. │─ POST /mcp + Bearer ─┼───────────────────────▶│ validates Entra JWT │
│ │ │ ➜ acts as adm.luke │
Key properties
- Step 5 vs. step 6 is the double sign-in. The whole browser leg runs in
the user's default browser. That one browser profile needs both an
active session at the AI product (
lukeat claude.ai — same account as in the desktop app) and the Entra sign-in asadm.luke.prompt= select_accountforces the Entra account picker so an existing SSO session for the wrong account cannot be silently reused. - Step 6 is authenticated at the vendor.
claude.ai/api/mcp/auth_callbackis an authenticated Anthropic endpoint; the authorization code is bound to the Claude user via the browser's claude.ai session cookies. No claude.ai session (or a different browser profile) → the code lands on a login page and the connect stalls, even though the Entra half succeeded. This is the single most common setup failure. - The MCP server is a pure intermediary. Its client secret never leaves the server; the AI client never talks to Entra directly. The redirect target is checked twice (authorize + callback) against a strict allowlist of exact vendor callback URIs, and the proxy state that transits the user-agent is HMAC-signed with a 10-minute lifetime.
- PKCE is end-to-end. The AI client generates the verifier; the proxy forwards the challenge (step 4) and the verifier (step 7) untouched, and Entra validates the pair. The proxy additionally requires S256 PKCE and rejects downgrades.
- Tokens are Entra tokens for
adm.lukewith audienceapi://<client_id>/access_as_user. For hosted surfaces they are stored in the vendor's backend per connector (which is why the connector then works across desktop, web and mobile). Renewal is a silentrefresh_tokengrant through the same/oauth/tokenproxy — no account picker again. - Native CLIs skip step 6's hosted callback. Claude Code, Gemini CLI and
VS Code's loopback flow use RFC 8252
http://localhost:<port>/http://127.0.0.1:<port>redirects instead; there the token exchange runs on the user's machine and no vendor-side session is involved.
One-line summary
luke authenticates the return path of the authorization code to the AI
vendor (step 6); adm.luke is the identity inside the token that the MCP
server authorizes on every tool call (step 8).