/** * Minimal JSON-RPC 2.0 MCP client for the `tools/call` method over HTTP. * * Exa streams Server-Sent Events while Parallel returns a JSON document. * Both response forms are handled without a runtime dependency. */ export interface McpCallOptions { url: string; tool: string; args: Record; headers?: Record; timeoutMs: number; signal?: AbortSignal; } type RequestSignal = { signal: AbortSignal; didTimeout(): boolean; dispose(): void; }; function createRequestSignal(callerSignal: AbortSignal | undefined, timeoutMs: number): RequestSignal { const controller = new AbortController(); let timedOut = false; const abortFromCaller = () => controller.abort(); if (callerSignal?.aborted) abortFromCaller(); else callerSignal?.addEventListener("abort", abortFromCaller, { once: true }); const timeout = setTimeout(() => { if (!controller.signal.aborted) { timedOut = true; controller.abort(); } }, timeoutMs); return { signal: controller.signal, didTimeout: () => timedOut, dispose: () => { clearTimeout(timeout); callerSignal?.removeEventListener("abort", abortFromCaller); }, }; } function truncate(text: string, max: number): string { return text.length > max ? `${text.slice(0, max)}...` : text; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function errorMessage(value: unknown, fallback: string): string { if (isRecord(value) && typeof value.message === "string") return value.message; try { return JSON.stringify(value) || fallback; } catch { return fallback; } } function extractText(data: unknown): string | undefined { if (!isRecord(data)) return undefined; if (data.error !== undefined) throw new Error(errorMessage(data.error, "MCP request failed")); const result = data.result; if (typeof result === "string") return result; if (!isRecord(result)) return undefined; const content = Array.isArray(result.content) ? result.content : []; const texts = content.flatMap((item) => isRecord(item) && typeof item.text === "string" ? [item.text] : [] ); if (result.isError) { throw new Error(texts.join("\n") || "MCP tool returned an error result"); } return texts.length > 0 ? texts.join("\n\n") : undefined; } function parseJsonPayload(payload: string): string | undefined { const trimmed = payload.trim(); if (!trimmed.startsWith("{")) return undefined; let data: unknown; try { data = JSON.parse(trimmed); } catch { return undefined; } return extractText(data); } export function parseResponse(body: string): string | undefined { const direct = parseJsonPayload(body); if (direct !== undefined) return direct; for (const line of body.split("\n")) { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) continue; const parsed = parseJsonPayload(trimmed.slice("data:".length).trimStart()); if (parsed !== undefined) return parsed; } return undefined; } export async function callTool(options: McpCallOptions): Promise { const { url, tool, args, headers, timeoutMs, signal } = options; const request = createRequestSignal(signal, timeoutMs); try { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", ...headers, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args }, }), signal: request.signal, }); if (!response.ok) { const detail = await response.text().catch(() => ""); throw new Error(`${tool} request failed (${response.status}): ${truncate(detail, 300)}`); } const body = await response.text(); const parsed = parseResponse(body); if (parsed === undefined) throw new Error(`${tool} returned an unparseable response`); return parsed; } catch (error) { if (request.didTimeout()) { throw new Error(`${tool} request timed out after ${Math.round(timeoutMs / 1000)}s`); } if (signal?.aborted) throw new Error(`${tool} request was cancelled`); if (error instanceof Error) throw error; throw new Error(String(error)); } finally { request.dispose(); } }