Skip to content

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:

  1. The problem MCP solves and why it is fundamentally different from built-in tool implementations
  2. The five-scope configuration system: how local, user, project, and enterprise configs are merged and prioritized
  3. The five transport types — stdio, SSE, HTTP Streamable, WebSocket, and SDK — and when each applies
  4. How connectToServer establishes a connection, negotiates capabilities, and registers heartbeat detection
  5. The design of the MCPTool wrapper: how an MCP tool becomes a first-class citizen in Claude Code's tool system
  6. The mcp__serverName__toolName naming convention and its role in permission checking
  7. The OAuth authentication flow and how the needs-auth cache prevents redundant auth detection
  8. 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:

ScopeStorage LocationNotes
pluginPlugin systemServers bundled with plugins
user~/.claude.json mcpServers fieldUser-global configuration
project.mcp.json (searched upward from CWD)Project-level; can be committed to VCS
localProject-local configNot committed to VCS
enterpriseManaged managed-mcp.jsonHighest priority; if present, takes exclusive control

The merge logic is explicit in getClaudeCodeMcpConfigs:

typescript
// 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:

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

typescript
// 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:

typescript
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:

typescript
// 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:

typescript
// 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:

typescript
// 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:

typescript
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:

typescript
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":

typescript
// 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:

typescript
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:

typescript
// 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:

  1. Call client.listTools() to get the raw tool list
  2. For each tool, generate a complete name with buildMcpToolName(serverName, tool.name)
  3. Clone the MCPTool object, overriding name, description, call, etc.
  4. The call method internally calls client.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:

typescript
// 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:

typescript
// 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 directly
  • image: Base64-encoded image, size-checked and possibly downsampled
  • resource: 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:

typescript
// 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:

typescript
// 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):

  1. On first connection, if the server returns 401, UnauthorizedError is caught
  2. handleRemoteAuthFailure is called; server state transitions to needs-auth
  3. The needs-auth state is persisted in a local cache with a 15-minute TTL
  4. The UI prompts the user to authenticate; the user triggers auth via /mcp auth
  5. 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:

typescript
// 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:

typescript
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:

typescript
// 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 server
  • ReadMcpResourceTool: 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).

typescript
// 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:

VariableDefaultEffect
MCP_TIMEOUT30000 msConnection attempt timeout
MCP_TOOL_TIMEOUT~100M msTool call timeout (effectively unlimited)
MCP_SERVER_CONNECTION_BATCH_SIZE3Max concurrent local server connections
MCP_REMOTE_SERVER_CONNECTION_BATCH_SIZE20Max 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:

typescript
// 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.

Built for learners who want to read Claude Code like a real system.