mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
271 lines
7.7 KiB
TypeScript
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)
|
|
}
|