|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
type ListedTool = { |
|
|
name: string; |
|
|
inputSchema?: Record<string, unknown>; |
|
|
description?: string; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function isExaServer(server: McpServerConfig): boolean { |
|
|
try { |
|
|
const url = new URL(server.url); |
|
|
return url.hostname.toLowerCase() === "mcp.exa.ai"; |
|
|
} catch { |
|
|
return false; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function getExaApiKey(server: McpServerConfig): string | undefined { |
|
|
|
|
|
const headerKey = server.headers?.["x-api-key"]; |
|
|
if (headerKey && headerKey.trim().length > 0) { |
|
|
return headerKey; |
|
|
} |
|
|
|
|
|
|
|
|
try { |
|
|
const url = new URL(server.url); |
|
|
const urlKey = url.searchParams.get("exaApiKey"); |
|
|
if (urlKey) return urlKey; |
|
|
} catch {} |
|
|
|
|
|
|
|
|
const configKey = config.EXA_API_KEY; |
|
|
if (configKey && configKey.trim().length > 0) { |
|
|
return configKey; |
|
|
} |
|
|
|
|
|
return undefined; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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>; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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}`); |
|
|
} |
|
|
|
|
|
|
|
|
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"); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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"); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const requestBody: Record<string, unknown> = { |
|
|
...args, |
|
|
query, |
|
|
contents: { |
|
|
|
|
|
highlights: { |
|
|
numSentences: 3, |
|
|
highlightsPerUrl: 2, |
|
|
}, |
|
|
|
|
|
text: { |
|
|
maxCharacters: 500, |
|
|
}, |
|
|
}, |
|
|
}; |
|
|
|
|
|
|
|
|
const controller = new AbortController(); |
|
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); |
|
|
|
|
|
|
|
|
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})` |
|
|
); |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
} |
|
|
|