/** * McpClient — MCP protocol client. * * Manages a single MCP server connection: initialize handshake, * tool discovery, tool invocation, and list_changed notifications. */ import type { Connection } from "./transport.js"; import type { McpCallResult, McpInitializeResult, McpListToolsResult, McpServerCapabilities, McpToolInfo, } from "./types.js"; export type McpClientState = "disconnected" | "connecting" | "connected" | "error"; export interface McpClientDeps { readonly connection: Connection; } export class McpClient { private state: McpClientState = "disconnected"; private capabilities: McpServerCapabilities = {}; private tools: readonly McpToolInfo[] = []; private connection: Connection; private toolsChangedHandler: (() => void) | null = null; constructor(deps: McpClientDeps) { this.connection = deps.connection; } getState(): McpClientState { return this.state; } getCapabilities(): McpServerCapabilities { return this.capabilities; } getTools(): readonly McpToolInfo[] { return this.tools; } onToolsChanged(handler: () => void): void { this.toolsChangedHandler = handler; } async initialize(): Promise { this.state = "connecting"; try { const result = (await this.connection.send("initialize", { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "dispatch", version: "0.0.0" }, })) as McpInitializeResult; this.capabilities = result.capabilities; this.connection.notify("notifications/initialized", {}); this.connection.onNotification("notifications/tools/list_changed", () => { if (this.toolsChangedHandler) { this.toolsChangedHandler(); } }); this.state = "connected"; return result; } catch (err: unknown) { this.state = "error"; throw err; } } async listTools(): Promise { if (this.state !== "connected") { throw new Error("Client not connected"); } const result = (await this.connection.send("tools/list")) as McpListToolsResult; this.tools = result.tools; return this.tools; } async callTool(name: string, args: unknown, signal?: AbortSignal): Promise { if (this.state !== "connected") { throw new Error("Client not connected"); } if (signal?.aborted) { throw new Error("Aborted"); } const resultPromise = this.connection.send("tools/call", { name, arguments: args, }) as Promise; if (!signal) { return resultPromise; } return new Promise((resolve, reject) => { const onAbort = () => reject(new Error("Aborted")); signal.addEventListener("abort", onAbort, { once: true }); resultPromise.then( (result) => { signal.removeEventListener("abort", onAbort); resolve(result); }, (err) => { signal.removeEventListener("abort", onAbort); reject(err); }, ); }); } close(): void { this.state = "disconnected"; this.connection.close(); } }