@tonydua/dsh-web-search-exa 0.1.5 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +223 -304
- package/README.zh.md +210 -188
- package/lib/index.d.ts +55 -4
- package/lib/index.js +197 -9
- package/package.json +12 -5
package/lib/index.d.ts
CHANGED
|
@@ -67,6 +67,8 @@ interface ExaMcpSection {
|
|
|
67
67
|
}
|
|
68
68
|
//#endregion
|
|
69
69
|
//#region src/provider.d.ts
|
|
70
|
+
/** MCP tool names the anonymous path can call. */
|
|
71
|
+
type ExaMcpTool = 'web_search_exa' | 'web_search_advanced_exa';
|
|
70
72
|
/**
|
|
71
73
|
* Fully resolved options the provider serves one search with. Produced by
|
|
72
74
|
* {@link resolveOptions} from the current Settings section, so every field is
|
|
@@ -83,6 +85,7 @@ interface ExaSearchProviderOptions {
|
|
|
83
85
|
readonly baseURL: string;
|
|
84
86
|
readonly apiURL?: string | undefined;
|
|
85
87
|
readonly mcpURL: string;
|
|
88
|
+
readonly mcpTool: ExaMcpTool;
|
|
86
89
|
readonly searchType: ExaSearchType;
|
|
87
90
|
readonly numResults?: number | undefined;
|
|
88
91
|
readonly highlightsPerResult: number;
|
|
@@ -96,6 +99,7 @@ interface ExaSearchProviderConfig {
|
|
|
96
99
|
/** @deprecated Use `baseURL`; this full endpoint remains supported for compatibility. */
|
|
97
100
|
apiURL?: string;
|
|
98
101
|
mcpURL?: string;
|
|
102
|
+
mcpTool?: ExaMcpTool;
|
|
99
103
|
searchType?: ExaSearchType;
|
|
100
104
|
numResults?: number;
|
|
101
105
|
highlightsPerResult?: number;
|
|
@@ -154,6 +158,18 @@ export declare class ExaTransientError extends WebError {
|
|
|
154
158
|
export declare class ExaRateLimitError extends WebError {
|
|
155
159
|
constructor(message: string);
|
|
156
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* An anonymous response body that exceeded {@link MAX_MCP_RESPONSE_BYTES}.
|
|
163
|
+
*
|
|
164
|
+
* Extends {@link ExaTransientError} because the endpoint, not the caller's
|
|
165
|
+
* configuration, produced it: a body this size is a bad day on Exa's side, and
|
|
166
|
+
* the breaker should get to count it.
|
|
167
|
+
*/
|
|
168
|
+
export declare class ExaResponseTooLargeError extends ExaTransientError {
|
|
169
|
+
/** The number of bytes observed, or `undefined` when the server declared the size. */
|
|
170
|
+
readonly observedBytes: number | undefined;
|
|
171
|
+
constructor(limit: number, observedBytes?: number);
|
|
172
|
+
}
|
|
157
173
|
/**
|
|
158
174
|
* Consecutive-transient-failure breaker for the keyless path.
|
|
159
175
|
*
|
|
@@ -242,16 +258,37 @@ export declare const PROVIDER_ID = "exa";
|
|
|
242
258
|
export declare const DEFAULT_BASE_URL = "https://api.exa.ai";
|
|
243
259
|
/** Legacy full REST endpoint; `baseURL` is the canonical dsh-compatible option. */
|
|
244
260
|
export declare const DEFAULT_API_URL = "https://api.exa.ai/search";
|
|
245
|
-
/**
|
|
246
|
-
|
|
261
|
+
/**
|
|
262
|
+
* Exa hosted MCP endpoint; the anonymous fallback path.
|
|
263
|
+
*
|
|
264
|
+
* The `tools` query is part of the default because `web_search_advanced_exa`
|
|
265
|
+
* is not servable without it — a request naming it against the bare endpoint
|
|
266
|
+
* fails with `MCP error -32602: Tool web_search_advanced_exa not found`.
|
|
267
|
+
* A configured `mcpURL` that omits `tools` gets the query spliced in at request
|
|
268
|
+
* time, so existing configurations keep working.
|
|
269
|
+
*/
|
|
270
|
+
export declare const DEFAULT_MCP_URL = "https://mcp.exa.ai/mcp?tools=web_search_exa,web_search_advanced_exa";
|
|
247
271
|
/** Environment variable consulted when no literal `apiKey` is configured. */
|
|
248
272
|
export declare const DEFAULT_API_KEY_ENV = "EXA_API_KEY";
|
|
249
273
|
/** Default retrieval mode for the REST path: let Exa pick. */
|
|
250
274
|
export declare const DEFAULT_SEARCH_TYPE = "auto";
|
|
251
275
|
/** Default number of highlight sentences requested per result (REST path). */
|
|
252
276
|
export declare const DEFAULT_HIGHLIGHTS_PER_RESULT = 1;
|
|
253
|
-
/** MCP tool name for plain web search on Exa's hosted server. */
|
|
277
|
+
/** MCP tool name for plain web search on Exa's hosted server (text-blob output). */
|
|
254
278
|
export declare const MCP_TOOL = "web_search_exa";
|
|
279
|
+
/** MCP tool whose text content is a sanitized structured search response. */
|
|
280
|
+
export declare const MCP_TOOL_ADVANCED = "web_search_advanced_exa";
|
|
281
|
+
/** The tool the anonymous path calls by default. */
|
|
282
|
+
export declare const DEFAULT_MCP_TOOL = "web_search_advanced_exa";
|
|
283
|
+
/** Query parameter enabling both MCP tools when a configured URL omits it. */
|
|
284
|
+
export declare const MCP_TOOLS_QUERY = "tools=web_search_exa,web_search_advanced_exa";
|
|
285
|
+
/**
|
|
286
|
+
* Reject anonymous MCP responses larger than this.
|
|
287
|
+
*
|
|
288
|
+
* Structured results are kilobytes; anything past this is a malformed or
|
|
289
|
+
* hostile body and parsing it would only burn memory before failing anyway.
|
|
290
|
+
*/
|
|
291
|
+
export declare const MAX_MCP_RESPONSE_BYTES: number;
|
|
255
292
|
/**
|
|
256
293
|
* Attribution header sent on anonymous MCP requests. This is the only signal
|
|
257
294
|
* Exa's public endpoint receives about the caller, so it is deliberately a
|
|
@@ -295,6 +332,13 @@ declare const Config: z<Schemastery.ObjectS<{
|
|
|
295
332
|
apiURL: z<string, string>;
|
|
296
333
|
/** Exa hosted MCP endpoint, used by the anonymous fallback. */
|
|
297
334
|
mcpURL: z<string, string>;
|
|
335
|
+
/**
|
|
336
|
+
* MCP tool the anonymous path calls. `web_search_advanced_exa` returns a
|
|
337
|
+
* sanitized structured JSON response; `web_search_exa` returns the
|
|
338
|
+
* `Title:`-section text blob. The structured tool is the default because it
|
|
339
|
+
* needs no text parsing, and the text path stays available as a fallback.
|
|
340
|
+
*/
|
|
341
|
+
mcpTool: z<"web_search_exa" | "web_search_advanced_exa", "web_search_exa" | "web_search_advanced_exa">;
|
|
298
342
|
/** REST retrieval mode: `auto`, `keyword`, or `neural`. */
|
|
299
343
|
searchType: z<"auto" | "keyword" | "neural", "auto" | "keyword" | "neural">;
|
|
300
344
|
/** Default result count when the request carries no `maxResults`. */
|
|
@@ -324,6 +368,13 @@ declare const Config: z<Schemastery.ObjectS<{
|
|
|
324
368
|
apiURL: z<string, string>;
|
|
325
369
|
/** Exa hosted MCP endpoint, used by the anonymous fallback. */
|
|
326
370
|
mcpURL: z<string, string>;
|
|
371
|
+
/**
|
|
372
|
+
* MCP tool the anonymous path calls. `web_search_advanced_exa` returns a
|
|
373
|
+
* sanitized structured JSON response; `web_search_exa` returns the
|
|
374
|
+
* `Title:`-section text blob. The structured tool is the default because it
|
|
375
|
+
* needs no text parsing, and the text path stays available as a fallback.
|
|
376
|
+
*/
|
|
377
|
+
mcpTool: z<"web_search_exa" | "web_search_advanced_exa", "web_search_exa" | "web_search_advanced_exa">;
|
|
327
378
|
/** REST retrieval mode: `auto`, `keyword`, or `neural`. */
|
|
328
379
|
searchType: z<"auto" | "keyword" | "neural", "auto" | "keyword" | "neural">;
|
|
329
380
|
/** Default result count when the request carries no `maxResults`. */
|
|
@@ -383,4 +434,4 @@ declare function installSettingsSection(settings: SettingsServiceLike, owner: un
|
|
|
383
434
|
*/
|
|
384
435
|
export declare function apply(ctx: Context, config: ExaSearchProviderConfig): void;
|
|
385
436
|
//#endregion
|
|
386
|
-
export { Config, type ExaApiKeyResolver, type ExaKeyEnvironment, type ExaMcpSection, type ExaOptionsResolver, type ExaRestResponse, type ExaRestResult, type ExaSearchProviderConfig, type ExaSearchProviderOptions, type ExaSearchType, type McpContentItem, type McpJsonRpcError, type McpPayload, type McpToolResult, installSettingsSection };
|
|
437
|
+
export { Config, type ExaApiKeyResolver, type ExaKeyEnvironment, type ExaMcpSection, type ExaMcpTool, type ExaOptionsResolver, type ExaRestResponse, type ExaRestResult, type ExaSearchProviderConfig, type ExaSearchProviderOptions, type ExaSearchType, type McpContentItem, type McpJsonRpcError, type McpPayload, type McpToolResult, installSettingsSection };
|
package/lib/index.js
CHANGED
|
@@ -20,16 +20,37 @@ const PROVIDER_ID = "exa";
|
|
|
20
20
|
const DEFAULT_BASE_URL = "https://api.exa.ai";
|
|
21
21
|
/** Legacy full REST endpoint; `baseURL` is the canonical dsh-compatible option. */
|
|
22
22
|
const DEFAULT_API_URL = `${DEFAULT_BASE_URL}/search`;
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Exa hosted MCP endpoint; the anonymous fallback path.
|
|
25
|
+
*
|
|
26
|
+
* The `tools` query is part of the default because `web_search_advanced_exa`
|
|
27
|
+
* is not servable without it — a request naming it against the bare endpoint
|
|
28
|
+
* fails with `MCP error -32602: Tool web_search_advanced_exa not found`.
|
|
29
|
+
* A configured `mcpURL` that omits `tools` gets the query spliced in at request
|
|
30
|
+
* time, so existing configurations keep working.
|
|
31
|
+
*/
|
|
32
|
+
const DEFAULT_MCP_URL = "https://mcp.exa.ai/mcp?tools=web_search_exa,web_search_advanced_exa";
|
|
25
33
|
/** Environment variable consulted when no literal `apiKey` is configured. */
|
|
26
34
|
const DEFAULT_API_KEY_ENV = "EXA_API_KEY";
|
|
27
35
|
/** Default retrieval mode for the REST path: let Exa pick. */
|
|
28
36
|
const DEFAULT_SEARCH_TYPE = "auto";
|
|
29
37
|
/** Default number of highlight sentences requested per result (REST path). */
|
|
30
38
|
const DEFAULT_HIGHLIGHTS_PER_RESULT = 1;
|
|
31
|
-
/** MCP tool name for plain web search on Exa's hosted server. */
|
|
39
|
+
/** MCP tool name for plain web search on Exa's hosted server (text-blob output). */
|
|
32
40
|
const MCP_TOOL = "web_search_exa";
|
|
41
|
+
/** MCP tool whose text content is a sanitized structured search response. */
|
|
42
|
+
const MCP_TOOL_ADVANCED = "web_search_advanced_exa";
|
|
43
|
+
/** The tool the anonymous path calls by default. */
|
|
44
|
+
const DEFAULT_MCP_TOOL = MCP_TOOL_ADVANCED;
|
|
45
|
+
/** Query parameter enabling both MCP tools when a configured URL omits it. */
|
|
46
|
+
const MCP_TOOLS_QUERY = "tools=web_search_exa,web_search_advanced_exa";
|
|
47
|
+
/**
|
|
48
|
+
* Reject anonymous MCP responses larger than this.
|
|
49
|
+
*
|
|
50
|
+
* Structured results are kilobytes; anything past this is a malformed or
|
|
51
|
+
* hostile body and parsing it would only burn memory before failing anyway.
|
|
52
|
+
*/
|
|
53
|
+
const MAX_MCP_RESPONSE_BYTES = 262144;
|
|
33
54
|
/**
|
|
34
55
|
* Attribution header sent on anonymous MCP requests. This is the only signal
|
|
35
56
|
* Exa's public endpoint receives about the caller, so it is deliberately a
|
|
@@ -110,6 +131,136 @@ function mapRestResult(result) {
|
|
|
110
131
|
};
|
|
111
132
|
}
|
|
112
133
|
/**
|
|
134
|
+
* Read an anonymous response body while refusing to buffer more than `limit`
|
|
135
|
+
* bytes.
|
|
136
|
+
*
|
|
137
|
+
* The cap has to be enforced *while reading*, not after: `await
|
|
138
|
+
* response.text()` materializes the whole body first, so a size check that
|
|
139
|
+
* follows it protects nothing — the memory has already been spent, and
|
|
140
|
+
* `new TextEncoder().encode(text)` spends a second copy of it just to measure
|
|
141
|
+
* the first. This helper instead:
|
|
142
|
+
*
|
|
143
|
+
* 1. rejects immediately when the server declares an over-limit
|
|
144
|
+
* `content-length`, so the body is never requested;
|
|
145
|
+
* 2. otherwise reads the stream chunk by chunk, aborting the transfer as soon
|
|
146
|
+
* as the running byte count passes `limit`.
|
|
147
|
+
*
|
|
148
|
+
* The limit is a memory boundary on the keyless path (a shared, unauthenticated
|
|
149
|
+
* endpoint), not a judgement about the result set: the structured tool returns
|
|
150
|
+
* whole-page text for every hit, so a large-but-legitimate response is possible
|
|
151
|
+
* and is reported as a transient failure rather than a silent empty result.
|
|
152
|
+
*
|
|
153
|
+
* @param response - the ok response whose body is to be read.
|
|
154
|
+
* @param limit - the maximum number of bytes to buffer.
|
|
155
|
+
* @returns the decoded body text.
|
|
156
|
+
* @throws {ExaResponseTooLargeError} when the body is, or grows, past `limit`.
|
|
157
|
+
*/
|
|
158
|
+
async function readBoundedBody(response, limit) {
|
|
159
|
+
const declared = response.headers.get("content-length");
|
|
160
|
+
if (declared !== null) {
|
|
161
|
+
const declaredBytes = Number(declared);
|
|
162
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > limit) {
|
|
163
|
+
await response.body?.cancel().catch(() => {});
|
|
164
|
+
throw new ExaResponseTooLargeError(limit, declaredBytes);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const body = response.body;
|
|
168
|
+
if (body === null) return await response.text();
|
|
169
|
+
const reader = body.getReader();
|
|
170
|
+
const chunks = [];
|
|
171
|
+
let received = 0;
|
|
172
|
+
try {
|
|
173
|
+
for (;;) {
|
|
174
|
+
const { done, value } = await reader.read();
|
|
175
|
+
if (done) break;
|
|
176
|
+
received += value.byteLength;
|
|
177
|
+
if (received > limit) {
|
|
178
|
+
await reader.cancel().catch(() => {});
|
|
179
|
+
throw new ExaResponseTooLargeError(limit, received);
|
|
180
|
+
}
|
|
181
|
+
chunks.push(value);
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
reader.releaseLock();
|
|
185
|
+
}
|
|
186
|
+
const merged = new Uint8Array(received);
|
|
187
|
+
let offset = 0;
|
|
188
|
+
for (const chunk of chunks) {
|
|
189
|
+
merged.set(chunk, offset);
|
|
190
|
+
offset += chunk.byteLength;
|
|
191
|
+
}
|
|
192
|
+
return new TextDecoder().decode(merged);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Return the request URL for an anonymous advanced-tool search: when the
|
|
196
|
+
* configured MCP URL carries no `tools` parameter, splice in the query that
|
|
197
|
+
* enables both tools — the advanced tool is not servable otherwise. An existing
|
|
198
|
+
* query string is preserved.
|
|
199
|
+
*
|
|
200
|
+
* @param baseURL - the configured MCP endpoint.
|
|
201
|
+
* @returns the request URL with a non-empty `tools` query present.
|
|
202
|
+
*/
|
|
203
|
+
function endpointFor(baseURL) {
|
|
204
|
+
const queryIndex = baseURL.indexOf("?");
|
|
205
|
+
if (queryIndex >= 0) {
|
|
206
|
+
const tools = new URLSearchParams(baseURL.slice(queryIndex + 1)).get("tools");
|
|
207
|
+
if (tools != null && tools.length > 0) return baseURL;
|
|
208
|
+
return `${baseURL}&${MCP_TOOLS_QUERY}`;
|
|
209
|
+
}
|
|
210
|
+
return `${baseURL}?${MCP_TOOLS_QUERY}`;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Map one sanitized advanced-tool result to a normalized source, or `undefined`
|
|
214
|
+
* when it has no portable snippet. The advanced tool returns the REST result
|
|
215
|
+
* vocabulary as JSON, so this mirrors {@link mapRestResult} — including its rule
|
|
216
|
+
* that a snippet must be a real highlight, never the long-form `text` field,
|
|
217
|
+
* because a fabricated snippet would make the seam lie.
|
|
218
|
+
*
|
|
219
|
+
* @param result - one structured entry from the sanitized response.
|
|
220
|
+
* @returns a normalized source, or `undefined` when the entry is unusable.
|
|
221
|
+
*/
|
|
222
|
+
function mapAdvancedResult(result) {
|
|
223
|
+
if (typeof result !== "object" || result === null) return void 0;
|
|
224
|
+
const entry = result;
|
|
225
|
+
if (typeof entry.url !== "string" || entry.url.length === 0) return void 0;
|
|
226
|
+
const snippet = (Array.isArray(entry.highlights) ? entry.highlights : void 0)?.find((highlight) => typeof highlight === "string" && highlight.trim().length > 0);
|
|
227
|
+
if (snippet === void 0) return void 0;
|
|
228
|
+
const title = typeof entry.title === "string" ? entry.title : void 0;
|
|
229
|
+
const publishedDate = typeof entry.publishedDate === "string" ? entry.publishedDate : void 0;
|
|
230
|
+
return {
|
|
231
|
+
url: entry.url,
|
|
232
|
+
...title != null && title.length > 0 ? { title } : {},
|
|
233
|
+
snippet,
|
|
234
|
+
...publishedDate != null && publishedDate.length > 0 ? { publishedAt: publishedDate } : {}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Extract sources from a successful advanced-tool payload: the first text item
|
|
239
|
+
* is the sanitized search response JSON, in the REST envelope shape
|
|
240
|
+
* (`{ results: [...] }`).
|
|
241
|
+
*
|
|
242
|
+
* @param payload - the parsed JSON-RPC payload.
|
|
243
|
+
* @returns the normalized sources, or `null` when the body is not the expected
|
|
244
|
+
* shape — the caller then falls back to `Title:`-section parsing, so a future
|
|
245
|
+
* change to the tool's output degrades instead of breaking.
|
|
246
|
+
*/
|
|
247
|
+
function parseAdvancedPayload(payload) {
|
|
248
|
+
const content = payload?.result?.content;
|
|
249
|
+
if (!Array.isArray(content)) return null;
|
|
250
|
+
const text = content.find((item) => typeof item?.text === "string")?.text;
|
|
251
|
+
if (text === void 0) return null;
|
|
252
|
+
let parsed;
|
|
253
|
+
try {
|
|
254
|
+
parsed = JSON.parse(text);
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
259
|
+
if (parsed.results === void 0) return [];
|
|
260
|
+
if (!Array.isArray(parsed.results)) return null;
|
|
261
|
+
return parsed.results.map(mapAdvancedResult).filter((source) => source !== void 0);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
113
264
|
* Parse an SSE (`text/event-stream`) response body into its first `data:`
|
|
114
265
|
* payload, falling back to plain JSON. Returns `null` when neither parses.
|
|
115
266
|
*/
|
|
@@ -235,6 +386,22 @@ var ExaRateLimitError = class extends WebError {
|
|
|
235
386
|
}
|
|
236
387
|
};
|
|
237
388
|
/**
|
|
389
|
+
* An anonymous response body that exceeded {@link MAX_MCP_RESPONSE_BYTES}.
|
|
390
|
+
*
|
|
391
|
+
* Extends {@link ExaTransientError} because the endpoint, not the caller's
|
|
392
|
+
* configuration, produced it: a body this size is a bad day on Exa's side, and
|
|
393
|
+
* the breaker should get to count it.
|
|
394
|
+
*/
|
|
395
|
+
var ExaResponseTooLargeError = class extends ExaTransientError {
|
|
396
|
+
/** The number of bytes observed, or `undefined` when the server declared the size. */
|
|
397
|
+
observedBytes;
|
|
398
|
+
constructor(limit, observedBytes) {
|
|
399
|
+
super(`Exa anonymous MCP response exceeded ${limit} bytes` + (observedBytes === void 0 ? "" : ` (received ${observedBytes})`));
|
|
400
|
+
this.name = "ExaResponseTooLargeError";
|
|
401
|
+
this.observedBytes = observedBytes;
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
/**
|
|
238
405
|
* Consecutive-transient-failure breaker for the keyless path.
|
|
239
406
|
*
|
|
240
407
|
* The public MCP endpoint is best-effort: when it is throttling or down, every
|
|
@@ -291,7 +458,8 @@ function resolveOptions(section) {
|
|
|
291
458
|
apiKeyEnv: section.apiKeyEnv ?? "EXA_API_KEY",
|
|
292
459
|
baseURL,
|
|
293
460
|
apiURL: section.apiURL ?? `${baseURL.replace(/\/+$/, "")}/search`,
|
|
294
|
-
mcpURL: section.mcpURL ?? "https://mcp.exa.ai/mcp",
|
|
461
|
+
mcpURL: section.mcpURL ?? "https://mcp.exa.ai/mcp?tools=web_search_exa,web_search_advanced_exa",
|
|
462
|
+
mcpTool: section.mcpTool ?? "web_search_advanced_exa",
|
|
295
463
|
searchType: section.searchType ?? "auto",
|
|
296
464
|
numResults: section.numResults,
|
|
297
465
|
highlightsPerResult: section.highlightsPerResult ?? 1
|
|
@@ -424,12 +592,18 @@ var ExaSearchProvider = class {
|
|
|
424
592
|
*/
|
|
425
593
|
async #anonymousMcpSearch(request, options, signal) {
|
|
426
594
|
throwIfAborted(signal);
|
|
595
|
+
const tool = options.mcpTool;
|
|
596
|
+
const isAdvanced = tool === "web_search_advanced_exa";
|
|
427
597
|
const args = { query: request.query };
|
|
428
598
|
const numResults = request.maxResults ?? options.numResults;
|
|
429
599
|
if (numResults !== void 0) args.numResults = numResults;
|
|
600
|
+
if (isAdvanced) {
|
|
601
|
+
args.enableHighlights = true;
|
|
602
|
+
args.highlightsNumSentences = options.highlightsPerResult ?? 1;
|
|
603
|
+
}
|
|
430
604
|
let response;
|
|
431
605
|
try {
|
|
432
|
-
response = await fetch(options.mcpURL, {
|
|
606
|
+
response = await fetch(isAdvanced ? endpointFor(options.mcpURL) : options.mcpURL, {
|
|
433
607
|
method: "POST",
|
|
434
608
|
redirect: "error",
|
|
435
609
|
headers: {
|
|
@@ -442,7 +616,7 @@ var ExaSearchProvider = class {
|
|
|
442
616
|
id: Math.random().toString(36).slice(2),
|
|
443
617
|
method: "tools/call",
|
|
444
618
|
params: {
|
|
445
|
-
name:
|
|
619
|
+
name: tool,
|
|
446
620
|
arguments: args
|
|
447
621
|
}
|
|
448
622
|
}),
|
|
@@ -457,19 +631,26 @@ var ExaSearchProvider = class {
|
|
|
457
631
|
if (isTransientStatus(response.status)) throw new ExaTransientError(`Exa anonymous MCP error (HTTP ${response.status})`);
|
|
458
632
|
throw new WebError(`Exa anonymous MCP error (HTTP ${response.status})`, "WEB_PROVIDER_ERROR");
|
|
459
633
|
}
|
|
460
|
-
let
|
|
634
|
+
let text;
|
|
461
635
|
try {
|
|
462
|
-
|
|
636
|
+
text = await readBoundedBody(response, MAX_MCP_RESPONSE_BYTES);
|
|
463
637
|
} catch (error) {
|
|
464
638
|
if (signal?.aborted === true || isAbortError(error)) throw new WebError("Exa anonymous search aborted", "WEB_ABORTED", { cause: signal?.reason ?? error });
|
|
639
|
+
if (error instanceof ExaResponseTooLargeError) throw error;
|
|
465
640
|
throw new ExaTransientError(`Exa returned an unprocessable response body: ${String(error)}`, error);
|
|
466
641
|
}
|
|
642
|
+
const payload = parseSsePayload(text);
|
|
467
643
|
if (payload === null) throw new ExaTransientError("Exa anonymous MCP returned an unprocessable response body");
|
|
468
644
|
if (payload.error != null) throw new WebError(`Exa MCP error: ${String(payload.error.message ?? JSON.stringify(payload.error))}`, "WEB_PROVIDER_ERROR");
|
|
469
645
|
if (payload.result?.isError === true) {
|
|
470
646
|
const detail = collectMcpText(payload).join("\n").trim();
|
|
471
647
|
throw new WebError(`Exa MCP tool error${detail.length > 0 ? `: ${detail}` : ""}`, "WEB_PROVIDER_ERROR");
|
|
472
648
|
}
|
|
649
|
+
const structured = isAdvanced ? parseAdvancedPayload(payload) : null;
|
|
650
|
+
if (structured !== null) return {
|
|
651
|
+
sources: structured,
|
|
652
|
+
truncated: false
|
|
653
|
+
};
|
|
473
654
|
return {
|
|
474
655
|
sources: mapMcpSections(splitExaSections(collectMcpText(payload).join("\n\n"))),
|
|
475
656
|
truncated: false
|
|
@@ -501,6 +682,13 @@ const Config = z.object({
|
|
|
501
682
|
apiURL: z.string(),
|
|
502
683
|
/** Exa hosted MCP endpoint, used by the anonymous fallback. */
|
|
503
684
|
mcpURL: z.string().default(DEFAULT_MCP_URL),
|
|
685
|
+
/**
|
|
686
|
+
* MCP tool the anonymous path calls. `web_search_advanced_exa` returns a
|
|
687
|
+
* sanitized structured JSON response; `web_search_exa` returns the
|
|
688
|
+
* `Title:`-section text blob. The structured tool is the default because it
|
|
689
|
+
* needs no text parsing, and the text path stays available as a fallback.
|
|
690
|
+
*/
|
|
691
|
+
mcpTool: z.union(["web_search_exa", "web_search_advanced_exa"]).default(DEFAULT_MCP_TOOL),
|
|
504
692
|
/** REST retrieval mode: `auto`, `keyword`, or `neural`. */
|
|
505
693
|
searchType: z.union([
|
|
506
694
|
"auto",
|
|
@@ -557,4 +745,4 @@ function apply(ctx, config) {
|
|
|
557
745
|
ctx.web.registerSearchProvider(new ExaSearchProvider(() => resolveOptions(current()), (options) => resolveApiKey(options, environment)));
|
|
558
746
|
}
|
|
559
747
|
//#endregion
|
|
560
|
-
export { Config, DEFAULT_API_KEY_ENV, DEFAULT_API_URL, DEFAULT_BASE_URL, DEFAULT_BREAKER_COOLDOWN_MS, DEFAULT_BREAKER_THRESHOLD, DEFAULT_HIGHLIGHTS_PER_RESULT, DEFAULT_MCP_URL, DEFAULT_PROVIDER_ID, DEFAULT_SEARCH_TYPE, ExaAvailabilityBreaker, ExaRateLimitError, ExaSearchProvider, ExaTransientError, MAX_SNIPPET_CHARS, MCP_SOURCE, MCP_TOOL, PROVIDER_ID, SETTINGS_NAMESPACE, USER_AGENT, apply, inject, installSettingsSection, name, resolveApiKey, resolveApiKeyFromProcess, resolveOptions };
|
|
748
|
+
export { Config, DEFAULT_API_KEY_ENV, DEFAULT_API_URL, DEFAULT_BASE_URL, DEFAULT_BREAKER_COOLDOWN_MS, DEFAULT_BREAKER_THRESHOLD, DEFAULT_HIGHLIGHTS_PER_RESULT, DEFAULT_MCP_TOOL, DEFAULT_MCP_URL, DEFAULT_PROVIDER_ID, DEFAULT_SEARCH_TYPE, ExaAvailabilityBreaker, ExaRateLimitError, ExaResponseTooLargeError, ExaSearchProvider, ExaTransientError, MAX_MCP_RESPONSE_BYTES, MAX_SNIPPET_CHARS, MCP_SOURCE, MCP_TOOL, MCP_TOOLS_QUERY, MCP_TOOL_ADVANCED, PROVIDER_ID, SETTINGS_NAMESPACE, USER_AGENT, apply, inject, installSettingsSection, name, resolveApiKey, resolveApiKeyFromProcess, resolveOptions };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tonydua/dsh-web-search-exa",
|
|
3
3
|
"description": "Zero-config Exa web search provider for DeepSeek Harness (dsh): keyless anonymous MCP fallback (mcp.exa.ai/mcp) plus keyed REST search — a drop-in WebSearchProvider for the ctx.web seam, no API key required.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.2",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
7
7
|
"dsh",
|
|
@@ -42,6 +42,13 @@
|
|
|
42
42
|
"dsh": {
|
|
43
43
|
"bundle": {
|
|
44
44
|
"patch": "./cordis.patch.yml"
|
|
45
|
+
},
|
|
46
|
+
"compatibility": {
|
|
47
|
+
"dshReleases": {
|
|
48
|
+
"0.2.0-rc.1": "compatible",
|
|
49
|
+
"0.2.0-rc.2": "compatible",
|
|
50
|
+
"0.2.1-alpha.1": "compatible"
|
|
51
|
+
}
|
|
45
52
|
}
|
|
46
53
|
},
|
|
47
54
|
"publishConfig": {
|
|
@@ -57,10 +64,10 @@
|
|
|
57
64
|
"compat": "bash scripts/compat-matrix.sh"
|
|
58
65
|
},
|
|
59
66
|
"peerDependencies": {
|
|
60
|
-
"@deepseek-ai/cordis": ">=4.0.2",
|
|
61
|
-
"@deepseek-ai/dsh-launch-environment": ">=0.1.2-alpha.2 || >=0.1.3-alpha.2 || >=0.1.4-0 || >=0.1.5-alpha.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.1.8",
|
|
62
|
-
"@deepseek-ai/dsh-settings": ">=0.1.2-alpha.2 || >=0.1.3-alpha.2 || >=0.1.4-0 || >=0.1.5-alpha.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.1.8",
|
|
63
|
-
"@deepseek-ai/dsh-web": ">=0.1.2-alpha.2 || >=0.1.3-alpha.2 || >=0.1.4-0 || >=0.1.5-alpha.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.1.8"
|
|
67
|
+
"@deepseek-ai/cordis": ">=4.0.2 || >=4.0.5-alpha.1",
|
|
68
|
+
"@deepseek-ai/dsh-launch-environment": ">=0.1.2-alpha.2 || >=0.1.3-alpha.2 || >=0.1.4-0 || >=0.1.5-alpha.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.1.8 || >=0.2.0-rc.1 || >=0.2.1-alpha.1",
|
|
69
|
+
"@deepseek-ai/dsh-settings": ">=0.1.2-alpha.2 || >=0.1.3-alpha.2 || >=0.1.4-0 || >=0.1.5-alpha.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.1.8 || >=0.2.0-rc.1 || >=0.2.1-alpha.1",
|
|
70
|
+
"@deepseek-ai/dsh-web": ">=0.1.2-alpha.2 || >=0.1.3-alpha.2 || >=0.1.4-0 || >=0.1.5-alpha.1 || >=0.1.6-alpha.1 || >=0.1.7-alpha.1 || >=0.1.8 || >=0.2.0-rc.1 || >=0.2.1-alpha.1"
|
|
64
71
|
},
|
|
65
72
|
"peerDependenciesMeta": {
|
|
66
73
|
"@deepseek-ai/dsh-settings": {
|