McpHub: MCP server lifecycle management
Responsibilities
McpHub is Cline's central hub for integrating with external MCP servers. It reads every server configured in cline_mcp_settings.json, establishes an independent client + transport connection per server, and caches the exposed tools, resources, and prompts in memory for the main agent to call. The entire MCP pipeline — from config changes, file watching, connect/reconnect, capability discovery, to tools/call invocation — converges in this one class; the main Task never touches the MCP SDK directly.
It sits between "the main agent loop" and "the specific MCP protocol". When a Task wants to call an MCP tool, it goes out through McpHub.callTool (callTool:1233); when the UI wants to see the current server list, it gets a sorted snapshot via getServers() (getServers:112). There is only one McpHub per Extension instance, but each entry in the connections: McpConnection[] array is an independent transport, so one crashing does not affect the others.
Design motivation
- One client per server: different MCP servers have different capabilities, configurations, and error handling; independent clients make reconnect and scope isolation simple (
per-server client:363). - Three unified transports:
stdioruns a local process,sseuses Server-Sent Events, andstreamableHttpuses the new HTTP streaming. They are dispatched in aswitchinsideconnectToServer(transport switch:382). - Dual channels for file watching vs internal updates:
watchMcpSettingsFilewatches for external editor changes to the config, whileisUpdatingClineSettingsmarks when writes come from inside Cline itself and skips the trigger (isUpdatingClineSettings:76), avoiding loops. - OAuth loaded lazily: only
sse/streamableHttpaskMcpOAuthManagerfor an authProvider;stdiodoes not (authProvider setup:377). - Enterprise policy precedes connection: enterprise deployments can disable the marketplace and set allow-lists; blocks happen before a stdio connection is even attempted (
enterprise allowlist:310). - UID shortens tool names: each server is assigned a short uid of
c+ nanoid(5), to keep MCP tool names from becoming long concatenated strings (getMcpServerKey:130).
Key files
McpHub class:51— Singleton class; holds connections, file watcher, OAuth manager, and notification callbacks.constructor:108— Immediately callswatchMcpSettingsFile+initializeMcpServersat startup.watchMcpSettingsFile:213— Uses chokidar to watch config file changes; diffs additions/deletions/modifications and triggers reconnects.connectToServer:286— Core connection method; validates, picks transport, builds the client, and discovers capabilities.transport switch:382— Three branches: stdio / sse / streamableHttp.stdio stderr pipe:415— stdio servers pipe stderr out as info/error log streams.fetchToolsList:677— Sendstools/listand tags each tool with anautoApproveflag.fetchResourcesList:713— Sendsresources/list; disabled servers return empty.notification handler:611— Registers thenotifications/messagecallback; routes server logs to the current Task or stages them.deleteConnection:784— Closes transport + client and filters the entry out of connections.restartConnection:1047— Reconnects a server, with an artificial 500ms delay so the UI can show the "connecting" state.callTool:1233— Sendstools/call; picks the timeout from server config, with telemetry throughout.
Data flow
connectToServer does three things up front: validate enterprise policy, construct either a disabled placeholder or a real client, and pick the transport. Once the transport is selected, it builds an McpConnection, pushes it into the array, and then client.connect(transport) does the actual handshake. Only after a successful handshake does it register notification callbacks and fetch the four capability lists:
// apps/vscode/src/services/mcp/McpHub.ts
connection.server.status = "connected"
connection.server.error = ""
// Register notification handler for real-time messages
try {
// ...
connection.client.setNotificationHandler(NotificationMessageSchema as any, async (notification: any) => {
// ...
if (this.notificationCallback) {
this.notificationCallback(name, level, message)
} else {
this.pendingNotifications.push({ serverName: name, level, message, timestamp: Date.now() })
}
})
} catch (error) {
Logger.error(`[MCP Debug] Error setting notification handlers for ${name}:`, error)
}
// Initial fetch of tools, resources, and prompts
connection.server.tools = await this.fetchToolsList(name)
connection.server.resources = await this.fetchResourcesList(name)
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(name)
connection.server.prompts = await this.fetchPromptsList(name)This sits around post-connect setup:584. Once the connection is up, external tool calls all go through callTool — look up the connection, compute the timeout from config.timeout, and send tools/call (tools/call request:1270). Resource reads are similar, via resources/read (resources/read:1183).
Boundaries and failure modes
- Missing OAuth means graceful degradation: when
client.connectthrowsUnauthorizedError, it does not surface as an error; instead the connection is markedoauthRequired: trueand waits for the user to authorize on the front-end (UnauthorizedError branch:556). - Disabled servers are placeholders but not connected: a disabled config still enters the connections array so the UI can display it, but its client/transport are both
null(disabled placeholder:339). - stdio stderr classification: stderr lines containing the word
errorare recorded as errors; everything else is treated as info log (stderr classification:420). - streamableHttp treats 404 as 405: many servers that should return 405 when SSE is unsupported instead return 404; the fetch layer normalizes 404 into 405 so the SDK accepts it (
404 to 405:499). - SSE token refresh on demand: the sse branch uses a custom fetch that calls
authProvider.tokens()every time; otherwise an expired token would keep returning 401 on reconnect (dynamic token fetch:459). - streamableHttp reconnects are delegated:
StreamableHttpReconnectHandlertakes aconnectToServerclosure and decides its own reconnect cadence (reconnect handler:519). - Notifications staged: when no Task is active, server notifications are first stored in
pendingNotificationsand drained when the next Task starts (pendingNotifications:631).
Summary
McpHub folds all MCP protocol details into itself and exposes three entry points to the main agent: callTool / readResource / getServers. To see how it is wrapped as use_mcp_tool by the tool layer, read cap-tools/use-mcp-tool; to see how a subagent isolates its own context window, read subagent; to see how the main Task injects the MCP server list into the system prompt, read agent-loop/task-class.