Studio Notebook

Claude Code Atlas

MCP Client Connections And Tool Bridges

Learn how MCP client connections turn remote servers into Claude Code tools and resources.

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.