Files
claude-code/packages/mcp-client/src/manager.ts
2026-05-01 21:39:30 +08:00

271 lines
7.7 KiB
TypeScript

// 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<MCPServerConnection>
disconnect(name: string): Promise<void>
disconnectAll(): Promise<void>
getConnections(): Map<string, MCPServerConnection>
getTools(serverName: string): CoreTool[]
getAllTools(): CoreTool[]
callTool(
serverName: string,
toolName: string,
args: unknown,
): Promise<unknown>
on<E extends keyof McpManagerEvents>(
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<string, MCPServerConnection>()
private toolsCache = new Map<string, CoreTool[]>()
private listeners = new Map<string, Set<EventHandler>>()
private deps: McpClientDependencies
private connectFn:
| ((
name: string,
config: ScopedMcpServerConfig,
) => Promise<MCPServerConnection>)
| 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<MCPServerConnection>,
): void {
this.connectFn = fn
}
async connect(
name: string,
config: McpServerConfig,
): Promise<MCPServerConnection> {
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<void> {
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<void> {
const names = [...this.connections.keys()]
await Promise.all(names.map(name => this.disconnect(name)))
}
getConnections(): Map<string, MCPServerConnection> {
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<unknown> {
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<string, unknown>,
signal: new AbortController().signal,
},
this.deps,
)
}
on<E extends keyof McpManagerEvents>(
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<void> {
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)
}