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 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 and convert the content from a URL to markdown with advanced content extraction options
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`):
@@ -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. Returns page text as markdown.",
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 its text content converted to markdown. " +
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. " +
@@ -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
- async function readResponseBodyWithLimit(response, maxBytes) {
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
- if (bytesRead > maxBytes) {
335
+ const limitResult = evaluateChunkLimits(bytesRead, maxBytes, hasNulInPrefix, abortOnNulInPrefix);
336
+ if (limitResult) {
190
337
  await reader.cancel();
191
- return { exceeded: true, bytesRead };
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
- // Retrieve HTML content
310
- let htmlContent;
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
- htmlContent = bodyRead.text;
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 (!htmlContent || htmlContent.trim().length === 0) {
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 HTML to Markdown
481
+ // Convert readable content to Markdown
325
482
  let markdownContent;
326
- try {
327
- markdownContent = NodeHtmlMarkdown.translate(htmlContent);
483
+ if (contentType.kind === "json") {
484
+ markdownContent = renderJsonMarkdown(rawContent);
328
485
  }
329
- catch {
330
- throw createConversionError(url);
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, htmlContent, markdownContent);
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.9.0";
1
+ export declare const packageVersion = "1.10.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "1.9.0";
1
+ export const packageVersion = "1.10.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-searxng",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "mcpName": "io.github.ihor-sokoliuk/mcp-searxng",
5
5
  "description": "MCP server for SearXNG integration",
6
6
  "license": "MIT",