@240xu/dsh-websearch 2.0.0
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/LICENSE +21 -0
- package/README.md +136 -0
- package/cordis.patch.yml +3 -0
- package/examples/cordis.patch.yml +17 -0
- package/lib/backends/anthropic-like.js +262 -0
- package/lib/backends/brave.js +98 -0
- package/lib/backends/ddg.js +136 -0
- package/lib/backends/exa.js +97 -0
- package/lib/backends/index.js +121 -0
- package/lib/backends/mojeek.js +103 -0
- package/lib/backends/openai.js +171 -0
- package/lib/backends/parallel.js +105 -0
- package/lib/backends/searxng.js +107 -0
- package/lib/backends/serper.js +115 -0
- package/lib/backends/tavily.js +112 -0
- package/lib/client.js +317 -0
- package/lib/index.js +277 -0
- package/lib/provider.js +225 -0
- package/lib/util/abort.js +82 -0
- package/lib/util/log.js +51 -0
- package/lib/util/mcp-client.js +233 -0
- package/lib/util/rpc.js +262 -0
- package/package.json +70 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP (Model Context Protocol) Streamable-HTTP Client Utility.
|
|
3
|
+
* Handles the 3-step handshake: initialize -> notifications/initialized -> tools/call
|
|
4
|
+
* with Mcp-Session-Id header caching (10 min TTL).
|
|
5
|
+
* Supports both SSE (Exa) and plain JSON (Parallel) response formats.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
9
|
+
|
|
10
|
+
const UA = "dsh-websearch/2.0";
|
|
11
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
12
|
+
const SESSION_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
13
|
+
|
|
14
|
+
/** Per-URL session cache: { sessionId, initializedAt } */
|
|
15
|
+
const sessionCache = new Map();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Ensure MCP session is initialized for the given URL.
|
|
19
|
+
* Performs the 3-step handshake if needed.
|
|
20
|
+
* @returns {Promise<string>} sessionId
|
|
21
|
+
*/
|
|
22
|
+
async function ensureSession(url, signal) {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
const cached = sessionCache.get(url);
|
|
25
|
+
|
|
26
|
+
if (cached && (now - cached.initializedAt) < SESSION_TTL_MS) {
|
|
27
|
+
return cached.sessionId;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Step 1: initialize
|
|
31
|
+
const initBody = {
|
|
32
|
+
jsonrpc: "2.0",
|
|
33
|
+
id: 1,
|
|
34
|
+
method: "initialize",
|
|
35
|
+
params: {
|
|
36
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
37
|
+
clientInfo: { name: "dsh-websearch", version: "2.0" },
|
|
38
|
+
capabilities: {},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let initResponse;
|
|
43
|
+
try {
|
|
44
|
+
initResponse = await fetch(url, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: {
|
|
47
|
+
"Content-Type": "application/json",
|
|
48
|
+
"Accept": "application/json, text/event-stream",
|
|
49
|
+
"User-Agent": UA,
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify(initBody),
|
|
52
|
+
signal,
|
|
53
|
+
redirect: "error",
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (signal?.aborted) throw error;
|
|
57
|
+
throw new WebError(`MCP initialize failed: ${error}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!initResponse.ok) {
|
|
61
|
+
const text = await initResponse.text().catch(() => "");
|
|
62
|
+
throw new WebError(`MCP initialize HTTP ${initResponse.status}: ${text}`, "WEB_PROVIDER_ERROR");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Extract Mcp-Session-Id from response headers. Stateless MCP servers may
|
|
66
|
+
// omit it; cache undefined and skip the header on subsequent calls instead
|
|
67
|
+
// of failing — the initialize round-trip still validated reachability.
|
|
68
|
+
const sessionId = initResponse.headers.get("Mcp-Session-Id") ?? undefined;
|
|
69
|
+
|
|
70
|
+
// Step 2: notifications/initialized (best-effort, fire and forget)
|
|
71
|
+
const notifyBody = {
|
|
72
|
+
jsonrpc: "2.0",
|
|
73
|
+
method: "notifications/initialized",
|
|
74
|
+
params: {},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await fetch(url, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: {
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
|
|
83
|
+
"User-Agent": UA,
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify(notifyBody),
|
|
86
|
+
signal,
|
|
87
|
+
redirect: "error",
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
// Best-effort: Exa requires this, Parallel tolerates absence
|
|
91
|
+
if (signal?.aborted) throw error;
|
|
92
|
+
// Ignore other errors for notification
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Cache the session
|
|
96
|
+
sessionCache.set(url, { sessionId, initializedAt: now });
|
|
97
|
+
return sessionId;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse MCP response envelope - handles both SSE and plain JSON.
|
|
102
|
+
* @returns {object} { id, result?, error? }
|
|
103
|
+
*/
|
|
104
|
+
function parseEnvelope(text, expectedId) {
|
|
105
|
+
// Try SSE format first: "event: message\ndata: {...}\n\n"
|
|
106
|
+
if (text.includes("data:")) {
|
|
107
|
+
const lines = text.trim().split("\n");
|
|
108
|
+
let dataLine = null;
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
if (line.startsWith("data:")) {
|
|
111
|
+
dataLine = line.slice(5).trim();
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (dataLine) {
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(dataLine);
|
|
118
|
+
} catch {}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Try plain JSON (single line or JSON-RPC batch)
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(text);
|
|
125
|
+
if (Array.isArray(parsed)) {
|
|
126
|
+
// Batch response - find matching id
|
|
127
|
+
return parsed.find((r) => r.id === expectedId) ?? parsed[0];
|
|
128
|
+
}
|
|
129
|
+
return parsed;
|
|
130
|
+
} catch {}
|
|
131
|
+
|
|
132
|
+
throw new WebError("MCP: failed to parse response envelope", "WEB_PROVIDER_ERROR");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Invalidate cached session for a URL (call on 401/403/404).
|
|
137
|
+
*/
|
|
138
|
+
export function invalidateSession(url) {
|
|
139
|
+
sessionCache.delete(url);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Make a tools/call RPC to an MCP server with session management.
|
|
144
|
+
* @param {string} url - MCP endpoint URL
|
|
145
|
+
* @param {string} toolName - Tool name to call
|
|
146
|
+
* @param {object} args - Tool arguments
|
|
147
|
+
* @param {object} extraHeaders - Additional headers
|
|
148
|
+
* @param {AbortSignal} signal - Abort signal
|
|
149
|
+
* @returns {Promise<object>} Parsed result envelope
|
|
150
|
+
*/
|
|
151
|
+
export async function mcpCall(url, toolName, args, extraHeaders = {}, signal) {
|
|
152
|
+
const sessionId = await ensureSession(url, signal);
|
|
153
|
+
|
|
154
|
+
const callBody = {
|
|
155
|
+
jsonrpc: "2.0",
|
|
156
|
+
id: 2, // increment from initialize(1)
|
|
157
|
+
method: "tools/call",
|
|
158
|
+
params: { name: toolName, arguments: args },
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
let response;
|
|
162
|
+
try {
|
|
163
|
+
response = await fetch(url, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: {
|
|
166
|
+
"Content-Type": "application/json",
|
|
167
|
+
"Accept": "application/json, text/event-stream",
|
|
168
|
+
...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
|
|
169
|
+
"User-Agent": UA,
|
|
170
|
+
...extraHeaders,
|
|
171
|
+
},
|
|
172
|
+
body: JSON.stringify(callBody),
|
|
173
|
+
signal,
|
|
174
|
+
redirect: "error",
|
|
175
|
+
});
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (signal?.aborted) throw error;
|
|
178
|
+
throw new WebError(`MCP tools/call failed: ${error}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// On 401/403/404 with a sessionful server, invalidate and retry once.
|
|
182
|
+
// Stateless servers (no session id) have no session to invalidate — fail loud.
|
|
183
|
+
if (sessionId && (response.status === 401 || response.status === 403 || response.status === 404)) {
|
|
184
|
+
invalidateSession(url);
|
|
185
|
+
// Retry with fresh session
|
|
186
|
+
const newSessionId = await ensureSession(url, signal);
|
|
187
|
+
try {
|
|
188
|
+
response = await fetch(url, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: {
|
|
191
|
+
"Content-Type": "application/json",
|
|
192
|
+
"Accept": "application/json, text/event-stream",
|
|
193
|
+
...(newSessionId ? { "Mcp-Session-Id": newSessionId } : {}),
|
|
194
|
+
"User-Agent": UA,
|
|
195
|
+
...extraHeaders,
|
|
196
|
+
},
|
|
197
|
+
body: JSON.stringify(callBody),
|
|
198
|
+
signal,
|
|
199
|
+
redirect: "error",
|
|
200
|
+
});
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (signal?.aborted) throw error;
|
|
203
|
+
throw new WebError(`MCP tools/call retry failed: ${error}`, "WEB_PROVIDER_ERROR", { cause: error });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
const text = await response.text().catch(() => "");
|
|
209
|
+
throw new WebError(`MCP tools/call HTTP ${response.status}: ${text}`, "WEB_PROVIDER_ERROR");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const text = await response.text();
|
|
213
|
+
const envelope = parseEnvelope(text, callBody.id);
|
|
214
|
+
|
|
215
|
+
if (envelope.error) {
|
|
216
|
+
throw new WebError(`MCP error ${envelope.error.code}: ${envelope.error.message}`, "WEB_PROVIDER_ERROR");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!envelope.result) {
|
|
220
|
+
throw new WebError("MCP: missing result in response", "WEB_PROVIDER_ERROR");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return envelope.result;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Re-export abort helpers for convenience
|
|
227
|
+
export {
|
|
228
|
+
isAbortedWebError,
|
|
229
|
+
searchAborted,
|
|
230
|
+
throwIfSearchAborted,
|
|
231
|
+
maybeAbortError,
|
|
232
|
+
} from "./abort.js";
|
|
233
|
+
|
package/lib/util/rpc.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module dsh-websearch/util/rpc
|
|
3
|
+
*
|
|
4
|
+
* Minimal MCP JSON-RPC 2.0 client for the streamable-http transport used by
|
|
5
|
+
* Exa (https://mcp.exa.ai/mcp) and Parallel (https://search.parallel.ai/mcp).
|
|
6
|
+
*
|
|
7
|
+
* Streamable-HTTP MCP servers require a 2-step handshake per session:
|
|
8
|
+
* 1. POST initialize → server replies with its capabilities and (for Exa)
|
|
9
|
+
* a Mcp-Session-Id header. Parallel also returns this header.
|
|
10
|
+
* 2. POST notifications/initialized → notify the server the client is ready.
|
|
11
|
+
* (Exa rejects tools/call without this; Parallel tolerates its absence
|
|
12
|
+
* but sending it is harmless.)
|
|
13
|
+
* 3. POST tools/call with the Mcp-Session-Id header → returns either a plain
|
|
14
|
+
* JSON body OR a text/event-stream of "data: {…}\n\n" frames.
|
|
15
|
+
*
|
|
16
|
+
* We cache the Mcp-Session-Id per server URL inside the process so subsequent
|
|
17
|
+
* searches skip the handshake (and one cache entry survives across calls).
|
|
18
|
+
* Sessions are NOT shared across processes (each dsh-web worker owns its own).
|
|
19
|
+
*
|
|
20
|
+
* Failures are zh-folded into WebError so the provider fan-out can demote them
|
|
21
|
+
* to soft nulls when only one backend fails.
|
|
22
|
+
*/
|
|
23
|
+
import { WebError } from "@deepseek-ai/dsh-web";
|
|
24
|
+
import { throwIfSearchAborted, searchAborted, isAbortError } from "./abort.js";
|
|
25
|
+
|
|
26
|
+
/** User-Agent header; bumped with the package version. */
|
|
27
|
+
export const UA = "dsh-websearch/2.0";
|
|
28
|
+
|
|
29
|
+
/** MCP protocol version we negotiate. */
|
|
30
|
+
const PROTOCOL_VERSION = "2025-06-18";
|
|
31
|
+
|
|
32
|
+
/** Per-URL session cache: url → { sessionId, initializedAt }. */
|
|
33
|
+
const sessionCache = new Map();
|
|
34
|
+
const SESSION_TTL_MS = 10 * 60 * 1000; // Exa sessions live indefinitely; this is a courtesy cap.
|
|
35
|
+
|
|
36
|
+
/** Monotonic request id so concurrent inits don't collide. */
|
|
37
|
+
let nextReqId = 1;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Initialize (or reuse) a streamable-http MCP session.
|
|
41
|
+
* Sends initialize + notifications/initialized. Returns the Mcp-Session-Id,
|
|
42
|
+
* caching it so callers don't pay the handshake more than once per TTL.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} url
|
|
45
|
+
* @param {AbortSignal} [signal]
|
|
46
|
+
* @returns {Promise<string|null>} Mcp-Session-Id (or null if the server omits it).
|
|
47
|
+
* @throws {WebError} on transport error or abort.
|
|
48
|
+
*/
|
|
49
|
+
async function ensureSession(url, signal) {
|
|
50
|
+
throwIfSearchAborted(signal);
|
|
51
|
+
const cached = sessionCache.get(url);
|
|
52
|
+
if (cached && Date.now() - cached.initializedAt < SESSION_TTL_MS && cached.sessionId) {
|
|
53
|
+
return cached.sessionId;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const initId = nextReqId++;
|
|
57
|
+
const initBody = JSON.stringify({
|
|
58
|
+
jsonrpc: "2.0",
|
|
59
|
+
id: initId,
|
|
60
|
+
method: "initialize",
|
|
61
|
+
params: {
|
|
62
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
63
|
+
capabilities: {},
|
|
64
|
+
clientInfo: { name: "dsh-websearch", version: "1.0" },
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
let res;
|
|
69
|
+
try {
|
|
70
|
+
res = await fetch(url, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
redirect: "error",
|
|
73
|
+
headers: {
|
|
74
|
+
"Content-Type": "application/json",
|
|
75
|
+
"Accept": "application/json, text/event-stream",
|
|
76
|
+
"User-Agent": UA,
|
|
77
|
+
},
|
|
78
|
+
body: initBody,
|
|
79
|
+
...(signal !== undefined ? { signal } : {}),
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
83
|
+
throw new WebError(
|
|
84
|
+
`mcp initialize fetch failed for ${url}: ${String(error?.message ?? error)}`,
|
|
85
|
+
"WEB_PROVIDER_ERROR",
|
|
86
|
+
{ cause: error },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
throwIfSearchAborted(signal);
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
let detail = "";
|
|
92
|
+
try { detail = (await res.text()).slice(0, 300); } catch {}
|
|
93
|
+
throw new WebError(
|
|
94
|
+
`mcp initialize HTTP ${res.status} for ${url}${detail ? ": " + detail : ""}`,
|
|
95
|
+
"WEB_PROVIDER_ERROR",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Drain the initialize response body (we don't need its content).
|
|
100
|
+
await res.text();
|
|
101
|
+
|
|
102
|
+
const sessionId = res.headers.get("Mcp-Session-Id") || null;
|
|
103
|
+
|
|
104
|
+
// Send notifications/initialized (some servers reject tools/call without it).
|
|
105
|
+
if (sessionId) {
|
|
106
|
+
try {
|
|
107
|
+
await fetch(url, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
redirect: "error",
|
|
110
|
+
headers: {
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
"Accept": "application/json, text/event-stream",
|
|
113
|
+
"User-Agent": UA,
|
|
114
|
+
"Mcp-Session-Id": sessionId,
|
|
115
|
+
},
|
|
116
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
117
|
+
...(signal !== undefined ? { signal } : {}),
|
|
118
|
+
});
|
|
119
|
+
} catch (error) {
|
|
120
|
+
// notifications/initialized is best-effort; ignore transport errors here.
|
|
121
|
+
// (If the session is gone we'll discover that via tools/call.) Re-raise aborts.
|
|
122
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
sessionCache.set(url, { sessionId, initializedAt: Date.now() });
|
|
127
|
+
return sessionId;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Parse a streamable-HTTP MCP response body into the first JSON-RPC envelope.
|
|
132
|
+
*
|
|
133
|
+
* Servers respond with EITHER:
|
|
134
|
+
* - a single JSON object (Parallel: {"jsonrpc":"2.0","id":N,"result":{…}} or {...,"error":{…}}), OR
|
|
135
|
+
* - a text/event-stream of "event: <type>\n data: {…}\n\n" frames (Exa).
|
|
136
|
+
*
|
|
137
|
+
* We scan line-by-line; SSE data: frames take precedence, otherwise we fall back
|
|
138
|
+
* to scanning whole-line JSON.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} text
|
|
141
|
+
* @param {number} reqId the request id we expect to be echoed back
|
|
142
|
+
* @returns {{result?:object, error?:object}}
|
|
143
|
+
* @throws if no parseable envelope is found.
|
|
144
|
+
*/
|
|
145
|
+
function parseEnvelope(text, reqId) {
|
|
146
|
+
const lines = String(text).split("\n");
|
|
147
|
+
let lastJson = null;
|
|
148
|
+
for (const raw of lines) {
|
|
149
|
+
const line = raw.trim();
|
|
150
|
+
if (!line) continue;
|
|
151
|
+
let json;
|
|
152
|
+
if (line.startsWith("data: ")) {
|
|
153
|
+
json = line.slice(6).trim();
|
|
154
|
+
} else if (line.startsWith("{")) {
|
|
155
|
+
json = line;
|
|
156
|
+
} else {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!json.startsWith("{")) continue;
|
|
160
|
+
let obj;
|
|
161
|
+
try { obj = JSON.parse(json); } catch { continue; }
|
|
162
|
+
// Prefer the envelope matching our request id; otherwise keep the last seen.
|
|
163
|
+
if (typeof obj.id === "number" && obj.id === reqId) return obj;
|
|
164
|
+
lastJson = obj;
|
|
165
|
+
}
|
|
166
|
+
return lastJson;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* JSON-RPC 2.0 tools/call against a streamable-http MCP server (after the
|
|
171
|
+
* initialize + notifications/initialized handshake is established or reused).
|
|
172
|
+
*
|
|
173
|
+
* @param {string} url the MCP server endpoint
|
|
174
|
+
* @param {string} toolName the tool to invoke (e.g. "web_search_exa", "web_search")
|
|
175
|
+
* @param {object} args the tool's arguments (per its input schema)
|
|
176
|
+
* @param {object} [extraHeaders]
|
|
177
|
+
* @param {AbortSignal} [signal]
|
|
178
|
+
* @returns {Promise<object>} the RPC `result` payload (the `content` array etc.)
|
|
179
|
+
* @throws {WebError} on HTTP failure, RPC error, abort, or unparseable response.
|
|
180
|
+
*/
|
|
181
|
+
export async function rpcCall(url, toolName, args, extraHeaders, signal) {
|
|
182
|
+
const sessionId = await ensureSession(url, signal);
|
|
183
|
+
const reqId = nextReqId++;
|
|
184
|
+
|
|
185
|
+
const headers = {
|
|
186
|
+
"Content-Type": "application/json",
|
|
187
|
+
"Accept": "application/json, text/event-stream",
|
|
188
|
+
"User-Agent": UA,
|
|
189
|
+
...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
|
|
190
|
+
...(extraHeaders || {}),
|
|
191
|
+
};
|
|
192
|
+
const body = JSON.stringify({
|
|
193
|
+
jsonrpc: "2.0",
|
|
194
|
+
id: reqId,
|
|
195
|
+
method: "tools/call",
|
|
196
|
+
params: { name: toolName, arguments: args },
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
let res;
|
|
200
|
+
try {
|
|
201
|
+
res = await fetch(url, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
redirect: "error",
|
|
204
|
+
headers,
|
|
205
|
+
body,
|
|
206
|
+
...(signal !== undefined ? { signal } : {}),
|
|
207
|
+
});
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error);
|
|
210
|
+
throw new WebError(
|
|
211
|
+
`mcp rpc ${toolName} fetch failed: ${String(error?.message ?? error)}`,
|
|
212
|
+
"WEB_PROVIDER_ERROR",
|
|
213
|
+
{ cause: error },
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
throwIfSearchAborted(signal);
|
|
217
|
+
|
|
218
|
+
if (!res.ok) {
|
|
219
|
+
let detail = "";
|
|
220
|
+
try { detail = (await res.text()).slice(0, 300); } catch {}
|
|
221
|
+
// If the server rejected the request id/session, nuke the cached session
|
|
222
|
+
// so the next call re-initializes. 404/401/403 typically mean session death.
|
|
223
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
224
|
+
sessionCache.delete(url);
|
|
225
|
+
}
|
|
226
|
+
throw new WebError(
|
|
227
|
+
`mcp rpc ${toolName} HTTP ${res.status}${detail ? ": " + detail : ""}`,
|
|
228
|
+
"WEB_PROVIDER_ERROR",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const text = await res.text();
|
|
233
|
+
throwIfSearchAborted(signal);
|
|
234
|
+
|
|
235
|
+
const env = parseEnvelope(text, reqId);
|
|
236
|
+
if (!env) {
|
|
237
|
+
throw new WebError(
|
|
238
|
+
`mcp rpc ${toolName} returned an unparseable response`,
|
|
239
|
+
"WEB_PROVIDER_ERROR",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (env.error) {
|
|
243
|
+
throw new WebError(
|
|
244
|
+
`mcp rpc ${toolName} error: ${env.error.message || JSON.stringify(env.error)}`,
|
|
245
|
+
"WEB_PROVIDER_ERROR",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (!env.result) {
|
|
249
|
+
throw new WebError(
|
|
250
|
+
`mcp rpc ${toolName} returned a response with no result`,
|
|
251
|
+
"WEB_PROVIDER_ERROR",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return env.result;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Drop the cached session for a url (used by backends after a fatal error). */
|
|
258
|
+
export function invalidateSession(url) {
|
|
259
|
+
sessionCache.delete(url);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export { throwIfSearchAborted, searchAborted, isAbortError };
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@240xu/dsh-websearch",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Unified web search provider for DeepSeek Harness (ctx.web): 11 backends — Exa + Parallel + DDG + SearXNG (keyless), DeepSeek + Anthropic + OpenAI + Brave + Tavily + Serper + Mojeek (key-gated via Settings credentials). Registers provider id \"unified\" for zero-config web_search tool.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./package.json": "./package.json",
|
|
12
|
+
"./client": "./lib/client.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"lib",
|
|
16
|
+
"examples",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"cordis.patch.yml"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"dsh",
|
|
23
|
+
"deepseek-harness",
|
|
24
|
+
"plugin",
|
|
25
|
+
"web-search",
|
|
26
|
+
"mcp",
|
|
27
|
+
"exa",
|
|
28
|
+
"parallel",
|
|
29
|
+
"brave",
|
|
30
|
+
"tavily",
|
|
31
|
+
"serper",
|
|
32
|
+
"searxng",
|
|
33
|
+
"mojeek",
|
|
34
|
+
"claude",
|
|
35
|
+
"codex",
|
|
36
|
+
"cordis"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"author": {
|
|
40
|
+
"name": "240xu",
|
|
41
|
+
"email": "2401826246@qq.com"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/240xu/dsh-websearch.git"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@deepseek-ai/dsh-web": "*",
|
|
49
|
+
"@deepseek-ai/cordis": "*",
|
|
50
|
+
"@deepseek-ai/schemastery": "*",
|
|
51
|
+
"@deepseek-ai/dsh-credentials": "*",
|
|
52
|
+
"@deepseek-ai/dsh-settings": "*",
|
|
53
|
+
"@deepseek-ai/dsh-launch-environment": "*"
|
|
54
|
+
},
|
|
55
|
+
"dsh": {
|
|
56
|
+
"bundle": {
|
|
57
|
+
"patch": "./cordis.patch.yml"
|
|
58
|
+
},
|
|
59
|
+
"client": {
|
|
60
|
+
"inject": [
|
|
61
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
62
|
+
"@deepseek-ai/dsh-client-locale",
|
|
63
|
+
"@deepseek-ai/dsh-client-connection",
|
|
64
|
+
"@deepseek-ai/dsh-api-remotes",
|
|
65
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
66
|
+
],
|
|
67
|
+
"platform": "web"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|