victor HF Staff commited on
Commit
1d3e986
·
unverified ·
1 Parent(s): 30b54ae

feat(mcp): add direct Exa API integration to bypass slow mcp.exa.ai (#2021)

Browse files

* feat(mcp): add direct Exa API integration to bypass slow mcp.exa.ai

Intercepts calls to mcp.exa.ai and routes them directly to api.exa.ai,
eliminating the slow hosted MCP server (~3-12s latency) while keeping
the same user configuration.

- Add exaDirect.ts module with direct API client
- Intercept tool listing in tools.ts to return hardcoded definitions
- Intercept tool calls in httpClient.ts to use direct API
- Add EXA_API_KEY config for authentication

No user config changes needed - existing MCP_SERVERS with mcp.exa.ai
continue to work transparently.

* Add scroll to metadata output in ToolUpdate

Wrapped the metadata <pre> block with custom scrollbar, max height, and vertical overflow for better readability when displaying large metadata content.

* fix(mcp): bypass mcp.exa.ai in health check, security/leak fixes

- Add Exa bypass to /api/mcp/health endpoint for instant response
- Support x-api-key header for API key (preferred over URL param)
- Fix event listener leak by adding { once: true }
- Remove query logging to avoid PII leakage

.env CHANGED
@@ -132,6 +132,8 @@ ALLOW_IFRAME=true # Allow the app to be embedded in an iframe
132
  MCP_SERVERS=
133
  # When true, forward the logged-in user's Hugging Face access token
134
  MCP_FORWARD_HF_USER_TOKEN=
 
 
135
  ENABLE_DATA_EXPORT=true
136
 
137
  ### Rate limits ###
 
132
  MCP_SERVERS=
133
  # When true, forward the logged-in user's Hugging Face access token
134
  MCP_FORWARD_HF_USER_TOKEN=
135
+ # Exa API key for direct API calls (bypasses slow mcp.exa.ai endpoint)
136
+ EXA_API_KEY=
137
  ENABLE_DATA_EXPORT=true
138
 
139
  ### Rate limits ###
src/lib/components/chat/ToolUpdate.svelte CHANGED
@@ -230,7 +230,8 @@
230
  {/if}
231
 
232
  {#if parsedOutput.metadata.length > 0}
233
- <pre class="whitespace-pre-wrap break-all font-mono text-xs">{formatValue(
 
234
  Object.fromEntries(parsedOutput.metadata)
235
  )}</pre>
236
  {/if}
 
230
  {/if}
231
 
232
  {#if parsedOutput.metadata.length > 0}
233
+ <pre
234
+ class="scrollbar-custom max-h-60 overflow-y-auto whitespace-pre-wrap break-all font-mono text-xs">{formatValue(
235
  Object.fromEntries(parsedOutput.metadata)
236
  )}</pre>
237
  {/if}
src/lib/server/config.ts CHANGED
@@ -158,7 +158,8 @@ type ExtraConfigKeys =
158
  | "METRICS_ENABLED"
159
  | "METRICS_PORT"
160
  | "MCP_SERVERS"
161
- | "MCP_FORWARD_HF_USER_TOKEN";
 
162
 
163
  type ConfigProxy = ConfigManager & { [K in ConfigKey | ExtraConfigKeys]: string };
164
 
 
158
  | "METRICS_ENABLED"
159
  | "METRICS_PORT"
160
  | "MCP_SERVERS"
161
+ | "MCP_FORWARD_HF_USER_TOKEN"
162
+ | "EXA_API_KEY";
163
 
164
  type ConfigProxy = ConfigManager & { [K in ConfigKey | ExtraConfigKeys]: string };
165
 
src/lib/server/mcp/exaDirect.ts ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Direct Exa API integration - bypasses mcp.exa.ai for faster responses
3
+ *
4
+ * Instead of: MCP protocol → mcp.exa.ai (slow) → Exa API
5
+ * We do: Direct call → api.exa.ai (fast)
6
+ */
7
+
8
+ import { config } from "$lib/server/config";
9
+ import type { McpServerConfig, McpToolTextResponse } from "./httpClient";
10
+
11
+ const EXA_API_BASE = "https://api.exa.ai";
12
+ const DEFAULT_TIMEOUT_MS = 30_000;
13
+
14
+ // Tool definitions matching what Exa MCP server exposes
15
+ type ListedTool = {
16
+ name: string;
17
+ inputSchema?: Record<string, unknown>;
18
+ description?: string;
19
+ };
20
+
21
+ /**
22
+ * Detect if a server is the Exa MCP server
23
+ */
24
+ export function isExaServer(server: McpServerConfig): boolean {
25
+ try {
26
+ const url = new URL(server.url);
27
+ return url.hostname.toLowerCase() === "mcp.exa.ai";
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Extract Exa API key from server headers, URL, or config
35
+ * Priority: headers["x-api-key"] > URL param > EXA_API_KEY env var
36
+ */
37
+ export function getExaApiKey(server: McpServerConfig): string | undefined {
38
+ // First check headers (preferred - avoids key in URL logs)
39
+ const headerKey = server.headers?.["x-api-key"];
40
+ if (headerKey && headerKey.trim().length > 0) {
41
+ return headerKey;
42
+ }
43
+
44
+ // Check URL params (e.g., ?exaApiKey=xxx) - discouraged but supported
45
+ try {
46
+ const url = new URL(server.url);
47
+ const urlKey = url.searchParams.get("exaApiKey");
48
+ if (urlKey) return urlKey;
49
+ } catch {}
50
+
51
+ // Fall back to config
52
+ const configKey = config.EXA_API_KEY;
53
+ if (configKey && configKey.trim().length > 0) {
54
+ return configKey;
55
+ }
56
+
57
+ return undefined;
58
+ }
59
+
60
+ /**
61
+ * Hardcoded tool definitions for Exa (matches what mcp.exa.ai returns)
62
+ */
63
+ export function getExaToolDefinitions(): ListedTool[] {
64
+ return [
65
+ {
66
+ name: "web_search_exa",
67
+ description:
68
+ "Search the web using Exa AI. Returns relevant web pages with titles, URLs, and content snippets.",
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {
72
+ query: {
73
+ type: "string",
74
+ description: "The search query",
75
+ },
76
+ numResults: {
77
+ type: "number",
78
+ description: "Number of results to return (default: 10, max: 100)",
79
+ },
80
+ type: {
81
+ type: "string",
82
+ enum: ["auto", "neural", "keyword"],
83
+ description: "Search type (default: auto)",
84
+ },
85
+ includeDomains: {
86
+ type: "array",
87
+ items: { type: "string" },
88
+ description: "Only include results from these domains",
89
+ },
90
+ excludeDomains: {
91
+ type: "array",
92
+ items: { type: "string" },
93
+ description: "Exclude results from these domains",
94
+ },
95
+ },
96
+ required: ["query"],
97
+ },
98
+ },
99
+ {
100
+ name: "get_code_context_exa",
101
+ description:
102
+ "Search for code snippets, documentation, and programming resources. Optimized for finding code examples and technical documentation.",
103
+ inputSchema: {
104
+ type: "object",
105
+ properties: {
106
+ query: {
107
+ type: "string",
108
+ description: "The code or programming-related search query",
109
+ },
110
+ numResults: {
111
+ type: "number",
112
+ description: "Number of results to return (default: 10)",
113
+ },
114
+ },
115
+ required: ["query"],
116
+ },
117
+ },
118
+ ];
119
+ }
120
+
121
+ interface ExaSearchResult {
122
+ title: string;
123
+ url: string;
124
+ id: string;
125
+ score?: number;
126
+ publishedDate?: string;
127
+ author?: string;
128
+ text?: string;
129
+ highlights?: string[];
130
+ highlightScores?: number[];
131
+ summary?: string;
132
+ }
133
+
134
+ interface ExaSearchResponse {
135
+ requestId: string;
136
+ resolvedSearchType: string;
137
+ results: ExaSearchResult[];
138
+ costDollars?: Record<string, number>;
139
+ }
140
+
141
+ /**
142
+ * Format Exa search results as human-readable text
143
+ */
144
+ function formatSearchResultsAsText(results: ExaSearchResult[]): string {
145
+ if (results.length === 0) {
146
+ return "No results found.";
147
+ }
148
+
149
+ return results
150
+ .map((result, index) => {
151
+ const parts = [`${index + 1}. ${result.title}`, ` URL: ${result.url}`];
152
+
153
+ if (result.publishedDate) {
154
+ parts.push(` Published: ${result.publishedDate}`);
155
+ }
156
+
157
+ if (result.text) {
158
+ parts.push(` ${result.text}`);
159
+ } else if (result.highlights && result.highlights.length > 0) {
160
+ parts.push(` ${result.highlights.join(" ... ")}`);
161
+ }
162
+
163
+ return parts.join("\n");
164
+ })
165
+ .join("\n\n");
166
+ }
167
+
168
+ /**
169
+ * Call Exa API directly (bypasses MCP protocol)
170
+ */
171
+ export async function callExaDirectApi(
172
+ tool: string,
173
+ args: Record<string, unknown>,
174
+ apiKey: string,
175
+ options?: { signal?: AbortSignal; timeoutMs?: number }
176
+ ): Promise<McpToolTextResponse> {
177
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
178
+
179
+ // Both tools use the /search endpoint
180
+ if (tool !== "web_search_exa" && tool !== "get_code_context_exa") {
181
+ throw new Error(`Unsupported Exa tool: ${tool}`);
182
+ }
183
+
184
+ const query = args.query as string;
185
+ if (!query || typeof query !== "string") {
186
+ throw new Error("Missing required parameter: query");
187
+ }
188
+
189
+ // Build request body - pass through all args, ensure query exists and request text content
190
+ const requestBody: Record<string, unknown> = {
191
+ ...args,
192
+ query,
193
+ // Required to get page text content, not just metadata
194
+ contents: {
195
+ text: true,
196
+ },
197
+ };
198
+
199
+ // Create abort controller for timeout
200
+ const controller = new AbortController();
201
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
202
+
203
+ // Combine with external signal if provided
204
+ if (options?.signal) {
205
+ options.signal.addEventListener("abort", () => controller.abort(), { once: true });
206
+ }
207
+
208
+ const startTime = Date.now();
209
+
210
+ try {
211
+ const response = await fetch(`${EXA_API_BASE}/search`, {
212
+ method: "POST",
213
+ headers: {
214
+ "Content-Type": "application/json",
215
+ "x-api-key": apiKey,
216
+ },
217
+ body: JSON.stringify(requestBody),
218
+ signal: controller.signal,
219
+ });
220
+
221
+ clearTimeout(timeoutId);
222
+
223
+ if (!response.ok) {
224
+ const errorText = await response.text();
225
+ console.log(`[EXA DIRECT] API error: ${response.status} - ${errorText}`);
226
+ throw new Error(`Exa API error: ${response.status} ${response.statusText} - ${errorText}`);
227
+ }
228
+
229
+ const data = (await response.json()) as ExaSearchResponse;
230
+ const duration = Date.now() - startTime;
231
+ console.log(
232
+ `[EXA DIRECT] Success in ${duration}ms - ${data.results.length} results (type: ${data.resolvedSearchType})`
233
+ );
234
+
235
+ // Format response to match MCP tool response format
236
+ const text = formatSearchResultsAsText(data.results);
237
+
238
+ return {
239
+ text,
240
+ structured: data.results,
241
+ content: [{ type: "text", text }],
242
+ };
243
+ } catch (err) {
244
+ clearTimeout(timeoutId);
245
+ const duration = Date.now() - startTime;
246
+
247
+ if (err instanceof Error && err.name === "AbortError") {
248
+ console.log(`[EXA DIRECT] Timeout after ${duration}ms`);
249
+ throw new Error(`Exa API request timed out after ${timeoutMs}ms`);
250
+ }
251
+
252
+ console.log(`[EXA DIRECT] Failed after ${duration}ms: ${err}`);
253
+ throw err;
254
+ }
255
+ }
src/lib/server/mcp/httpClient.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { Client } from "@modelcontextprotocol/sdk/client";
2
  import { getClient, evictFromPool } from "./clientPool";
 
3
 
4
  function isConnectionClosedError(err: unknown): boolean {
5
  const message = err instanceof Error ? err.message : String(err);
@@ -32,6 +33,21 @@ export async function callMcpTool(
32
  client,
33
  }: { timeoutMs?: number; signal?: AbortSignal; client?: Client } = {}
34
  ): Promise<McpToolTextResponse> {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  const normalizedArgs =
36
  typeof args === "object" && args !== null && !Array.isArray(args)
37
  ? (args as Record<string, unknown>)
 
1
  import { Client } from "@modelcontextprotocol/sdk/client";
2
  import { getClient, evictFromPool } from "./clientPool";
3
+ import { isExaServer, getExaApiKey, callExaDirectApi } from "./exaDirect";
4
 
5
  function isConnectionClosedError(err: unknown): boolean {
6
  const message = err instanceof Error ? err.message : String(err);
 
33
  client,
34
  }: { timeoutMs?: number; signal?: AbortSignal; client?: Client } = {}
35
  ): Promise<McpToolTextResponse> {
36
+ // Bypass MCP protocol for Exa - call direct API
37
+ if (isExaServer(server)) {
38
+ const apiKey = getExaApiKey(server);
39
+ if (!apiKey) {
40
+ throw new Error(
41
+ "Exa API key not found. Set EXA_API_KEY environment variable or add ?exaApiKey= to the server URL."
42
+ );
43
+ }
44
+ const normalizedArgs =
45
+ typeof args === "object" && args !== null && !Array.isArray(args)
46
+ ? (args as Record<string, unknown>)
47
+ : {};
48
+ return callExaDirectApi(tool, normalizedArgs, apiKey, { signal, timeoutMs });
49
+ }
50
+
51
  const normalizedArgs =
52
  typeof args === "object" && args !== null && !Array.isArray(args)
53
  ? (args as Record<string, unknown>)
src/lib/server/mcp/tools.ts CHANGED
@@ -3,6 +3,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
3
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
  import type { McpServerConfig } from "./httpClient";
5
  import { logger } from "$lib/server/logger";
 
6
  // use console.* for lightweight diagnostics in production logs
7
 
8
  export type OpenAiTool = {
@@ -65,6 +66,16 @@ async function listServerTools(
65
  server: McpServerConfig,
66
  opts: { signal?: AbortSignal } = {}
67
  ): Promise<ListedTool[]> {
 
 
 
 
 
 
 
 
 
 
68
  const url = new URL(server.url);
69
  const client = new Client({ name: "chat-ui-mcp", version: "0.1.0" });
70
  try {
 
3
  import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
  import type { McpServerConfig } from "./httpClient";
5
  import { logger } from "$lib/server/logger";
6
+ import { isExaServer, getExaToolDefinitions } from "./exaDirect";
7
  // use console.* for lightweight diagnostics in production logs
8
 
9
  export type OpenAiTool = {
 
66
  server: McpServerConfig,
67
  opts: { signal?: AbortSignal } = {}
68
  ): Promise<ListedTool[]> {
69
+ // Bypass MCP protocol for Exa - return hardcoded tool definitions
70
+ if (isExaServer(server)) {
71
+ const tools = getExaToolDefinitions();
72
+ logger.debug(
73
+ { server: server.name, count: tools.length, toolNames: tools.map((t) => t.name) },
74
+ "[mcp] using direct Exa tool definitions (bypassing MCP)"
75
+ );
76
+ return tools;
77
+ }
78
+
79
  const url = new URL(server.url);
80
  const client = new Client({ name: "chat-ui-mcp", version: "0.1.0" });
81
  try {
src/routes/api/mcp/health/+server.ts CHANGED
@@ -7,6 +7,7 @@ import { logger } from "$lib/server/logger";
7
  import type { RequestHandler } from "./$types";
8
  import { isValidUrl } from "$lib/server/urlSafety";
9
  import { isStrictHfMcpLogin, hasNonEmptyToken } from "$lib/server/mcp/hf";
 
10
 
11
  interface HealthCheckRequest {
12
  url: string;
@@ -50,6 +51,24 @@ export const POST: RequestHandler = async ({ request, locals }) => {
50
  );
51
  }
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  const baseUrl = new URL(url);
54
 
55
  // Minimal header handling
 
7
  import type { RequestHandler } from "./$types";
8
  import { isValidUrl } from "$lib/server/urlSafety";
9
  import { isStrictHfMcpLogin, hasNonEmptyToken } from "$lib/server/mcp/hf";
10
+ import { isExaServer, getExaToolDefinitions } from "$lib/server/mcp/exaDirect";
11
 
12
  interface HealthCheckRequest {
13
  url: string;
 
51
  );
52
  }
53
 
54
+ // Bypass MCP protocol for Exa - return hardcoded tool definitions immediately
55
+ if (isExaServer({ name: "", url, headers: {} })) {
56
+ const tools = getExaToolDefinitions();
57
+ const response: HealthCheckResponse = {
58
+ ready: true,
59
+ tools: tools.map((tool) => ({
60
+ name: tool.name,
61
+ description: tool.description,
62
+ inputSchema: tool.inputSchema,
63
+ })),
64
+ authRequired: false,
65
+ };
66
+ return new Response(JSON.stringify(response), {
67
+ status: 200,
68
+ headers: { "Content-Type": "application/json" },
69
+ });
70
+ }
71
+
72
  const baseUrl = new URL(url);
73
 
74
  // Minimal header handling