mcp-searxng 1.9.0 → 1.10.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/README.md +9 -1
- package/dist/error-handler.js +1 -1
- package/dist/types.js +4 -2
- package/dist/url-reader.js +181 -16
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -114,7 +114,13 @@ AI Assistant (e.g. Claude)
|
|
|
114
114
|
- `refresh` (boolean, optional): Bypass the process cache and fetch fresh `/config` data. (default: false)
|
|
115
115
|
|
|
116
116
|
- **web_url_read**
|
|
117
|
-
- Read
|
|
117
|
+
- Read URL content as markdown with content-type-aware handling and advanced extraction options
|
|
118
|
+
- Supported readable content:
|
|
119
|
+
- HTML (`text/html`, `application/xhtml+xml`) is converted to markdown
|
|
120
|
+
- JSON (`application/json`, `*+json`) is pretty-printed in a fenced block
|
|
121
|
+
- Plain text, YAML, TOML, XML, and other safe explicit `text/*` responses are returned as readable fenced text
|
|
122
|
+
- Missing or generic content types are read under the existing size cap; non-binary bodies continue through the HTML-to-markdown path for compatibility
|
|
123
|
+
- Binary, media, archive, PDF, and octet-stream downloads are intentionally rejected with a short hint instead of returning raw bytes
|
|
118
124
|
- Inputs:
|
|
119
125
|
- `url` (string): The URL to fetch and process
|
|
120
126
|
- `startChar` (number, optional): Starting character position for content extraction (default: 0)
|
|
@@ -258,6 +264,8 @@ Full environment variable reference: [CONFIGURATION.md](CONFIGURATION.md)
|
|
|
258
264
|
|
|
259
265
|
## Troubleshooting
|
|
260
266
|
|
|
267
|
+
If HTTPS requests fail behind a TLS-inspecting corporate proxy with certificate errors, see [TLS / Corporate CA](CONFIGURATION.md#tls--corporate-ca).
|
|
268
|
+
|
|
261
269
|
### 403 Forbidden from SearXNG
|
|
262
270
|
|
|
263
271
|
Your SearXNG instance likely has JSON format disabled. Edit `settings.yml` (usually `/etc/searxng/settings.yml`):
|
package/dist/error-handler.js
CHANGED
|
@@ -86,7 +86,7 @@ export function createServerError(status, statusText, responseBody, context) {
|
|
|
86
86
|
}
|
|
87
87
|
export function createJSONError(responseText) {
|
|
88
88
|
const preview = responseText.substring(0, 100).replace(/\n/g, ' ');
|
|
89
|
-
return new MCPSearXNGError(`🔍 SearXNG Response Error: Invalid JSON format. Response: "${preview}..."
|
|
89
|
+
return new MCPSearXNGError(`🔍 SearXNG Response Error: Invalid JSON format. Response: "${preview}...". Enable - json under search.formats in your SearXNG settings.yml, or set SEARXNG_HTML_FALLBACK=true.`);
|
|
90
90
|
}
|
|
91
91
|
export function createDataError() {
|
|
92
92
|
return new MCPSearXNGError(`🔍 SearXNG Data Error: Missing results array in response`);
|
package/dist/types.js
CHANGED
|
@@ -237,7 +237,7 @@ export const LITE_INSTANCE_INFO_TOOL = {
|
|
|
237
237
|
};
|
|
238
238
|
export const LITE_READ_URL_TOOL = {
|
|
239
239
|
name: "web_url_read",
|
|
240
|
-
description: "Fetch URL.
|
|
240
|
+
description: "Fetch URL. Converts HTML to markdown; returns explicit JSON, plain text, YAML, TOML, and XML as readable markdown; binary/media/archive downloads are rejected.",
|
|
241
241
|
inputSchema: {
|
|
242
242
|
type: "object",
|
|
243
243
|
properties: { url: { type: "string", description: "URL to fetch." } },
|
|
@@ -246,7 +246,9 @@ export const LITE_READ_URL_TOOL = {
|
|
|
246
246
|
};
|
|
247
247
|
export const READ_URL_TOOL = {
|
|
248
248
|
name: "web_url_read",
|
|
249
|
-
description: "Fetches a URL and returns
|
|
249
|
+
description: "Fetches a URL and returns readable content as markdown. " +
|
|
250
|
+
"Content-type aware: HTML is converted to markdown; JSON is pretty-printed; plain text, YAML, TOML, and XML are returned as fenced readable text. " +
|
|
251
|
+
"Binary, media, archive, PDF, and octet-stream downloads are intentionally rejected instead of being returned as raw bytes. " +
|
|
250
252
|
"Three modes: " +
|
|
251
253
|
"(1) Full content — omit filtering params; use `startChar`/`maxLength` to paginate large pages. " +
|
|
252
254
|
"(2) Section extraction — set `section` to return content under a specific heading. " +
|
package/dist/url-reader.js
CHANGED
|
@@ -9,6 +9,40 @@ const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
|
|
|
9
9
|
const MAX_REDIRECTS = 5;
|
|
10
10
|
export const DEFAULT_MAX_CONTENT_LENGTH_BYTES = 5 * 1024 * 1024;
|
|
11
11
|
const HEAD_TIMEOUT_CAP_MS = 3000;
|
|
12
|
+
const BINARY_SNIFF_PREFIX_BYTES = 1024;
|
|
13
|
+
const EXACT_READABLE_CONTENT_TYPES = new Map([
|
|
14
|
+
["text/html", (mediaType) => ({ kind: "html", mediaType, language: "html" })],
|
|
15
|
+
["application/xhtml+xml", (mediaType) => ({ kind: "html", mediaType, language: "html" })],
|
|
16
|
+
["application/json", (mediaType) => ({ kind: "json", mediaType, language: "json" })],
|
|
17
|
+
["application/xml", (mediaType) => ({ kind: "text", mediaType, language: "xml" })],
|
|
18
|
+
["text/xml", (mediaType) => ({ kind: "text", mediaType, language: "xml" })],
|
|
19
|
+
["application/yaml", (mediaType) => ({ kind: "text", mediaType, language: "yaml" })],
|
|
20
|
+
["application/x-yaml", (mediaType) => ({ kind: "text", mediaType, language: "yaml" })],
|
|
21
|
+
["text/yaml", (mediaType) => ({ kind: "text", mediaType, language: "yaml" })],
|
|
22
|
+
["text/x-yaml", (mediaType) => ({ kind: "text", mediaType, language: "yaml" })],
|
|
23
|
+
["application/toml", (mediaType) => ({ kind: "text", mediaType, language: "toml" })],
|
|
24
|
+
["application/x-toml", (mediaType) => ({ kind: "text", mediaType, language: "toml" })],
|
|
25
|
+
["text/toml", (mediaType) => ({ kind: "text", mediaType, language: "toml" })],
|
|
26
|
+
]);
|
|
27
|
+
const EXACT_BINARY_CONTENT_TYPES = new Set([
|
|
28
|
+
"application/pdf",
|
|
29
|
+
"application/octet-stream",
|
|
30
|
+
"binary/octet-stream",
|
|
31
|
+
"application/zip",
|
|
32
|
+
"application/x-zip",
|
|
33
|
+
"application/x-zip-compressed",
|
|
34
|
+
"application/gzip",
|
|
35
|
+
"application/x-gzip",
|
|
36
|
+
"application/x-tar",
|
|
37
|
+
"application/tar",
|
|
38
|
+
"application/x-7z-compressed",
|
|
39
|
+
"application/x-rar-compressed",
|
|
40
|
+
"application/vnd.rar",
|
|
41
|
+
"application/x-bzip",
|
|
42
|
+
"application/x-bzip2",
|
|
43
|
+
"application/x-xz",
|
|
44
|
+
"application/zstd",
|
|
45
|
+
]);
|
|
12
46
|
function isRedirectResponse(response) {
|
|
13
47
|
return REDIRECT_STATUS_CODES.has(response.status);
|
|
14
48
|
}
|
|
@@ -160,6 +194,93 @@ function createContentTooLargeMessage(contentLength, maxBytes) {
|
|
|
160
194
|
return (`Content too large: server reports Content-Length of ${sizeMB} MB (limit: ${limitMB} MB). ` +
|
|
161
195
|
`Try using readHeadings or section to fetch only the relevant parts.`);
|
|
162
196
|
}
|
|
197
|
+
function normalizeMediaType(contentType) {
|
|
198
|
+
if (!contentType) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const mediaType = contentType.split(";")[0].trim().toLowerCase();
|
|
202
|
+
return mediaType === "" ? null : mediaType;
|
|
203
|
+
}
|
|
204
|
+
function isBinaryMediaType(mediaType) {
|
|
205
|
+
if (mediaType.startsWith("image/") ||
|
|
206
|
+
mediaType.startsWith("audio/") ||
|
|
207
|
+
mediaType.startsWith("video/") ||
|
|
208
|
+
mediaType.startsWith("font/")) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
return EXACT_BINARY_CONTENT_TYPES.has(mediaType);
|
|
212
|
+
}
|
|
213
|
+
function classifyContentType(contentType) {
|
|
214
|
+
const mediaType = normalizeMediaType(contentType);
|
|
215
|
+
if (mediaType === null) {
|
|
216
|
+
return { kind: "generic", mediaType };
|
|
217
|
+
}
|
|
218
|
+
const exactReadable = EXACT_READABLE_CONTENT_TYPES.get(mediaType);
|
|
219
|
+
if (exactReadable) {
|
|
220
|
+
return exactReadable(mediaType);
|
|
221
|
+
}
|
|
222
|
+
if (mediaType.endsWith("+json")) {
|
|
223
|
+
return { kind: "json", mediaType, language: "json" };
|
|
224
|
+
}
|
|
225
|
+
else if (isBinaryMediaType(mediaType)) {
|
|
226
|
+
return { kind: "binary", mediaType };
|
|
227
|
+
}
|
|
228
|
+
else if (mediaType.endsWith("+xml")) {
|
|
229
|
+
return { kind: "text", mediaType, language: "xml" };
|
|
230
|
+
}
|
|
231
|
+
else if (mediaType.startsWith("text/")) {
|
|
232
|
+
return { kind: "text", mediaType, language: "text" };
|
|
233
|
+
}
|
|
234
|
+
return { kind: "generic", mediaType };
|
|
235
|
+
}
|
|
236
|
+
function createUnsupportedContentTypeMessage(classification, reason) {
|
|
237
|
+
const contentType = classification.mediaType ?? "missing";
|
|
238
|
+
const reasonText = reason ? ` ${reason}` : "";
|
|
239
|
+
return (`Unsupported content type: ${contentType}.${reasonText} ` +
|
|
240
|
+
"Binary, media, archive, and PDF downloads are intentionally not read by web_url_read.");
|
|
241
|
+
}
|
|
242
|
+
function createNulRejectedContentMessage(classification) {
|
|
243
|
+
if (classification.kind !== "generic" && classification.mediaType !== null) {
|
|
244
|
+
return (`Body was declared ${classification.mediaType} but appears binary (NUL byte in first 1KB); not read. ` +
|
|
245
|
+
"Binary, media, archive, and PDF downloads are intentionally not read by web_url_read.");
|
|
246
|
+
}
|
|
247
|
+
return createUnsupportedContentTypeMessage(classification, `Body appears binary: NUL byte found in the first ${BINARY_SNIFF_PREFIX_BYTES} bytes.`);
|
|
248
|
+
}
|
|
249
|
+
async function cancelResponseBody(response) {
|
|
250
|
+
try {
|
|
251
|
+
await response.body?.cancel();
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// Best-effort cancellation: returning the unsupported hint is more useful than surfacing cancellation noise.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function getLongestBacktickRun(text) {
|
|
258
|
+
let longestRun = 0;
|
|
259
|
+
let currentRun = 0;
|
|
260
|
+
for (const char of text) {
|
|
261
|
+
if (char === "`") {
|
|
262
|
+
currentRun++;
|
|
263
|
+
longestRun = Math.max(longestRun, currentRun);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
currentRun = 0;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return longestRun;
|
|
270
|
+
}
|
|
271
|
+
function renderFencedMarkdown(language, text) {
|
|
272
|
+
const fence = "`".repeat(Math.max(3, getLongestBacktickRun(text) + 1));
|
|
273
|
+
return `${fence}${language}\n${text}\n${fence}`;
|
|
274
|
+
}
|
|
275
|
+
function renderJsonMarkdown(text) {
|
|
276
|
+
try {
|
|
277
|
+
const parsed = JSON.parse(text);
|
|
278
|
+
return renderFencedMarkdown("json", JSON.stringify(parsed, null, 2));
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return `Note: Response declared JSON but could not be parsed.\n\n${renderFencedMarkdown("text", text)}`;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
163
284
|
function concatenateChunks(chunks, totalBytes) {
|
|
164
285
|
const result = new Uint8Array(totalBytes);
|
|
165
286
|
let offset = 0;
|
|
@@ -169,13 +290,35 @@ function concatenateChunks(chunks, totalBytes) {
|
|
|
169
290
|
}
|
|
170
291
|
return result;
|
|
171
292
|
}
|
|
172
|
-
|
|
293
|
+
function scanPrefixForNul(value, prefixBytesChecked) {
|
|
294
|
+
const remainingPrefixBytes = BINARY_SNIFF_PREFIX_BYTES - prefixBytesChecked;
|
|
295
|
+
const bytesToCheck = Math.min(value.byteLength, remainingPrefixBytes);
|
|
296
|
+
if (bytesToCheck <= 0) {
|
|
297
|
+
return { hasNul: false, prefixBytesChecked };
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
hasNul: value.subarray(0, bytesToCheck).includes(0),
|
|
301
|
+
prefixBytesChecked: prefixBytesChecked + bytesToCheck,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function evaluateChunkLimits(bytesRead, maxBytes, hasNulInPrefix, abortOnNulInPrefix) {
|
|
305
|
+
if (hasNulInPrefix && abortOnNulInPrefix) {
|
|
306
|
+
return { exceeded: false, text: "", bytesRead, hasNulInPrefix };
|
|
307
|
+
}
|
|
308
|
+
if (bytesRead > maxBytes) {
|
|
309
|
+
return { exceeded: true, bytesRead };
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
async function readResponseBodyWithLimit(response, maxBytes, abortOnNulInPrefix = false) {
|
|
173
314
|
if (response.body === null) {
|
|
174
|
-
return { exceeded: false, text: "", bytesRead: 0 };
|
|
315
|
+
return { exceeded: false, text: "", bytesRead: 0, hasNulInPrefix: false };
|
|
175
316
|
}
|
|
176
317
|
const reader = response.body.getReader();
|
|
177
318
|
const chunks = [];
|
|
178
319
|
let bytesRead = 0;
|
|
320
|
+
let prefixBytesChecked = 0;
|
|
321
|
+
let hasNulInPrefix = false;
|
|
179
322
|
try {
|
|
180
323
|
while (true) {
|
|
181
324
|
const { done, value } = await reader.read();
|
|
@@ -185,10 +328,14 @@ async function readResponseBodyWithLimit(response, maxBytes) {
|
|
|
185
328
|
if (!value) {
|
|
186
329
|
continue;
|
|
187
330
|
}
|
|
331
|
+
const nulScan = scanPrefixForNul(value, prefixBytesChecked);
|
|
332
|
+
hasNulInPrefix = hasNulInPrefix || nulScan.hasNul;
|
|
333
|
+
prefixBytesChecked = nulScan.prefixBytesChecked;
|
|
188
334
|
bytesRead += value.byteLength;
|
|
189
|
-
|
|
335
|
+
const limitResult = evaluateChunkLimits(bytesRead, maxBytes, hasNulInPrefix, abortOnNulInPrefix);
|
|
336
|
+
if (limitResult) {
|
|
190
337
|
await reader.cancel();
|
|
191
|
-
return
|
|
338
|
+
return limitResult;
|
|
192
339
|
}
|
|
193
340
|
chunks.push(value);
|
|
194
341
|
}
|
|
@@ -197,7 +344,7 @@ async function readResponseBodyWithLimit(response, maxBytes) {
|
|
|
197
344
|
reader.releaseLock();
|
|
198
345
|
}
|
|
199
346
|
const bodyBytes = concatenateChunks(chunks, bytesRead);
|
|
200
|
-
return { exceeded: false, text: new TextDecoder("utf-8").decode(bodyBytes), bytesRead };
|
|
347
|
+
return { exceeded: false, text: new TextDecoder("utf-8").decode(bodyBytes), bytesRead, hasNulInPrefix };
|
|
201
348
|
}
|
|
202
349
|
export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 10000, paginationOptions = {}) {
|
|
203
350
|
const startTime = Date.now();
|
|
@@ -306,28 +453,46 @@ export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 1000
|
|
|
306
453
|
const context = { url };
|
|
307
454
|
throw createServerError(response.status, response.statusText, responseBody, context);
|
|
308
455
|
}
|
|
309
|
-
|
|
310
|
-
|
|
456
|
+
const contentType = classifyContentType(response.headers.get("content-type"));
|
|
457
|
+
if (contentType.kind === "binary") {
|
|
458
|
+
await cancelResponseBody(response);
|
|
459
|
+
return createUnsupportedContentTypeMessage(contentType);
|
|
460
|
+
}
|
|
461
|
+
// Retrieve readable content
|
|
462
|
+
let rawContent;
|
|
463
|
+
let hasNulInPrefix = false;
|
|
311
464
|
try {
|
|
312
|
-
const bodyRead = await readResponseBodyWithLimit(response, maxContentLengthBytes);
|
|
465
|
+
const bodyRead = await readResponseBodyWithLimit(response, maxContentLengthBytes, true);
|
|
313
466
|
if (bodyRead.exceeded) {
|
|
314
467
|
return createContentTooLargeMessage(bodyRead.bytesRead, maxContentLengthBytes);
|
|
315
468
|
}
|
|
316
|
-
|
|
469
|
+
rawContent = bodyRead.text;
|
|
470
|
+
hasNulInPrefix = bodyRead.hasNulInPrefix;
|
|
317
471
|
}
|
|
318
472
|
catch (error) {
|
|
319
473
|
throw createContentError(`Failed to read website content: ${error.message || 'Unknown error reading content'}`, url);
|
|
320
474
|
}
|
|
321
|
-
if (
|
|
475
|
+
if (hasNulInPrefix) {
|
|
476
|
+
return createNulRejectedContentMessage(contentType);
|
|
477
|
+
}
|
|
478
|
+
if (!rawContent || rawContent.trim().length === 0) {
|
|
322
479
|
throw createContentError("Website returned empty content.", url);
|
|
323
480
|
}
|
|
324
|
-
// Convert
|
|
481
|
+
// Convert readable content to Markdown
|
|
325
482
|
let markdownContent;
|
|
326
|
-
|
|
327
|
-
markdownContent =
|
|
483
|
+
if (contentType.kind === "json") {
|
|
484
|
+
markdownContent = renderJsonMarkdown(rawContent);
|
|
328
485
|
}
|
|
329
|
-
|
|
330
|
-
|
|
486
|
+
else if (contentType.kind === "text") {
|
|
487
|
+
markdownContent = renderFencedMarkdown(contentType.language, rawContent);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
try {
|
|
491
|
+
markdownContent = NodeHtmlMarkdown.translate(rawContent);
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
throw createConversionError(url);
|
|
495
|
+
}
|
|
331
496
|
}
|
|
332
497
|
if (!markdownContent || markdownContent.trim().length === 0) {
|
|
333
498
|
logMessage(mcpServer, "warning", `Empty content after conversion: ${url}`);
|
|
@@ -335,7 +500,7 @@ export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 1000
|
|
|
335
500
|
return createEmptyContentWarning(url);
|
|
336
501
|
}
|
|
337
502
|
// Only cache successful markdown conversion
|
|
338
|
-
urlCache.set(url,
|
|
503
|
+
urlCache.set(url, rawContent, markdownContent);
|
|
339
504
|
// Apply pagination options
|
|
340
505
|
const result = applyPaginationOptions(markdownContent, paginationOptions);
|
|
341
506
|
const duration = Date.now() - startTime;
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const packageVersion = "1.
|
|
1
|
+
export declare const packageVersion = "1.10.0";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const packageVersion = "1.
|
|
1
|
+
export const packageVersion = "1.10.0";
|