cb2d5b859a
Restored Exa's unauthenticated MCP fallback when neither auth storage nor EXA_API_KEY provides credentials. Preserved API-key search ordering and bounded the MCP request with the web-search hard timeout. Updated provider copy and regression coverage for the no-key path.\n\nFixes #1860
93 lines
2.3 KiB
TypeScript
93 lines
2.3 KiB
TypeScript
/**
|
|
* MCP JSON-RPC 2.0 over HTTPS.
|
|
*
|
|
* Lightweight utilities for calling MCP servers directly via HTTP
|
|
* without maintaining persistent connections.
|
|
*/
|
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
|
|
/** Parse SSE response format (lines starting with "data: ") */
|
|
export function parseSSE(text: string): unknown {
|
|
const lines = text.split("\n");
|
|
for (const line of lines) {
|
|
if (line.startsWith("data: ")) {
|
|
const data = line.slice(6).trim();
|
|
if (data === "[DONE]") continue;
|
|
const result = JSON.parse(data) as unknown;
|
|
if (result) return result;
|
|
}
|
|
}
|
|
// Fallback: try parsing entire response as JSON
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** JSON-RPC 2.0 response structure */
|
|
export interface JsonRpcResponse<T = unknown> {
|
|
jsonrpc: "2.0";
|
|
id: string | number;
|
|
result?: T;
|
|
error?: {
|
|
code: number;
|
|
message: string;
|
|
data?: unknown;
|
|
};
|
|
}
|
|
|
|
/** Options controlling a single MCP JSON-RPC HTTP request. */
|
|
export interface CallMcpOptions {
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
/**
|
|
* Call an MCP server with JSON-RPC 2.0 over HTTPS.
|
|
*
|
|
* @param url - Full MCP server URL (including any query parameters)
|
|
* @param method - JSON-RPC method name (e.g., "tools/list", "tools/call")
|
|
* @param params - Method parameters
|
|
* @param options - Optional transport controls such as cancellation.
|
|
* @returns Parsed JSON-RPC response
|
|
*/
|
|
export async function callMCP<T = unknown>(
|
|
url: string,
|
|
method: string,
|
|
params?: Record<string, unknown>,
|
|
options?: CallMcpOptions,
|
|
): Promise<JsonRpcResponse<T>> {
|
|
const body = {
|
|
jsonrpc: "2.0",
|
|
id: Math.random().toString(36).slice(2),
|
|
method,
|
|
params: params ?? {},
|
|
};
|
|
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json, text/event-stream",
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal: options?.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorMsg = `MCP request failed: ${response.status} ${response.statusText}`;
|
|
logger.error(errorMsg, { url, method, params });
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
const text = await response.text();
|
|
const result = parseSSE(text) as JsonRpcResponse<T> | null;
|
|
|
|
if (!result) {
|
|
logger.error("Failed to parse MCP response", { url, method, responseText: text.slice(0, 500) });
|
|
throw new Error("Failed to parse MCP response");
|
|
}
|
|
|
|
return result;
|
|
}
|