Chapter 15: MCP Protocol Integration
What You'll Learn
This chapter dissects how Claude Code connects to external MCP (Model Context Protocol) servers. By the end of the chapter you will understand:
- The problem MCP solves and why it is fundamentally different from built-in tool implementations
- The five-scope configuration system: how local, user, project, and enterprise configs are merged and prioritized
- The five transport types — stdio, SSE, HTTP Streamable, WebSocket, and SDK — and when each applies
- How
connectToServerestablishes a connection, negotiates capabilities, and registers heartbeat detection - The design of the
MCPToolwrapper: how an MCP tool becomes a first-class citizen in Claude Code's tool system - The
mcp__serverName__toolNamenaming convention and its role in permission checking - The OAuth authentication flow and how the needs-auth cache prevents redundant auth detection
- The difference between MCP Resources and Tools, and the resource URI scheme
15.1 What MCP Is and Why It Exists
Building an AI coding assistant confronts a fundamental tension: Claude needs access to a wide variety of external capabilities — querying databases, calling REST APIs, managing Git repositories, posting Slack messages — but hardcoding all of that logic into Claude Code is neither realistic nor sustainable.
MCP's core idea is to separate the capability provider (the server) from the capability consumer (the client, i.e., Claude Code) and connect the two with a standardized protocol. Servers can be written by anyone in any language; as long as they follow the protocol, Claude Code can discover and use the tools, resources, and prompt templates they expose.
The protocol is built on JSON-RPC 2.0 and defines three core primitives:
- Tools: callable functions with structured input schemas and structured outputs
- Resources: readable data identified by URI, analogous to a file system
- Prompt templates: predefined prompt fragments that Claude can reuse in specific scenarios
Architecturally, Claude Code plays the role of MCP client:
Why not just add more built-in tools? Built-in tools like BashTool and ReadTool are determined at compile time and have short call paths with simple permission models. MCP tools are discovered at runtime — users add or remove servers in a config file, and Claude Code gains new capabilities without recompilation. That dynamic extensibility is MCP's core value.
15.2 The Configuration System
15.2.1 Four Scopes
Claude Code loads MCP server configurations from four sources, implemented in .src/services/mcp/config.ts. Priority from lowest to highest:
| Scope | Storage Location | Notes |
|---|---|---|
| plugin | Plugin system | Servers bundled with plugins |
| user | ~/.claude.json mcpServers field | User-global configuration |
| project | .mcp.json (searched upward from CWD) | Project-level; can be committed to VCS |
| local | Project-local config | Not committed to VCS |
| enterprise | Managed managed-mcp.json | Highest priority; if present, takes exclusive control |
The merge logic is explicit in getClaudeCodeMcpConfigs:
// Merge in order of precedence: plugin < user < project < local
const configs = Object.assign(
{},
dedupedPluginServers,
userServers,
approvedProjectServers,
localServers,
)Later-merged entries override earlier ones, so local wins over user for the same server name.
Enterprise mode has unique behavior: once managed-mcp.json is detected (doesEnterpriseMcpConfigExist() is memoized to avoid repeated disk I/O), all other scopes are ignored entirely.
15.2.2 Configuration Format
A typical .mcp.json:
{
"mcpServers": {
"github": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"
}
},
"slack": {
"type": "http",
"url": "https://mcp.slack.com/api/v1",
"headers": {
"Authorization": "Bearer ${SLACK_BOT_TOKEN}"
}
},
"filesystem": {
"type": "sse",
"url": "http://localhost:8080/sse"
}
}
}The ${GITHUB_TOKEN} syntax is expanded by expandEnvVarsInString at config-load time. If a variable is unset the config loader does not abort; it records the issue as a warning-level validation error and continues.
The type system uses Zod schemas to describe each server type precisely (in .src/services/mcp/types.ts):
// Six transport variants, two are internal-only
export const TransportSchema = lazySchema(() =>
z.enum(['stdio', 'sse', 'sse-ide', 'http', 'ws', 'sdk']),
)sse-ide and ws-ide are internal types for IDE extensions (e.g., the VS Code extension) and cannot be used in user config files. sdk is a special SDK V2 channel — tool calls route back through the SDK rather than opening a real network connection.
15.2.3 Policy Controls
Enterprise deployments can configure allowedMcpServers and deniedMcpServers with three matching modes:
- By name:
{"serverName": "github"} - By command:
{"serverCommand": ["npx", "-y", "@modelcontextprotocol/server-github"]} - By URL (wildcard supported):
{"serverUrl": "https://*.slack.com/*"}
The deny list always takes precedence over the allow list:
function isMcpServerAllowedByPolicy(serverName, config) {
// Denylist takes absolute precedence
if (isMcpServerDenied(serverName, config)) {
return false
}
// ... then check allowlist
}15.3 Transport Implementations
15.3.1 stdio Transport
The most common type. Claude Code spawns the MCP server as a child process and exchanges JSON-RPC messages over stdin/stdout:
// connectToServer() in client.ts, around line 944
transport = new StdioClientTransport({
command: finalCommand,
args: finalArgs,
env: {
...subprocessEnv(),
...serverRef.env,
} as Record<string, string>,
stderr: 'pipe', // prevents server error output from printing to the UI
})The stderr: 'pipe' is intentional: the child process's error output is captured to an in-memory buffer (capped at 64 MB) and emitted as diagnostic information on connection failure, rather than polluting the user interface.
Process cleanup on exit follows an escalating three-step strategy: SIGINT → SIGTERM → SIGKILL, with brief waits between each step to allow graceful shutdown.
15.3.2 SSE Transport
A persistent HTTP connection using Server-Sent Events for server-to-client push. There is a subtle design here: the SSE stream itself must live indefinitely and cannot have a timeout; but OAuth token refresh and other POST requests need timeout protection. The code handles this with two distinct fetch implementations:
// SSE transport in client.ts
transportOptions.eventSourceInit = {
// Long-lived stream: NO timeout wrapper applied
fetch: async (url, init) => {
const tokens = await authProvider.tokens()
if (tokens) {
authHeaders.Authorization = `Bearer ${tokens.access_token}`
}
return fetch(url, { ...init, headers: { ...authHeaders } })
},
}
// Regular API calls: WITH 60-second timeout wrapper
fetch: wrapFetchWithTimeout(
wrapFetchWithStepUpDetection(createFetchWithInit(), authProvider),
),15.3.3 HTTP Streamable Transport
Introduced in the MCP 2025-03-26 specification. Each request is an independent HTTP POST; the response can be either JSON or an SSE stream. The client must advertise acceptance of both formats in the Accept header:
// Guaranteed by wrapFetchWithTimeout() for every POST
const MCP_STREAMABLE_HTTP_ACCEPT = 'application/json, text/event-stream'HTTP transport also has session management: servers track state via a Session ID. When a session expires, the server returns HTTP 404 with JSON-RPC error code -32001. Claude Code detects this precise combination:
export function isMcpSessionExpiredError(error: Error): boolean {
const httpStatus = 'code' in error ? error.code : undefined
if (httpStatus !== 404) return false
return (
error.message.includes('"code":-32001') ||
error.message.includes('"code": -32001')
)
}On session expiry detection, the old connection is closed and the next tool call triggers a fresh reconnect.
15.3.4 WebSocket Transport
Supports both Bun's native WebSocket and the Node.js ws package, selected via runtime detection:
if (typeof Bun !== 'undefined') {
wsClient = new globalThis.WebSocket(serverRef.url, {
protocols: ['mcp'],
headers: wsHeaders,
// ...
})
} else {
wsClient = await createNodeWsClient(serverRef.url, { ... })
}15.4 Connection Lifecycle
15.4.1 Establishing a Connection
connectToServer (roughly 300 lines) is the core of the entire MCP subsystem, handling the complete connection setup:
Connection timeout is controlled by the MCP_TIMEOUT environment variable (default 30 seconds). After a successful connect, the ConnectedMCPServer object carries the capabilities manifest — this determines whether the server supports tools, resources, prompt templates, and resource subscription push.
15.4.2 Disconnect Detection and Reconnection
There is an asymmetry in the MCP transport design: when a connection drops, the SDK calls onerror but does not always call onclose. Without an onclose event, in-flight tool call requests wait forever and never fail.
Claude Code solves this with a "terminal error counter":
// client.ts around line 1228
let consecutiveConnectionErrors = 0
const MAX_ERRORS_BEFORE_RECONNECT = 3
client.onerror = (error) => {
if (isTerminalConnectionError(error.message)) {
consecutiveConnectionErrors++
if (consecutiveConnectionErrors >= MAX_ERRORS_BEFORE_RECONNECT) {
closeTransportAndRejectPending('consecutive terminal errors')
}
}
}closeTransportAndRejectPending calls client.close(), which triggers the SDK's close chain. That chain rejects all pending Promises, allowing the upper-layer tool call code to detect the failure and initiate reconnection.
Terminal connection error signatures include ECONNRESET, ETIMEDOUT, EPIPE, EHOSTUNREACH, ECONNREFUSED, SSE stream disconnected, and Maximum reconnection attempts.
15.4.3 Connection Caching
connectToServer is wrapped with memoize, using the server name and the JSON serialization of its config as the cache key:
export function getServerCacheKey(name, serverRef) {
return `${name}-${jsonStringify(serverRef)}`
}
export const connectToServer = memoize(
async (name, serverRef, serverStats) => { /* ... */ },
getServerCacheKey,
)If the config is unchanged, no duplicate connection is made. When a connection drops, clearServerCache removes the cache entry, and the next call to connectToServer triggers a completely fresh attempt.
15.5 Tool Enumeration and the MCPTool Wrapper
15.5.1 From MCP Tools to Claude Code Tools
After a connection is established, Claude Code calls client.listTools() to retrieve the tool list, then creates a customized copy of MCPTool for each tool.
MCPTool (in .src/tools/MCPTool/MCPTool.ts) is a template whose defining characteristic is the passthrough schema:
// MCPTool.ts
export const inputSchema = lazySchema(() => z.object({}.passthrough())This means the MCP tool's JSON Schema is not validated in Claude Code — validation happens on the MCP server side. Claude Code simply forwards the input object as-is.
The actual tool customization happens in getMCPTools inside client.ts, roughly:
- Call
client.listTools()to get the raw tool list - For each tool, generate a complete name with
buildMcpToolName(serverName, tool.name) - Clone the
MCPToolobject, overridingname,description,call, etc. - The
callmethod internally callsclient.callTool()and converts the result to Claude Code format
15.5.2 Naming Convention
MCP tool names follow the mcp__serverName__toolName format, generated by buildMcpToolName:
// mcpStringUtils.ts
export function buildMcpToolName(serverName: string, toolName: string): string {
return `${getMcpPrefix(serverName)}${normalizeNameForMCP(toolName)}`
}
export function getMcpPrefix(serverName: string): string {
return `mcp__${normalizeNameForMCP(serverName)}__`
}normalizeNameForMCP replaces any character not matching [a-zA-Z0-9_-] with an underscore. A tool named create-pull-request on a server named github becomes mcp__github__create-pull-request.
Why this prefix? Two reasons.
First, permission isolation. Claude Code's permission system matches rules against tool names. The mcp__ prefix guarantees MCP tools cannot be confused with built-in tools.
Second, reverse parsing. mcpInfoFromString can extract the server name and tool name from the full name for permission checking:
// mcpStringUtils.ts
export function mcpInfoFromString(toolString: string) {
const parts = toolString.split('__')
const [mcpPart, serverName, ...toolNameParts] = parts
if (mcpPart !== 'mcp' || !serverName) return null
const toolName = toolNameParts.length > 0 ? toolNameParts.join('__') : undefined
return { serverName, toolName }
}15.5.3 The Tool Call Flow
Result processing is one of the most complex parts of the MCP integration because tool results can contain multiple content block types:
text: plain text content, passed through directlyimage: Base64-encoded image, size-checked and possibly downsampledresource: a reference to an MCP resource, which must be embedded or linked
15.6 Permission Scoping
15.6.1 IDE Tool Filtering
MCP tools pass through an IDE-specific filter before entering Claude's tool list. IDE servers only expose a vetted subset of tools:
// client.ts
const ALLOWED_IDE_TOOLS = ['mcp__ide__executeCode', 'mcp__ide__getDiagnostics']
function isIncludedMcpTool(tool: Tool): boolean {
return (
!tool.name.startsWith('mcp__ide__') || ALLOWED_IDE_TOOLS.includes(tool.name)
)
}This ensures that an IDE plugin expanding its internal tool set cannot accidentally expose those tools to Claude.
15.6.2 The Full Permission Check Path
When the user sets allowedTools or deniedTools in settings, the permission system uses getToolNameForPermissionCheck to get the name for matching:
// mcpStringUtils.ts
export function getToolNameForPermissionCheck(tool: {
name: string
mcpInfo?: { serverName: string; toolName: string }
}): string {
return tool.mcpInfo
? buildMcpToolName(tool.mcpInfo.serverName, tool.mcpInfo.toolName)
: tool.name
}For MCP tools, the full mcp__server__tool name is used, not the display name. The design intent: if an MCP tool happens to share a name like Write with a built-in tool, the prefix-based distinction prevents a deny rule targeting the built-in Write from inadvertently blocking the MCP tool (and vice versa).
15.7 OAuth Authentication
15.7.1 Authentication Flow
For sse and http remote MCP servers, Claude Code implements a complete OAuth 2.0 PKCE flow managed by the ClaudeAuthProvider class (in .src/services/mcp/auth.ts):
- On first connection, if the server returns 401,
UnauthorizedErroris caught handleRemoteAuthFailureis called; server state transitions toneeds-auth- The needs-auth state is persisted in a local cache with a 15-minute TTL
- The UI prompts the user to authenticate; the user triggers auth via
/mcp auth - On success, the token is stored in the system keychain; server state resets to
pending(awaiting reconnect)
15.7.2 Auth Cache Implementation
To avoid repeatedly reading the auth cache file when batch-connecting to multiple servers, Claude Code uses an in-memory cache with explicit invalidation:
// client.ts around line 269
let authCachePromise: Promise<McpAuthCacheData> | null = null
function getMcpAuthCache(): Promise<McpAuthCacheData> {
if (!authCachePromise) {
authCachePromise = readFile(getMcpAuthCachePath(), 'utf-8')
.then(data => jsonParse(data) as McpAuthCacheData)
.catch(() => ({}))
}
return authCachePromise // N concurrent callers share one file read
}Writes are serialized through a promise chain to prevent concurrent read-modify-write races when multiple servers return 401 in the same batch:
let writeChain = Promise.resolve()
function setMcpAuthCacheEntry(serverId) {
writeChain = writeChain.then(async () => {
const cache = await getMcpAuthCache()
cache[serverId] = { timestamp: Date.now() }
await writeFile(cachePath, jsonStringify(cache))
authCachePromise = null // invalidate the read cache
})
}15.7.3 Cross-App Access (XAA)
The type system references McpXaaConfigSchema, which is a boolean flag per server:
// types.ts
const McpXaaConfigSchema = lazySchema(() => z.boolean())XAA (Cross-App Access) is a mechanism that allows an enterprise identity provider to authorize MCP server connections. The IdP connection details (issuer, clientId, callbackPort) are configured once in settings.xaaIdp and shared across all XAA-enabled servers, rather than configured per server.
15.8 MCP Resources
15.8.1 Resources vs. Tools
Tools are for "doing things"; resources are for "reading things." Tools may have side effects; resources are conceptually read-only. From an API perspective, resources are identified by URI rather than name, and they return content rather than execution results.
Claude Code provides two corresponding built-in tools:
ListMcpResourcesTool: enumerates all resources on a given MCP serverReadMcpResourceTool: reads the content at a specific URI
15.8.2 Resource Enumeration
Resources are listed via client.listResources(). Each resource in the response includes a URI, name, description, and MIME type. The URI format is defined by the MCP server — common patterns include file-system style (file:///path/to/file) and custom schemes (github://repo/issues/123).
// types.ts
export type ServerResource = Resource & { server: string }ServerResource extends the standard MCP Resource type with a server field recording which server the resource comes from — necessary because Claude Code may manage resources from multiple MCP servers simultaneously.
15.9 UI Layer: Rendering MCP Tool Results
The MCPTool UI component (.src/tools/MCPTool/UI.tsx) applies a three-tier degradation strategy when rendering results, from most optimized to most generic:
Strategy 1: Unwrap dominant text payload. If the result is JSON like {"messages": "line1\nline2..."}, extract the dominant string field and let OutputLine handle truncation and line breaks. This handles the common pattern where MCP servers (like Slack's) wrap text content in a JSON envelope with escaped \n characters.
Strategy 2: Flatten to key-value pairs. If the JSON is a simple object with at most 12 keys where every value is a scalar or small nested object, render as aligned key: value rows.
Strategy 3: Fall through to raw output. Otherwise, pass the content to OutputLine for standard pretty-printing and truncation.
The output warning threshold is 10,000 estimated tokens. Results larger than this display a visible warning in the UI.
There is also a Slack-specific compact rendering path: when a tool result contains a message_link key matching the Slack archives URL pattern, the result is collapsed to a single "Sent a message to #channel" line with a hyperlink.
15.10 Debugging MCP Connections
Setting MCP_DEBUG=true activates verbose logging with per-server prefixes:
[MCP:github] SSE transport initialized, awaiting connection
[MCP:github] Successfully connected (transport: sse) in 342ms
[MCP:github] Connection established with capabilities: {"hasTools":true,"hasPrompts":false}Key environment variables for tuning MCP behavior:
| Variable | Default | Effect |
|---|---|---|
MCP_TIMEOUT | 30000 ms | Connection attempt timeout |
MCP_TOOL_TIMEOUT | ~100M ms | Tool call timeout (effectively unlimited) |
MCP_SERVER_CONNECTION_BATCH_SIZE | 3 | Max concurrent local server connections |
MCP_REMOTE_SERVER_CONNECTION_BATCH_SIZE | 20 | Max concurrent remote server connections |
The distinction between local and remote batch sizes reflects the cost profile: spawning stdio processes is CPU-bound and benefits from serialization, while remote connections are I/O-bound and can be issued in large parallel batches.
15.11 Relationship to the Tool System (Chapter 6)
MCP tools enter the same tool dispatch pipeline as built-in tools like BashTool and ReadTool. The MCPTool base object satisfies the ToolDef interface (introduced in Chapter 6), meaning it participates in the same permission checking, progress reporting, and result serialization flow.
The key integration point is the isMcp: true flag on the MCPTool definition:
// MCPTool.ts
export const MCPTool = buildTool({
isMcp: true,
isOpenWorld() { return false },
name: 'mcp',
maxResultSizeChars: 100_000,
// ... default implementations, all overridden per-tool in client.ts
})This flag lets the rest of the system distinguish MCP tools from built-in tools for display purposes (showing server-prefixed names, the (MCP) suffix in some UI contexts) and for permission scoping (using the full mcp__server__tool name in rules).
Key Takeaways
The MCP integration divides cleanly into three layers:
Configuration layer (services/mcp/config.ts) collects, merges, and filters server configs from five scopes, handling environment variable expansion, policy enforcement, and deduplication.
Connection layer (services/mcp/client.ts) establishes actual connections based on transport type, manages connection caching, error recovery, session expiry handling, and OAuth authentication. It is the most complex single file in the subsystem, covering all runtime edge cases.
Tool layer (tools/MCPTool/) wraps MCP tools into a form Claude Code's tool system understands, managing naming conventions, permission check integration, and result rendering.
The design achieves dynamic extensibility — users can connect dozens of different MCP servers at startup without any code changes — while maintaining strict control over permissions and security boundaries. This combination of openness and control is what makes MCP a practical foundation for an extensible AI coding assistant.