import type { CallToolResult } from "./protocol/content.ts"; import { isJsonRpcId, isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, isObject, JSON_RPC_ERROR_CODES, type JsonRpcId, type JsonRpcMessage, type JsonRpcRequest, type JsonRpcResponse, McpAbortError, McpConnectionClosedError, McpError, McpTimeoutError, toError, } from "./protocol/jsonrpc.ts"; import { type ClientCapabilities, type Implementation, type InitializeResult, LATEST_PROTOCOL_VERSION, type ListResourcesResult, type ListResourceTemplatesResult, type ProgressNotification, type ReadResourceResult, type Resource, type ResourceTemplate, type Root, type ServerCapabilities, SUPPORTED_PROTOCOL_VERSIONS, type SupportedProtocolVersion, type Tool, } from "./protocol/types.ts"; import type { McpTransport } from "./transports/transport.ts"; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; const MAX_LIST_PAGES = 1_000; type ClientState = "idle" | "connecting" | "connected" | "closed"; type NotificationListener = (params: unknown) => void; type ErrorListener = (error: Error) => void; type CloseListener = () => void; type RequestHandler = (params: unknown, context: { signal: AbortSignal }) => unknown | Promise; export interface McpClientOptions extends Implementation { capabilities?: ClientCapabilities; protocolVersion?: SupportedProtocolVersion; requestTimeoutMs?: number; roots?: readonly Root[] | (() => readonly Root[] | Promise); } export interface McpRequestOptions { signal?: AbortSignal; timeoutMs?: number; onProgress?: (progress: ProgressNotification) => void; } interface PendingRequest { resolve: (value: unknown) => void; reject: (reason: unknown) => void; timeoutMs: number; timer: ReturnType | undefined; signal: AbortSignal | undefined; onAbort: () => void; /** Aborted when the request settles, so the transport does not dispatch (or retry) it afterwards. */ sendController: AbortController; cancellable: boolean; onProgress: ((progress: ProgressNotification) => void) | undefined; progressToken: JsonRpcId | undefined; } function validateInitializeResult(value: unknown): InitializeResult { if ( !isObject(value) || typeof value.protocolVersion !== "string" || !isObject(value.capabilities) || !isObject(value.serverInfo) || typeof value.serverInfo.name !== "string" || typeof value.serverInfo.version !== "string" || (value.instructions !== undefined && typeof value.instructions !== "string") ) { throw new McpError(JSON_RPC_ERROR_CODES.invalidRequest, "Invalid MCP initialize result"); } return value as unknown as InitializeResult; } function invalid(message: string): McpError { return new McpError(JSON_RPC_ERROR_CODES.invalidRequest, message); } /** One page of a paginated list: the items under `key`, each checked by `isItem`. */ function validateListPage( method: string, key: string, value: unknown, isItem: (item: Record) => boolean, ): { items: Record[]; nextCursor?: string } { const items = isObject(value) ? value[key] : undefined; if (!isObject(value) || !Array.isArray(items)) throw invalid(`Invalid MCP ${method} result`); for (const item of items) { if (!isObject(item) || !isItem(item)) throw invalid(`Invalid entry in MCP ${method} result`); } // Some servers end pagination with `null` or `""` instead of omitting the cursor. const nextCursor = value.nextCursor === null || value.nextCursor === "" ? undefined : value.nextCursor; if (nextCursor !== undefined && typeof nextCursor !== "string") throw invalid(`Invalid MCP ${method} cursor`); return { items, ...(nextCursor === undefined ? {} : { nextCursor }) }; } const isTool = (tool: Record) => typeof tool.name === "string" && isObject(tool.inputSchema); // `name` is required by the spec, but some servers omit it; the URI stands in. const isResource = (resource: Record) => typeof resource.uri === "string" && (resource.name === undefined || typeof resource.name === "string"); const isResourceTemplate = (template: Record) => typeof template.uriTemplate === "string" && (template.name === undefined || typeof template.name === "string"); function toResource(item: Record): Resource { return { ...item, name: item.name ?? item.uri } as Resource; } function toResourceTemplate(item: Record): ResourceTemplate { return { ...item, name: item.name ?? item.uriTemplate } as ResourceTemplate; } function pageCursor(page: { nextCursor?: string }): { nextCursor?: string } { return page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }; } function validateReadResourceResult(value: unknown): ReadResourceResult { if (!isObject(value) || !Array.isArray(value.contents)) throw invalid("Invalid MCP resources/read result"); for (const contents of value.contents) { if ( !isObject(contents) || typeof contents.uri !== "string" || (typeof contents.text !== "string" && typeof contents.blob !== "string") ) { throw invalid("Invalid contents in MCP resources/read result"); } } return value as unknown as ReadResourceResult; } /** * `content` is required by the spec, but servers that only return `structuredContent` omit it (the SDK defaults it too). * `structuredContent` may be any JSON value, as MCP 2026-07-28 allows. */ function validateCallToolResult(value: unknown): CallToolResult { if (!isObject(value) || (value.content !== undefined && !Array.isArray(value.content))) { throw new McpError(JSON_RPC_ERROR_CODES.invalidRequest, "Invalid MCP tools/call result"); } return (value.content === undefined ? { ...value, content: [] } : value) as unknown as CallToolResult; } export class McpClient { readonly options: Readonly; private state: ClientState = "idle"; private transport: McpTransport | undefined; private nextRequestId = 1; private serverInfoValue: Implementation | undefined; private serverCapabilitiesValue: ServerCapabilities | undefined; private instructionsValue: string | undefined; private protocolVersionValue: string | undefined; private pending = new Map(); private progressRequests = new Map(); private incoming = new Map(); private requestHandlers = new Map(); private notificationListeners = new Map>(); private errorListeners = new Set(); private closeListeners = new Set(); private disposers: (() => void)[] = []; constructor(options: McpClientOptions) { this.options = Object.freeze({ ...options }); this.requestHandlers.set("ping", () => ({})); const roots = options.roots; if (roots) { this.requestHandlers.set("roots/list", async () => ({ roots: [...(typeof roots === "function" ? await roots() : roots)], })); } } get connectionState(): ClientState { return this.state; } get serverInfo(): Implementation | undefined { return this.serverInfoValue; } get serverCapabilities(): ServerCapabilities | undefined { return this.serverCapabilitiesValue; } get instructions(): string | undefined { return this.instructionsValue; } get protocolVersion(): string | undefined { return this.protocolVersionValue; } async connect(transport: McpTransport): Promise { if (this.state !== "idle") throw new Error(`Cannot connect MCP client in ${this.state} state`); this.state = "connecting"; this.transport = transport; this.disposers = [ transport.onMessage((message) => this.handleMessage(message)), // Transport errors are reported only. Pending requests fail when the transport closes. transport.onError((error) => this.emitError(error)), transport.onClose(() => this.handleTransportClose()), ]; try { await transport.start(); const capabilities: ClientCapabilities = { ...this.options.capabilities }; if (this.options.roots && capabilities.roots === undefined) capabilities.roots = {}; const result = validateInitializeResult( await this.requestInternal( "initialize", { protocolVersion: this.options.protocolVersion ?? LATEST_PROTOCOL_VERSION, capabilities, clientInfo: { name: this.options.name, version: this.options.version, ...(this.options.title === undefined ? {} : { title: this.options.title }), }, }, {}, true, ), ); if (!(SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(result.protocolVersion)) { throw new Error(`MCP server selected unsupported protocol version ${result.protocolVersion}`); } this.protocolVersionValue = result.protocolVersion; this.serverInfoValue = result.serverInfo; this.serverCapabilitiesValue = result.capabilities; this.instructionsValue = result.instructions; transport.setProtocolVersion?.(result.protocolVersion); await this.notifyInternal("notifications/initialized", undefined, true); this.state = "connected"; return result; } catch (error) { await this.close().catch(() => {}); throw error; } } request( method: string, params?: Record, options: McpRequestOptions = {}, ): Promise { return this.requestInternal(method, params, options, false) as Promise; } notify(method: string, params?: Record): Promise { return this.notifyInternal(method, params, false); } setRequestHandler(method: string, handler: RequestHandler): () => void { this.requestHandlers.set(method, handler); return () => { if (this.requestHandlers.get(method) === handler) this.requestHandlers.delete(method); }; } onNotification(method: string, listener: NotificationListener): () => void { const listeners = this.notificationListeners.get(method) ?? new Set(); this.notificationListeners.set(method, listeners); listeners.add(listener); return () => { listeners.delete(listener); if (listeners.size === 0) this.notificationListeners.delete(method); }; } onError(listener: ErrorListener): () => void { this.errorListeners.add(listener); return () => this.errorListeners.delete(listener); } /** Called once when the connection closes, whether the transport dropped or `close()` was called. */ onClose(listener: CloseListener): () => void { this.closeListeners.add(listener); return () => this.closeListeners.delete(listener); } async ping(options: McpRequestOptions = {}): Promise { await this.request("ping", undefined, options); } async listTools(options: McpRequestOptions = {}): Promise { return (await this.listAll("tools/list", "tools", isTool, options)) as unknown as Tool[]; } /** Every resource, following `nextCursor` through all pages. */ async listResources(options: McpRequestOptions = {}): Promise { return (await this.listAll("resources/list", "resources", isResource, options)).map(toResource); } /** One page of resources, starting at `cursor`. */ async listResourcesPage(cursor?: string, options: McpRequestOptions = {}): Promise { const page = await this.listPage("resources/list", "resources", isResource, cursor, options); return { resources: page.items.map(toResource), ...pageCursor(page) }; } /** Every resource template, following `nextCursor` through all pages. */ async listResourceTemplates(options: McpRequestOptions = {}): Promise { const templates = await this.listAll( "resources/templates/list", "resourceTemplates", isResourceTemplate, options, ); return templates.map(toResourceTemplate); } /** One page of resource templates, starting at `cursor`. */ async listResourceTemplatesPage( cursor?: string, options: McpRequestOptions = {}, ): Promise { const page = await this.listPage( "resources/templates/list", "resourceTemplates", isResourceTemplate, cursor, options, ); return { resourceTemplates: page.items.map(toResourceTemplate), ...pageCursor(page) }; } async readResource(uri: string, options: McpRequestOptions = {}): Promise { return validateReadResourceResult(await this.request("resources/read", { uri }, options)); } private async listPage( method: string, key: string, isItem: (item: Record) => boolean, cursor: string | undefined, options: McpRequestOptions, ): Promise<{ items: Record[]; nextCursor?: string }> { return validateListPage( method, key, await this.request(method, cursor === undefined ? undefined : { cursor }, options), isItem, ); } /** Every item of a paginated list method. */ private async listAll( method: string, key: string, isItem: (item: Record) => boolean, options: McpRequestOptions, ): Promise[]> { const items: Record[] = []; const cursors = new Set(); let cursor: string | undefined; for (let pageNumber = 0; pageNumber < MAX_LIST_PAGES; pageNumber++) { const page = await this.listPage(method, key, isItem, cursor, options); items.push(...page.items); if (page.nextCursor === undefined) return items; if (cursors.has(page.nextCursor)) throw new Error(`MCP ${method} returned duplicate cursor: ${page.nextCursor}`); cursors.add(page.nextCursor); cursor = page.nextCursor; } throw new Error(`MCP ${method} exceeded ${MAX_LIST_PAGES} pages`); } async callTool( name: string, args?: Record, options: McpRequestOptions = {}, ): Promise { return validateCallToolResult( await this.request("tools/call", { name, ...(args === undefined ? {} : { arguments: args }) }, options), ); } async close(): Promise { const transport = this.transport; this.transport = undefined; this.disposeTransportListeners(); this.markClosed(new McpConnectionClosedError()); await transport?.close(); } private async requestInternal( method: string, params: Record | undefined, options: McpRequestOptions, allowConnecting: boolean, ): Promise { const transport = this.requireTransport(allowConnecting); if (options.signal?.aborted) throw new McpAbortError(); const id = this.nextRequestId++; const progressToken = options.onProgress ? id : undefined; const requestParams = progressToken === undefined ? params : { ...params, _meta: { ...(isObject(params?._meta) ? params._meta : {}), progressToken } }; const message: JsonRpcRequest = { jsonrpc: "2.0", id, method, ...(requestParams === undefined ? {} : { params: requestParams }), }; return new Promise((resolve, reject) => { const entry: PendingRequest = { resolve, reject, timeoutMs: options.timeoutMs ?? this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, timer: undefined, signal: options.signal, // The spec forbids cancelling `initialize`. onAbort: () => this.cancelPending( id, new McpAbortError(), method !== "initialize", String(options.signal?.reason ?? "Aborted"), ), sendController: new AbortController(), cancellable: method !== "initialize", onProgress: options.onProgress, progressToken, }; this.pending.set(id, entry); if (progressToken !== undefined) this.progressRequests.set(progressToken, id); options.signal?.addEventListener("abort", entry.onAbort, { once: true }); this.armTimeout(id, entry); transport .send(message, { signal: entry.sendController.signal }) .catch((error) => this.cancelPending(id, error, false)); }); } private async notifyInternal( method: string, params: Record | undefined, allowConnecting: boolean, ): Promise { await this.requireTransport(allowConnecting).send({ jsonrpc: "2.0", method, ...(params === undefined ? {} : { params }), }); } private requireTransport(allowConnecting: boolean): McpTransport { if (this.transport && (this.state === "connected" || (allowConnecting && this.state === "connecting"))) { return this.transport; } throw new McpConnectionClosedError(`MCP client is ${this.state}`); } private handleMessage(message: JsonRpcMessage): void { if (isJsonRpcResponse(message)) { this.handleResponse(message); return; } if (isJsonRpcRequest(message)) { void this.handleRequest(message); return; } if (isJsonRpcNotification(message)) { this.handleNotification(message.method, message.params); return; } this.emitError(new McpError(JSON_RPC_ERROR_CODES.invalidRequest, "Received invalid JSON-RPC message")); } private handleResponse(message: JsonRpcResponse): void { const entry = this.pending.get(message.id); if (!entry) { this.emitError(new Error(`Received response for unknown MCP request ${String(message.id)}`)); return; } this.removePending(message.id, entry); if ("error" in message) entry.reject(new McpError(message.error.code, message.error.message, message.error.data)); else entry.resolve(message.result); } private async handleRequest(message: JsonRpcRequest): Promise { const transport = this.transport; if (!transport) return; const handler = this.requestHandlers.get(message.method); if (!handler) { await transport .send({ jsonrpc: "2.0", id: message.id, error: { code: JSON_RPC_ERROR_CODES.methodNotFound, message: `Method not found: ${message.method}` }, }) .catch((error) => this.emitError(error)); return; } const controller = new AbortController(); this.incoming.set(message.id, controller); try { const result = await handler(message.params, { signal: controller.signal }); await transport.send({ jsonrpc: "2.0", id: message.id, result: result ?? {} }); } catch (error) { const responseError = error instanceof McpError ? { code: error.code, message: error.message, data: error.data } : { code: JSON_RPC_ERROR_CODES.internalError, message: toError(error).message }; await transport .send({ jsonrpc: "2.0", id: message.id, error: responseError }) .catch((sendError) => this.emitError(sendError)); } finally { this.incoming.delete(message.id); } } private handleNotification(method: string, params: unknown): void { if (method === "notifications/progress") this.handleProgress(params); else if (method === "notifications/cancelled") this.handleCancelled(params); for (const listener of this.notificationListeners.get(method) ?? []) { try { listener(params); } catch (error) { this.emitError(error); } } } private handleProgress(params: unknown): void { if (!isObject(params) || !isJsonRpcId(params.progressToken) || typeof params.progress !== "number") return; const requestId = this.progressRequests.get(params.progressToken); const entry = requestId === undefined ? undefined : this.pending.get(requestId); if (requestId === undefined || !entry) return; this.armTimeout(requestId, entry); try { entry.onProgress?.(params as unknown as ProgressNotification); } catch (error) { this.emitError(error); } } private handleCancelled(params: unknown): void { if (isObject(params) && isJsonRpcId(params.requestId)) this.incoming.get(params.requestId)?.abort(params.reason); } private armTimeout(id: JsonRpcId, entry: PendingRequest): void { if (entry.timer) clearTimeout(entry.timer); if (!Number.isFinite(entry.timeoutMs) || entry.timeoutMs <= 0) return; entry.timer = setTimeout(() => { this.cancelPending(id, new McpTimeoutError(entry.timeoutMs), entry.cancellable, "Request timed out"); }, entry.timeoutMs); } private cancelPending(id: JsonRpcId, error: unknown, notifyServer: boolean, reason?: string): void { const entry = this.pending.get(id); if (!entry) return; this.removePending(id, entry); entry.reject(error); if (notifyServer && this.transport) { void this.transport .send({ jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: id, ...(reason ? { reason } : {}) }, }) .catch((sendError) => this.emitError(sendError)); } } private removePending(id: JsonRpcId, entry: PendingRequest): void { this.pending.delete(id); if (entry.timer) clearTimeout(entry.timer); if (entry.progressToken !== undefined) this.progressRequests.delete(entry.progressToken); entry.signal?.removeEventListener("abort", entry.onAbort); entry.sendController.abort(); } private rejectPending(error: unknown): void { for (const [id, entry] of this.pending) { this.removePending(id, entry); entry.reject(error); } } private handleTransportClose(): void { this.markClosed(new McpConnectionClosedError()); } /** Idempotent: rejects in-flight requests, aborts server requests we are serving, and flips the state. */ private markClosed(error: Error): void { const wasClosed = this.state === "closed"; this.state = "closed"; this.rejectPending(error); for (const controller of this.incoming.values()) controller.abort(error); this.incoming.clear(); if (wasClosed) return; for (const listener of [...this.closeListeners]) { try { listener(); } catch (listenerError) { this.emitError(listenerError); } } } private emitError(error: unknown): void { const normalized = toError(error); for (const listener of this.errorListeners) listener(normalized); } private disposeTransportListeners(): void { for (const dispose of this.disposers.splice(0)) dispose(); } }