chat-ui / src /lib /server /mcp /exaDirect.ts
victor's picture
victor HF Staff
fix(mcp): reduce Exa API context size sent to LLM (#2026)
2d994c9 unverified
/**
* Direct Exa API integration - bypasses mcp.exa.ai for faster responses
*
* Instead of: MCP protocol → mcp.exa.ai (slow) → Exa API
* We do: Direct call → api.exa.ai (fast)
*/
import { config } from "$lib/server/config";
import type { McpServerConfig, McpToolTextResponse } from "./httpClient";
const EXA_API_BASE = "https://api.exa.ai";
const DEFAULT_TIMEOUT_MS = 30_000;
// Tool definitions matching what Exa MCP server exposes
type ListedTool = {
name: string;
inputSchema?: Record<string, unknown>;
description?: string;
};
/**
* Detect if a server is the Exa MCP server
*/
export function isExaServer(server: McpServerConfig): boolean {
try {
const url = new URL(server.url);
return url.hostname.toLowerCase() === "mcp.exa.ai";
} catch {
return false;
}
}
/**
* Extract Exa API key from server headers, URL, or config
* Priority: headers["x-api-key"] > URL param > EXA_API_KEY env var
*/
export function getExaApiKey(server: McpServerConfig): string | undefined {
// First check headers (preferred - avoids key in URL logs)
const headerKey = server.headers?.["x-api-key"];
if (headerKey && headerKey.trim().length > 0) {
return headerKey;
}
// Check URL params (e.g., ?exaApiKey=xxx) - discouraged but supported
try {
const url = new URL(server.url);
const urlKey = url.searchParams.get("exaApiKey");
if (urlKey) return urlKey;
} catch {}
// Fall back to config
const configKey = config.EXA_API_KEY;
if (configKey && configKey.trim().length > 0) {
return configKey;
}
return undefined;
}
/**
* Hardcoded tool definitions for Exa (matches what mcp.exa.ai returns)
*/
export function getExaToolDefinitions(): ListedTool[] {
return [
{
name: "web_search_exa",
description:
"Search the web using Exa AI. Returns relevant web pages with titles, URLs, and content snippets.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "The search query",
},
numResults: {
type: "number",
description: "Number of results to return (default: 10, max: 100)",
},
type: {
type: "string",
enum: ["auto", "neural", "keyword"],
description: "Search type (default: auto)",
},
includeDomains: {
type: "array",
items: { type: "string" },
description: "Only include results from these domains",
},
excludeDomains: {
type: "array",
items: { type: "string" },
description: "Exclude results from these domains",
},
},
required: ["query"],
},
},
{
name: "get_code_context_exa",
description:
"Search for code snippets, documentation, and programming resources. Optimized for finding code examples and technical documentation.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "The code or programming-related search query",
},
numResults: {
type: "number",
description: "Number of results to return (default: 10)",
},
},
required: ["query"],
},
},
];
}
interface ExaSearchResult {
title: string;
url: string;
id: string;
score?: number;
publishedDate?: string;
author?: string;
text?: string;
highlights?: string[];
highlightScores?: number[];
summary?: string;
}
interface ExaSearchResponse {
requestId: string;
resolvedSearchType: string;
results: ExaSearchResult[];
costDollars?: Record<string, number>;
}
/**
* Format Exa search results as human-readable text
* Prioritizes highlights (concise snippets) over full text to minimize LLM context
*/
function formatSearchResultsAsText(results: ExaSearchResult[]): string {
if (results.length === 0) {
return "No results found.";
}
return results
.map((result, index) => {
const parts = [`${index + 1}. ${result.title}`, ` URL: ${result.url}`];
if (result.publishedDate) {
parts.push(` Published: ${result.publishedDate}`);
}
// Prefer highlights (concise, query-relevant) over full text
if (result.highlights && result.highlights.length > 0) {
parts.push(` ${result.highlights.join(" ... ")}`);
} else if (result.text) {
parts.push(` ${result.text}`);
}
return parts.join("\n");
})
.join("\n\n");
}
/**
* Call Exa API directly (bypasses MCP protocol)
*/
export async function callExaDirectApi(
tool: string,
args: Record<string, unknown>,
apiKey: string,
options?: { signal?: AbortSignal; timeoutMs?: number }
): Promise<McpToolTextResponse> {
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
// Both tools use the /search endpoint
if (tool !== "web_search_exa" && tool !== "get_code_context_exa") {
throw new Error(`Unsupported Exa tool: ${tool}`);
}
const query = args.query as string;
if (!query || typeof query !== "string") {
throw new Error("Missing required parameter: query");
}
// Build request body - pass through all args, ensure query exists
// Use highlights (not full text) to minimize context size sent to LLM
// This matches what the MCP server at mcp.exa.ai returns
const requestBody: Record<string, unknown> = {
...args,
query,
contents: {
// Use highlights for concise, query-relevant snippets (much smaller than full text)
highlights: {
numSentences: 3,
highlightsPerUrl: 2,
},
// Also get limited text as fallback if highlights are empty
text: {
maxCharacters: 500,
},
},
};
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
// Combine with external signal if provided
if (options?.signal) {
options.signal.addEventListener("abort", () => controller.abort(), { once: true });
}
const startTime = Date.now();
try {
const response = await fetch(`${EXA_API_BASE}/search`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
},
body: JSON.stringify(requestBody),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
console.log(`[EXA DIRECT] API error: ${response.status} - ${errorText}`);
throw new Error(`Exa API error: ${response.status} ${response.statusText} - ${errorText}`);
}
const data = (await response.json()) as ExaSearchResponse;
const duration = Date.now() - startTime;
console.log(
`[EXA DIRECT] Success in ${duration}ms - ${data.results.length} results (type: ${data.resolvedSearchType})`
);
// Format response to match MCP tool response format
const text = formatSearchResultsAsText(data.results);
return {
text,
structured: data.results,
content: [{ type: "text", text }],
};
} catch (err) {
clearTimeout(timeoutId);
const duration = Date.now() - startTime;
if (err instanceof Error && err.name === "AbortError") {
console.log(`[EXA DIRECT] Timeout after ${duration}ms`);
throw new Error(`Exa API request timed out after ${timeoutMs}ms`);
}
console.log(`[EXA DIRECT] Failed after ${duration}ms: ${err}`);
throw err;
}
}