1 What MCP Is in Claude Code
MCP (Model Context Protocol) is a standard for connecting AI models to external tools and data sources. Inside Claude Code, every MCP server is treated as a first-class tool supplier: its tools are registered with the same Tool interface as built-in tools like Write and Bash, just namespaced under mcp__<server>__<tool>.
The architecture is split across three layers:
Connection lifecycle, config loading, OAuth auth, transport construction, elicitation, deduplication.
Thin proxy tool that wraps every remote MCP call. Overridden at runtime with real name, schema, and call() from the server's tool list.
User-facing /mcp slash command: reconnect, enable/disable, settings UI.
React UI panels: MCPSettings, MCPListPanel, ElicitationDialog, MCPReconnect, CapabilitiesSection.
2 Transport Types
The type system in services/mcp/types.ts defines the full set of supported transports. Each has distinct setup requirements and use cases:
Subprocess spawn] B -->|sse| D[SSEClientTransport
Persistent EventSource +
OAuth / ClaudeAuthProvider] B -->|http| E[StreamableHTTPClientTransport
JSON + SSE on same POST
OAuth / session ingress] B -->|ws| F[WebSocketTransport
ws module or Bun WS] B -->|sse-ide| G[SSEClientTransport
IDE extension only
no auth] B -->|ws-ide| H[WebSocketTransport
IDE extension with
optional auth token] B -->|sdk| I[SdkControlClientTransport
In-process / control msgs] B -->|in-process| J[InProcessTransport
Linked pair
Chrome / Computer Use] style C fill:#22201d,stroke:#7d9ab8,color:#b8b0a4 style D fill:#22201d,stroke:#6e9468,color:#b8b0a4 style E fill:#22201d,stroke:#6e9468,color:#b8b0a4 style F fill:#22201d,stroke:#b8965e,color:#b8b0a4 style G fill:#22201d,stroke:#c47a50,color:#b8b0a4 style H fill:#22201d,stroke:#c47a50,color:#b8b0a4 style I fill:#22201d,stroke:#8e82ad,color:#b8b0a4 style J fill:#22201d,stroke:#8e82ad,color:#b8b0a4
Spawns a subprocess, communicates via stdin/stdout. Default type (omitting type defaults here). Stderr piped and capped at 64 MB.
HTTP Server-Sent Events. Uses ClaudeAuthProvider for OAuth. GET (SSE stream) intentionally skips the 60 s request timeout — only POSTs get it.
Streamable HTTP (MCP 2025-03-26 spec). Advertises Accept: application/json, text/event-stream on every POST. Supports OAuth + session-ingress JWT.
WebSocket with protocols: ['mcp']. Uses ws module on Node, native Bun WS. Supports proxy agent and mTLS.
SSE variant for IDE extensions (VS Code etc.). No OAuth. Lockfile-based auth token planned but not yet wired.
WebSocket variant for IDE extensions. Accepts optional authToken sent as X-Claude-Code-Ide-Authorization header.
Control-message bridge to an SDK process. Tool calls route via stdout/stdin. Never directly spawns a process.
Used by Chrome MCP and Computer Use. createLinkedTransportPair() creates two InProcessTransport instances; messages delivered via queueMicrotask to avoid stack overflows.
Code: InProcessTransport linked pair
// services/mcp/InProcessTransport.ts
class InProcessTransport implements Transport {
private peer: InProcessTransport | undefined
async send(message: JSONRPCMessage): Promise<void> {
// Deliver async to avoid stack depth in sync req/resp cycles
queueMicrotask(() => { this.peer?.onmessage?.(message) })
}
}
export function createLinkedTransportPair(): [Transport, Transport] {
const a = new InProcessTransport()
const b = new InProcessTransport()
a._setPeer(b); b._setPeer(a)
return [a, b]
}
3 Config Scope Cascade
MCP server configs come from multiple sources that are merged with a clear precedence order. The ConfigScope type names them:
| Priority | Scope | Source File / Location | Notes |
|---|---|---|---|
| 1 highest | enterprise |
managed-mcp.json (MDM-managed path) |
When present, blocks all user-managed add/remove. Exclusive control. |
| 2 | dynamic |
CLI flag --mcp-config <path> |
Passed at startup; policy-filtered before use. |
| 3 | claudeai |
Claude.ai connector API (remote fetch) | Deduplicated against manually-configured servers by URL signature. |
| 4 | project |
.mcp.json (CWD & parent dirs, root-down) |
Nearest-to-cwd wins. Parent dirs are also searched; child overrides parent. |
| 5 | local |
~/.claude/projects/<hash>/ |
Per-project local state, not checked into git. |
| 6 | user |
~/.claude/settings.json (global config) |
User-wide defaults. |
| 7 | managed |
Plugin-provided servers | Namespaced plugin:name:server. Content-deduplicated against manual servers by signature (URL or command array). |
managed-mcp.json exists, calling addMcpConfig() throws immediately: "enterprise MCP configuration is active and has exclusive control". Tools like claude mcp add are completely blocked.
Code: scope cascade assembly (simplified from getAllMcpConfigs)
// services/mcp/config.ts — conceptual merge order
const allConfigs = {
...enterpriseServers, // wins if present
...dynamicServers, // --mcp-config flag
...claudeAiServers, // deduplicated by URL sig
...projectServers, // .mcp.json, root-down
...localServers, // ~/.claude/projects/…
...userServers, // ~/.claude/settings.json
...pluginServers, // namespaced, sig-deduplicated
}
// Env vars expanded in all configs before connection
// e.g. command: "npx", args: ["$MY_SERVER_PATH"]
Policy Allow/Deny Lists
Enterprise policy can define allowedMcpServers and deniedMcpServers in settings. The denylist takes absolute precedence. Matching can be by:
- Name — exact server name string
- Command — full command+args array for stdio servers
- URL pattern — glob with
*wildcard for remote servers
4 Connection Lifecycle
Every server goes through a state machine managed by the MCPServerConnection union type:
Config assembly
All scopes merged and policy-filtered. Env vars expanded ($VAR / ${VAR}). Missing vars logged as warnings but don't block connection.
Batched connection
Stdio servers connect in batches of 3 (MCP_SERVER_CONNECTION_BATCH_SIZE). Remote servers batch at 20. Each call to connectToServer() is memoized by name + JSON(config).
Transport construction
Based on serverRef.type, the correct SDK transport class is instantiated. Auth providers, proxy agents, and mTLS options are attached here.
client.connect() with timeout
Default 30 s (MCP_TIMEOUT env var). Races connectPromise vs timeoutPromise. Timeout also closes in-process server if one was started.
Auth handling
If UnauthorizedError (401): server moves to needs-auth state. A McpAuthTool pseudo-tool is injected so the model can trigger OAuth. After 15 min the needs-auth cache entry expires.
Capability negotiation
Claude Code declares roots: {} and elicitation: {} capabilities. Server's capabilities read via getServerCapabilities(). Server instructions truncated to 2048 chars.
Tool/resource/prompt fetch
Tools, resources, prompts fetched in parallel. Tool names normalized (mcp__server__tool). Each tool is a cloned MCPTool with overridden name, description, inputSchema, and call().
Live notifications
Subscriptions to ToolListChanged, ResourceListChanged, PromptListChanged notifications. Any change triggers a re-fetch and AppState update. Exponential back-off reconnect (1 s → 30 s cap, max 5 attempts).
Code: connection with timeout race
// services/mcp/client.ts (simplified)
const connectPromise = client.connect(transport)
const timeoutPromise = new Promise<never>((_, reject) => {
const id = setTimeout(() => {
transport.close().catch(() => {})
reject(new Error(`MCP server "${name}" timed out after ${timeout}ms`))
}, getConnectionTimeoutMs())
connectPromise.then(() => clearTimeout(id), () => clearTimeout(id))
})
await Promise.race([connectPromise, timeoutPromise])
5 Tool Proxying
The MCPTool defined in tools/MCPTool/MCPTool.ts is a template. For every tool reported by a connected server, fetchToolsForClient() in client.ts creates a deep clone with the real metadata overridden:
tool.description
inputSchema
tool_name
desc, schema
call()
available to
Claude
→ JSON-RPC
→ transport
Name normalization
// services/mcp/normalization.ts
export function normalizeNameForMCP(name: string): string {
// Replace any char not in [a-zA-Z0-9_-] with underscore
let normalized = name.replace(/[^a-zA-Z0-9_-]/g, '_')
// For claude.ai servers: collapse consecutive underscores,
// strip leading/trailing (__ delimiter must stay clean)
if (name.startsWith('claude.ai ')) {
normalized = normalized.replace(/_+/g, '_').replace(/^_|_$/g, '')
}
return normalized
}
// Full tool name: "mcp__my_server__list_files"
export function buildMcpToolName(server: string, tool: string): string {
return `mcp__${normalizeNameForMCP(server)}__${normalizeNameForMCP(tool)}`
}
tool.description. Claude Code hard-caps both tool descriptions and server instructions at 2048 characters to keep the context window manageable.
Code: result handling — images, binary blobs, truncation
// After client.callTool() returns a CallToolResult...
// 1. Image content items → resized/downsampled, returned as base64
if (content.type === 'image' && IMAGE_MIME_TYPES.has(mimeType)) {
const buf = maybeResizeAndDownsampleImageBuffer(rawBuf)
// → ContentBlockParam with base64 data
}
// 2. Binary blobs → persisted to disk, path returned as text
if (!IMAGE_MIME_TYPES.has(mimeType)) {
await persistBinaryContent(content)
// → getBinaryBlobSavedMessage(path)
}
// 3. Total result > 100 KB → truncation with instructions
if (mcpContentNeedsTruncation(result)) {
result = truncateMcpContentIfNeeded(result)
}
6 OAuth Authentication
MCP servers using SSE or HTTP transport can require OAuth. The auth system in services/mcp/auth.ts implements the full PKCE flow with XAA (Cross-App Access) extension support:
needs-auth state, a pseudo-tool named mcp__<server>__authenticate is injected. The model can call it to start the OAuth flow and receive an authorization URL to show the user. Once the callback fires, the real tools automatically replace the pseudo-tool (prefix-based replacement on AppState).
Token refresh & Slack quirk
The standard RFC 6749 invalid_grant error triggers token invalidation. But Slack returns HTTP 200 with {"error":"invalid_refresh_token"} — the SDK would see this as a ZodError. Claude Code normalizes these non-standard codes to invalid_grant before passing to the SDK's error-class mapper.
Code: Slack 200-error normalization
// services/mcp/auth.ts
const NONSTANDARD_INVALID_GRANT_ALIASES = new Set([
'invalid_refresh_token',
'expired_refresh_token',
'token_expired',
])
// Wraps fetch: peeks at 2xx POST responses, rewrites error bodies
// matching OAuthErrorResponseSchema (but NOT OAuthTokensSchema)
// to a synthetic 400 response — so SDK error-class mapping applies.
XAA — Cross-App Access
Enterprise extension for SSO flows. When xaa: true is set on an MCP server config, instead of launching a browser the system exchanges an IdP ID-token for the MCP server's OAuth token silently. Configured once in settings.xaaIdp, shared across all XAA-enabled servers.
7 Elicitation
Elicitation is the MCP mechanism for servers to request structured input from the user mid-operation. Claude Code supports both modes:
Server sends a JSON Schema; user fills a form. Response is accept with content, or decline / cancel.
Server sends a URL (e.g. OAuth step-up, external confirmation). Two-phase: open URL → wait for ElicitationComplete notification. User can dismiss or retry.
Requests land in AppState.elicitation.queue as ElicitationRequestEvent objects with a respond() callback. The ElicitationDialog React component polls this queue. Hooks (executeElicitationHooks) can satisfy requests programmatically without showing UI.
Code: elicitation handler registration
// services/mcp/elicitationHandler.ts
client.setRequestHandler(ElicitRequestSchema, async (request, extra) => {
// 1. Try hooks first (programmatic response)
const hookResponse = await runElicitationHooks(serverName, request.params, extra.signal)
if (hookResponse) return hookResponse
// 2. Queue for user interaction
const response = new Promise<ElicitResult>(resolve => {
setAppState(prev => ({
...prev,
elicitation: {
queue: [...prev.elicitation.queue, {
serverName, requestId: extra.requestId,
params: request.params,
respond: resolve,
}],
},
}))
})
return await response
})
8 Server Deduplication
When multiple config sources supply the same underlying server, Claude Code deduplicates by content signature, not just name. The signature for stdio servers is stdio:["cmd","arg1"]; for remote servers it's url:https://vendor.example.com/mcp.
mcp_url query param. unwrapCcrProxyUrl() extracts it before signature comparison, so a plugin pointing directly at Slack's MCP server still deduplicates correctly against the claude.ai connector routed through CCR.
Deduplication rules:
- Manual wins over plugin — user-configured always beats plugin-provided.
- First plugin wins — if two plugins provide the same server, first-loaded wins.
- Enabled manual wins over claude.ai — a disabled manual server does not suppress its connector twin (so neither would run).
- Plugin servers are namespaced
plugin:name:serverto avoid key collisions even before dedup.
Key Takeaways
- 7 transport types — stdio, sse, http, ws, sse-ide, ws-ide, sdk — plus the internal in-process pair for Chrome/Computer Use. Public transports support OAuth; IDE variants are auth-free or token-based.
- 7-layer scope cascade — enterprise > dynamic > claudeai > project > local > user > managed. Enterprise presence locks out all manual add/remove operations.
- Config files walk the directory tree —
.mcp.jsonis read from every parent directory up to filesystem root; child directories override parents. - All tool names pass through normalization —
mcp__<server>__<tool>, with any non-alphanumeric characters replaced by underscores. Claude.ai server names get extra collapse/strip treatment to protect the__delimiter. - OAuth is model-triggerable — the
McpAuthToolpseudo-tool lets Claude start the auth flow autonomously and return the URL to the user; real tools auto-replace after callback. - Tool descriptions are hard-capped at 2048 chars — prevents context explosion from OpenAPI-generated servers.
- Deduplication is content-based, not name-based — same command array or URL = same server, regardless of what they're called in different config sources.
9 Knowledge Check
.mcp.json (scope: project) and ~/.claude/settings.json (scope: user). Which configuration wins?stderr output is printing noise directly to the Claude Code UI. How does the code prevent this?wrapFetchWithTimeout() and why does it intentionally skip GET requests?{"error":"invalid_refresh_token"} after a token refresh attempt. What happens in Claude Code?managed-mcp.json file is present. A user runs claude mcp add my-tool --command npx my-tool. What happens?