// McpManager — imperative API for MCP protocol client // Factory function that creates a manager instance with event-based notifications import type { Client } from '@modelcontextprotocol/sdk/client/index.js' import type { ListToolsResult } from '@modelcontextprotocol/sdk/types.js' import memoize from 'lodash-es/memoize.js' import { buildMcpToolName } from './strings.js' import type { CoreTool } from '@claude-code-best/agent-tools' import type { McpServerConfig, ScopedMcpServerConfig, MCPServerConnection, ConnectedMCPServer, FailedMCPServer, NeedsAuthMCPServer, } from './types.js' import type { McpClientDependencies } from './interfaces.js' import { McpConnectionError, McpAuthError, McpTimeoutError } from './errors.js' import { memoizeWithLRU } from './cache.js' import { discoverTools } from './discovery.js' import { callMcpTool } from './execution.js' // ============================================================================ // Event types // ============================================================================ export type McpManagerEvents = { connected: (name: string) => void disconnected: (name: string, error?: Error) => void toolsChanged: (serverName: string, tools: CoreTool[]) => void error: (name: string, error: Error) => void authRequired: (name: string) => void } type EventHandler = (...args: any[]) => void // ============================================================================ // Manager interface // ============================================================================ export interface McpManager { connect(name: string, config: McpServerConfig): Promise disconnect(name: string): Promise disconnectAll(): Promise getConnections(): Map getTools(serverName: string): CoreTool[] getAllTools(): CoreTool[] callTool( serverName: string, toolName: string, args: unknown, ): Promise on( event: E, handler: McpManagerEvents[E], ): void off(event: string, handler: EventHandler): void } // ============================================================================ // Default timeout // ============================================================================ const MCP_TIMEOUT_MS = 30_000 const MCP_REQUEST_TIMEOUT_MS = 60_000 // ============================================================================ // Manager implementation // ============================================================================ class McpManagerImpl implements McpManager { private connections = new Map() private toolsCache = new Map() private listeners = new Map>() private deps: McpClientDependencies private connectFn: | (( name: string, config: ScopedMcpServerConfig, ) => Promise) | null = null constructor(deps: McpClientDependencies) { this.deps = deps } /** Set the connect function — the host provides this with all transport logic */ setConnectFn( fn: ( name: string, config: ScopedMcpServerConfig, ) => Promise, ): void { this.connectFn = fn } async connect( name: string, config: McpServerConfig, ): Promise { if (!this.connectFn) { throw new Error( 'McpManager: connectFn not set. Call setConnectFn() first.', ) } const scopedConfig: ScopedMcpServerConfig = { ...config, scope: 'dynamic' } try { const connection = await this.connectFn(name, scopedConfig) this.connections.set(name, connection) if (connection.type === 'connected') { this.emit('connected', name) // Fetch tools for this server await this.refreshTools(name, connection) } else if (connection.type === 'needs-auth') { this.emit('authRequired', name) } return connection } catch (err) { const error = err instanceof Error ? err : new Error(String(err)) this.emit('error', name, error) throw err } } async disconnect(name: string): Promise { const conn = this.connections.get(name) if (!conn) return if (conn.type === 'connected') { try { await conn.cleanup() } catch (err) { this.deps.logger.warn(`Error disconnecting ${name}:`, err) } } this.connections.delete(name) this.toolsCache.delete(name) this.emit('disconnected', name) } async disconnectAll(): Promise { const names = [...this.connections.keys()] await Promise.all(names.map(name => this.disconnect(name))) } getConnections(): Map { return new Map(this.connections) } getTools(serverName: string): CoreTool[] { return this.toolsCache.get(serverName) ?? [] } getAllTools(): CoreTool[] { const all: CoreTool[] = [] for (const tools of this.toolsCache.values()) { all.push(...tools) } return all } async callTool( serverName: string, toolName: string, args: unknown, ): Promise { const conn = this.connections.get(serverName) if (!conn || conn.type !== 'connected') { throw new McpConnectionError( serverName, `Server ${serverName} is not connected`, ) } return callMcpTool( { client: conn, tool: toolName, args: args as Record, signal: new AbortController().signal, }, this.deps, ) } on( event: E, handler: McpManagerEvents[E], ): void { if (!this.listeners.has(event)) { this.listeners.set(event, new Set()) } this.listeners.get(event)!.add(handler) } off(event: string, handler: EventHandler): void { this.listeners.get(event)?.delete(handler) } // ── Private ── private emit(event: string, ...args: unknown[]): void { this.listeners.get(event)?.forEach(handler => { try { handler(...args) } catch (err) { this.deps.logger.error(`Error in ${event} handler:`, err) } }) } private async refreshTools( name: string, conn: ConnectedMCPServer, ): Promise { try { const tools = await discoverTools({ serverName: name, client: conn.client, capabilities: conn.capabilities ?? {}, deps: this.deps, }) this.toolsCache.set(name, tools) this.emit('toolsChanged', name, tools) } catch (err) { this.deps.logger.warn(`Failed to fetch tools for ${name}:`, err) } } } // ============================================================================ // Factory function // ============================================================================ /** * Creates a new MCP manager instance. * * The manager handles connection lifecycle, tool discovery, and event notification. * The host must call `setConnectFn()` to provide the transport-level connection logic. * * @param deps Host dependency injections (logger, auth, proxy, etc.) * @returns McpManager instance * * @example * ```typescript * const manager = createMcpManager({ * logger: console, * httpConfig: { getUserAgent: () => 'my-app/1.0' }, * }) * * manager.setConnectFn(async (name, config) => { * // Transport-level connection logic here * }) * * manager.on('connected', (name) => console.log(`Connected to ${name}`)) * manager.on('toolsChanged', (name, tools) => console.log(`${name}: ${tools.length} tools`)) * * await manager.connect('my-server', { command: 'npx', args: ['my-mcp-server'] }) * const tools = manager.getAllTools() * ``` */ export function createMcpManager(deps: McpClientDependencies): McpManager { return new McpManagerImpl(deps) }