MCP Protocol Integration: How AI Tools Connect to Everything

A deep dive into Claude Code's MCP integration — Server lifecycle management, dynamic tool generation, Resource/Prompt support, and inter-Agent Server inheritance

The Problem

Claude Code can query databases, access APIs, and manipulate Figma — these capabilities aren't hardcoded but dynamically loaded through MCP.

When you configure a Slack Server in .mcp.json, Claude Code automatically connects to it, discovers all the tools it provides (search messages, send messages, list channels), then registers those tools as callable Tools for Claude, as if they were built-in. When you say "send a message in the #general channel," Claude chooses to call mcp__slack__send_message, serializes the arguments as JSON, sends them through a stdio pipe to the Slack Server process, waits for the response, and presents the result to you.

This mechanism is far more than simple RPC calls. It involves:

  • A unified abstraction over multiple transport protocols (stdio, SSE, HTTP Streamable, WebSocket, SDK in-process)
  • Connection pooling with memoize caching to avoid redundant handshakes
  • Dynamic tool discovery: after a Server declares its capabilities, Claude Code automatically converts MCP Tools to the internal Tool interface
  • JSON Schema pass-through: MCP tools bypass Zod parsing, passing inputSchema directly to the API as JSON Schema
  • Integration of two auxiliary primitives: Resources and Prompts
  • Handling of edge cases like OAuth authentication, URL Elicitation, and Session expiration reconnection
  • MCP Server sharing and isolation between Agent subprocesses

This article starts with a protocol overview and progressively dives into Claude Code's MCP integration implementation.

MCP Protocol Overview

The Model Context Protocol (MCP) is an open protocol proposed by Anthropic to standardize how AI models interact with external tools and data sources. Its core design philosophy is: the model doesn't need to know implementation details of tools — it only needs the tool's name, description, and input Schema.

Claude Code Process
MCP Client
(@modelcontextprotocol/sdk)
Tool System
(Tool.ts)
Query Engine
(query.ts)
MCP Server Processes
Slack Server
GitHub Server
Database Server

MCP defines five core concepts:

ConceptRoleDescription
ClientConsumerThe MCP client within the Claude Code process, responsible for connecting to Servers, discovering tools, and initiating calls
ServerProviderAn independent process or remote service that exposes tools/resources/prompts
ToolExecutable actionA function provided by a Server, with a name, description, and JSON Schema-defined input parameters
ResourceContext dataRead-only data provided by a Server (file contents, API responses, etc.) that can be injected into conversation context
PromptPreset templateA prompt template provided by a Server that users can invoke as a slash command

Transport Layer Diversity

The transport types supported by Claude Code go well beyond the basic specification. The full type list can be seen from the Schema definition in types.ts:

src/services/mcp/types.ts
TypeScript
1export const TransportSchema = lazySchema(() =>
2 z.enum(['stdio', 'sse', 'sse-ide', 'http', 'ws', 'sdk']),
3)

Each transport type corresponds to a different use case:

TransportScenarioCharacteristics
stdioLocal CLI toolsSpawns a subprocess, communicates via stdin/stdout
sseRemote HTTP servicesServer-Sent Events, supports OAuth
httpStreamable HTTPNew transport from MCP 2025-03-26 spec
wsWebSocketBidirectional real-time communication
sse-ideIDE extensionsUsed internally by VS Code/JetBrains
sdkIn-process ServerAgent SDK scenarios, no subprocess needed

The sdk type uses an elegant InProcessTransport:

src/services/mcp/InProcessTransport.ts
TypeScript
1class InProcessTransport implements Transport {
2 private peer: InProcessTransport | undefined
3 private closed = false
4
5 onclose?: () => void
6 onerror?: (error: Error) => void
7 onmessage?: (message: JSONRPCMessage) => void
8
9 /** @internal */
10 _setPeer(peer: InProcessTransport): void {
11 this.peer = peer
12 }
13
14 async start(): Promise<void> {}
15
16 async send(message: JSONRPCMessage): Promise<void> {
17 if (this.closed) {
18 throw new Error('Transport is closed')
19 }
20 // Async delivery to avoid stack depth issues from synchronous request/response
21 queueMicrotask(() => {
22 this.peer?.onmessage?.(message)
23 })
24 }
25
26 async close(): Promise<void> {
27 if (this.closed) {
28 return
29 }
30 this.closed = true
31 this.onclose?.()
32 if (this.peer && !this.peer.closed) {
33 this.peer.closed = true
34 this.peer.onclose?.()
35 }
36 }
37}

createLinkedTransportPair() creates a pair of interconnected Transports — one for the Client and one for the Server. send() uses queueMicrotask internally to deliver messages asynchronously, avoiding stack overflow from synchronous RPC calls — this is critical in high-frequency tool call scenarios.

Server Configuration and Discovery

Configuration Hierarchy

MCP Server configuration comes from multiple layers, each with a different scope:

...

The ConfigScope type for each configuration source is defined as:

src/services/mcp/types.ts
TypeScript
1export const ConfigScopeSchema = lazySchema(() =>
2 z.enum([
3 'local',
4 'user',
5 'project',
6 'dynamic',
7 'enterprise',
8 'claudeai',
9 'managed',
10 ]),
11)

The project scope comes from the .mcp.json file at the project root — this is the most common configuration method. Since project configurations may contain malicious Servers, Claude Code introduces an approval mechanism:

src/services/mcp/utils.ts
TypeScript
1export function getProjectMcpServerStatus(
2 serverName: string,
3): 'approved' | 'rejected' | 'pending' {
4 const settings = getSettings_DEPRECATED()
5 const normalizedName = normalizeNameForMCP(serverName)
6
7 if (
8 settings?.disabledMcpjsonServers?.some(
9 name => normalizeNameForMCP(name) === normalizedName,
10 )
11 ) {
12 return 'rejected'
13 }
14
15 if (
16 settings?.enabledMcpjsonServers?.some(
17 name => normalizeNameForMCP(name) === normalizedName,
18 ) ||
19 settings?.enableAllProjectMcpServers
20 ) {
21 return 'approved'
22 }
23
24 // Auto-approve in non-interactive mode when projectSettings are enabled
25 if (
26 getIsNonInteractiveSession() &&
27 isSettingSourceEnabled('projectSettings')
28 ) {
29 return 'approved'
30 }
31
32 return 'pending'
33}

Note the security boundary: auto-approval in --dangerously-skip-permissions mode only checks hasSkipDangerousModePermissionPrompt() — this function deliberately excludes projectSettings, preventing malicious repositories from self-approving bypass mode through project configuration.

Name Normalization

The MCP protocol requires tool names to match ^[a-zA-Z0-9_-]{1,64}$. Since Server names may contain spaces, dots, and other special characters (especially claude.ai Servers), normalization is needed:

src/services/mcp/normalization.ts
TypeScript
1export function normalizeNameForMCP(name: string): string {
2 let normalized = name.replace(/[^a-zA-Z0-9_-]/g, '_')
3 if (name.startsWith(CLAUDEAI_SERVER_PREFIX)) {
4 // claude.ai Servers additionally compress consecutive underscores to avoid conflict with the __ separator
5 normalized = normalized.replace(/_+/g, '_').replace(/^_|_$/g, '')
6 }
7 return normalized
8}

The fully qualified tool name format is mcp__<serverName>__<toolName>:

src/services/mcp/mcpStringUtils.ts
TypeScript
1export function buildMcpToolName(serverName: string, toolName: string): string {
2 return `${getMcpPrefix(serverName)}${normalizeNameForMCP(toolName)}`
3}

This naming convention has a known limitation: if a Server name itself contains __, parsing will break. The code comments explicitly document this — in practice, this scenario is extremely rare.

Server Lifecycle Management

Connection Flow

connectToServer is the entry function for the entire MCP integration. It's wrapped with memoize, using name + JSON(config) as the cache key to ensure Servers with identical configurations aren't connected redundantly:

src/services/mcp/client.ts
TypeScript
1export const connectToServer = memoize(
2 async (
3 name: string,
4 serverRef: ScopedMcpServerConfig,
5 serverStats?: {
6 totalServers: number
7 stdioCount: number
8 sseCount: number
9 httpCount: number
10 sseIdeCount: number
11 wsIdeCount: number
12 },
13 ): Promise<MCPServerConnection> => {
14 // ...connection logic
15 },
16 getServerCacheKey,
17)

The connection result is a union type that precisely expresses five possible states:

src/services/mcp/types.ts
TypeScript
1export type MCPServerConnection =
2 | ConnectedMCPServer // Connected successfully, holds Client instance
3 | FailedMCPServer // Connection failed, retains error information
4 | NeedsAuthMCPServer // Needs OAuth authentication
5 | PendingMCPServer // Awaiting connection (with reconnection attempt count)
6 | DisabledMCPServer // Disabled by user/policy

ConnectedMCPServer holds the core state of a connection:

src/services/mcp/types.ts
TypeScript
1export type ConnectedMCPServer = {
2 client: Client // MCP SDK Client instance
3 name: string
4 type: 'connected'
5 capabilities: ServerCapabilities // Capabilities declared by the Server
6 serverInfo?: {
7 name: string
8 version: string
9 }
10 instructions?: string // Instructions from the Server (injected into system prompt)
11 config: ScopedMcpServerConfig
12 cleanup: () => Promise<void> // Cleanup function
13}

Batch Connection Strategy

Claude Code doesn't connect to all Servers at once. It distinguishes between local Servers (stdio/sdk) and remote Servers, using different concurrency limits for each:

src/services/mcp/client.ts
TypeScript
1export function getMcpServerConnectionBatchSize(): number {
2 return parseInt(process.env.MCP_SERVER_CONNECTION_BATCH_SIZE || '', 10) || 3
3}
4
5function getRemoteMcpServerConnectionBatchSize(): number {
6 return (
7 parseInt(process.env.MCP_REMOTE_SERVER_CONNECTION_BATCH_SIZE || '', 10) ||
8 20
9 )
10}

Local Servers default to concurrency of 3 (high process startup overhead), while remote Servers default to 20 (network connections only). In getMcpToolsCommandsAndResources, both groups are processed in parallel:

src/services/mcp/client.ts
TypeScript
1await Promise.all([
2 processBatched(
3 localServers,
4 getMcpServerConnectionBatchSize(),
5 processServer,
6 ),
7 processBatched(
8 remoteServers,
9 getRemoteMcpServerConnectionBatchSize(),
10 processServer,
11 ),
12])

Cleanup and Reconnection

Each stdio Server connection registers a cleanup function to the global cleanup registry, ensuring all subprocesses are properly terminated when the process exits:

src/services/mcp/client.ts
TypeScript
1// All transport types register cleanup — even network transports may need it
2const cleanupUnregister = registerCleanup(cleanup)
3
4// Create a wrapped cleanup function that includes deregistration
5const wrappedCleanup = async () => {
6 cleanupUnregister?.()
7 await cleanup()
8}

Cleanup is especially important for stdio Servers — it first sends SIGTERM, waits for the process to exit, and escalates to SIGKILL if it times out:

src/services/mcp/client.ts
TypeScript
1logMCPDebug(
2 name,
3 'SIGTERM failed, sending SIGKILL to MCP server process',
4)
5try {
6 process.kill(childPid, 'SIGKILL')
7} catch (killError) {
8 logMCPDebug(
9 name,
10 `Error sending SIGKILL: ${killError}`,
11 )
12}

When the cache is invalidated, clearServerCache clears all associated caches:

src/services/mcp/client.ts
TypeScript
1export async function clearServerCache(
2 name: string,
3 serverRef: ScopedMcpServerConfig,
4): Promise<void> {
5 const key = getServerCacheKey(name, serverRef)
6
7 try {
8 const wrappedClient = await connectToServer(name, serverRef)
9 if (wrappedClient.type === 'connected') {
10 await wrappedClient.cleanup()
11 }
12 } catch {
13 // Ignore — Server may have failed to connect
14 }
15
16 // Clear connection cache and all fetch caches to ensure reconnection gets fresh data
17 connectToServer.cache.delete(key)
18 fetchToolsForClient.cache.delete(name)
19 fetchResourcesForClient.cache.delete(name)
20 fetchCommandsForClient.cache.delete(name)
21}

Note that four independent caches are cleared here — the connection cache and three data fetch caches. If only the connection cache were cleared while retaining the fetch caches, a reconnection would use stale tool lists.

Session Expiration Reconnection

For HTTP Streamable transport, the MCP spec defines a Session expiration mechanism (HTTP 404 + JSON-RPC error code -32001). Claude Code has dedicated detection logic:

src/services/mcp/client.ts
TypeScript
1export function isMcpSessionExpiredError(error: Error): boolean {
2 const httpStatus =
3 'code' in error ? (error as Error & { code?: number }).code : undefined
4 if (httpStatus !== 404) {
5 return false
6 }
7 // Check JSON-RPC error code to distinguish from generic 404
8 return (
9 error.message.includes('"code":-32001') ||
10 error.message.includes('"code": -32001')
11 )
12}

When a Session expiration is encountered during a tool call, it automatically retries once:

src/services/mcp/client.ts
TypeScript
1const MAX_SESSION_RETRIES = 1
2for (let attempt = 0; ; attempt++) {
3 try {
4 const connectedClient = await ensureConnectedClient(client)
5 const mcpResult = await callMCPToolWithUrlElicitationRetry({
6 client: connectedClient,
7 // ...
8 })
9 return { data: mcpResult.content, /* ... */ }
10 } catch (error) {
11 if (
12 error instanceof McpSessionExpiredError &&
13 attempt < MAX_SESSION_RETRIES
14 ) {
15 logMCPDebug(
16 client.name,
17 `Retrying tool '${tool.name}' after session recovery`,
18 )
19 continue
20 }
21 // ...error handling
22 }
23}

Dynamic Tool Generation

This is the most critical part of the MCP integration: how MCP Server-declared tools are converted into Claude Code's internal Tool interface.

The MCPTool Template

MCPTool.ts defines a "template" object containing the base behavior shared by all MCP tools:

src/tools/MCPTool/MCPTool.ts
TypeScript
1export const MCPTool = buildTool({
2 isMcp: true,
3 // Overridden with real MCP tool name + params in mcpClient.ts
4 isOpenWorld() {
5 return false
6 },
7 name: 'mcp', // Overridden
8 maxResultSizeChars: 100_000,
9 async description() {
10 return DESCRIPTION // Overridden
11 },
12 async prompt() {
13 return PROMPT // Overridden
14 },
15 get inputSchema(): InputSchema {
16 return inputSchema() // Generic z.object({}).passthrough()
17 },
18 async call() {
19 return { data: '' } // Overridden
20 },
21 async checkPermissions(): Promise<PermissionResult> {
22 return {
23 behavior: 'passthrough',
24 message: 'MCPTool requires permission.',
25 }
26 },
27 userFacingName: () => 'mcp', // Overridden
28 // ...render functions
29} satisfies ToolDef<InputSchema, Output>)

Note the design pattern here: MCPTool uses z.object({}).passthrough() as its inputSchema — this is a Zod Schema that "accepts any object," because the actual Schema for MCP tools is passed through via inputJSONSchema.

fetchToolsForClient: From Server to Tool

fetchToolsForClient is the core function for tool discovery. It calls the MCP protocol's tools/list method, then maps each MCP Tool to an internal Tool object:

src/services/mcp/client.ts
TypeScript
1export const fetchToolsForClient = memoizeWithLRU(
2 async (client: MCPServerConnection): Promise<Tool[]> => {
3 if (client.type !== 'connected') return []
4
5 const result = (await client.client.request(
6 { method: 'tools/list' },
7 ListToolsResultSchema,
8 )) as ListToolsResult
9
10 // Clean up Unicode control characters
11 const toolsToProcess = recursivelySanitizeUnicode(result.tools)
12
13 // Skip mcp__ prefix in SDK mode if configured
14 const skipPrefix =
15 client.config.type === 'sdk' &&
16 isEnvTruthy(process.env.CLAUDE_AGENT_SDK_MCP_NO_PREFIX)
17
18 return toolsToProcess.map((tool): Tool => {
19 const fullyQualifiedName = buildMcpToolName(client.name, tool.name)
20 return {
21 ...MCPTool, // Spread the template
22 name: skipPrefix ? tool.name : fullyQualifiedName,
23 mcpInfo: { serverName: client.name, toolName: tool.name },
24 isMcp: true,
25 // Read search hints from _meta
26 searchHint:
27 typeof tool._meta?.['anthropic/searchHint'] === 'string'
28 ? tool._meta['anthropic/searchHint']
29 .replace(/\s+/g, ' ').trim() || undefined
30 : undefined,
31 alwaysLoad: tool._meta?.['anthropic/alwaysLoad'] === true,
32 // Use the MCP tool's original description
33 async description() {
34 return tool.description ?? ''
35 },
36 // Truncate overly long descriptions (2048 character limit)
37 async prompt() {
38 const desc = tool.description ?? ''
39 return desc.length > MAX_MCP_DESCRIPTION_LENGTH
40 ? desc.slice(0, MAX_MCP_DESCRIPTION_LENGTH) + '... [truncated]'
41 : desc
42 },
43 // Derive behavioral characteristics from annotations
44 isConcurrencySafe() {
45 return tool.annotations?.readOnlyHint ?? false
46 },
47 isReadOnly() {
48 return tool.annotations?.readOnlyHint ?? false
49 },
50 isDestructive() {
51 return tool.annotations?.destructiveHint ?? false
52 },
53 isOpenWorld() {
54 return tool.annotations?.openWorldHint ?? false
55 },
56 // Pass JSON Schema directly, no Zod conversion
57 inputJSONSchema: tool.inputSchema as Tool['inputJSONSchema'],
58 // ...call implementation, checkPermissions, etc.
59 }
60 }).filter(isIncludedMcpTool)
61 },
62 { maxSize: MCP_FETCH_CACHE_SIZE, getCacheKey: client => client.name },
63)

This code reveals several key design decisions:

1. Object spread override pattern: { ...MCPTool, ...overrides } uses the template object as a base, overriding field by field. This is more flexible than inheritance and better aligned with TypeScript's structural type system.

2. MCP Annotations mapping: The MCP 2025-03-26 spec introduced Tool Annotations (readOnlyHint, destructiveHint, openWorldHint), which Claude Code directly maps to corresponding methods on the internal Tool interface.

3. Description length limit: MAX_MCP_DESCRIPTION_LENGTH = 2048. Some OpenAPI auto-generated MCP Servers produce tool descriptions of 15-60KB; without limits, this would waste enormous amounts of tokens.

4. IDE tool filtering: The isIncludedMcpTool function filters out non-whitelisted IDE tools, only allowing executeCode and getDiagnostics.

Tool Call Chain

When the model decides to call an MCP tool, the call chain proceeds as follows:

sequenceDiagram
    participant M as Claude API
    participant Q as Query Engine
    participant T as Tool System
    participant MC as MCP Client
    participant S as MCP Server

    M->>Q: tool_use: mcp__slack__send_message
    Q->>T: Look up Tool instance
    T->>T: call(args, context)
    T->>MC: ensureConnectedClient()
    MC-->>T: ConnectedMCPServer
    T->>MC: callMCPToolWithUrlElicitationRetry()
    MC->>S: tools/call {name, arguments}
    S-->>MC: CallToolResult
    MC->>MC: processMCPResult()
    MC->>MC: transformResultContent()
    MC-->>T: {data: string, mcpMeta?}
    T-->>Q: ToolResult
    Q-->>M: tool_result content

    Note over MC,S: Timeout: ~27.8 hours by default<br/>Session expiration auto-retry

The retry logic within the call method operates at two levels:

  1. Session expiration retry: Up to 1 attempt, clears the connection cache and re-obtains the Client
  2. URL Elicitation retry: Up to 3 attempts, handles MCP -32042 error code (Server requests user to open a URL for authorization)

JSON Schema vs Zod: Dual-Track Parameter Validation

This is an interesting design divergence in Claude Code's tool system. Built-in tools use Zod Schema, while MCP tools use JSON Schema — two systems running in parallel.

The Zod Path for Built-in Tools

Built-in tools (like Read, Write, Bash) define inputSchema as a Zod Schema:

TypeScript
1// Typical built-in tool inputSchema
2const inputSchema = z.object({
3 file_path: z.string().describe('Absolute path to the file'),
4 offset: z.number().optional().describe('Line offset'),
5 limit: z.number().optional().describe('Number of lines'),
6})

Zod Schemas are automatically converted to JSON Schema before being sent to the API.

JSON Schema Pass-Through for MCP Tools

MCP tools bypass the Zod layer entirely. The Tool interface specifically defines an inputJSONSchema field:

src/Tool.ts
TypeScript
1export type ToolInputJSONSchema = {
2 [x: string]: unknown
3 type: 'object'
4 properties?: {
5 [x: string]: unknown
6 }
7}
src/Tool.ts
TypeScript
1// MCP tools can specify their input Schema directly in JSON Schema format,
2// rather than converting from a Zod Schema
3readonly inputJSONSchema?: ToolInputJSONSchema

In fetchToolsForClient, the MCP tool's inputSchema (JSON Schema from the Server) is directly assigned to inputJSONSchema:

src/services/mcp/client.ts
TypeScript
1inputJSONSchema: tool.inputSchema as Tool['inputJSONSchema'],

Why not convert JSON Schema to Zod? Three reasons:

  1. Performance: Runtime conversion from JSON Schema to Zod has overhead, and MCP Servers may provide complex nested Schemas
  2. Fidelity: Certain JSON Schema features (patternProperties, additionalProperties, oneOf combinations) have no direct Zod equivalents
  3. Unnecessary: The Claude API itself accepts JSON Schema, so no intermediate conversion is needed

This is why MCPTool's inputSchema is a permissive z.object({}).passthrough() — it performs no actual validation at runtime, and the real Schema is passed through to the API via inputJSONSchema.

Resource and Prompt Integration

Resource: Context Data Injection

MCP Resources allow Servers to expose read-only data. Claude Code provides two built-in tools for this:

  • ListMcpResourcesTool: Lists all Resources provided by MCP Servers
  • ReadMcpResourceTool: Reads the content of a specific Resource

The Resource type definition extends the MCP SDK type with Server attribution:

src/services/mcp/types.ts
TypeScript
1export type ServerResource = Resource & { server: string }

In getMcpToolsCommandsAndResources, Resource tools are only added when a Server has declared the resources capability, and they're added only once globally:

src/services/mcp/client.ts
TypeScript
1const resourceTools: Tool[] = []
2if (supportsResources && !resourceToolsAdded) {
3 resourceToolsAdded = true
4 resourceTools.push(ListMcpResourcesTool, ReadMcpResourceTool)
5}

The resourceToolsAdded flag ensures that even if 10 Servers all declare the Resource capability, the List and Read tools are only registered once — they can access Resources from all Servers.

Resource fetching also uses LRU caching and concurrent processing. prefetchAllMcpResources prefetches tools, commands, and Resources from all Servers at startup, avoiding delays during the first conversation.

Prompt: Preset Command Templates

MCP Prompts are converted to Claude Code Commands (slash commands). When a Server declares the prompts capability, fetchCommandsForClient calls prompts/list to get the list:

TypeScript
1// Prompt command naming follows the mcp__<server>__<prompt> format
2// Consistent with Tool naming, using double underscore separation

The distinction between Prompts and Skills is subtle: MCP Prompts set isMcp: true, while MCP Skills (discovered from skill:// Resources) set loadedFrom: 'mcp'. This distinction affects capability display in the /mcp menu:

src/services/mcp/utils.ts
TypeScript
1export function filterMcpPromptsByServer(
2 commands: Command[],
3 serverName: string,
4): Command[] {
5 return commands.filter(
6 c =>
7 commandBelongsToServer(c, serverName) &&
8 !(c.type === 'prompt' && c.loadedFrom === 'mcp'),
9 )
10}

Inter-Agent MCP Server Sharing and Isolation

Claude Code's multi-Agent architecture (main thread + sub-Agents) introduces the question of MCP Server sharing.

Sharing Mechanism

After the main thread connects to MCP Servers, sub-Agents inherit the parent process's connections through ToolUseContext.options.mcpClients:

src/Tool.ts
TypeScript
1mcpClients: MCPServerConnection[]

Sub-Agents don't need to reconnect to MCP Servers — they share the connections already established by the parent process. This is because connectToServer's memoize cache is shared globally within the process.

Isolation Mechanism

However, Server configuration changes don't automatically propagate. excludeStalePluginClients is responsible for detecting stale connections:

src/services/mcp/utils.ts
TypeScript
1export function excludeStalePluginClients(
2 mcp: {
3 clients: MCPServerConnection[]
4 tools: Tool[]
5 commands: Command[]
6 resources: Record<string, ServerResource[]>
7 },
8 configs: Record<string, ScopedMcpServerConfig>,
9): {
10 clients: MCPServerConnection[]
11 tools: Tool[]
12 commands: Command[]
13 resources: Record<string, ServerResource[]>
14 stale: MCPServerConnection[]
15} {
16 const stale = mcp.clients.filter(c => {
17 const fresh = configs[c.name]
18 if (!fresh) return c.config.scope === 'dynamic'
19 return hashMcpConfig(c.config) !== hashMcpConfig(fresh)
20 })
21 // ...remove stale tools/commands/resources
22}

Staleness detection uses SHA-256 hash comparison of configurations, excluding the scope field (since scope is metadata that doesn't affect connection parameters).

Change Notifications

The MCP protocol supports Server-side push change notifications. useManageMCPConnections listens for three notification types:

TypeScript
1// Notification subscriptions from useManageMCPConnections.ts
2ToolListChangedNotificationSchema // Tool list changed
3ResourceListChangedNotificationSchema // Resource list changed
4PromptListChangedNotificationSchema // Prompt list changed

Upon receiving a notification, Claude Code clears the corresponding fetch cache and re-fetches data. This allows Servers to dynamically add/remove tools at runtime — for example, a database Server might update available query tools after the user switches database connections.

Reconnection with Exponential Backoff

Disconnection reconnection uses an exponential backoff strategy:

src/services/mcp/useManageMCPConnections.ts
TypeScript
1const MAX_RECONNECT_ATTEMPTS = 5
2const INITIAL_BACKOFF_MS = 1000
3const MAX_BACKOFF_MS = 30000

The interval between each reconnection attempt doubles, up to a 30-second cap, with a maximum of 5 attempts.

OAuth and Elicitation

Authentication Cache

For remote Servers requiring OAuth (SSE, HTTP), Claude Code maintains an authentication cache to avoid repeated probing:

src/services/mcp/client.ts
TypeScript
1const MCP_AUTH_CACHE_TTL_MS = 15 * 60 * 1000 // 15 minutes
2
3type McpAuthCacheData = Record<string, { timestamp: number }>
4
5// Use memoize to ensure concurrent isMcpAuthCached() calls share the same file read
6let authCachePromise: Promise<McpAuthCacheData> | null = null
7
8function getMcpAuthCache(): Promise<McpAuthCacheData> {
9 if (!authCachePromise) {
10 authCachePromise = readFile(getMcpAuthCachePath(), 'utf-8')
11 .then(data => jsonParse(data) as McpAuthCacheData)
12 .catch(() => ({}))
13 }
14 return authCachePromise
15}

Cache writes are serialized through a promise chain, preventing concurrent read-modify-write race conditions:

src/services/mcp/client.ts
TypeScript
1let writeChain = Promise.resolve()
2
3function setMcpAuthCacheEntry(serverId: string): void {
4 writeChain = writeChain
5 .then(async () => {
6 const cache = await getMcpAuthCache()
7 cache[serverId] = { timestamp: Date.now() }
8 // ...write to file
9 // Invalidate read cache after write
10 authCachePromise = null
11 })
12 .catch(() => {
13 // Best effort
14 })
15}

URL Elicitation

The MCP spec's -32042 error code indicates the Server needs the user to open a URL to complete authorization. Claude Code has a complete handling flow for this:

src/services/mcp/client.ts
TypeScript
1const MAX_URL_ELICITATION_RETRIES = 3
2for (let attempt = 0; ; attempt++) {
3 try {
4 return await callToolFn({
5 client: connectedClient,
6 tool, args, meta, signal, onProgress,
7 })
8 } catch (error) {
9 if (
10 !(error instanceof McpError) ||
11 error.code !== ErrorCode.UrlElicitationRequired
12 ) {
13 throw error
14 }
15 // ...handle Elicitation
16 }
17}

Elicitation handling operates at three layers:

  1. Hooks first: runElicitationHooks allows custom logic to handle it automatically
  2. SDK/Print mode: Delegates to structured IO via the handleElicitation callback
  3. REPL mode: Displays a UI dialog through the AppState queue

Large Result Handling

MCP tools may return large amounts of data. processMCPResult implements a tiered handling strategy:

src/services/mcp/client.ts
TypeScript
1export async function processMCPResult(
2 result: unknown,
3 tool: string,
4 name: string,
5): Promise<MCPToolResult> {
6 const { content, type, schema } = await transformMCPResult(result, tool, name)
7
8 // IDE tools aren't sent to the model, skip size checks
9 if (name === 'ide') {
10 return content
11 }
12
13 // Check if truncation is needed
14 if (!(await mcpContentNeedsTruncation(content))) {
15 return content
16 }
17
18 // When images are present, fall back to truncation (maintain image compression and viewability)
19 if (contentContainsImages(content)) {
20 return await truncateMcpContentIfNeeded(content)
21 }
22
23 // Large text results: persist to file, return path and read instructions
24 const persistId = `mcp-${normalizeNameForMCP(name)}-${normalizeNameForMCP(tool)}-${timestamp}`
25 const persistResult = await persistToolResult(contentStr, persistId)
26 // ...return read instructions
27}

Handling strategy priority:

  1. Small results: Return directly
  2. Large image results: Truncate (maintain viewability)
  3. Large text results: Persist to file, return Read instructions for the model to fetch on demand
  4. Persistence failure: Fall back to truncation

The inferCompactSchema function generates a compact Schema description for structured content, helping the model understand the output format:

src/services/mcp/client.ts
TypeScript
1export function inferCompactSchema(value: unknown, depth = 2): string {
2 // Infer a brief structural description of the value
3 // e.g.: {name: string, items: [{id: number, ...}]}
4}

Claude.ai Proxy Connections

Claude Code can access MCP Servers configured on claude.ai, using a special claudeai-proxy transport type. Proxy connections need to handle OAuth token refresh:

src/services/mcp/client.ts
TypeScript
1export function createClaudeAiProxyFetch(innerFetch: FetchLike): FetchLike {
2 return async (url, init) => {
3 const doRequest = async () => {
4 await checkAndRefreshOAuthTokenIfNeeded()
5 const currentTokens = getClaudeAIOAuthTokens()
6 if (!currentTokens) {
7 throw new Error('No claude.ai OAuth token available')
8 }
9 const headers = new Headers(init?.headers)
10 headers.set('Authorization', `Bearer ${currentTokens.accessToken}`)
11 const response = await innerFetch(url, { ...init, headers })
12 // Return the token used when sending, not the current token
13 return { response, sentToken: currentTokens.accessToken }
14 }
15
16 const { response, sentToken } = await doRequest()
17 if (response.status !== 401) {
18 return response
19 }
20 // On 401, try refreshing the token and retry once
21 const tokenChanged = await handleOAuth401Error(sentToken).catch(() => false)
22 if (!tokenChanged) {
23 return response
24 }
25 try {
26 return (await doRequest()).response
27 } catch {
28 return response
29 }
30 }
31}

Note the use of sentToken — the code deliberately records the token used when sending the request, rather than re-reading the current token after the 401 response. This is because a concurrent connector may have already refreshed the token via handleOAuth401Error in the meantime; re-reading would get the new token, which when passed to handleOAuth401Error would be judged as "token unchanged" and skip the refresh.

Timeouts and Request Wrapping

Tool Call Timeout

The default timeout for MCP tool calls is approximately 27.8 hours (effectively "infinite"):

src/services/mcp/client.ts
TypeScript
1const DEFAULT_MCP_TOOL_TIMEOUT_MS = 100_000_000

This seemingly outrageous value is intentional — some MCP tools (like database migrations, large-scale analyses) genuinely need long execution times. Users can customize this via the MCP_TOOL_TIMEOUT environment variable.

Per-Request Timeout Wrapping

Unlike the tool timeout, each HTTP request has a 60-second timeout. wrapFetchWithTimeout creates an independent AbortController for each request:

src/services/mcp/client.ts
TypeScript
1export function wrapFetchWithTimeout(baseFetch: FetchLike): FetchLike {
2 return async (url: string | URL, init?: RequestInit) => {
3 const method = (init?.method ?? 'GET').toUpperCase()
4
5 // GET requests skip timeout — MCP GETs are long-lived SSE streams
6 if (method === 'GET') {
7 return baseFetch(url, init)
8 }
9
10 // Use setTimeout instead of AbortSignal.timeout()
11 // because in Bun, AbortSignal.timeout's internal timer
12 // isn't released until GC, leaking ~2.4KB of native memory per request
13 const controller = new AbortController()
14 const timer = setTimeout(
15 c => c.abort(new DOMException('The operation timed out.', 'TimeoutError')),
16 MCP_REQUEST_TIMEOUT_MS,
17 controller,
18 )
19 timer.unref?.()
20
21 // Link parent signal
22 const parentSignal = init?.signal
23 const abort = () => controller.abort(parentSignal?.reason)
24 parentSignal?.addEventListener('abort', abort)
25 // ...
26 }
27}

A code comment reveals an interesting Bun runtime detail: internal timers created by AbortSignal.timeout() are only released during GC, causing each request to leak approximately 2.4KB of native memory. Hence the switch to manually managing lifecycle with setTimeout + clearTimeout.

Serialized State and CLI Integration

MCP state can be serialized to JSON for the /mcp command and CLI state exports:

src/services/mcp/types.ts
TypeScript
1export interface SerializedTool {
2 name: string
3 description: string
4 inputJSONSchema?: {
5 [x: string]: unknown
6 type: 'object'
7 properties?: { [x: string]: unknown }
8 }
9 isMcp?: boolean
10 originalToolName?: string // Original unnormalized tool name
11}
12
13export interface MCPCliState {
14 clients: SerializedClient[]
15 configs: Record<string, ScopedMcpServerConfig>
16 tools: SerializedTool[]
17 resources: Record<string, ServerResource[]>
18 normalizedNames?: Record<string, string> // Mapping from normalized names to original names
19}

The normalizedNames mapping solves a practical problem: users reference original names in permission rules, but the system internally uses normalized names. This mapping ensures permission checks can correctly associate the two.

Portable Patterns and Best Practices

Configuration Organization Recommendations

Based on Claude Code's configuration hierarchy design, the following organization is recommended:

ScenarioRecommended Config LocationReason
Team-shared project tools.mcp.json (project scope)Version-controlled with code, new members get it automatically
Personal preference tools~/.claude.json (user scope)Available across projects
CI/CD environments--mcp-server argument (dynamic scope)Temporary injection without modifying config files
Enterprise compliance toolsmanaged config (enterprise scope)Organization-wide management, users cannot modify

Tool Design Principles

From Claude Code's MCP integration code, several MCP Server tool design principles can be distilled:

  1. Use annotations: Declare readOnlyHint to enable concurrent tool execution; declare destructiveHint to trigger additional confirmation
  2. Control description length: Descriptions over 2048 characters will be truncated — put core usage information first
  3. Support pagination: Large results will be truncated or persisted to files — provide pagination parameters so the model can fetch on demand
  4. Use searchHint: Help ToolSearch find lazily-loaded tools via _meta['anthropic/searchHint']
  5. Use alwaysLoad: For critical tools the model must see in the first turn, set _meta['anthropic/alwaysLoad'] to skip ToolSearch

Security Boundaries

The MCP integration's security model deserves special attention:

  • Project Server approval: Servers in .mcp.json require explicit user approval (unless in non-interactive mode with projectSettings enabled)
  • Permission isolation: MCP tools use passthrough permission mode, meaning each call is checked against global permission rules
  • Namespace isolation: The mcp__ prefix ensures MCP tools don't conflict with built-in tools (can be optionally disabled in SDK mode)
  • Description truncation: Prevents malicious Servers from injecting prompts via overly long descriptions
  • Unicode sanitization: recursivelySanitizeUnicode removes control characters that could interfere with model behavior

Architecture Summary

Claude Code's MCP integration is a carefully designed extensible architecture. It cleanly separates "connection management" from "tool adaptation": client.ts handles establishing and maintaining connections to MCP Servers, MCPTool.ts provides the tool template, and fetchToolsForClient bridges the two — from a Server's raw capability declarations to Tool objects callable by Claude, passing through name normalization, description truncation, Schema pass-through, permission configuration, and a series of other transformations.

Core principles of the overall design:

  1. Lazy discovery: Only connect to Servers and fetch tool lists when needed, using memoize caching to avoid redundant operations
  2. Graceful degradation: Connection failures don't block the entire system — only that Server's tools become unavailable; Servers requiring authentication register an McpAuthTool to guide users through authentication
  3. Dual-track Schema: Built-in tools use Zod for type safety and validation, while MCP tools use JSON Schema pass-through for flexibility and efficiency
  4. Defensive design: From Unicode sanitization to description truncation, from configuration hash comparison to authentication cache serialization, edge case considerations are evident throughout

For projects looking to build similar extension systems, the MCP integration provides a referenceable pattern: use template objects + object spread for dynamic tool registration, memoize caching for connection lifecycle management, union types for precise connection state expression, and configuration hierarchies for flexible scope management.