Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
622 lines
21 KiB
TypeScript
622 lines
21 KiB
TypeScript
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<unknown>;
|
|
|
|
export interface McpClientOptions extends Implementation {
|
|
capabilities?: ClientCapabilities;
|
|
protocolVersion?: SupportedProtocolVersion;
|
|
requestTimeoutMs?: number;
|
|
roots?: readonly Root[] | (() => readonly Root[] | Promise<readonly Root[]>);
|
|
}
|
|
|
|
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<typeof setTimeout> | 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<string, unknown>) => boolean,
|
|
): { items: Record<string, unknown>[]; 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<string, unknown>) => 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<string, unknown>) =>
|
|
typeof resource.uri === "string" && (resource.name === undefined || typeof resource.name === "string");
|
|
const isResourceTemplate = (template: Record<string, unknown>) =>
|
|
typeof template.uriTemplate === "string" && (template.name === undefined || typeof template.name === "string");
|
|
|
|
function toResource(item: Record<string, unknown>): Resource {
|
|
return { ...item, name: item.name ?? item.uri } as Resource;
|
|
}
|
|
|
|
function toResourceTemplate(item: Record<string, unknown>): 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<McpClientOptions>;
|
|
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<JsonRpcId, PendingRequest>();
|
|
private progressRequests = new Map<JsonRpcId, JsonRpcId>();
|
|
private incoming = new Map<JsonRpcId, AbortController>();
|
|
private requestHandlers = new Map<string, RequestHandler>();
|
|
private notificationListeners = new Map<string, Set<NotificationListener>>();
|
|
private errorListeners = new Set<ErrorListener>();
|
|
private closeListeners = new Set<CloseListener>();
|
|
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<InitializeResult> {
|
|
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<Result = unknown>(
|
|
method: string,
|
|
params?: Record<string, unknown>,
|
|
options: McpRequestOptions = {},
|
|
): Promise<Result> {
|
|
return this.requestInternal(method, params, options, false) as Promise<Result>;
|
|
}
|
|
|
|
notify(method: string, params?: Record<string, unknown>): Promise<void> {
|
|
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<NotificationListener>();
|
|
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<void> {
|
|
await this.request("ping", undefined, options);
|
|
}
|
|
|
|
async listTools(options: McpRequestOptions = {}): Promise<Tool[]> {
|
|
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<Resource[]> {
|
|
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<ListResourcesResult> {
|
|
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<ResourceTemplate[]> {
|
|
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<ListResourceTemplatesResult> {
|
|
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<ReadResourceResult> {
|
|
return validateReadResourceResult(await this.request("resources/read", { uri }, options));
|
|
}
|
|
|
|
private async listPage(
|
|
method: string,
|
|
key: string,
|
|
isItem: (item: Record<string, unknown>) => boolean,
|
|
cursor: string | undefined,
|
|
options: McpRequestOptions,
|
|
): Promise<{ items: Record<string, unknown>[]; 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<string, unknown>) => boolean,
|
|
options: McpRequestOptions,
|
|
): Promise<Record<string, unknown>[]> {
|
|
const items: Record<string, unknown>[] = [];
|
|
const cursors = new Set<string>();
|
|
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<string, unknown>,
|
|
options: McpRequestOptions = {},
|
|
): Promise<CallToolResult> {
|
|
return validateCallToolResult(
|
|
await this.request("tools/call", { name, ...(args === undefined ? {} : { arguments: args }) }, options),
|
|
);
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
const transport = this.transport;
|
|
this.transport = undefined;
|
|
this.disposeTransportListeners();
|
|
this.markClosed(new McpConnectionClosedError());
|
|
await transport?.close();
|
|
}
|
|
|
|
private async requestInternal(
|
|
method: string,
|
|
params: Record<string, unknown> | undefined,
|
|
options: McpRequestOptions,
|
|
allowConnecting: boolean,
|
|
): Promise<unknown> {
|
|
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<unknown>((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<string, unknown> | undefined,
|
|
allowConnecting: boolean,
|
|
): Promise<void> {
|
|
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<void> {
|
|
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();
|
|
}
|
|
}
|