chore(vendor): import pi 1.3.0

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
This commit is contained in:
alexion committed 2026-10-15 17:58:48 -04:00
1 parent 719370c8f7
commit 6081e3e325
1743 files changed
+205459 -131704

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
import { type JsonRpcMessage, McpConnectionClosedError } from "../protocol/jsonrpc.ts";
import { type McpTransport, TransportEvents } from "./transport.ts";
export class InMemoryTransport extends TransportEvents implements McpTransport {
private peer: InMemoryTransport | undefined;
private started = false;
private closed = false;
connectPeer(peer: InMemoryTransport): void {
if (this.peer) throw new Error("In-memory MCP transport already has a peer");
this.peer = peer;
}
async start(): Promise<void> {
if (this.closed) throw new McpConnectionClosedError();
this.started = true;
}
async send(message: JsonRpcMessage): Promise<void> {
if (!this.started || this.closed) throw new McpConnectionClosedError();
const peer = this.peer;
if (!peer?.started || peer.closed) throw new McpConnectionClosedError("In-memory MCP peer is not connected");
const copy = structuredClone(message);
queueMicrotask(() => peer.deliver(copy));
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
this.emitClose();
await this.peer?.close();
}
/** Exposed so tests can simulate transport-level failures. */
override emitError(error: unknown): void {
super.emitError(error);
}
private deliver(message: JsonRpcMessage): void {
if (this.closed) return;
this.emitMessage(message);
}
}
export function createInMemoryTransportPair(): { client: InMemoryTransport; server: InMemoryTransport } {
const client = new InMemoryTransport();
const server = new InMemoryTransport();
client.connectPeer(server);
server.connectPeer(client);
return { client, server };
}
+216
View File
@@ -0,0 +1,216 @@
import { type ChildProcess, spawn } from "node:child_process";
import process from "node:process";
import crossSpawn from "cross-spawn";
import { type JsonRpcMessage, McpConnectionClosedError, parseJsonRpcMessage } from "../protocol/jsonrpc.ts";
import { DEFAULT_MAX_MESSAGE_BYTES, type McpTransport, TransportEvents } from "./transport.ts";
const DEFAULT_MAX_STDERR_BYTES = 64 * 1024;
const DEFAULT_CLOSE_TIMEOUT_MS = 2_000;
/** How long a server gets to exit on its own after stdin closes, before it is sent SIGTERM. */
const STDIN_CLOSE_GRACE_MS = 500;
const USE_PROCESS_GROUPS = process.platform !== "win32";
/** Process groups of running servers, killed if the host exits without closing them. */
const liveProcessGroups = new Set<number>();
let exitHookInstalled = false;
function killProcessTree(child: ChildProcess, signal: NodeJS.Signals): void {
const pid = child.pid;
if (process.platform === "win32" && pid !== undefined) {
if (child.exitCode !== null) return;
// Windows has no graceful signals, and cross-spawn runs `.cmd` shims through cmd.exe; killing only
// that would leave the server running.
spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }).on(
"error",
() => {},
);
return;
}
if (USE_PROCESS_GROUPS && pid !== undefined) {
try {
// Negative pid: the whole group, so wrappers like `npx` or `uvx` do not leave the server behind.
process.kill(-pid, signal);
return;
} catch {
// The group is gone or was never created; fall back to the direct child.
}
}
try {
child.kill(signal);
} catch {}
}
function installExitHook(): void {
if (exitHookInstalled) return;
exitHookInstalled = true;
process.once("exit", () => {
for (const pid of liveProcessGroups) {
try {
process.kill(-pid, "SIGTERM");
} catch {}
}
});
}
export interface StdioTransportOptions {
command: string;
args?: readonly string[];
cwd?: string;
env?: Record<string, string>;
inheritEnv?: boolean;
stderr?: "pipe" | "inherit";
onStderr?: (chunk: string) => void;
maxMessageBytes?: number;
maxStderrBytes?: number;
/** Time to wait for the server to exit after SIGTERM before sending SIGKILL. Default: 2000. */
closeTimeoutMs?: number;
}
export class StdioTransport extends TransportEvents implements McpTransport {
readonly options: Readonly<StdioTransportOptions>;
private child: ChildProcess | undefined;
private stdoutBuffer = Buffer.alloc(0);
private stderrBuffer = Buffer.alloc(0);
private started = false;
private closed = false;
constructor(options: StdioTransportOptions) {
super();
this.options = Object.freeze({ ...options, args: options.args ? [...options.args] : undefined });
}
get pid(): number | undefined {
return this.child?.pid;
}
get stderr(): string {
return this.stderrBuffer.toString("utf8");
}
async start(): Promise<void> {
if (this.started) throw new Error("MCP stdio transport already started");
if (this.closed) throw new McpConnectionClosedError();
this.started = true;
const env = this.options.inheritEnv === false ? { ...this.options.env } : { ...process.env, ...this.options.env };
const child = crossSpawn(this.options.command, this.options.args ?? [], {
cwd: this.options.cwd,
env,
stdio: ["pipe", "pipe", this.options.stderr === "inherit" ? "inherit" : "pipe"],
windowsHide: true,
// Own process group, so closing the transport can terminate the server's children too.
detached: USE_PROCESS_GROUPS,
});
this.child = child;
const pid = child.pid;
if (USE_PROCESS_GROUPS && pid !== undefined) {
installExitHook();
liveProcessGroups.add(pid);
child.once("exit", () => liveProcessGroups.delete(pid));
}
child.stdout?.on("data", (chunk: Buffer | string) => this.handleStdout(chunk));
child.stdout?.on("error", (error) => this.emitError(error));
child.stdin?.on("error", (error) => {
if (!this.closed) this.emitError(error);
});
child.stderr?.on("data", (chunk: Buffer | string) => this.handleStderr(chunk));
child.stderr?.on("error", (error) => this.emitError(error));
child.on("close", () => {
this.child = undefined;
if (this.stdoutBuffer.toString("utf8").trim()) {
this.emitError(new Error("MCP stdio server closed with an incomplete JSON-RPC message"));
}
this.stdoutBuffer = Buffer.alloc(0);
this.emitClose();
});
await new Promise<void>((resolve, reject) => {
const onSpawn = () => {
child.off("error", onError);
resolve();
};
const onError = (error: Error) => {
child.off("spawn", onSpawn);
reject(error);
};
child.once("spawn", onSpawn);
child.once("error", onError);
});
child.on("error", (error) => {
if (!this.closed) this.emitError(error);
});
}
async send(message: JsonRpcMessage): Promise<void> {
const stdin = this.child?.stdin;
if (!this.started || this.closed || !stdin?.writable) throw new McpConnectionClosedError();
const payload = `${JSON.stringify(message)}\n`;
await new Promise<void>((resolve, reject) => {
stdin.write(payload, (error) => (error ? reject(error) : resolve()));
});
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
const child = this.child;
if (!child) {
this.emitClose();
return;
}
if (child.exitCode !== null || child.signalCode !== null) {
child.stdin?.end();
return;
}
// Shutdown per the spec: close stdin and let the server exit, then SIGTERM, then SIGKILL.
const closeTimeoutMs = this.options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
await new Promise<void>((resolve) => {
const timers: ReturnType<typeof setTimeout>[] = [];
child.once("close", () => {
for (const timer of timers) clearTimeout(timer);
// Children of the server that ignored stdin closing would otherwise outlive it.
killProcessTree(child, "SIGTERM");
resolve();
});
const grace = Math.min(STDIN_CLOSE_GRACE_MS, closeTimeoutMs);
timers.push(setTimeout(() => killProcessTree(child, "SIGTERM"), grace));
timers.push(setTimeout(() => killProcessTree(child, "SIGKILL"), grace + closeTimeoutMs));
child.stdin?.end();
});
}
private handleStdout(chunk: Buffer | string): void {
this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
const maxMessageBytes = this.options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
while (true) {
const newline = this.stdoutBuffer.indexOf(0x0a);
if (newline < 0) {
if (this.stdoutBuffer.length > maxMessageBytes) {
this.stdoutBuffer = Buffer.alloc(0);
this.emitError(new Error(`MCP stdio message exceeds ${maxMessageBytes} bytes`));
}
return;
}
const line = this.stdoutBuffer.subarray(0, newline);
this.stdoutBuffer = this.stdoutBuffer.subarray(newline + 1);
if (line.length > maxMessageBytes) {
this.emitError(new Error(`MCP stdio message exceeds ${maxMessageBytes} bytes`));
continue;
}
const text = line.toString("utf8").replace(/\r$/, "");
if (!text.trim()) continue;
try {
this.emitMessage(parseJsonRpcMessage(JSON.parse(text)));
} catch (error) {
this.emitError(error);
}
}
}
private handleStderr(chunk: Buffer | string): void {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
const maxStderrBytes = this.options.maxStderrBytes ?? DEFAULT_MAX_STDERR_BYTES;
this.stderrBuffer = Buffer.concat([this.stderrBuffer, buffer]);
if (this.stderrBuffer.length > maxStderrBytes) this.stderrBuffer = this.stderrBuffer.subarray(-maxStderrBytes);
this.options.onStderr?.(buffer.toString("utf8"));
}
}
@@ -0,0 +1,521 @@
import type { AuthProvider, McpFetch } from "../auth-provider.ts";
import {
isJsonRpcRequest,
isJsonRpcResponse,
JSON_RPC_ERROR_CODES,
type JsonRpcId,
type JsonRpcMessage,
McpAbortError,
McpConnectionClosedError,
parseJsonRpcMessage,
toError,
} from "../protocol/jsonrpc.ts";
import {
DEFAULT_MAX_MESSAGE_BYTES,
type McpTransport,
type McpTransportSendOptions,
TransportEvents,
} from "./transport.ts";
const MAX_ERROR_BODY_BYTES = 8 * 1024;
const ERROR_MESSAGE_BODY_CHARS = 500;
const DEFAULT_RECONNECT_INITIAL_DELAY_MS = 1_000;
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30_000;
const DEFAULT_RECONNECT_MAX_RETRIES = 5;
export interface SseEvent {
event?: string;
data: string;
id?: string;
}
export interface ConsumeSseOptions {
maxEventBytes?: number;
onEvent(event: SseEvent): void;
/** Called for every `id` field, including events without data (for example resumption priming events). */
onId?(id: string): void;
/** Called for every valid `retry` field, in milliseconds. */
onRetry?(delayMs: number): void;
}
export async function consumeSseStream(stream: ReadableStream<Uint8Array>, options: ConsumeSseOptions): Promise<void> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffered = "";
let eventName: string | undefined;
let eventId: string | undefined;
let dataLines: string[] = [];
// Bytes of the pending event's data, including the "\n" joins, so events streamed as many
// short `data:` lines without a terminating blank line cannot grow without bound.
let dataBytes = 0;
const maxEventBytes = options.maxEventBytes ?? DEFAULT_MAX_MESSAGE_BYTES;
const dispatch = () => {
if (dataLines.length === 0) {
eventName = undefined;
eventId = undefined;
return;
}
const data = dataLines.join("\n");
options.onEvent({ ...(eventName ? { event: eventName } : {}), data, ...(eventId ? { id: eventId } : {}) });
eventName = undefined;
eventId = undefined;
dataLines = [];
dataBytes = 0;
};
const processLine = (rawLine: string) => {
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
if (line === "") {
dispatch();
return;
}
if (line.startsWith(":")) return;
const colon = line.indexOf(":");
const field = colon < 0 ? line : line.slice(0, colon);
let value = colon < 0 ? "" : line.slice(colon + 1);
if (value.startsWith(" ")) value = value.slice(1);
if (field === "data") {
dataBytes += Buffer.byteLength(value) + (dataLines.length > 0 ? 1 : 0);
if (dataBytes > maxEventBytes) throw new Error(`MCP SSE event exceeds ${maxEventBytes} bytes`);
dataLines.push(value);
} else if (field === "event") eventName = value;
else if (field === "id" && !value.includes("\0")) {
eventId = value;
options.onId?.(value);
} else if (field === "retry" && /^\d+$/.test(value)) options.onRetry?.(Number(value));
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffered += decoder.decode(value, { stream: true });
let newline = buffered.indexOf("\n");
while (newline >= 0) {
processLine(buffered.slice(0, newline));
buffered = buffered.slice(newline + 1);
newline = buffered.indexOf("\n");
}
if (Buffer.byteLength(buffered) > maxEventBytes)
throw new Error(`MCP SSE event exceeds ${maxEventBytes} bytes`);
}
buffered += decoder.decode();
if (buffered) processLine(buffered);
dispatch();
} finally {
reader.releaseLock();
}
}
export interface StreamableHttpReconnectOptions {
/** Delay before the first reconnection attempt, unless the server sent a `retry` field. Default: 1000. */
initialDelayMs?: number;
/** Upper bound for the exponential backoff. Default: 30000. */
maxDelayMs?: number;
/** Consecutive failed attempts before giving up on a stream. Default: 5. */
maxRetries?: number;
}
export interface StreamableHttpTransportOptions {
url: string | URL;
headers?: Record<string, string>;
fetch?: McpFetch;
/** Open the server-to-client GET stream after initialization. Default: true. */
openGetStream?: boolean;
maxMessageBytes?: number;
authProvider?: AuthProvider;
/** Reconnection of dropped SSE streams (the GET stream, and response streams that carry event IDs). */
reconnect?: StreamableHttpReconnectOptions;
}
export class McpHttpError extends Error {
readonly status: number;
readonly body: string;
constructor(status: number, message: string, body = "") {
super(message);
this.name = "McpHttpError";
this.status = status;
this.body = body;
}
}
export class McpAuthRequiredError extends McpHttpError {
readonly wwwAuthenticate: string | null;
constructor(response: Response, body = "") {
super(401, "MCP server requires authentication", body);
this.name = "McpAuthRequiredError";
this.wwwAuthenticate = response.headers.get("www-authenticate");
}
}
export class McpSessionExpiredError extends McpHttpError {
constructor(body = "") {
super(404, "MCP session expired", body);
this.name = "McpSessionExpiredError";
}
}
function contentType(response: Response): string | undefined {
return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
}
/** 401, or 403 with an `insufficient_scope` bearer challenge (step-up authorization). */
function needsAuthorization(response: Response): boolean {
if (response.status === 401) return true;
if (response.status !== 403) return false;
return /(?:^|[\s,])error="?insufficient_scope"?/i.test(response.headers.get("www-authenticate") ?? "");
}
/** Statuses worth retrying when a stream fails to (re)open. */
function isTransientStatus(status: number): boolean {
return status === 408 || status === 429 || status >= 500;
}
function discard(response: Response): Promise<void> {
return response.body?.cancel().catch(() => {}) ?? Promise.resolve();
}
function describeHttpFailure(status: number, body: string): string {
const text = body.trim();
const snippet = text.length > ERROR_MESSAGE_BODY_CHARS ? `${text.slice(0, ERROR_MESSAGE_BODY_CHARS - 3)}...` : text;
return `MCP HTTP request failed with status ${status}${snippet ? `: ${snippet}` : ""}`;
}
interface StreamCursor {
lastEventId: string | undefined;
retryMs: number | undefined;
/** Whether the stream delivered any event since it was (re)opened. */
received: boolean;
}
export class StreamableHttpTransport extends TransportEvents implements McpTransport {
readonly url: URL;
readonly options: Readonly<StreamableHttpTransportOptions>;
private fetch: McpFetch;
private controller = new AbortController();
private started = false;
private closed = false;
private sessionIdValue: string | undefined;
private protocolVersion: string | undefined;
private getStreamStarted = false;
/** Access token of the latest request, which `close()` reuses instead of asking the auth provider. */
private lastToken: string | undefined;
constructor(options: StreamableHttpTransportOptions) {
super();
this.options = Object.freeze({ ...options, headers: options.headers ? { ...options.headers } : undefined });
this.url = new URL(options.url);
const fetch = options.fetch ?? globalThis.fetch;
// Call fetch without a receiver. `this.fetch(...)` and `context.fetch(...)` would pass the transport or
// the auth context as `this`, which Cloudflare Workers reject for the platform fetch ("Illegal invocation").
this.fetch = (input, init) => fetch(input, init);
}
get sessionId(): string | undefined {
return this.sessionIdValue;
}
async start(): Promise<void> {
if (this.started) throw new Error("MCP Streamable HTTP transport already started");
if (this.closed) throw new McpConnectionClosedError();
this.started = true;
}
setProtocolVersion(version: string): void {
this.protocolVersion = version;
}
async send(message: JsonRpcMessage, { signal }: McpTransportSendOptions = {}): Promise<void> {
if (!this.started || this.closed) throw new McpConnectionClosedError();
const response = await this.authorizedFetch(
"POST",
{
headers: { accept: "application/json, text/event-stream", "content-type": "application/json" },
body: JSON.stringify(message),
},
signal,
);
await this.checkResponse(response);
this.captureSession(response);
if (!isJsonRpcRequest(message)) {
// Notifications and responses are acknowledged with 202 and carry no reply; ignore any body.
await discard(response);
// The server-to-client stream may only open once the session is initialized.
if ("method" in message && message.method === "notifications/initialized") this.startGetStream();
return;
}
if (response.status === 202 || response.status === 204) {
throw new McpHttpError(response.status, `MCP server accepted request ${message.method} without a response`);
}
const type = contentType(response);
if (type === "application/json") {
const body: unknown = await response.json();
for (const item of Array.isArray(body) ? body : [body]) this.emitMessage(parseJsonRpcMessage(item));
return;
}
if (type === "text/event-stream" && response.body) {
void this.consumeResponseStream(response.body, message.id);
return;
}
await discard(response);
throw new McpHttpError(response.status, `Unsupported MCP response content type: ${type ?? "missing"}`);
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
this.controller.abort();
if (this.started && this.sessionIdValue) {
// Best effort: the session expires on the server anyway. The auth provider may refresh tokens
// over the network, so it is not asked here and closing never waits for a refresh.
try {
const response = await this.fetch(this.url, {
method: "DELETE",
headers: this.buildHeaders({}, this.lastToken),
signal: AbortSignal.timeout(1_000),
});
await discard(response);
} catch {}
}
this.emitClose();
}
/**
* Fetch with auth headers. A 401 (or a 403 asking for more scope) is handed to the auth provider
* once, and the request is retried with whatever credentials it left behind.
*
* `signal` only stops dispatch: getting a token or the authorization recovery can take a while, and a
* request its sender gave up on in the meantime must not reach the server. It does not abort the shared
* recovery, which other requests may wait for.
*/
private async authorizedFetch(
method: "GET" | "POST",
init: { headers: Record<string, string>; body?: string },
signal?: AbortSignal,
): Promise<Response> {
const onUnauthorized = this.options.authProvider?.onUnauthorized?.bind(this.options.authProvider);
for (let attempt = 0; ; attempt++) {
const { headers, token } = await this.headers(init.headers);
if (signal?.aborted) throw new McpAbortError();
const response = await this.fetch(this.url, {
method,
headers,
body: init.body,
signal: this.controller.signal,
});
if (attempt > 0 || !onUnauthorized || !needsAuthorization(response)) return response;
try {
await onUnauthorized({ response, serverUrl: this.url, fetch: this.fetch, token });
} finally {
await discard(response);
}
}
}
private async headers(extra: Record<string, string> = {}): Promise<{ headers: Headers; token?: string }> {
const token = await this.options.authProvider?.token();
this.lastToken = token;
return { headers: this.buildHeaders(extra, token), ...(token ? { token } : {}) };
}
private buildHeaders(extra: Record<string, string>, token: string | undefined): Headers {
const headers = new Headers(this.options.headers);
for (const [name, value] of Object.entries(extra)) headers.set(name, value);
if (this.sessionIdValue) headers.set("Mcp-Session-Id", this.sessionIdValue);
if (this.protocolVersion) headers.set("MCP-Protocol-Version", this.protocolVersion);
if (token) headers.set("Authorization", `Bearer ${token}`);
return headers;
}
private captureSession(response: Response): void {
const sessionId = response.headers.get("mcp-session-id");
if (sessionId) this.sessionIdValue = sessionId;
}
private async checkResponse(response: Response): Promise<void> {
if (response.ok) return;
const body = (await response.text().catch(() => "")).slice(0, MAX_ERROR_BODY_BYTES);
if (response.status === 401) throw new McpAuthRequiredError(response, body);
if (response.status === 404 && this.sessionIdValue) throw new McpSessionExpiredError(body);
throw new McpHttpError(response.status, describeHttpFailure(response.status, body), body);
}
private async consumeSse(
stream: ReadableStream<Uint8Array>,
cursor: StreamCursor,
onMessage?: (message: JsonRpcMessage) => void,
): Promise<void> {
await consumeSseStream(stream, {
maxEventBytes: this.options.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES,
onId: (id) => {
cursor.lastEventId = id;
},
onRetry: (delayMs) => {
cursor.retryMs = delayMs;
},
onEvent: (event) => {
cursor.received = true;
// Events without data prime resumption; other event types are not JSON-RPC.
if (!event.data.trim() || (event.event !== undefined && event.event !== "message")) return;
let message: JsonRpcMessage;
try {
message = parseJsonRpcMessage(JSON.parse(event.data));
} catch (error) {
this.emitError(error);
return;
}
onMessage?.(message);
this.emitMessage(message);
},
});
}
/**
* Read the SSE stream answering one request. When the stream ends or breaks before the response
* arrives and the server assigned event IDs, resume it with GET and `Last-Event-ID`, as the
* server may close response streams at will. Otherwise only this request fails.
*/
private async consumeResponseStream(body: ReadableStream<Uint8Array>, requestId: JsonRpcId): Promise<void> {
const cursor: StreamCursor = { lastEventId: undefined, retryMs: undefined, received: false };
let answered = false;
const onMessage = (message: JsonRpcMessage) => {
if (isJsonRpcResponse(message) && message.id === requestId) answered = true;
};
let stream: ReadableStream<Uint8Array> | undefined = body;
let failure: unknown;
for (let attempt = 0; ; ) {
if (stream) {
try {
await this.consumeSse(stream, cursor, onMessage);
failure = undefined;
} catch (error) {
failure = error;
}
}
if (answered || this.closed) return;
if (failure !== undefined && !this.isRetryable(failure)) break;
if (cursor.lastEventId === undefined || attempt >= this.maxRetries()) break;
if (cursor.received) attempt = 0;
cursor.received = false;
if (!(await this.sleep(this.reconnectDelay(attempt++, cursor.retryMs)))) return;
try {
stream = await this.openSseStream(cursor.lastEventId);
} catch (error) {
failure = error;
if (!this.isRetryable(error)) break;
stream = undefined;
}
}
if (this.closed) return;
const reason = failure === undefined ? "stream ended without a response" : toError(failure).message;
this.emitMessage({
jsonrpc: "2.0",
id: requestId,
error: { code: JSON_RPC_ERROR_CODES.internalError, message: `MCP response stream failed: ${reason}` },
});
}
private startGetStream(): void {
if (this.options.openGetStream === false || this.getStreamStarted || this.closed) return;
this.getStreamStarted = true;
void this.runGetStream();
}
/** Keep the server-to-client stream open, reconnecting with backoff when it drops. */
private async runGetStream(): Promise<void> {
const cursor: StreamCursor = { lastEventId: undefined, retryMs: undefined, received: false };
for (let attempt = 0; !this.closed; ) {
try {
const stream = await this.openSseStream(cursor.lastEventId);
// The server does not offer a GET stream.
if (!stream) return;
const openedAt = Date.now();
await this.consumeSse(stream, cursor);
// A stream that stayed up for a while counts as healthy, even if it was idle.
if (cursor.received || Date.now() - openedAt > this.maxDelay()) attempt = 0;
} catch (error) {
if (this.closed) return;
if (!this.isRetryable(error)) {
this.emitError(error);
return;
}
}
cursor.received = false;
if (attempt >= this.maxRetries()) {
this.emitError(new Error("MCP server-to-client stream dropped and could not be reopened"));
return;
}
if (!(await this.sleep(this.reconnectDelay(attempt++, cursor.retryMs)))) return;
}
}
/** Open a GET SSE stream. Resolves to undefined when the server answers 405 (no GET stream). */
private async openSseStream(lastEventId: string | undefined): Promise<ReadableStream<Uint8Array> | undefined> {
const response = await this.authorizedFetch("GET", {
headers: {
accept: "text/event-stream",
...(lastEventId === undefined ? {} : { "last-event-id": lastEventId }),
},
});
if (response.status === 405) {
await discard(response);
return undefined;
}
await this.checkResponse(response);
this.captureSession(response);
const type = contentType(response);
if (type !== "text/event-stream" || !response.body) {
await discard(response);
throw new McpHttpError(response.status, `Unsupported MCP GET response content type: ${type ?? "missing"}`);
}
return response.body;
}
/**
* Network failures and transient statuses are retried; auth, session, and protocol errors are not.
* `fetch` reports network failures, including a connection dropped mid-body, as `TypeError`.
*/
private isRetryable(error: unknown): boolean {
if (error instanceof McpHttpError) return isTransientStatus(error.status);
if (error instanceof TypeError) return true;
const code = (error as { code?: unknown } | undefined)?.code;
return typeof code === "string" && (code.startsWith("E") || code.startsWith("UND_ERR"));
}
private reconnectDelay(attempt: number, serverDelayMs: number | undefined): number {
if (serverDelayMs !== undefined) return serverDelayMs;
const initial = this.options.reconnect?.initialDelayMs ?? DEFAULT_RECONNECT_INITIAL_DELAY_MS;
return Math.min(initial * 2 ** attempt, this.maxDelay());
}
private maxDelay(): number {
return this.options.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
}
private maxRetries(): number {
return this.options.reconnect?.maxRetries ?? DEFAULT_RECONNECT_MAX_RETRIES;
}
/** Resolves false when the transport closed while waiting. */
private sleep(ms: number): Promise<boolean> {
const signal = this.controller.signal;
if (signal.aborted) return Promise.resolve(false);
return new Promise((resolve) => {
const onAbort = () => {
clearTimeout(timer);
resolve(false);
};
const timer = setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve(true);
}, ms);
// A reconnect wait alone does not keep the process alive.
timer.unref?.();
signal.addEventListener("abort", onAbort, { once: true });
});
}
}
+64
View File
@@ -0,0 +1,64 @@
import { type JsonRpcMessage, toError } from "../protocol/jsonrpc.ts";
export const DEFAULT_MAX_MESSAGE_BYTES = 16 * 1024 * 1024;
export type McpTransportMessageListener = (message: JsonRpcMessage) => void;
export type McpTransportErrorListener = (error: Error) => void;
export type McpTransportCloseListener = () => void;
export interface McpTransportSendOptions {
/**
* Aborted once the sender no longer waits for a reply (the request was cancelled, timed out, answered,
* or the client closed). A transport must not dispatch the message after that, for example when
* retrying it after an authorization refresh. It does not undo a message already dispatched.
*/
signal?: AbortSignal;
}
export interface McpTransport {
start(): Promise<void>;
send(message: JsonRpcMessage, options?: McpTransportSendOptions): Promise<void>;
close(): Promise<void>;
onMessage(listener: McpTransportMessageListener): () => void;
onError(listener: McpTransportErrorListener): () => void;
onClose(listener: McpTransportCloseListener): () => void;
setProtocolVersion?(version: string): void;
}
/** Listener bookkeeping shared by transports. `emitClose` fires at most once per transport. */
export abstract class TransportEvents {
private messageListeners = new Set<McpTransportMessageListener>();
private errorListeners = new Set<McpTransportErrorListener>();
private closeListeners = new Set<McpTransportCloseListener>();
private closeEmitted = false;
onMessage(listener: McpTransportMessageListener): () => void {
this.messageListeners.add(listener);
return () => this.messageListeners.delete(listener);
}
onError(listener: McpTransportErrorListener): () => void {
this.errorListeners.add(listener);
return () => this.errorListeners.delete(listener);
}
onClose(listener: McpTransportCloseListener): () => void {
this.closeListeners.add(listener);
return () => this.closeListeners.delete(listener);
}
protected emitMessage(message: JsonRpcMessage): void {
for (const listener of this.messageListeners) listener(message);
}
protected emitError(error: unknown): void {
const normalized = toError(error);
for (const listener of this.errorListeners) listener(normalized);
}
protected emitClose(): void {
if (this.closeEmitted) return;
this.closeEmitted = true;
for (const listener of this.closeListeners) listener();
}
}