- Moved Streamable HTTP request and notify timeout cleanup after response body consumption.\n- Added regression coverage for stalled request JSON bodies and stalled notify error bodies.\n\nFixes #3974
524 lines
16 KiB
TypeScript
524 lines
16 KiB
TypeScript
/**
|
|
* MCP HTTP transport (Streamable HTTP).
|
|
*
|
|
* Implements JSON-RPC 2.0 over HTTP POST with optional SSE streaming.
|
|
* Based on MCP spec 2025-03-26.
|
|
*/
|
|
import * as AIError from "@oh-my-pi/pi-ai/error";
|
|
import { logger, readSseJson, Snowflake } from "@oh-my-pi/pi-utils";
|
|
import type {
|
|
JsonRpcError,
|
|
JsonRpcMessage,
|
|
JsonRpcRequest,
|
|
JsonRpcResponse,
|
|
MCPHttpServerConfig,
|
|
MCPRequestOptions,
|
|
MCPSseServerConfig,
|
|
MCPTransport,
|
|
} from "../../mcp/types";
|
|
import { toJsonRpcError } from "../../mcp/types";
|
|
import { createMCPTimeout, getNeverAbortSignal, isMCPTimeoutEnabled, resolveMCPTimeoutMs } from "../timeout";
|
|
|
|
const HTTP_SSE_CONNECT_TIMEOUT_MS = 1_000;
|
|
/**
|
|
* Best-effort startup deadline for the optional Streamable HTTP GET SSE listener.
|
|
*
|
|
* Returns `0` (disabled) when the operator has explicitly disabled MCP client-side
|
|
* timeouts via `timeout: 0` or `OMP_MCP_TIMEOUT_MS=0`, mirroring the rest of the
|
|
* MCP timeout surface. Otherwise caps the wait at one second and scales below
|
|
* short request timeouts so connect-time never exceeds the request budget.
|
|
*/
|
|
export function resolveSSEConnectTimeoutMs(configTimeout?: number): number {
|
|
const requestTimeout = resolveMCPTimeoutMs(configTimeout);
|
|
if (!isMCPTimeoutEnabled(requestTimeout)) return 0;
|
|
const boundedTimeout = Math.min(HTTP_SSE_CONNECT_TIMEOUT_MS, Math.floor(requestTimeout / 4));
|
|
return Math.max(1, boundedTimeout);
|
|
}
|
|
/**
|
|
* HTTP transport for MCP servers.
|
|
* Uses POST for requests, supports SSE responses.
|
|
*/
|
|
export class HttpTransport implements MCPTransport {
|
|
#connected = false;
|
|
#sessionId: string | null = null;
|
|
#sseConnection: AbortController | null = null;
|
|
|
|
onClose?: () => void;
|
|
onError?: (error: Error) => void;
|
|
onNotification?: (method: string, params: unknown) => void;
|
|
onRequest?: (method: string, params: unknown) => Promise<unknown>;
|
|
/** Called on 401/403 to attempt token refresh. Returns updated headers or null. */
|
|
onAuthError?: () => Promise<Record<string, string> | null>;
|
|
|
|
constructor(private config: MCPHttpServerConfig | MCPSseServerConfig) {}
|
|
|
|
get connected(): boolean {
|
|
return this.#connected;
|
|
}
|
|
|
|
get url(): string {
|
|
return this.config.url;
|
|
}
|
|
|
|
/**
|
|
* Mark transport as connected.
|
|
* HTTP doesn't need persistent connection, but we track state.
|
|
*/
|
|
async connect(): Promise<void> {
|
|
if (this.#connected) return;
|
|
this.#connected = true;
|
|
}
|
|
|
|
/**
|
|
* Start SSE listener for server-initiated messages.
|
|
* Resolves once the SSE connection is established (or fails/unsupported).
|
|
* Message reading continues in the background.
|
|
*/
|
|
async startSSEListener(): Promise<void> {
|
|
if (!this.#connected) return;
|
|
if (this.#sseConnection) return;
|
|
|
|
this.#sseConnection = new AbortController();
|
|
const headers: Record<string, string> = {
|
|
Accept: "text/event-stream",
|
|
...this.config.headers,
|
|
};
|
|
|
|
if (this.#sessionId) {
|
|
headers["Mcp-Session-Id"] = this.#sessionId;
|
|
}
|
|
|
|
let response: Response | null;
|
|
let timedOut = false;
|
|
let startupFinished = false;
|
|
const connection = this.#sseConnection;
|
|
const startupTimeoutMs = resolveSSEConnectTimeoutMs(this.config.timeout);
|
|
const fetchPromise = fetch(this.config.url, {
|
|
method: "GET",
|
|
headers,
|
|
signal: connection.signal,
|
|
});
|
|
const timeoutPromise =
|
|
startupTimeoutMs > 0
|
|
? new Promise<null>(resolve => {
|
|
setTimeout(() => {
|
|
if (!startupFinished) {
|
|
timedOut = true;
|
|
connection.abort();
|
|
}
|
|
resolve(null);
|
|
}, startupTimeoutMs);
|
|
})
|
|
: null;
|
|
try {
|
|
response = timeoutPromise === null ? await fetchPromise : await Promise.race([fetchPromise, timeoutPromise]);
|
|
} catch (error) {
|
|
if (this.#sseConnection === connection) this.#sseConnection = null;
|
|
if (error instanceof Error && error.name !== "AbortError" && !timedOut) {
|
|
this.onError?.(error);
|
|
}
|
|
return;
|
|
} finally {
|
|
startupFinished = true;
|
|
}
|
|
if (response === null) {
|
|
if (this.#sseConnection === connection) this.#sseConnection = null;
|
|
void fetchPromise.then(lateResponse => lateResponse.body?.cancel()).catch(() => {});
|
|
return;
|
|
}
|
|
|
|
if (this.#sseConnection !== connection) {
|
|
await response.body?.cancel();
|
|
return;
|
|
}
|
|
if (response.status === 405 || !response.ok || !response.body) {
|
|
await response.body?.cancel();
|
|
if (this.#sseConnection === connection) this.#sseConnection = null;
|
|
return;
|
|
}
|
|
|
|
// Connection established — read messages in background.
|
|
// If the stream ends unexpectedly (server restart, network drop),
|
|
// fire onClose so the manager can trigger reconnection.
|
|
const signal = connection.signal;
|
|
void this.#readSSEStream(response.body!, signal).finally(() => {
|
|
const wasConnected = this.#connected;
|
|
if (this.#sseConnection === connection) this.#sseConnection = null;
|
|
if (wasConnected) this.onClose?.();
|
|
});
|
|
}
|
|
async #readSSEStream(body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void> {
|
|
try {
|
|
for await (const message of readSseJson<JsonRpcMessage>(body, signal)) {
|
|
if (!this.#connected) break;
|
|
this.#dispatchSSEMessage(message);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name !== "AbortError") {
|
|
logger.debug("HTTP SSE stream error", { url: this.config.url, error: error.message });
|
|
this.onError?.(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Route an SSE message (or batch) to the appropriate handler. */
|
|
#dispatchSSEMessage(message: JsonRpcMessage | JsonRpcMessage[]): void {
|
|
if (Array.isArray(message)) {
|
|
for (const m of message) this.#dispatchSSEMessage(m);
|
|
return;
|
|
}
|
|
// Server-to-client request: has both method and id
|
|
if ("method" in message && "id" in message && message.id != null) {
|
|
void this.#handleServerRequest(message as JsonRpcRequest);
|
|
return;
|
|
}
|
|
// Notification: has method but no id
|
|
if ("method" in message && !("id" in message)) {
|
|
this.onNotification?.(message.method, message.params);
|
|
}
|
|
}
|
|
|
|
async request<T = unknown>(
|
|
method: string,
|
|
params?: Record<string, unknown>,
|
|
options?: MCPRequestOptions,
|
|
): Promise<T> {
|
|
try {
|
|
return await this.#executeRequest<T>(method, params, options);
|
|
} catch (error) {
|
|
// Retry once on auth failure if onAuthError is wired
|
|
const status = error instanceof Error ? AIError.status(error) : undefined;
|
|
if (this.onAuthError && (status === 401 || status === 403)) {
|
|
const newHeaders = await this.onAuthError();
|
|
if (newHeaders) {
|
|
// Persist refreshed headers so subsequent requests use them directly
|
|
this.config = { ...this.config, headers: newHeaders };
|
|
return this.#executeRequest<T>(method, params, options);
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async #executeRequest<T>(
|
|
method: string,
|
|
params: Record<string, unknown> | undefined,
|
|
options: MCPRequestOptions | undefined,
|
|
): Promise<T> {
|
|
if (!this.#connected) {
|
|
throw new Error("Transport not connected");
|
|
}
|
|
|
|
const id = Snowflake.next();
|
|
const body = {
|
|
jsonrpc: "2.0" as const,
|
|
id,
|
|
method,
|
|
params: params ?? {},
|
|
};
|
|
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json, text/event-stream",
|
|
...this.config.headers,
|
|
};
|
|
|
|
if (this.#sessionId) {
|
|
headers["Mcp-Session-Id"] = this.#sessionId;
|
|
}
|
|
|
|
const timeout = resolveMCPTimeoutMs(this.config.timeout);
|
|
const operation = createMCPTimeout(timeout, options?.signal);
|
|
|
|
try {
|
|
const response = await fetch(this.config.url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
signal: operation.signal,
|
|
});
|
|
|
|
// Check for session ID in response
|
|
const newSessionId = response.headers.get("Mcp-Session-Id");
|
|
if (newSessionId) {
|
|
this.#sessionId = newSessionId;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
const wwwAuthenticate = response.headers.get("WWW-Authenticate");
|
|
const mcpAuthServer = response.headers.get("Mcp-Auth-Server");
|
|
const authHints = [
|
|
wwwAuthenticate ? `WWW-Authenticate: ${wwwAuthenticate}` : null,
|
|
mcpAuthServer ? `Mcp-Auth-Server: ${mcpAuthServer}` : null,
|
|
]
|
|
.filter(Boolean)
|
|
.join("; ");
|
|
const suffix = authHints ? ` [${authHints}]` : "";
|
|
throw new Error(`HTTP ${response.status}: ${text}${suffix}`);
|
|
}
|
|
|
|
const contentType = response.headers.get("Content-Type") ?? "";
|
|
|
|
// Handle SSE response
|
|
if (contentType.includes("text/event-stream")) {
|
|
return this.#parseSSEResponse<T>(response, id, options);
|
|
}
|
|
|
|
// Handle JSON response
|
|
const result = (await response.json()) as JsonRpcResponse;
|
|
|
|
if (result.error) {
|
|
throw new Error(`MCP error ${result.error.code}: ${result.error.message}`);
|
|
}
|
|
|
|
return result.result as T;
|
|
} catch (error) {
|
|
if (operation.isTimeoutAbort(error)) {
|
|
throw new Error(`Request timeout after ${timeout}ms`);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
operation.clear();
|
|
}
|
|
}
|
|
|
|
#parseSSEResponse<T>(response: Response, expectedId: string | number, options?: MCPRequestOptions): Promise<T> {
|
|
if (!response.body) {
|
|
throw new Error("No response body");
|
|
}
|
|
|
|
const timeout = resolveMCPTimeoutMs(this.config.timeout);
|
|
const operation = createMCPTimeout(timeout, options?.signal);
|
|
const signal = operation.signal ?? getNeverAbortSignal();
|
|
|
|
const { promise, resolve, reject } = Promise.withResolvers<T>();
|
|
let captured = false;
|
|
|
|
// Drain the SSE stream from a single iterator. We resolve the deferred
|
|
// promise as soon as the matching response arrives, then keep iterating
|
|
// in the background to pick up piggybacked notifications/requests.
|
|
// Re-reading `response.body` after `for await` breaks would lock the
|
|
// stream a second time and surface as "ReadableStream already has a
|
|
// controller", so we must not exit the loop early.
|
|
const drain = async (): Promise<void> => {
|
|
try {
|
|
for await (const raw of readSseJson<JsonRpcMessage | JsonRpcMessage[]>(response.body!, signal)) {
|
|
const messages = Array.isArray(raw) ? raw : [raw];
|
|
for (const message of messages) {
|
|
if (
|
|
!captured &&
|
|
"id" in message &&
|
|
message.id === expectedId &&
|
|
("result" in message || "error" in message)
|
|
) {
|
|
captured = true;
|
|
operation.clear();
|
|
if (message.error) {
|
|
reject(new Error(`MCP error ${message.error.code}: ${message.error.message}`));
|
|
} else {
|
|
resolve(message.result as T);
|
|
}
|
|
continue;
|
|
}
|
|
if (!this.#connected) continue;
|
|
this.#dispatchSSEMessage(message);
|
|
}
|
|
}
|
|
if (!captured) {
|
|
reject(new Error(`No response received for request ID ${expectedId}`));
|
|
}
|
|
} catch (error) {
|
|
if (captured) return;
|
|
if (operation.isTimeoutAbort(error)) {
|
|
reject(new Error(`SSE response timeout after ${timeout}ms`));
|
|
} else {
|
|
reject(error as Error);
|
|
}
|
|
} finally {
|
|
operation.clear();
|
|
}
|
|
};
|
|
|
|
void drain();
|
|
return promise;
|
|
}
|
|
|
|
async #handleServerRequest(request: JsonRpcRequest): Promise<void> {
|
|
if (!this.onRequest) {
|
|
await this.#sendServerResponse(request.id, undefined, { code: -32601, message: "Method not found" });
|
|
return;
|
|
}
|
|
try {
|
|
const result = await this.onRequest(request.method, request.params);
|
|
await this.#sendServerResponse(request.id, result);
|
|
} catch (error) {
|
|
await this.#sendServerResponse(request.id, undefined, toJsonRpcError(error));
|
|
}
|
|
}
|
|
|
|
/** POST a JSON-RPC response back to the server (for server-to-client requests received via SSE). */
|
|
async #sendServerResponse(id: string | number, result?: unknown, error?: JsonRpcError): Promise<void> {
|
|
if (!this.#connected) return;
|
|
const body = error
|
|
? { jsonrpc: "2.0" as const, id, error }
|
|
: { jsonrpc: "2.0" as const, id, result: result ?? {} };
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json, text/event-stream",
|
|
...this.config.headers,
|
|
};
|
|
if (this.#sessionId) {
|
|
headers["Mcp-Session-Id"] = this.#sessionId;
|
|
}
|
|
const timeout = resolveMCPTimeoutMs(this.config.timeout);
|
|
const operation = createMCPTimeout(timeout);
|
|
try {
|
|
const resp = await fetch(this.config.url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
signal: operation.signal,
|
|
});
|
|
// Retry once on auth failure if onAuthError is wired
|
|
if (this.onAuthError && (resp.status === 401 || resp.status === 403)) {
|
|
await resp.body?.cancel();
|
|
const newHeaders = await this.onAuthError();
|
|
if (newHeaders) {
|
|
this.config.headers ??= {};
|
|
Object.assign(this.config.headers, newHeaders);
|
|
Object.assign(headers, newHeaders);
|
|
operation.clear();
|
|
const retryOperation = createMCPTimeout(timeout);
|
|
try {
|
|
const retry = await fetch(this.config.url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
signal: retryOperation.signal,
|
|
});
|
|
await retry.body?.cancel();
|
|
} finally {
|
|
retryOperation.clear();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
await resp.body?.cancel();
|
|
} catch {
|
|
// Best-effort response delivery — server may have disconnected
|
|
} finally {
|
|
operation.clear();
|
|
}
|
|
}
|
|
|
|
async notify(method: string, params?: Record<string, unknown>): Promise<void> {
|
|
if (!this.#connected) {
|
|
throw new Error("Transport not connected");
|
|
}
|
|
|
|
const body = {
|
|
jsonrpc: "2.0" as const,
|
|
method,
|
|
params: params ?? {},
|
|
};
|
|
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json, text/event-stream",
|
|
...this.config.headers,
|
|
};
|
|
|
|
if (this.#sessionId) {
|
|
headers["Mcp-Session-Id"] = this.#sessionId;
|
|
}
|
|
|
|
const timeout = resolveMCPTimeoutMs(this.config.timeout);
|
|
const operation = createMCPTimeout(timeout);
|
|
|
|
try {
|
|
const response = await fetch(this.config.url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
signal: operation.signal,
|
|
});
|
|
|
|
// 202 Accepted is success for notifications
|
|
if (!response.ok && response.status !== 202) {
|
|
const text = await response.text();
|
|
throw new Error(`HTTP ${response.status}: ${text}`);
|
|
}
|
|
|
|
// The server may piggyback server-to-client requests or notifications
|
|
// on the notification response (MCP Streamable HTTP spec). Read them.
|
|
const contentType = response.headers.get("Content-Type") ?? "";
|
|
if (contentType.includes("text/event-stream") && response.body) {
|
|
// Use the SSE connection's signal if available; otherwise keep the existing finite read timeout.
|
|
if (this.#sseConnection) {
|
|
void this.#readSSEStream(response.body, this.#sseConnection.signal);
|
|
} else {
|
|
const readOperation = createMCPTimeout(timeout);
|
|
const signal = readOperation.signal ?? getNeverAbortSignal();
|
|
void this.#readSSEStream(response.body, signal).finally(() => readOperation.clear());
|
|
}
|
|
} else {
|
|
await response.body?.cancel();
|
|
}
|
|
} catch (error) {
|
|
if (operation.isTimeoutAbort(error)) {
|
|
throw new Error(`Notify timeout after ${timeout}ms`);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
operation.clear();
|
|
}
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
if (!this.#connected) return;
|
|
this.#connected = false;
|
|
|
|
// Abort SSE listener
|
|
if (this.#sseConnection) {
|
|
this.#sseConnection.abort();
|
|
this.#sseConnection = null;
|
|
}
|
|
|
|
// Send session termination if we have a session
|
|
if (this.#sessionId) {
|
|
const timeout = resolveMCPTimeoutMs(this.config.timeout);
|
|
const operation = createMCPTimeout(timeout);
|
|
try {
|
|
const headers: Record<string, string> = {
|
|
...this.config.headers,
|
|
"Mcp-Session-Id": this.#sessionId,
|
|
};
|
|
|
|
await fetch(this.config.url, {
|
|
method: "DELETE",
|
|
headers,
|
|
signal: operation.signal,
|
|
});
|
|
operation.clear();
|
|
} catch {
|
|
operation.clear();
|
|
// Ignore termination errors
|
|
}
|
|
this.#sessionId = null;
|
|
}
|
|
|
|
this.onClose?.();
|
|
this.onClose = undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create and connect an HTTP transport.
|
|
*/
|
|
export async function createHttpTransport(config: MCPHttpServerConfig | MCPSseServerConfig): Promise<HttpTransport> {
|
|
const transport = new HttpTransport(config);
|
|
await transport.connect();
|
|
return transport;
|
|
}
|