Why this matters
MCP is the bridge that lets Claude Code reach a remote server and surface that server’s capabilities inside the app. This page is not a full MCP manual. It shows one concrete client setup path, then the client-side failure and bridge model that turns remote server features into Claude Code surfaces.
MCP in plain English
Think of MCP as “Claude Code talks to another program over a standard connection.” Claude Code does not need to know whether that program is a filesystem service, a browser helper, or some custom backend. The client is the adapter in the middle.
Claude Code connects to a remote server. The client normalizes and authenticates that connection. The server’s tools and resources become Claude Code surfaces.
Why this lives in the extensions root
The MCP client belongs in the extensions root because it turns external systems into Claude Code surfaces. This is not a basic local tool primitive. It is the boundary where a remote capability enters the extension model.
Data structures you need first
Before the connection code makes sense, it helps to know the shapes it is
moving around. Transport is one core transport enum for the common
connection paths, but it is not the whole server-type taxonomy. The wider
config layer also includes special cases like ws-ide and
claudeai-proxy. MCPServerConnection is the runtime state machine for a
server after the client has tried to connect.
export const TransportSchema = lazySchema(() =>
z.enum(['stdio', 'sse', 'sse-ide', 'http', 'ws', 'sdk']),
)
export type Transport = z.infer<ReturnType<typeof TransportSchema>>
export type ConnectedMCPServer = {
client: Client
name: string
type: 'connected'
capabilities: ServerCapabilities
serverInfo?: {
name: string
version: string
}
instructions?: string
config: ScopedMcpServerConfig
cleanup: () => Promise<void>
}
export type FailedMCPServer = {
name: string
type: 'failed'
config: ScopedMcpServerConfig
error?: string
}
export type NeedsAuthMCPServer = {
name: string
type: 'needs-auth'
config: ScopedMcpServerConfig
}
export type PendingMCPServer = {
name: string
type: 'pending'
config: ScopedMcpServerConfig
reconnectAttempt?: number
maxReconnectAttempts?: number
}
export type DisabledMCPServer = {
name: string
type: 'disabled'
config: ScopedMcpServerConfig
}
export type MCPServerConnection =
| ConnectedMCPServer
| FailedMCPServer
| NeedsAuthMCPServer
| PendingMCPServer
| DisabledMCPServer
// Resource types
export type ServerResource = Resource & { server: string }
Plain English version: Transport says how the client should connect, and
MCPServerConnection says what happened after that attempt. A server can end
up connected, failed, waiting for auth, still pending, or disabled.
One concrete transport setup
This chapter uses one real example from the client: the SSE path with auth and headers. It is enough to see the pattern without dragging in every other transport.
// Create an auth provider for this server
const authProvider = new ClaudeAuthProvider(name, serverRef)
// Get combined headers (static + dynamic)
const combinedHeaders = await getMcpServerHeaders(name, serverRef)
// Use the auth provider with SSEClientTransport
const transportOptions: SSEClientTransportOptions = {
authProvider,
// Use fresh timeout per request to avoid stale AbortSignal bug.
// Step-up detection wraps innermost so the 403 is seen before the
// SDK's handler calls auth() → tokens().
fetch: wrapFetchWithTimeout(
wrapFetchWithStepUpDetection(createFetchWithInit(), authProvider),
),
requestInit: {
headers: {
'User-Agent': getMCPUserAgent(),
...combinedHeaders,
},
},
}
// IMPORTANT: Always set eventSourceInit with a fetch that does NOT use the
// timeout wrapper. The EventSource connection is long-lived (stays open indefinitely
// to receive server-sent events), so applying a 60-second timeout would kill it.
// The timeout is only meant for individual API requests (POST, auth refresh), not
// the persistent SSE stream.
transportOptions.eventSourceInit = {
fetch: async (url: string | URL, init?: RequestInit) => {
// Get auth headers from the auth provider
const authHeaders: Record<string, string> = {}
const tokens = await authProvider.tokens()
if (tokens) {
authHeaders.Authorization = `Bearer ${tokens.access_token}`
}
const proxyOptions = getProxyFetchOptions()
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
return fetch(url, {
...init,
...proxyOptions,
headers: {
'User-Agent': getMCPUserAgent(),
...authHeaders,
...init?.headers,
...combinedHeaders,
Accept: 'text/event-stream',
},
})
},
}
transport = new SSEClientTransport(
new URL(serverRef.url),
transportOptions,
)
logMCPDebug(name, `SSE transport initialized, awaiting connection`)
The client is doing three things here: attaching auth, normalizing headers, and keeping the SSE stream open without the request timeout wrapper.
Client-side failure model
The page does not try to explain every auth edge case in services/mcp/auth.ts.
It stays with the client-side errors that matter for connection recovery and
tool calls.
export class McpAuthError extends Error {
serverName: string
constructor(serverName: string, message: string) {
super(message)
this.name = 'McpAuthError'
this.serverName = serverName
}
}
class McpSessionExpiredError extends Error {
constructor(serverName: string) {
super(`MCP server "${serverName}" session expired`)
this.name = 'McpSessionExpiredError'
}
}
export function isMcpSessionExpiredError(error: Error): boolean {
const httpStatus =
'code' in error ? (error as Error & { code?: number }).code : undefined
if (httpStatus !== 404) {
return false
}
// The SDK embeds the response body text in the error message.
// MCP servers return: {"error":{"code":-32001,"message":"Session not found"},...}
// Check for the JSON-RPC error code to distinguish from generic web server 404s.
return (
error.message.includes('"code":-32001') ||
error.message.includes('"code": -32001')
)
}
const DEFAULT_MCP_TOOL_TIMEOUT_MS = 100_000_000
function getMcpToolTimeoutMs(): number {
return (
parseInt(process.env.MCP_TOOL_TIMEOUT || '', 10) ||
DEFAULT_MCP_TOOL_TIMEOUT_MS
)
}
That is the client-side failure model in plain English. McpAuthError means
“this server wants re-authorization.” McpSessionExpiredError means “throw
away the stale connection and reconnect.” The timeout helper gives tool calls a
single timeout source of truth.
Tool bridge
Claude Code does not call the remote server object directly. It wraps the MCP
tool in a local Tool object, then layers on the common MCP fields and the
model-facing metadata.
// Convert MCP tools to our Tool format
return toolsToProcess
.map((tool): Tool => {
const fullyQualifiedName = buildMcpToolName(client.name, tool.name)
return {
...MCPTool,
// In skip-prefix mode, use the original name for model invocation so MCP tools
// can override builtins by name. mcpInfo is used for permission checking.
name: skipPrefix ? tool.name : fullyQualifiedName,
mcpInfo: { serverName: client.name, toolName: tool.name },
isMcp: true,
// Collapse whitespace: _meta is open to external MCP servers, and
// a newline here would inject orphan lines into the deferred-tool
// list (formatDeferredToolLine joins on '\n').
searchHint:
typeof tool._meta?.['anthropic/searchHint'] === 'string'
? tool._meta['anthropic/searchHint']
.replace(/\s+/g, ' ')
.trim() || undefined
: undefined,
alwaysLoad: tool._meta?.['anthropic/alwaysLoad'] === true,
async description() {
return tool.description ?? ''
},
async prompt() {
const desc = tool.description ?? ''
return desc.length > MAX_MCP_DESCRIPTION_LENGTH
? desc.slice(0, MAX_MCP_DESCRIPTION_LENGTH) + '… [truncated]'
: desc
},
isConcurrencySafe() {
return tool.annotations?.readOnlyHint ?? false
},
isReadOnly() {
return tool.annotations?.readOnlyHint ?? false
},
toAutoClassifierInput(input) {
return mcpToolInputToAutoClassifierInput(input, tool.name)
},
isDestructive() {
return tool.annotations?.destructiveHint ?? false
},
isOpenWorld() {
return tool.annotations?.openWorldHint ?? false
},
isSearchOrReadCommand() {
return classifyMcpToolForCollapse(client.name, tool.name)
},
inputJSONSchema: tool.inputSchema as Tool['inputJSONSchema'],
The important idea is the bridge, not the full call path: ...MCPTool supplies
the shared local tool behavior, and the following fields attach the remote
tool’s name, MCP identity, search hints, read-only hints, and schema to that
local surface.
Resource bridge
Resource tools are not inserted once per server. The first resource-capable
server that passes through getMcpToolsCommandsAndResources flips
resourceToolsAdded to true, and that one pass adds ListMcpResourcesTool and
ReadMcpResourceTool for the whole batch.
// If this server resources and we haven't added resource tools yet,
// include our resource tools with this client's tools
const resourceTools: Tool[] = []
if (supportsResources && !resourceToolsAdded) {
resourceToolsAdded = true
resourceTools.push(ListMcpResourcesTool, ReadMcpResourceTool)
That makes the plain-English model easy to remember: the first server with resources teaches Claude Code how to list and read them, and later resource servers reuse the same bridge instead of duplicating it.
One sentence version
Claude Code connects to a remote MCP server, the client normalizes and recovers that connection, and the server’s tools and resources become local Claude Code surfaces.