import { createMcpSocketClient, SocketConnectionError, } from './mcpSocketClient.js' import type { McpSocketClient } from './mcpSocketClient.js' import type { ClaudeForChromeContext, PermissionMode, PermissionOverrides, } from './types.js' /** * Manages connections to multiple Chrome native host sockets (one per Chrome profile). * Routes tool calls to the correct socket based on tab ID. * * For `tabs_context_mcp`: queries all connected sockets and merges results. * For other tools: routes based on the `tabId` argument using a routing table * built from tabs_context_mcp responses. */ export class McpSocketPool { private clients: Map = new Map() private tabRoutes: Map = new Map() private context: ClaudeForChromeContext private notificationHandler: | ((notification: { method: string params?: Record }) => void) | null = null constructor(context: ClaudeForChromeContext) { this.context = context } public setNotificationHandler( handler: (notification: { method: string params?: Record }) => void, ): void { this.notificationHandler = handler for (const client of this.clients.values()) { client.setNotificationHandler(handler) } } /** * Discover available sockets and ensure at least one is connected. */ public async ensureConnected(): Promise { const { logger, serverName } = this.context this.refreshClients() // Try to connect any disconnected clients const connectPromises: Promise[] = [] for (const client of this.clients.values()) { if (!client.isConnected()) { connectPromises.push(client.ensureConnected().catch(() => false)) } } if (connectPromises.length > 0) { await Promise.all(connectPromises) } const connectedCount = this.getConnectedClients().length if (connectedCount === 0) { logger.info(`[${serverName}] No connected sockets in pool`) return false } logger.info(`[${serverName}] Socket pool: ${connectedCount} connected`) return true } /** * Call a tool, routing to the correct socket based on tab ID. * For tabs_context_mcp, queries all sockets and merges results. */ public async callTool( name: string, args: Record, _permissionOverrides?: PermissionOverrides, ): Promise { if (name === 'tabs_context_mcp') { return this.callTabsContext(args) } // Route by tabId if present const tabId = args.tabId as number | undefined if (tabId !== undefined) { const socketPath = this.tabRoutes.get(tabId) if (socketPath) { const client = this.clients.get(socketPath) if (client?.isConnected()) { return client.callTool(name, args) } } // Tab route not found or client disconnected — fall through to any connected } // Fallback: use first connected client const connected = this.getConnectedClients() if (connected.length === 0) { throw new SocketConnectionError( `[${this.context.serverName}] No connected sockets available`, ) } return connected[0]!.callTool(name, args) } public async setPermissionMode( mode: PermissionMode, allowedDomains?: string[], ): Promise { const connected = this.getConnectedClients() await Promise.all( connected.map(client => client.setPermissionMode(mode, allowedDomains)), ) } public isConnected(): boolean { return this.getConnectedClients().length > 0 } public disconnect(): void { for (const client of this.clients.values()) { client.disconnect() } this.clients.clear() this.tabRoutes.clear() } private getConnectedClients(): McpSocketClient[] { return [...this.clients.values()].filter(c => c.isConnected()) } /** * Query all connected sockets for tabs and merge results. * Updates the tab routing table. */ private async callTabsContext( args: Record, ): Promise { const { logger, serverName } = this.context const connected = this.getConnectedClients() if (connected.length === 0) { throw new SocketConnectionError( `[${serverName}] No connected sockets available`, ) } // If only one client, skip merging overhead if (connected.length === 1) { const result = await connected[0]!.callTool('tabs_context_mcp', args) this.updateTabRoutes(result, this.getSocketPathForClient(connected[0]!)) return result } // Query all connected clients in parallel const results = await Promise.allSettled( connected.map(async client => { const result = await client.callTool('tabs_context_mcp', args) const socketPath = this.getSocketPathForClient(client) return { result, socketPath } }), ) // Merge tab results const mergedTabs: unknown[] = [] this.tabRoutes.clear() for (const settledResult of results) { if (settledResult.status !== 'fulfilled') { logger.info( `[${serverName}] tabs_context_mcp failed on one socket: ${settledResult.reason}`, ) continue } const { result, socketPath } = settledResult.value this.updateTabRoutes(result, socketPath) const tabs = this.extractTabs(result) if (tabs) { mergedTabs.push(...tabs) } } // Return merged result in the same format as the extension response if (mergedTabs.length > 0) { const tabListText = mergedTabs .map(t => { const tab = t as { tabId: number; title: string; url: string } return ` • tabId ${tab.tabId}: "${tab.title}" (${tab.url})` }) .join('\n') return { result: { content: [ { type: 'text', text: JSON.stringify({ availableTabs: mergedTabs }), }, { type: 'text', text: `\n\nTab Context:\n- Available tabs:\n${tabListText}`, }, ], }, } } // Fallback: return first successful result as-is for (const settledResult of results) { if (settledResult.status === 'fulfilled') { return settledResult.value.result } } throw new SocketConnectionError( `[${serverName}] All sockets failed for tabs_context_mcp`, ) } /** * Extract tab objects from a tool response to update routing table. */ private updateTabRoutes(result: unknown, socketPath: string): void { const tabs = this.extractTabs(result) if (!tabs) return for (const tab of tabs) { if (typeof tab === 'object' && tab !== null && 'tabId' in tab) { const tabId = (tab as { tabId: number }).tabId this.tabRoutes.set(tabId, socketPath) } } } private extractTabs(result: unknown): unknown[] | null { if (!result || typeof result !== 'object') return null // Response format: { result: { content: [{ type: "text", text: "{\"availableTabs\":[...],\"tabGroupId\":...}" }] } } const asResponse = result as { result?: { content?: Array<{ type: string; text?: string }> } } const content = asResponse.result?.content if (!content || !Array.isArray(content)) return null for (const item of content) { if (item.type === 'text' && item.text) { try { const parsed = JSON.parse(item.text) if (Array.isArray(parsed)) return parsed // Handle { availableTabs: [...] } format if (parsed && Array.isArray(parsed.availableTabs)) { return parsed.availableTabs } } catch { // Not JSON, skip } } } return null } private getSocketPathForClient(client: McpSocketClient): string { for (const [path, c] of this.clients.entries()) { if (c === client) return path } return '' } /** * Scan for available sockets and create/remove clients as needed. */ private refreshClients(): void { const socketPaths = this.getAvailableSocketPaths() const { logger, serverName } = this.context // Add new clients for newly discovered sockets for (const path of socketPaths) { if (!this.clients.has(path)) { logger.info(`[${serverName}] Adding socket to pool: ${path}`) const clientContext: ClaudeForChromeContext = { ...this.context, socketPath: path, getSocketPath: undefined, getSocketPaths: undefined, } const client = createMcpSocketClient(clientContext) client.disableAutoReconnect = true if (this.notificationHandler) { client.setNotificationHandler(this.notificationHandler) } this.clients.set(path, client) } } // Remove clients for sockets that no longer exist for (const [path, client] of this.clients.entries()) { if (!socketPaths.includes(path)) { logger.info(`[${serverName}] Removing stale socket from pool: ${path}`) client.disconnect() this.clients.delete(path) for (const [tabId, socketPath] of this.tabRoutes.entries()) { if (socketPath === path) { this.tabRoutes.delete(tabId) } } } } } private getAvailableSocketPaths(): string[] { return this.context.getSocketPaths?.() ?? [] } } export function createMcpSocketPool( context: ClaudeForChromeContext, ): McpSocketPool { return new McpSocketPool(context) }