1
import type { CallToolResult } from "./protocol/content.ts";2
import {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";19
import {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";36
import type { McpTransport } from "./transports/transport.ts";38
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;39
const MAX_LIST_PAGES = 1_000;41
type ClientState = "idle" | "connecting" | "connected" | "closed";42
type NotificationListener = (params: unknown) => void;43
type ErrorListener = (error: Error) => void;44
type CloseListener = () => void;45
type RequestHandler = (params: unknown, context: { signal: AbortSignal }) => unknown | Promise<unknown>;47
export interface McpClientOptions extends Implementation {48
capabilities?: ClientCapabilities;49
protocolVersion?: SupportedProtocolVersion;50
requestTimeoutMs?: number;51
roots?: readonly Root[] | (() => readonly Root[] | Promise<readonly Root[]>);52
}54
export interface McpRequestOptions {55
signal?: AbortSignal;56
timeoutMs?: number;57
onProgress?: (progress: ProgressNotification) => void;58
}60
interface 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
}72
function 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
}87
function invalid(message: string): McpError {88
return new McpError(JSON_RPC_ERROR_CODES.invalidRequest, message);89
}91
/** One page of a paginated list: the items under `key`, each checked by `isItem`. */92
function 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
}109
const 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.111
const isResource = (resource: Record<string, unknown>) =>112
typeof resource.uri === "string" && (resource.name === undefined || typeof resource.name === "string");113
const isResourceTemplate = (template: Record<string, unknown>) =>114
typeof template.uriTemplate === "string" && (template.name === undefined || typeof template.name === "string");116
function toResource(item: Record<string, unknown>): Resource {117
return { ...item, name: item.name ?? item.uri } as Resource;118
}120
function toResourceTemplate(item: Record<string, unknown>): ResourceTemplate {121
return { ...item, name: item.name ?? item.uriTemplate } as ResourceTemplate;122
}124
function pageCursor(page: { nextCursor?: string }): { nextCursor?: string } {125
return page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor };126
}128
function 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
}142
/** `content` is required by the spec, but servers that only return `structuredContent` omit it (the SDK defaults it too). */143
function 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
}153
export 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)[] = [];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
}182
get connectionState(): ClientState {183
return this.state;184
}186
get serverInfo(): Implementation | undefined {187
return this.serverInfoValue;188
}190
get serverCapabilities(): ServerCapabilities | undefined {191
return this.serverCapabilitiesValue;192
}194
get instructions(): string | undefined {195
return this.instructionsValue;196
}198
get protocolVersion(): string | undefined {199
return this.protocolVersionValue;200
}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
];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
}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
}258
notify(method: string, params?: Record<string, unknown>): Promise<void> {259
return this.notifyInternal(method, params, false);260
}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
}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
}279
onError(listener: ErrorListener): () => void {280
this.errorListeners.add(listener);281
return () => this.errorListeners.delete(listener);282
}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
}290
async ping(options: McpRequestOptions = {}): Promise<void> {291
await this.request("ping", undefined, options);292
}294
async listTools(options: McpRequestOptions = {}): Promise<Tool[]> {295
return (await this.listAll("tools/list", "tools", isTool, options)) as unknown as Tool[];296
}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
}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
}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
}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
}335
async readResource(uri: string, options: McpRequestOptions = {}): Promise<ReadResourceResult> {336
return validateReadResourceResult(await this.request("resources/read", { uri }, options));337
}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
}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
}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
}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
}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 === undefined406
? params407
: { ...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
}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
}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
}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
}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
}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 transport493
.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 McpError509
? { code: error.code, message: error.message, data: error.data }510
: { code: JSON_RPC_ERROR_CODES.internalError, message: toError(error).message };511
await transport512
.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
}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
}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
}544
private handleCancelled(params: unknown): void {545
if (isObject(params) && isJsonRpcId(params.requestId)) this.incoming.get(params.requestId)?.abort(params.reason);546
}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
}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.transport563
.send({564
jsonrpc: "2.0",565
method: "notifications/cancelled",566
params: { requestId: id, ...(reason ? { reason } : {}) },567
})568
.catch((sendError) => this.emitError(sendError));569
}570
}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
}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
}586
private handleTransportClose(): void {587
this.markClosed(new McpConnectionClosedError());588
}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
}607
private emitError(error: unknown): void {608
const normalized = toError(error);609
for (const listener of this.errorListeners) listener(normalized);610
}612
private disposeTransportListeners(): void {613
for (const dispose of this.disposers.splice(0)) dispose();614
}615
}