markdown.engineering
Lesson 07

MCP Integration System

How Claude Code discovers, connects to, proxies, and authenticates Model Context Protocol servers — from stdio subprocesses to in-process Chrome bridges.

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:

services/mcp/

Connection lifecycle, config loading, OAuth auth, transport construction, elicitation, deduplication.

tools/MCPTool/

Thin proxy tool that wraps every remote MCP call. Overridden at runtime with real name, schema, and call() from the server's tool list.

commands/mcp/

User-facing /mcp slash command: reconnect, enable/disable, settings UI.

components/mcp/

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:

graph LR A([Claude Code CLI]) --> B{Transport Type} B -->|stdio| C[StdioClientTransport
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
stdio public

Spawns a subprocess, communicates via stdin/stdout. Default type (omitting type defaults here). Stderr piped and capped at 64 MB.

sse public

HTTP Server-Sent Events. Uses ClaudeAuthProvider for OAuth. GET (SSE stream) intentionally skips the 60 s request timeout — only POSTs get it.

http public

Streamable HTTP (MCP 2025-03-26 spec). Advertises Accept: application/json, text/event-stream on every POST. Supports OAuth + session-ingress JWT.

ws public

WebSocket with protocols: ['mcp']. Uses ws module on Node, native Bun WS. Supports proxy agent and mTLS.

sse-ide internal

SSE variant for IDE extensions (VS Code etc.). No OAuth. Lockfile-based auth token planned but not yet wired.

ws-ide internal

WebSocket variant for IDE extensions. Accepts optional authToken sent as X-Claude-Code-Ide-Authorization header.

sdk internal

Control-message bridge to an SDK process. Tool calls route via stdout/stdin. Never directly spawns a process.

in-process in-proc

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).
Watch out: Enterprise lock
When 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:

pending
connected
failed
needs-auth
disabled
1

Config assembly

All scopes merged and policy-filtered. Env vars expanded ($VAR / ${VAR}). Missing vars logged as warnings but don't block connection.

2

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).

3

Transport construction

Based on serverRef.type, the correct SDK transport class is instantiated. Auth providers, proxy agents, and mTLS options are attached here.

4

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.

5

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.

6

Capability negotiation

Claude Code declares roots: {} and elicitation: {} capabilities. Server's capabilities read via getServerCapabilities(). Server instructions truncated to 2048 chars.

7

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().

8

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:

MCP Server
tool.name
tool.description
inputSchema
Normalize
mcp__server__
tool_name
Clone MCPTool
override name
desc, schema
call()
AppState
mcp.tools[]
available to
Claude
Tool call
client.callTool()
→ 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 cap
OpenAPI-generated MCP servers have been observed dumping 15–60 KB of docs into 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:

sequenceDiagram participant C as Claude Code CLI participant KS as Keychain / SecureStorage participant AS as Auth Server participant MCP as MCP Server C->>C: client.connect(transport) → 401 UnauthorizedError C->>C: set server state: needs-auth C->>C: inject McpAuthTool pseudo-tool Note over C: Model calls mcp__server__authenticate C->>AS: discoverOAuthServerMetadata() AS-->>C: authServerMetadata (PKCE endpoint) C->>AS: /authorize?code_challenge=…&redirect_uri=… C-->>C: open browser / return URL AS-->>C: callback → authorization_code C->>AS: POST /token (code + verifier) AS-->>C: access_token + refresh_token C->>KS: store tokens (keychain on macOS) C->>MCP: reconnectMcpServerImpl() MCP-->>C: connected — real tools swap in
McpAuthTool: model-triggered OAuth
When a server enters 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:

form mode

Server sends a JSON Schema; user fills a form. Response is accept with content, or decline / cancel.

url mode

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.

CCR proxy unwrapping
In remote sessions, claude.ai connectors arrive with URLs rewritten to route through the CCR/session-ingress proxy. The original vendor URL is preserved in a 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:server to 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.json is read from every parent directory up to filesystem root; child directories override parents.
  • All tool names pass through normalizationmcp__<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 McpAuthTool pseudo-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

0 / 5 answered
1. You add the same MCP server in both a project's .mcp.json (scope: project) and ~/.claude/settings.json (scope: user). Which configuration wins?
A The user-level config, because it is global.
B The project-level .mcp.json, because project scope has higher priority than user scope.
C An error is thrown because duplicate server names are not allowed.
D Both are merged, with user settings as the base.
2. A stdio MCP server's stderr output is printing noise directly to the Claude Code UI. How does the code prevent this?
A Stderr is discarded entirely; nothing is captured.
B Stderr is redirected to a temp file on disk.
C The StdioClientTransport is created with stderr: 'pipe'; output is accumulated in memory and logged via logMCPError, not shown in the UI.
D Stderr is forwarded to the model as part of the tool result.
3. What is the purpose of wrapFetchWithTimeout() and why does it intentionally skip GET requests?
A It applies a 60 s timeout to POST requests. GETs are skipped because SSE GET connections are long-lived streams that must stay open indefinitely.
B It applies a 30 s timeout to all requests, including GETs, to prevent hanging.
C It cancels requests that are too large; GET requests have no body so no timeout is needed.
D It handles retries on timeout; GETs are idempotent so retries are not needed.
4. A Slack MCP server returns HTTP 200 with body {"error":"invalid_refresh_token"} after a token refresh attempt. What happens in Claude Code?
A A ZodError is thrown and the server is marked as failed permanently.
B The response is ignored and the request is retried with the same token.
C The server is moved to needs-auth state immediately without attempting normalization.
D The non-standard error code is normalized to invalid_grant and the response is rewritten to a synthetic 400, triggering the SDK's standard InvalidGrantError path and token invalidation.
5. An enterprise managed-mcp.json file is present. A user runs claude mcp add my-tool --command npx my-tool. What happens?
A The server is added to the user-scope config and runs alongside enterprise servers.
B addMcpConfig() throws: "enterprise MCP configuration is active and has exclusive control over MCP servers".
C The server is added but flagged as pending-approval until an admin reviews it.
D The server is added to a shadow list and will activate when the enterprise file is removed.