Files
oh-my-pi/packages/coding-agent/src/mcp/transports/http.ts
T
8b028224e3 feat: auto-reconnect MCP servers on connection loss (#482)
* feat: auto-reconnect MCP servers on connection loss

When an HTTP SSE stream drops (server restart, network interruption),
the transport fires onClose, and the manager proactively reconnects
with retry backoff (500ms, 1s, 2s, 4s). Tools are kept in the registry
during reconnection so they remain selected and available to the agent.

If proactive reconnection fails, stale tools stay registered. When the
agent calls one, the tool bridge detects the retriable connection error
(ECONNREFUSED, ECONNRESET, stale session 404/502/503, etc.), triggers
reconnectServer on the manager, and retries the call once on the fresh
connection. Concurrent reconnect attempts for the same server are deduped.

connectToServer now always installs a default onRequest handler for ping
and roots/list (using getProjectDir()), so all connections -- including
short-lived test/probe ones -- properly respond to server-initiated
requests during initialization.

Post-connection setup (resources, prompts, subscriptions) is extracted
into a shared #loadServerResourcesAndPrompts method used by both initial
connection and reconnection paths.

Add /mcp reconnect <name> command for manual recovery after extended
outages where both proactive and reactive reconnection have failed.

* docs: add changelog entry for MCP auto-reconnect

* fix: address P1 review findings in MCP reconnection

- Save server configs before connection attempt so deferred tools can
  reconnect even when the initial connection timed out (P1-1)
- Make waitForConnection() and getConnectionStatus() aware of in-flight
  reconnections so callers wait instead of failing immediately (P1-2)
- Add epoch counter incremented on disconnectAll() and checked in
  connectAndWireServer() to invalidate stale reconnect attempts that
  outlive a manager reset/reload (P1-3)
- Skip servers with pending reconnections in connectServers() to prevent
  parallel connection attempts for the same server

* fix: deferred tool reconnect and non-blocking transport teardown

- DeferredMCPTool.execute now reconnects when getConnection() fails
  ("MCP server not connected"), not only on network errors from
  callTool. Servers that missed the startup window can now be woken
  by the first tool call against their cached tools. (P1-4)
- #doReconnect fire-and-forgets the old transport close instead of
  awaiting it. HttpTransport.close() sends a DELETE with 30s timeout;
  blocking here delayed the first reconnect attempt by that amount
  on every server restart. (P1-5)

* fix: abort-aware reconnect waits and preserve tool selection on reconnect

- Wrap all reconnect() awaits with withAbort(signal) so user
  cancellation (Esc) interrupts the reconnect backoff loop instead
  of blocking for up to 7.5s. Applies to MCPTool (1 site) and
  DeferredMCPTool (2 sites). (P2-1)
- Remove activateDiscoveredMCPTools call from /mcp reconnect handler.
  refreshMCPTools already preserves the user's prior MCP tool
  selection; the extra activation was silently opting into all
  server tools including ones the user had not enabled. (P2-2)

* fix: rebind MCPTool connection after reconnect, add stdio retriable error

- MCPTool.connection is now mutable; after a successful reconnect retry,
  this.connection is rebound to the fresh connection so subsequent calls
  on the same instance (e.g. batched tool calls) use it instead of
  triggering another reconnect cycle. (P2-3)
- Add "Transport closed" to RETRIABLE_PATTERNS. StdioTransport rejects
  pending requests with this message when the subprocess dies, which
  should trigger the reconnect path just like HTTP transport errors. (P2-4)

---------

Co-authored-by: Miroslav Drbal <miroslav.drbal@gendigital.com>
Co-authored-by: Can Bölük <can1357@users.noreply.github.com>
2026-03-20 23:27:30 +01:00

476 lines
14 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 { 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";
/**
* 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;
try {
response = await fetch(this.config.url, {
method: "GET",
headers,
signal: this.#sseConnection.signal,
});
} catch (error) {
this.#sseConnection = null;
if (error instanceof Error && error.name !== "AbortError") {
this.onError?.(error);
}
return;
}
if (response.status === 405 || !response.ok || !response.body) {
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 = this.#sseConnection.signal;
void this.#readSSEStream(response.body!, signal).finally(() => {
const wasConnected = this.#connected;
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
if (this.onAuthError && error instanceof Error && /^HTTP (401|403):/.test(error.message)) {
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;
}
// Create AbortController for timeout
const timeout = this.config.timeout ?? 30000;
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), timeout);
const operationSignal = options?.signal
? AbortSignal.any([options.signal, abortController.signal])
: abortController.signal;
try {
const response = await fetch(this.config.url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: operationSignal,
});
clearTimeout(timeoutId);
// 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) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === "AbortError") {
if (options?.signal?.aborted) {
throw error;
}
throw new Error(`Request timeout after ${timeout}ms`);
}
throw error;
}
}
async #parseSSEResponse<T>(
response: Response,
expectedId: string | number,
options?: MCPRequestOptions,
): Promise<T> {
if (!response.body) {
throw new Error("No response body");
}
const timeout = this.config.timeout ?? 30000;
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), timeout);
const operationSignal = options?.signal
? AbortSignal.any([options.signal, abortController.signal])
: abortController.signal;
try {
let result: T | undefined;
let captured = false;
for await (const raw of readSseJson<JsonRpcMessage | JsonRpcMessage[]>(response.body, operationSignal)) {
// Flatten batches (JSON-RPC 2.0 section 6) into individual messages.
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)
) {
clearTimeout(timeoutId);
if (message.error) {
throw new Error(`MCP error ${message.error.code}: ${message.error.message}`);
}
result = message.result as T;
captured = true;
continue;
}
this.#dispatchSSEMessage(message);
}
if (captured) break;
}
if (!captured) {
throw new Error(`No response received for request ID ${expectedId}`);
}
// Reader released after break — safe to start a background drain
// for piggybacked notifications/requests on the same stream.
this.#drainSSEBackground(response.body, operationSignal);
return result as T;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
if (options?.signal?.aborted) {
throw error;
}
throw new Error(`SSE response timeout after ${timeout}ms`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
/** Continue reading SSE messages in the background after the primary response is captured. */
#drainSSEBackground(body: ReadableStream<Uint8Array>, signal: AbortSignal): void {
void this.#readSSEStream(body, signal);
}
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;
}
try {
const resp = await fetch(this.config.url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.config.timeout ?? 30000),
});
// 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);
const retry = await fetch(this.config.url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.config.timeout ?? 30000),
});
await retry.body?.cancel();
return;
}
}
await resp.body?.cancel();
} catch {
// Best-effort response delivery — server may have disconnected
}
}
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;
}
// Create AbortController for timeout
const timeout = this.config.timeout ?? 30000;
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), timeout);
try {
const response = await fetch(this.config.url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: abortController.signal,
});
clearTimeout(timeoutId);
// 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 read until stream ends
const signal = this.#sseConnection?.signal ?? AbortSignal.timeout(this.config.timeout ?? 30000);
void this.#readSSEStream(response.body, signal);
} else {
await response.body?.cancel();
}
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === "AbortError") {
throw new Error(`Notify timeout after ${timeout}ms`);
}
throw error;
}
}
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) {
try {
const timeout = this.config.timeout ?? 30000;
const headers: Record<string, string> = {
...this.config.headers,
"Mcp-Session-Id": this.#sessionId,
};
await fetch(this.config.url, {
method: "DELETE",
headers,
signal: AbortSignal.timeout(timeout),
});
} catch {
// 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;
}