返回源码地图

packages/mcp/src/client.ts

v1.0.0 · a13d35a742c6 · 09:独立 MCP client 契约;非所有协议分支审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1import type { CallToolResult } from "./protocol/content.ts";
2import {
3 isJsonRpcId,
4 isJsonRpcNotification,
5 isJsonRpcRequest,
6 isJsonRpcResponse,
7 isObject,
8 JSON_RPC_ERROR_CODES,
9 type JsonRpcId,
10 type JsonRpcMessage,
11 type JsonRpcRequest,
12 type JsonRpcResponse,
13 McpAbortError,
14 McpConnectionClosedError,
15 McpError,
16 McpTimeoutError,
17 toError,
18} from "./protocol/jsonrpc.ts";
19import {
20 type ClientCapabilities,
21 type Implementation,
22 type InitializeResult,
23 LATEST_PROTOCOL_VERSION,
24 type ListResourcesResult,
25 type ListResourceTemplatesResult,
26 type ProgressNotification,
27 type ReadResourceResult,
28 type Resource,
29 type ResourceTemplate,
30 type Root,
31 type ServerCapabilities,
32 SUPPORTED_PROTOCOL_VERSIONS,
33 type SupportedProtocolVersion,
34 type Tool,
35} from "./protocol/types.ts";
36import type { McpTransport } from "./transports/transport.ts";
37
38const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
39const MAX_LIST_PAGES = 1_000;
40
41type ClientState = "idle" | "connecting" | "connected" | "closed";
42type NotificationListener = (params: unknown) => void;
43type ErrorListener = (error: Error) => void;
44type CloseListener = () => void;
45type RequestHandler = (params: unknown, context: { signal: AbortSignal }) => unknown | Promise<unknown>;
46
47export interface McpClientOptions extends Implementation {
48 capabilities?: ClientCapabilities;
49 protocolVersion?: SupportedProtocolVersion;
50 requestTimeoutMs?: number;
51 roots?: readonly Root[] | (() => readonly Root[] | Promise<readonly Root[]>);
52}
53
54export interface McpRequestOptions {
55 signal?: AbortSignal;
56 timeoutMs?: number;
57 onProgress?: (progress: ProgressNotification) => void;
58}
59
60interface PendingRequest {
61 resolve: (value: unknown) => void;
62 reject: (reason: unknown) => void;
63 timeoutMs: number;
64 timer: ReturnType<typeof setTimeout> | undefined;
65 signal: AbortSignal | undefined;
66 onAbort: () => void;
67 cancellable: boolean;
68 onProgress: ((progress: ProgressNotification) => void) | undefined;
69 progressToken: JsonRpcId | undefined;
70}
71
72function validateInitializeResult(value: unknown): InitializeResult {
73 if (
74 !isObject(value) ||
75 typeof value.protocolVersion !== "string" ||
76 !isObject(value.capabilities) ||
77 !isObject(value.serverInfo) ||
78 typeof value.serverInfo.name !== "string" ||
79 typeof value.serverInfo.version !== "string" ||
80 (value.instructions !== undefined && typeof value.instructions !== "string")
81 ) {
82 throw new McpError(JSON_RPC_ERROR_CODES.invalidRequest, "Invalid MCP initialize result");
83 }
84 return value as unknown as InitializeResult;
85}
86
87function invalid(message: string): McpError {
88 return new McpError(JSON_RPC_ERROR_CODES.invalidRequest, message);
89}
90
91/** One page of a paginated list: the items under `key`, each checked by `isItem`. */
92function validateListPage(
93 method: string,
94 key: string,
95 value: unknown,
96 isItem: (item: Record<string, unknown>) => boolean,
97): { items: Record<string, unknown>[]; nextCursor?: string } {
98 const items = isObject(value) ? value[key] : undefined;
99 if (!isObject(value) || !Array.isArray(items)) throw invalid(`Invalid MCP ${method} result`);
100 for (const item of items) {
101 if (!isObject(item) || !isItem(item)) throw invalid(`Invalid entry in MCP ${method} result`);
102 }
103 // Some servers end pagination with `null` or `""` instead of omitting the cursor.
104 const nextCursor = value.nextCursor === null || value.nextCursor === "" ? undefined : value.nextCursor;
105 if (nextCursor !== undefined && typeof nextCursor !== "string") throw invalid(`Invalid MCP ${method} cursor`);
106 return { items, ...(nextCursor === undefined ? {} : { nextCursor }) };
107}
108
109const isTool = (tool: Record<string, unknown>) => typeof tool.name === "string" && isObject(tool.inputSchema);
110// `name` is required by the spec, but some servers omit it; the URI stands in.
111const isResource = (resource: Record<string, unknown>) =>
112 typeof resource.uri === "string" && (resource.name === undefined || typeof resource.name === "string");
113const isResourceTemplate = (template: Record<string, unknown>) =>
114 typeof template.uriTemplate === "string" && (template.name === undefined || typeof template.name === "string");
115
116function toResource(item: Record<string, unknown>): Resource {
117 return { ...item, name: item.name ?? item.uri } as Resource;
118}
119
120function toResourceTemplate(item: Record<string, unknown>): ResourceTemplate {
121 return { ...item, name: item.name ?? item.uriTemplate } as ResourceTemplate;
122}
123
124function pageCursor(page: { nextCursor?: string }): { nextCursor?: string } {
125 return page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor };
126}
127
128function validateReadResourceResult(value: unknown): ReadResourceResult {
129 if (!isObject(value) || !Array.isArray(value.contents)) throw invalid("Invalid MCP resources/read result");
130 for (const contents of value.contents) {
131 if (
132 !isObject(contents) ||
133 typeof contents.uri !== "string" ||
134 (typeof contents.text !== "string" && typeof contents.blob !== "string")
135 ) {
136 throw invalid("Invalid contents in MCP resources/read result");
137 }
138 }
139 return value as unknown as ReadResourceResult;
140}
141
142/** `content` is required by the spec, but servers that only return `structuredContent` omit it (the SDK defaults it too). */
143function validateCallToolResult(value: unknown): CallToolResult {
144 if (!isObject(value) || (value.content !== undefined && !Array.isArray(value.content))) {
145 throw new McpError(JSON_RPC_ERROR_CODES.invalidRequest, "Invalid MCP tools/call result");
146 }
147 if (value.structuredContent !== undefined && !isObject(value.structuredContent)) {
148 throw new McpError(JSON_RPC_ERROR_CODES.invalidRequest, "Invalid MCP tools/call structured content");
149 }
150 return (value.content === undefined ? { ...value, content: [] } : value) as unknown as CallToolResult;
151}
152
153export class McpClient {
154 readonly options: Readonly<McpClientOptions>;
155 private state: ClientState = "idle";
156 private transport: McpTransport | undefined;
157 private nextRequestId = 1;
158 private serverInfoValue: Implementation | undefined;
159 private serverCapabilitiesValue: ServerCapabilities | undefined;
160 private instructionsValue: string | undefined;
161 private protocolVersionValue: string | undefined;
162 private pending = new Map<JsonRpcId, PendingRequest>();
163 private progressRequests = new Map<JsonRpcId, JsonRpcId>();
164 private incoming = new Map<JsonRpcId, AbortController>();
165 private requestHandlers = new Map<string, RequestHandler>();
166 private notificationListeners = new Map<string, Set<NotificationListener>>();
167 private errorListeners = new Set<ErrorListener>();
168 private closeListeners = new Set<CloseListener>();
169 private disposers: (() => void)[] = [];
170
171 constructor(options: McpClientOptions) {
172 this.options = Object.freeze({ ...options });
173 this.requestHandlers.set("ping", () => ({}));
174 const roots = options.roots;
175 if (roots) {
176 this.requestHandlers.set("roots/list", async () => ({
177 roots: [...(typeof roots === "function" ? await roots() : roots)],
178 }));
179 }
180 }
181
182 get connectionState(): ClientState {
183 return this.state;
184 }
185
186 get serverInfo(): Implementation | undefined {
187 return this.serverInfoValue;
188 }
189
190 get serverCapabilities(): ServerCapabilities | undefined {
191 return this.serverCapabilitiesValue;
192 }
193
194 get instructions(): string | undefined {
195 return this.instructionsValue;
196 }
197
198 get protocolVersion(): string | undefined {
199 return this.protocolVersionValue;
200 }
201
202 async connect(transport: McpTransport): Promise<InitializeResult> {
203 if (this.state !== "idle") throw new Error(`Cannot connect MCP client in ${this.state} state`);
204 this.state = "connecting";
205 this.transport = transport;
206 this.disposers = [
207 transport.onMessage((message) => this.handleMessage(message)),
208 // Transport errors are reported only. Pending requests fail when the transport closes.
209 transport.onError((error) => this.emitError(error)),
210 transport.onClose(() => this.handleTransportClose()),
211 ];
212
213 try {
214 await transport.start();
215 const capabilities: ClientCapabilities = { ...this.options.capabilities };
216 if (this.options.roots && capabilities.roots === undefined) capabilities.roots = {};
217 const result = validateInitializeResult(
218 await this.requestInternal(
219 "initialize",
220 {
221 protocolVersion: this.options.protocolVersion ?? LATEST_PROTOCOL_VERSION,
222 capabilities,
223 clientInfo: {
224 name: this.options.name,
225 version: this.options.version,
226 ...(this.options.title === undefined ? {} : { title: this.options.title }),
227 },
228 },
229 {},
230 true,
231 ),
232 );
233 if (!(SUPPORTED_PROTOCOL_VERSIONS as readonly string[]).includes(result.protocolVersion)) {
234 throw new Error(`MCP server selected unsupported protocol version ${result.protocolVersion}`);
235 }
236 this.protocolVersionValue = result.protocolVersion;
237 this.serverInfoValue = result.serverInfo;
238 this.serverCapabilitiesValue = result.capabilities;
239 this.instructionsValue = result.instructions;
240 transport.setProtocolVersion?.(result.protocolVersion);
241 await this.notifyInternal("notifications/initialized", undefined, true);
242 this.state = "connected";
243 return result;
244 } catch (error) {
245 await this.close().catch(() => {});
246 throw error;
247 }
248 }
249
250 request<Result = unknown>(
251 method: string,
252 params?: Record<string, unknown>,
253 options: McpRequestOptions = {},
254 ): Promise<Result> {
255 return this.requestInternal(method, params, options, false) as Promise<Result>;
256 }
257
258 notify(method: string, params?: Record<string, unknown>): Promise<void> {
259 return this.notifyInternal(method, params, false);
260 }
261
262 setRequestHandler(method: string, handler: RequestHandler): () => void {
263 this.requestHandlers.set(method, handler);
264 return () => {
265 if (this.requestHandlers.get(method) === handler) this.requestHandlers.delete(method);
266 };
267 }
268
269 onNotification(method: string, listener: NotificationListener): () => void {
270 const listeners = this.notificationListeners.get(method) ?? new Set<NotificationListener>();
271 this.notificationListeners.set(method, listeners);
272 listeners.add(listener);
273 return () => {
274 listeners.delete(listener);
275 if (listeners.size === 0) this.notificationListeners.delete(method);
276 };
277 }
278
279 onError(listener: ErrorListener): () => void {
280 this.errorListeners.add(listener);
281 return () => this.errorListeners.delete(listener);
282 }
283
284 /** Called once when the connection closes, whether the transport dropped or `close()` was called. */
285 onClose(listener: CloseListener): () => void {
286 this.closeListeners.add(listener);
287 return () => this.closeListeners.delete(listener);
288 }
289
290 async ping(options: McpRequestOptions = {}): Promise<void> {
291 await this.request("ping", undefined, options);
292 }
293
294 async listTools(options: McpRequestOptions = {}): Promise<Tool[]> {
295 return (await this.listAll("tools/list", "tools", isTool, options)) as unknown as Tool[];
296 }
297
298 /** Every resource, following `nextCursor` through all pages. */
299 async listResources(options: McpRequestOptions = {}): Promise<Resource[]> {
300 return (await this.listAll("resources/list", "resources", isResource, options)).map(toResource);
301 }
302
303 /** One page of resources, starting at `cursor`. */
304 async listResourcesPage(cursor?: string, options: McpRequestOptions = {}): Promise<ListResourcesResult> {
305 const page = await this.listPage("resources/list", "resources", isResource, cursor, options);
306 return { resources: page.items.map(toResource), ...pageCursor(page) };
307 }
308
309 /** Every resource template, following `nextCursor` through all pages. */
310 async listResourceTemplates(options: McpRequestOptions = {}): Promise<ResourceTemplate[]> {
311 const templates = await this.listAll(
312 "resources/templates/list",
313 "resourceTemplates",
314 isResourceTemplate,
315 options,
316 );
317 return templates.map(toResourceTemplate);
318 }
319
320 /** One page of resource templates, starting at `cursor`. */
321 async listResourceTemplatesPage(
322 cursor?: string,
323 options: McpRequestOptions = {},
324 ): Promise<ListResourceTemplatesResult> {
325 const page = await this.listPage(
326 "resources/templates/list",
327 "resourceTemplates",
328 isResourceTemplate,
329 cursor,
330 options,
331 );
332 return { resourceTemplates: page.items.map(toResourceTemplate), ...pageCursor(page) };
333 }
334
335 async readResource(uri: string, options: McpRequestOptions = {}): Promise<ReadResourceResult> {
336 return validateReadResourceResult(await this.request("resources/read", { uri }, options));
337 }
338
339 private async listPage(
340 method: string,
341 key: string,
342 isItem: (item: Record<string, unknown>) => boolean,
343 cursor: string | undefined,
344 options: McpRequestOptions,
345 ): Promise<{ items: Record<string, unknown>[]; nextCursor?: string }> {
346 return validateListPage(
347 method,
348 key,
349 await this.request(method, cursor === undefined ? undefined : { cursor }, options),
350 isItem,
351 );
352 }
353
354 /** Every item of a paginated list method. */
355 private async listAll(
356 method: string,
357 key: string,
358 isItem: (item: Record<string, unknown>) => boolean,
359 options: McpRequestOptions,
360 ): Promise<Record<string, unknown>[]> {
361 const items: Record<string, unknown>[] = [];
362 const cursors = new Set<string>();
363 let cursor: string | undefined;
364 for (let pageNumber = 0; pageNumber < MAX_LIST_PAGES; pageNumber++) {
365 const page = await this.listPage(method, key, isItem, cursor, options);
366 items.push(...page.items);
367 if (page.nextCursor === undefined) return items;
368 if (cursors.has(page.nextCursor))
369 throw new Error(`MCP ${method} returned duplicate cursor: ${page.nextCursor}`);
370 cursors.add(page.nextCursor);
371 cursor = page.nextCursor;
372 }
373 throw new Error(`MCP ${method} exceeded ${MAX_LIST_PAGES} pages`);
374 }
375
376 async callTool(
377 name: string,
378 args?: Record<string, unknown>,
379 options: McpRequestOptions = {},
380 ): Promise<CallToolResult> {
381 return validateCallToolResult(
382 await this.request("tools/call", { name, ...(args === undefined ? {} : { arguments: args }) }, options),
383 );
384 }
385
386 async close(): Promise<void> {
387 const transport = this.transport;
388 this.transport = undefined;
389 this.disposeTransportListeners();
390 this.markClosed(new McpConnectionClosedError());
391 await transport?.close();
392 }
393
394 private async requestInternal(
395 method: string,
396 params: Record<string, unknown> | undefined,
397 options: McpRequestOptions,
398 allowConnecting: boolean,
399 ): Promise<unknown> {
400 const transport = this.requireTransport(allowConnecting);
401 if (options.signal?.aborted) throw new McpAbortError();
402 const id = this.nextRequestId++;
403 const progressToken = options.onProgress ? id : undefined;
404 const requestParams =
405 progressToken === undefined
406 ? params
407 : { ...params, _meta: { ...(isObject(params?._meta) ? params._meta : {}), progressToken } };
408 const message: JsonRpcRequest = {
409 jsonrpc: "2.0",
410 id,
411 method,
412 ...(requestParams === undefined ? {} : { params: requestParams }),
413 };
414 return new Promise<unknown>((resolve, reject) => {
415 const entry: PendingRequest = {
416 resolve,
417 reject,
418 timeoutMs: options.timeoutMs ?? this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
419 timer: undefined,
420 signal: options.signal,
421 // The spec forbids cancelling `initialize`.
422 onAbort: () =>
423 this.cancelPending(
424 id,
425 new McpAbortError(),
426 method !== "initialize",
427 String(options.signal?.reason ?? "Aborted"),
428 ),
429 cancellable: method !== "initialize",
430 onProgress: options.onProgress,
431 progressToken,
432 };
433 this.pending.set(id, entry);
434 if (progressToken !== undefined) this.progressRequests.set(progressToken, id);
435 options.signal?.addEventListener("abort", entry.onAbort, { once: true });
436 this.armTimeout(id, entry);
437 transport.send(message).catch((error) => this.cancelPending(id, error, false));
438 });
439 }
440
441 private async notifyInternal(
442 method: string,
443 params: Record<string, unknown> | undefined,
444 allowConnecting: boolean,
445 ): Promise<void> {
446 await this.requireTransport(allowConnecting).send({
447 jsonrpc: "2.0",
448 method,
449 ...(params === undefined ? {} : { params }),
450 });
451 }
452
453 private requireTransport(allowConnecting: boolean): McpTransport {
454 if (this.transport && (this.state === "connected" || (allowConnecting && this.state === "connecting"))) {
455 return this.transport;
456 }
457 throw new McpConnectionClosedError(`MCP client is ${this.state}`);
458 }
459
460 private handleMessage(message: JsonRpcMessage): void {
461 if (isJsonRpcResponse(message)) {
462 this.handleResponse(message);
463 return;
464 }
465 if (isJsonRpcRequest(message)) {
466 void this.handleRequest(message);
467 return;
468 }
469 if (isJsonRpcNotification(message)) {
470 this.handleNotification(message.method, message.params);
471 return;
472 }
473 this.emitError(new McpError(JSON_RPC_ERROR_CODES.invalidRequest, "Received invalid JSON-RPC message"));
474 }
475
476 private handleResponse(message: JsonRpcResponse): void {
477 const entry = this.pending.get(message.id);
478 if (!entry) {
479 this.emitError(new Error(`Received response for unknown MCP request ${String(message.id)}`));
480 return;
481 }
482 this.removePending(message.id, entry);
483 if ("error" in message) entry.reject(new McpError(message.error.code, message.error.message, message.error.data));
484 else entry.resolve(message.result);
485 }
486
487 private async handleRequest(message: JsonRpcRequest): Promise<void> {
488 const transport = this.transport;
489 if (!transport) return;
490 const handler = this.requestHandlers.get(message.method);
491 if (!handler) {
492 await transport
493 .send({
494 jsonrpc: "2.0",
495 id: message.id,
496 error: { code: JSON_RPC_ERROR_CODES.methodNotFound, message: `Method not found: ${message.method}` },
497 })
498 .catch((error) => this.emitError(error));
499 return;
500 }
501 const controller = new AbortController();
502 this.incoming.set(message.id, controller);
503 try {
504 const result = await handler(message.params, { signal: controller.signal });
505 await transport.send({ jsonrpc: "2.0", id: message.id, result: result ?? {} });
506 } catch (error) {
507 const responseError =
508 error instanceof McpError
509 ? { code: error.code, message: error.message, data: error.data }
510 : { code: JSON_RPC_ERROR_CODES.internalError, message: toError(error).message };
511 await transport
512 .send({ jsonrpc: "2.0", id: message.id, error: responseError })
513 .catch((sendError) => this.emitError(sendError));
514 } finally {
515 this.incoming.delete(message.id);
516 }
517 }
518
519 private handleNotification(method: string, params: unknown): void {
520 if (method === "notifications/progress") this.handleProgress(params);
521 else if (method === "notifications/cancelled") this.handleCancelled(params);
522 for (const listener of this.notificationListeners.get(method) ?? []) {
523 try {
524 listener(params);
525 } catch (error) {
526 this.emitError(error);
527 }
528 }
529 }
530
531 private handleProgress(params: unknown): void {
532 if (!isObject(params) || !isJsonRpcId(params.progressToken) || typeof params.progress !== "number") return;
533 const requestId = this.progressRequests.get(params.progressToken);
534 const entry = requestId === undefined ? undefined : this.pending.get(requestId);
535 if (requestId === undefined || !entry) return;
536 this.armTimeout(requestId, entry);
537 try {
538 entry.onProgress?.(params as unknown as ProgressNotification);
539 } catch (error) {
540 this.emitError(error);
541 }
542 }
543
544 private handleCancelled(params: unknown): void {
545 if (isObject(params) && isJsonRpcId(params.requestId)) this.incoming.get(params.requestId)?.abort(params.reason);
546 }
547
548 private armTimeout(id: JsonRpcId, entry: PendingRequest): void {
549 if (entry.timer) clearTimeout(entry.timer);
550 if (!Number.isFinite(entry.timeoutMs) || entry.timeoutMs <= 0) return;
551 entry.timer = setTimeout(() => {
552 this.cancelPending(id, new McpTimeoutError(entry.timeoutMs), entry.cancellable, "Request timed out");
553 }, entry.timeoutMs);
554 }
555
556 private cancelPending(id: JsonRpcId, error: unknown, notifyServer: boolean, reason?: string): void {
557 const entry = this.pending.get(id);
558 if (!entry) return;
559 this.removePending(id, entry);
560 entry.reject(error);
561 if (notifyServer && this.transport) {
562 void this.transport
563 .send({
564 jsonrpc: "2.0",
565 method: "notifications/cancelled",
566 params: { requestId: id, ...(reason ? { reason } : {}) },
567 })
568 .catch((sendError) => this.emitError(sendError));
569 }
570 }
571
572 private removePending(id: JsonRpcId, entry: PendingRequest): void {
573 this.pending.delete(id);
574 if (entry.timer) clearTimeout(entry.timer);
575 if (entry.progressToken !== undefined) this.progressRequests.delete(entry.progressToken);
576 entry.signal?.removeEventListener("abort", entry.onAbort);
577 }
578
579 private rejectPending(error: unknown): void {
580 for (const [id, entry] of this.pending) {
581 this.removePending(id, entry);
582 entry.reject(error);
583 }
584 }
585
586 private handleTransportClose(): void {
587 this.markClosed(new McpConnectionClosedError());
588 }
589
590 /** Idempotent: rejects in-flight requests, aborts server requests we are serving, and flips the state. */
591 private markClosed(error: Error): void {
592 const wasClosed = this.state === "closed";
593 this.state = "closed";
594 this.rejectPending(error);
595 for (const controller of this.incoming.values()) controller.abort(error);
596 this.incoming.clear();
597 if (wasClosed) return;
598 for (const listener of [...this.closeListeners]) {
599 try {
600 listener();
601 } catch (listenerError) {
602 this.emitError(listenerError);
603 }
604 }
605 }
606
607 private emitError(error: unknown): void {
608 const normalized = toError(error);
609 for (const listener of this.errorListeners) listener(normalized);
610 }
611
612 private disposeTransportListeners(): void {
613 for (const dispose of this.disposers.splice(0)) dispose();
614 }
615}