Files
oh-my-pi/packages/coding-agent/src/mcp/transports/http.ts
T

328 lines
8.3 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 { readSseJson, Snowflake } from "@oh-my-pi/pi-utils";
import type {
JsonRpcMessage,
JsonRpcResponse,
MCPHttpServerConfig,
MCPRequestOptions,
MCPSseServerConfig,
MCPTransport,
} 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;
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.
* Optional - only needed if server sends notifications.
*/
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;
}
try {
const response = await fetch(this.config.url, {
method: "GET",
headers,
signal: this.#sseConnection.signal,
});
if (response.status === 405) {
// Server doesn't support SSE listening, that's OK
this.#sseConnection = null;
return;
}
if (!response.ok || !response.body) {
this.#sseConnection = null;
return;
}
// Read SSE stream
for await (const message of readSseJson<JsonRpcMessage>(response.body, this.#sseConnection.signal)) {
if (!this.#connected) break;
if ("method" in message && !("id" in message)) {
this.onNotification?.(message.method, message.params);
}
}
} catch (error) {
if (error instanceof Error && error.name !== "AbortError") {
this.onError?.(error);
}
} finally {
this.#sseConnection = null;
}
}
async request<T = unknown>(
method: string,
params?: Record<string, unknown>,
options?: MCPRequestOptions,
): 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 {
for await (const message of readSseJson<JsonRpcMessage>(response.body, operationSignal)) {
if ("id" in message && message.id === expectedId && ("result" in message || "error" in message)) {
if (message.error) {
throw new Error(`MCP error ${message.error.code}: ${message.error.message}`);
}
return message.result as T;
}
if ("method" in message && !("id" in message)) {
this.onNotification?.(message.method, message.params);
}
}
throw new Error(`No response received for request ID ${expectedId}`);
} 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);
}
}
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}`);
}
} 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?.();
}
}
/**
* 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;
}