mcp-searxng 1.6.0 → 1.7.1
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 +18 -0
- package/dist/proxy.d.ts +11 -0
- package/dist/proxy.js +51 -0
- package/dist/search.js +142 -45
- package/dist/types.d.ts +2 -1
- package/dist/url-reader.js +57 -59
- package/dist/url-security.d.ts +8 -0
- package/dist/url-security.js +79 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -12,9 +12,12 @@
|
|
|
12
12
|
[](https://scorecard.dev/viewer/?uri=github.com/ihor-sokoliuk/mcp-searxng)
|
|
13
13
|
[](https://www.bestpractices.dev/projects/13143)
|
|
14
14
|
[](https://glama.ai/mcp/servers/ihor-sokoliuk/mcp-searxng)
|
|
15
|
+
[](https://github.com/mcp/ihor-sokoliuk/mcp-searxng)
|
|
15
16
|
|
|
16
17
|
An [MCP server](https://modelcontextprotocol.io/introduction) that integrates the [SearXNG](https://docs.searxng.org) API, giving AI assistants web search capabilities.
|
|
17
18
|
|
|
19
|
+
✨ Featured in the [GitHub MCP Registry](https://github.com/mcp/ihor-sokoliuk/mcp-searxng).
|
|
20
|
+
|
|
18
21
|
</div>
|
|
19
22
|
|
|
20
23
|
## Quick Start
|
|
@@ -273,6 +276,21 @@ You should receive a JSON response. If not, confirm the file is correctly mounte
|
|
|
273
276
|
|
|
274
277
|
See also: [SearXNG settings docs](https://docs.searxng.org/admin/settings/settings.html) · [discussion](https://github.com/searxng/searxng/discussions/1789)
|
|
275
278
|
|
|
279
|
+
### Can't enable JSON? (HTML fallback)
|
|
280
|
+
|
|
281
|
+
If you must use a public instance you don't control and it rejects `format=json` (the 403 above), set the opt-in flag instead of editing the server:
|
|
282
|
+
|
|
283
|
+
```json
|
|
284
|
+
"SEARXNG_HTML_FALLBACK": "true"
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
A search that gets a `403`/`404` or a non-JSON response is then retried automatically **without** `format=json` and parsed from the regular HTML results page.
|
|
288
|
+
|
|
289
|
+
- **On success:** you get normal results (title, URL, snippet). They are marked `sourceFormat: "html"` in JSON mode, and text mode adds the line *"Note: Results parsed from SearXNG HTML fallback; metadata is limited."* Relevance scores and engine names are not available from HTML.
|
|
290
|
+
- **On failure:** parsing is best-effort and varies by the instance's theme/version, so some results may be missed or sparse. If the HTML page itself also fails — still blocked, rate-limited (`429`), auth (`401`), or `5xx` — the **original error is surfaced unchanged**. The fallback only triggers on `403`/`404`/non-JSON, never on auth or network errors.
|
|
291
|
+
|
|
292
|
+
Enabling JSON on an instance you control (above) remains the recommended setup — the fallback is a compatibility aid, not a replacement.
|
|
293
|
+
|
|
276
294
|
## Contributing
|
|
277
295
|
|
|
278
296
|
See [CONTRIBUTING.md](CONTRIBUTING.md)
|
package/dist/proxy.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import * as dns from "node:dns";
|
|
1
2
|
import { Agent, ProxyAgent } from "undici";
|
|
3
|
+
type LookupCallback = (err: NodeJS.ErrnoException | null, address: string | dns.LookupAddress[], family?: number) => void;
|
|
4
|
+
export declare function createUrlReaderLookup(): (hostname: string, options: dns.LookupOptions, callback: LookupCallback) => void;
|
|
2
5
|
/**
|
|
3
6
|
* Proxy configuration type for separating search and URL reader proxies.
|
|
4
7
|
*/
|
|
@@ -38,3 +41,11 @@ export type ProxyType = typeof ProxyType[keyof typeof ProxyType];
|
|
|
38
41
|
*/
|
|
39
42
|
export declare function createProxyAgent(targetUrl?: string, type?: ProxyType): ProxyAgent | undefined;
|
|
40
43
|
export declare function createDefaultAgent(): Agent | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Returns a singleton undici Agent for direct `web_url_read` requests.
|
|
46
|
+
*
|
|
47
|
+
* Unlike the shared default agent, this is always created so the URL reader's
|
|
48
|
+
* DNS validation hook is present even when no system CA bundle is detected.
|
|
49
|
+
*/
|
|
50
|
+
export declare function createUrlReaderAgent(): Agent;
|
|
51
|
+
export {};
|
package/dist/proxy.js
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
|
+
import * as dns from "node:dns";
|
|
1
2
|
import { Agent, ProxyAgent } from "undici";
|
|
3
|
+
import { getHttpSecurityConfig } from "./http-security.js";
|
|
2
4
|
import { getConnectOptions } from "./tls-config.js";
|
|
5
|
+
import { createUrlSecurityPolicyDnsError, isPrivateAddress } from "./url-security.js";
|
|
6
|
+
export function createUrlReaderLookup() {
|
|
7
|
+
return (hostname, options, callback) => {
|
|
8
|
+
if (getHttpSecurityConfig().allowPrivateUrls) {
|
|
9
|
+
dns.lookup(hostname, options, callback);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
dns.lookup(hostname, { ...options, all: true }, (error, addresses) => {
|
|
13
|
+
if (error) {
|
|
14
|
+
callback(error, options.all ? [] : "");
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (addresses.length === 0) {
|
|
18
|
+
const notFound = new Error(`No DNS records found for ${hostname}`);
|
|
19
|
+
notFound.code = "ENOTFOUND";
|
|
20
|
+
callback(notFound, options.all ? [] : "");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (addresses.some(({ address }) => isPrivateAddress(address))) {
|
|
24
|
+
callback(createUrlSecurityPolicyDnsError(hostname), options.all ? [] : "");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const selected = addresses[0];
|
|
28
|
+
if (options.all) {
|
|
29
|
+
callback(null, [selected]);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
callback(null, selected.address, selected.family);
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
}
|
|
3
36
|
/**
|
|
4
37
|
* Checks if a target URL should bypass the proxy based on NO_PROXY environment variable.
|
|
5
38
|
*
|
|
@@ -203,6 +236,7 @@ export function createProxyAgent(targetUrl, type) {
|
|
|
203
236
|
*/
|
|
204
237
|
let _defaultAgentInitialized = false;
|
|
205
238
|
let _defaultAgent;
|
|
239
|
+
let _urlReaderAgent;
|
|
206
240
|
export function createDefaultAgent() {
|
|
207
241
|
if (!_defaultAgentInitialized) {
|
|
208
242
|
_defaultAgentInitialized = true;
|
|
@@ -213,3 +247,20 @@ export function createDefaultAgent() {
|
|
|
213
247
|
}
|
|
214
248
|
return _defaultAgent;
|
|
215
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Returns a singleton undici Agent for direct `web_url_read` requests.
|
|
252
|
+
*
|
|
253
|
+
* Unlike the shared default agent, this is always created so the URL reader's
|
|
254
|
+
* DNS validation hook is present even when no system CA bundle is detected.
|
|
255
|
+
*/
|
|
256
|
+
export function createUrlReaderAgent() {
|
|
257
|
+
if (!_urlReaderAgent) {
|
|
258
|
+
_urlReaderAgent = new Agent({
|
|
259
|
+
connect: {
|
|
260
|
+
...getConnectOptions(),
|
|
261
|
+
lookup: createUrlReaderLookup(),
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return _urlReaderAgent;
|
|
266
|
+
}
|
package/dist/search.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parse } from "node-html-parser";
|
|
1
2
|
import { getKnownCategories, getKnownEngines } from "./instance-info.js";
|
|
2
3
|
import { createProxyAgent, createDefaultAgent, ProxyType } from "./proxy.js";
|
|
3
4
|
import { logMessage } from "./logging.js";
|
|
@@ -32,6 +33,102 @@ function truncateResultContent(content, maxResultChars) {
|
|
|
32
33
|
}
|
|
33
34
|
return `${content.slice(0, maxResultChars)}…`;
|
|
34
35
|
}
|
|
36
|
+
function normalizeHtmlText(text) {
|
|
37
|
+
return text.replace(/\s+/g, " ").trim();
|
|
38
|
+
}
|
|
39
|
+
function isHtmlFallbackEnabled() {
|
|
40
|
+
return process.env.SEARXNG_HTML_FALLBACK === "true";
|
|
41
|
+
}
|
|
42
|
+
function shouldFallbackForStatus(status) {
|
|
43
|
+
return status === 403 || status === 404;
|
|
44
|
+
}
|
|
45
|
+
function buildHtmlFallbackUrl(jsonUrl) {
|
|
46
|
+
const htmlUrl = new URL(jsonUrl.toString());
|
|
47
|
+
htmlUrl.searchParams.delete("format");
|
|
48
|
+
return htmlUrl;
|
|
49
|
+
}
|
|
50
|
+
function parseHtmlSearchResults(html, query) {
|
|
51
|
+
const root = parse(html);
|
|
52
|
+
const articles = root.querySelectorAll("article.result");
|
|
53
|
+
const candidates = articles.length > 0 ? articles : root.querySelectorAll(".result");
|
|
54
|
+
const results = candidates
|
|
55
|
+
.map((entry) => {
|
|
56
|
+
const link = entry.querySelector("h3 > a") ?? entry.querySelector("h3 a") ?? entry.querySelector("a[href]");
|
|
57
|
+
if (!link) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const href = link?.getAttribute("href")?.trim();
|
|
61
|
+
if (!href) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
new URL(href);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
const title = normalizeHtmlText(link.text);
|
|
71
|
+
const snippetNode = entry.querySelector("p.content") ?? entry.querySelector(".content");
|
|
72
|
+
const content = snippetNode ? normalizeHtmlText(snippetNode.text) : "";
|
|
73
|
+
return {
|
|
74
|
+
title,
|
|
75
|
+
url: href,
|
|
76
|
+
content,
|
|
77
|
+
};
|
|
78
|
+
})
|
|
79
|
+
.filter((result) => result !== undefined);
|
|
80
|
+
return {
|
|
81
|
+
query,
|
|
82
|
+
number_of_results: results.length,
|
|
83
|
+
results,
|
|
84
|
+
sourceFormat: "html",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async function fetchWithSearchTimeout(mcpServer, url, requestOptions, timeoutMs, query, searxngUrl) {
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
90
|
+
try {
|
|
91
|
+
logMessage(mcpServer, "info", `Making request to: ${url.toString()}`);
|
|
92
|
+
return await fetch(url.toString(), {
|
|
93
|
+
...requestOptions,
|
|
94
|
+
signal: controller.signal,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
logMessage(mcpServer, "error", `Network error during search request: ${error.message}`, { query, url: url.toString() });
|
|
99
|
+
const context = {
|
|
100
|
+
url: url.toString(),
|
|
101
|
+
searxngUrl,
|
|
102
|
+
proxyAgent: !!requestOptions.dispatcher,
|
|
103
|
+
username: process.env.AUTH_USERNAME,
|
|
104
|
+
};
|
|
105
|
+
throw createNetworkError(error, context);
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
clearTimeout(timeoutId);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function fetchHtmlFallbackSearch(mcpServer, jsonUrl, requestOptions, timeoutMs, query, searxngUrl) {
|
|
112
|
+
const htmlUrl = buildHtmlFallbackUrl(jsonUrl);
|
|
113
|
+
logMessage(mcpServer, "info", `Retrying search with HTML fallback: ${htmlUrl.toString()}`);
|
|
114
|
+
const response = await fetchWithSearchTimeout(mcpServer, htmlUrl, requestOptions, timeoutMs, query, searxngUrl);
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
let responseBody;
|
|
117
|
+
try {
|
|
118
|
+
responseBody = await response.text();
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
responseBody = '[Could not read response body]';
|
|
122
|
+
}
|
|
123
|
+
const context = {
|
|
124
|
+
url: htmlUrl.toString(),
|
|
125
|
+
searxngUrl,
|
|
126
|
+
};
|
|
127
|
+
throw createServerError(response.status, response.statusText, responseBody, context);
|
|
128
|
+
}
|
|
129
|
+
const html = await response.text();
|
|
130
|
+
return parseHtmlSearchResults(html, query);
|
|
131
|
+
}
|
|
35
132
|
function hasItems(items) {
|
|
36
133
|
return Array.isArray(items) && items.length > 0;
|
|
37
134
|
}
|
|
@@ -278,57 +375,49 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
|
|
|
278
375
|
}
|
|
279
376
|
// Fetch with AbortController timeout and enhanced error handling
|
|
280
377
|
const SEARCH_TIMEOUT_MS = parseInt(process.env.SEARXNG_TIMEOUT_MS ?? "10000", 10);
|
|
281
|
-
const controller = new AbortController();
|
|
282
|
-
const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
|
|
283
378
|
let response;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
response = await fetch(url.toString(), {
|
|
287
|
-
...requestOptions,
|
|
288
|
-
signal: controller.signal,
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
catch (error) {
|
|
292
|
-
clearTimeout(timeoutId);
|
|
293
|
-
logMessage(mcpServer, "error", `Network error during search request: ${error.message}`, { query, url: url.toString() });
|
|
294
|
-
const context = {
|
|
295
|
-
url: url.toString(),
|
|
296
|
-
searxngUrl,
|
|
297
|
-
proxyAgent: !!dispatcher,
|
|
298
|
-
username
|
|
299
|
-
};
|
|
300
|
-
throw createNetworkError(error, context);
|
|
301
|
-
}
|
|
302
|
-
clearTimeout(timeoutId);
|
|
379
|
+
response = await fetchWithSearchTimeout(mcpServer, url, requestOptions, SEARCH_TIMEOUT_MS, query, searxngUrl);
|
|
380
|
+
let data;
|
|
303
381
|
if (!response.ok) {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
responseBody = await response.text();
|
|
382
|
+
if (isHtmlFallbackEnabled() && shouldFallbackForStatus(response.status)) {
|
|
383
|
+
data = await fetchHtmlFallbackSearch(mcpServer, url, requestOptions, SEARCH_TIMEOUT_MS, query, searxngUrl);
|
|
307
384
|
}
|
|
308
|
-
|
|
309
|
-
responseBody
|
|
385
|
+
else {
|
|
386
|
+
let responseBody;
|
|
387
|
+
try {
|
|
388
|
+
responseBody = await response.text();
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
responseBody = '[Could not read response body]';
|
|
392
|
+
}
|
|
393
|
+
const context = {
|
|
394
|
+
url: url.toString(),
|
|
395
|
+
searxngUrl
|
|
396
|
+
};
|
|
397
|
+
throw createServerError(response.status, response.statusText, responseBody, context);
|
|
310
398
|
}
|
|
311
|
-
const context = {
|
|
312
|
-
url: url.toString(),
|
|
313
|
-
searxngUrl
|
|
314
|
-
};
|
|
315
|
-
throw createServerError(response.status, response.statusText, responseBody, context);
|
|
316
|
-
}
|
|
317
|
-
// Parse JSON response
|
|
318
|
-
let data;
|
|
319
|
-
try {
|
|
320
|
-
data = (await response.json());
|
|
321
399
|
}
|
|
322
|
-
|
|
323
|
-
|
|
400
|
+
else {
|
|
401
|
+
// Parse JSON response
|
|
324
402
|
try {
|
|
325
|
-
|
|
403
|
+
data = (await response.json());
|
|
326
404
|
}
|
|
327
|
-
catch {
|
|
328
|
-
|
|
405
|
+
catch (error) {
|
|
406
|
+
if (isHtmlFallbackEnabled()) {
|
|
407
|
+
data = await fetchHtmlFallbackSearch(mcpServer, url, requestOptions, SEARCH_TIMEOUT_MS, query, searxngUrl);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
let responseText;
|
|
411
|
+
try {
|
|
412
|
+
responseText = await response.text();
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
responseText = '[Could not read response text]';
|
|
416
|
+
}
|
|
417
|
+
const context = { url: url.toString() };
|
|
418
|
+
throw createJSONError(responseText, context);
|
|
419
|
+
}
|
|
329
420
|
}
|
|
330
|
-
const context = { url: url.toString() };
|
|
331
|
-
throw createJSONError(responseText, context);
|
|
332
421
|
}
|
|
333
422
|
if (!data.results) {
|
|
334
423
|
const context = { url: url.toString(), query };
|
|
@@ -349,6 +438,7 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
|
|
|
349
438
|
const metadata = formatSearchMetadata(data);
|
|
350
439
|
const leadingSections = [
|
|
351
440
|
filters.validationNote ?? null,
|
|
441
|
+
data.sourceFormat === "html" ? "Note: Results parsed from SearXNG HTML fallback; metadata is limited." : null,
|
|
352
442
|
metadata || null,
|
|
353
443
|
].filter(Boolean).join("\n\n");
|
|
354
444
|
if (slicedResults.length === 0) {
|
|
@@ -365,8 +455,15 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
|
|
|
365
455
|
logMessage(mcpServer, "info", `Search completed: "${query}" (${searchParams}) - ${slicedResults.length} results in ${duration}ms`);
|
|
366
456
|
const formattedResults = slicedResults
|
|
367
457
|
.map((r) => {
|
|
368
|
-
const
|
|
369
|
-
|
|
458
|
+
const lines = [
|
|
459
|
+
`Title: ${r.title || ""}`,
|
|
460
|
+
`Description: ${truncateResultContent(r.content || "", maxResultChars)}`,
|
|
461
|
+
`URL: ${r.url || ""}`,
|
|
462
|
+
];
|
|
463
|
+
if (r.score !== undefined) {
|
|
464
|
+
lines.push(`Relevance Score: ${r.score.toFixed(3)}`);
|
|
465
|
+
}
|
|
466
|
+
return lines.join("\n");
|
|
370
467
|
})
|
|
371
468
|
.join("\n\n");
|
|
372
469
|
return leadingSections ? `${leadingSections}\n\n---\n\n${formattedResults}` : formattedResults;
|
package/dist/types.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export interface SearXNGWebResult {
|
|
|
3
3
|
title: string;
|
|
4
4
|
content: string;
|
|
5
5
|
url: string;
|
|
6
|
-
score
|
|
6
|
+
score?: number;
|
|
7
7
|
engine?: string;
|
|
8
8
|
engines?: string[];
|
|
9
9
|
category?: string;
|
|
@@ -23,6 +23,7 @@ export interface SearXNGWeb {
|
|
|
23
23
|
query: string;
|
|
24
24
|
number_of_results: number;
|
|
25
25
|
results: SearXNGWebResult[];
|
|
26
|
+
sourceFormat?: "json" | "html";
|
|
26
27
|
suggestions?: string[];
|
|
27
28
|
corrections?: string[];
|
|
28
29
|
answers?: string[];
|
package/dist/url-reader.js
CHANGED
|
@@ -1,68 +1,14 @@
|
|
|
1
|
-
import { isIP } from "node:net";
|
|
2
1
|
import { NodeHtmlMarkdown } from "node-html-markdown";
|
|
3
2
|
import { fetch as undiciFetch } from "undici";
|
|
4
|
-
import { createProxyAgent,
|
|
3
|
+
import { createProxyAgent, createUrlReaderAgent, ProxyType } from "./proxy.js";
|
|
5
4
|
import { logMessage } from "./logging.js";
|
|
6
5
|
import { urlCache } from "./cache.js";
|
|
7
|
-
import {
|
|
6
|
+
import { assertUrlAllowed, isUrlSecurityPolicyDnsError } from "./url-security.js";
|
|
8
7
|
import { createURLFormatError, createURLSecurityPolicyError, createNetworkError, createServerError, createContentError, createConversionError, createTimeoutError, createEmptyContentWarning, createUnexpectedError } from "./error-handler.js";
|
|
9
8
|
const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
|
|
10
9
|
const MAX_REDIRECTS = 5;
|
|
11
10
|
export const DEFAULT_MAX_CONTENT_LENGTH_BYTES = 5 * 1024 * 1024;
|
|
12
11
|
const HEAD_TIMEOUT_CAP_MS = 3000;
|
|
13
|
-
function isPrivateHostname(hostname) {
|
|
14
|
-
const lower = hostname.toLowerCase().replace(/\.+$/, "");
|
|
15
|
-
return lower === "localhost" || lower.endsWith(".localhost");
|
|
16
|
-
}
|
|
17
|
-
function isPrivateIpv4(hostname) {
|
|
18
|
-
if (isIP(hostname) !== 4) {
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
return (hostname.startsWith("0.") ||
|
|
22
|
-
hostname.startsWith("10.") ||
|
|
23
|
-
hostname.startsWith("127.") ||
|
|
24
|
-
hostname.startsWith("192.168.") ||
|
|
25
|
-
/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname) ||
|
|
26
|
-
hostname.startsWith("169.254."));
|
|
27
|
-
}
|
|
28
|
-
function isPrivateIPv6(hostname) {
|
|
29
|
-
// url.hostname wraps IPv6 in brackets (e.g. "[::1]") — strip them first
|
|
30
|
-
const addr = (hostname.startsWith("[") && hostname.endsWith("]")
|
|
31
|
-
? hostname.slice(1, -1)
|
|
32
|
-
: hostname).toLowerCase();
|
|
33
|
-
if (isIP(addr) !== 6)
|
|
34
|
-
return false;
|
|
35
|
-
if (addr === "::1")
|
|
36
|
-
return true; // loopback
|
|
37
|
-
if (addr === "::")
|
|
38
|
-
return true; // unspecified
|
|
39
|
-
if (/^f[cd]/i.test(addr))
|
|
40
|
-
return true; // ULA fc00::/7
|
|
41
|
-
if (/^fe[89ab][0-9a-f]:/i.test(addr))
|
|
42
|
-
return true; // link-local fe80::/10
|
|
43
|
-
// IPv4-mapped ::ffff:<ipv4> — delegate to the IPv4 check
|
|
44
|
-
const mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
45
|
-
if (mapped)
|
|
46
|
-
return isPrivateIpv4(mapped[1]);
|
|
47
|
-
// IPv4-mapped ::ffff:<hhhh>:<hhhh> — convert the hex segments to dotted decimal
|
|
48
|
-
const hexMapped = addr.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
49
|
-
if (hexMapped) {
|
|
50
|
-
const high = parseInt(hexMapped[1], 16);
|
|
51
|
-
const low = parseInt(hexMapped[2], 16);
|
|
52
|
-
const ipv4 = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
|
|
53
|
-
return isPrivateIpv4(ipv4);
|
|
54
|
-
}
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
|
-
function assertUrlAllowed(url) {
|
|
58
|
-
const security = getHttpSecurityConfig();
|
|
59
|
-
if (security.allowPrivateUrls) {
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
if (isPrivateHostname(url.hostname) || isPrivateIpv4(url.hostname) || isPrivateIPv6(url.hostname)) {
|
|
63
|
-
throw createURLSecurityPolicyError(url.toString());
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
12
|
function isRedirectResponse(response) {
|
|
67
13
|
return REDIRECT_STATUS_CODES.has(response.status);
|
|
68
14
|
}
|
|
@@ -186,6 +132,9 @@ export async function checkContentLength(mcpServer, url, timeoutMs, dispatcher,
|
|
|
186
132
|
return Number.isNaN(parsed) || parsed < 0 ? null : parsed;
|
|
187
133
|
}
|
|
188
134
|
catch (error) {
|
|
135
|
+
if (isUrlSecurityPolicyDnsError(error)) {
|
|
136
|
+
throw createURLSecurityPolicyError(url);
|
|
137
|
+
}
|
|
189
138
|
logMessage(mcpServer, "warning", `HEAD check failed (proceeding with GET): ${error.message}`);
|
|
190
139
|
return null;
|
|
191
140
|
}
|
|
@@ -211,6 +160,45 @@ function createContentTooLargeMessage(contentLength, maxBytes) {
|
|
|
211
160
|
return (`Content too large: server reports Content-Length of ${sizeMB} MB (limit: ${limitMB} MB). ` +
|
|
212
161
|
`Try using readHeadings or section to fetch only the relevant parts.`);
|
|
213
162
|
}
|
|
163
|
+
function concatenateChunks(chunks, totalBytes) {
|
|
164
|
+
const result = new Uint8Array(totalBytes);
|
|
165
|
+
let offset = 0;
|
|
166
|
+
for (const chunk of chunks) {
|
|
167
|
+
result.set(chunk, offset);
|
|
168
|
+
offset += chunk.byteLength;
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
async function readResponseBodyWithLimit(response, maxBytes) {
|
|
173
|
+
if (response.body === null) {
|
|
174
|
+
return { exceeded: false, text: "", bytesRead: 0 };
|
|
175
|
+
}
|
|
176
|
+
const reader = response.body.getReader();
|
|
177
|
+
const chunks = [];
|
|
178
|
+
let bytesRead = 0;
|
|
179
|
+
try {
|
|
180
|
+
while (true) {
|
|
181
|
+
const { done, value } = await reader.read();
|
|
182
|
+
if (done) {
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
if (!value) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
bytesRead += value.byteLength;
|
|
189
|
+
if (bytesRead > maxBytes) {
|
|
190
|
+
await reader.cancel();
|
|
191
|
+
return { exceeded: true, bytesRead };
|
|
192
|
+
}
|
|
193
|
+
chunks.push(value);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
finally {
|
|
197
|
+
reader.releaseLock();
|
|
198
|
+
}
|
|
199
|
+
const bodyBytes = concatenateChunks(chunks, bytesRead);
|
|
200
|
+
return { exceeded: false, text: new TextDecoder("utf-8").decode(bodyBytes), bytesRead };
|
|
201
|
+
}
|
|
214
202
|
export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 10000, paginationOptions = {}) {
|
|
215
203
|
const startTime = Date.now();
|
|
216
204
|
logMessage(mcpServer, "info", `Fetching URL: ${url}`);
|
|
@@ -258,7 +246,7 @@ export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 1000
|
|
|
258
246
|
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
|
|
259
247
|
// Add proxy or default dispatcher (includes system CA certs for TLS)
|
|
260
248
|
const proxyAgent = createProxyAgent(currentUrl.toString(), ProxyType.URL_READER);
|
|
261
|
-
const dispatcher = proxyAgent ??
|
|
249
|
+
const dispatcher = proxyAgent ?? createUrlReaderAgent();
|
|
262
250
|
usedDispatcher = !!dispatcher;
|
|
263
251
|
const currentRequestOptions = {
|
|
264
252
|
...requestOptions,
|
|
@@ -294,6 +282,9 @@ export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 1000
|
|
|
294
282
|
if (error.name === 'MCPSearXNGError') {
|
|
295
283
|
throw error;
|
|
296
284
|
}
|
|
285
|
+
if (isUrlSecurityPolicyDnsError(error)) {
|
|
286
|
+
throw createURLSecurityPolicyError(currentUrl.toString());
|
|
287
|
+
}
|
|
297
288
|
const context = {
|
|
298
289
|
url: currentUrl.toString(),
|
|
299
290
|
proxyAgent: usedDispatcher,
|
|
@@ -304,7 +295,10 @@ export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 1000
|
|
|
304
295
|
if (!response.ok) {
|
|
305
296
|
let responseBody;
|
|
306
297
|
try {
|
|
307
|
-
|
|
298
|
+
const bodyRead = await readResponseBodyWithLimit(response, maxContentLengthBytes);
|
|
299
|
+
responseBody = bodyRead.exceeded
|
|
300
|
+
? createContentTooLargeMessage(bodyRead.bytesRead, maxContentLengthBytes)
|
|
301
|
+
: bodyRead.text;
|
|
308
302
|
}
|
|
309
303
|
catch {
|
|
310
304
|
responseBody = '[Could not read response body]';
|
|
@@ -315,7 +309,11 @@ export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 1000
|
|
|
315
309
|
// Retrieve HTML content
|
|
316
310
|
let htmlContent;
|
|
317
311
|
try {
|
|
318
|
-
|
|
312
|
+
const bodyRead = await readResponseBodyWithLimit(response, maxContentLengthBytes);
|
|
313
|
+
if (bodyRead.exceeded) {
|
|
314
|
+
return createContentTooLargeMessage(bodyRead.bytesRead, maxContentLengthBytes);
|
|
315
|
+
}
|
|
316
|
+
htmlContent = bodyRead.text;
|
|
319
317
|
}
|
|
320
318
|
catch (error) {
|
|
321
319
|
throw createContentError(`Failed to read website content: ${error.message || 'Unknown error reading content'}`, url);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const URL_SECURITY_POLICY_DNS_ERROR = "URLSecurityPolicyDnsError";
|
|
2
|
+
export declare function isPrivateHostname(hostname: string): boolean;
|
|
3
|
+
export declare function isPrivateIpv4(hostname: string): boolean;
|
|
4
|
+
export declare function isPrivateIPv6(hostname: string): boolean;
|
|
5
|
+
export declare function isPrivateAddress(address: string): boolean;
|
|
6
|
+
export declare function assertUrlAllowed(url: URL): void;
|
|
7
|
+
export declare function createUrlSecurityPolicyDnsError(hostname: string): NodeJS.ErrnoException;
|
|
8
|
+
export declare function isUrlSecurityPolicyDnsError(error: unknown): boolean;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { createURLSecurityPolicyError } from "./error-handler.js";
|
|
3
|
+
import { getHttpSecurityConfig } from "./http-security.js";
|
|
4
|
+
export const URL_SECURITY_POLICY_DNS_ERROR = "URLSecurityPolicyDnsError";
|
|
5
|
+
export function isPrivateHostname(hostname) {
|
|
6
|
+
const lower = hostname.toLowerCase().replace(/\.+$/, "");
|
|
7
|
+
return lower === "localhost" || lower.endsWith(".localhost");
|
|
8
|
+
}
|
|
9
|
+
export function isPrivateIpv4(hostname) {
|
|
10
|
+
if (isIP(hostname) !== 4) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
return (hostname.startsWith("0.") ||
|
|
14
|
+
hostname.startsWith("10.") ||
|
|
15
|
+
hostname.startsWith("127.") ||
|
|
16
|
+
hostname.startsWith("192.168.") ||
|
|
17
|
+
/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname) ||
|
|
18
|
+
hostname.startsWith("169.254."));
|
|
19
|
+
}
|
|
20
|
+
export function isPrivateIPv6(hostname) {
|
|
21
|
+
// url.hostname wraps IPv6 in brackets (e.g. "[::1]") - strip them first
|
|
22
|
+
const addr = (hostname.startsWith("[") && hostname.endsWith("]")
|
|
23
|
+
? hostname.slice(1, -1)
|
|
24
|
+
: hostname).toLowerCase();
|
|
25
|
+
if (isIP(addr) !== 6)
|
|
26
|
+
return false;
|
|
27
|
+
if (addr === "::1")
|
|
28
|
+
return true; // loopback
|
|
29
|
+
if (addr === "::")
|
|
30
|
+
return true; // unspecified
|
|
31
|
+
if (/^f[cd]/i.test(addr))
|
|
32
|
+
return true; // ULA fc00::/7
|
|
33
|
+
if (/^fe[89ab][0-9a-f]:/i.test(addr))
|
|
34
|
+
return true; // link-local fe80::/10
|
|
35
|
+
// IPv4-mapped ::ffff:<ipv4> - delegate to the IPv4 check
|
|
36
|
+
const mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
37
|
+
if (mapped)
|
|
38
|
+
return isPrivateIpv4(mapped[1]);
|
|
39
|
+
// IPv4-mapped ::ffff:<hhhh>:<hhhh> - convert the hex segments to dotted decimal
|
|
40
|
+
const hexMapped = addr.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
41
|
+
if (hexMapped) {
|
|
42
|
+
const high = parseInt(hexMapped[1], 16);
|
|
43
|
+
const low = parseInt(hexMapped[2], 16);
|
|
44
|
+
const ipv4 = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
|
|
45
|
+
return isPrivateIpv4(ipv4);
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
export function isPrivateAddress(address) {
|
|
50
|
+
return isPrivateIpv4(address) || isPrivateIPv6(address);
|
|
51
|
+
}
|
|
52
|
+
export function assertUrlAllowed(url) {
|
|
53
|
+
const security = getHttpSecurityConfig();
|
|
54
|
+
if (security.allowPrivateUrls) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (isPrivateHostname(url.hostname) || isPrivateIpv4(url.hostname) || isPrivateIPv6(url.hostname)) {
|
|
58
|
+
throw createURLSecurityPolicyError(url.toString());
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function createUrlSecurityPolicyDnsError(hostname) {
|
|
62
|
+
const error = new Error(`Resolved private address blocked by security policy for ${hostname}`);
|
|
63
|
+
error.name = URL_SECURITY_POLICY_DNS_ERROR;
|
|
64
|
+
error.code = URL_SECURITY_POLICY_DNS_ERROR;
|
|
65
|
+
return error;
|
|
66
|
+
}
|
|
67
|
+
export function isUrlSecurityPolicyDnsError(error) {
|
|
68
|
+
let current = error;
|
|
69
|
+
while (current) {
|
|
70
|
+
if (current.name === URL_SECURITY_POLICY_DNS_ERROR || current.code === URL_SECURITY_POLICY_DNS_ERROR) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(current.errors) && current.errors.some(isUrlSecurityPolicyDnsError)) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
current = current.cause;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const packageVersion = "1.
|
|
1
|
+
export declare const packageVersion = "1.7.1";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const packageVersion = "1.
|
|
1
|
+
export const packageVersion = "1.7.1";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-searxng",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"mcpName": "io.github.ihor-sokoliuk/mcp-searxng",
|
|
5
5
|
"description": "MCP server for SearXNG integration",
|
|
6
6
|
"license": "MIT",
|
|
@@ -55,16 +55,17 @@
|
|
|
55
55
|
"express": "^5.2.1",
|
|
56
56
|
"express-rate-limit": "^8.5.2",
|
|
57
57
|
"node-html-markdown": "^2.0.0",
|
|
58
|
-
"
|
|
58
|
+
"node-html-parser": "^6.1.13",
|
|
59
|
+
"undici": "7.28.0"
|
|
59
60
|
},
|
|
60
61
|
"devDependencies": {
|
|
61
|
-
"@types/node": "22.19.
|
|
62
|
+
"@types/node": "22.19.21",
|
|
62
63
|
"@types/supertest": "^7.2.0",
|
|
63
|
-
"@typescript-eslint/eslint-plugin": "8.61.
|
|
64
|
-
"@typescript-eslint/parser": "8.61.
|
|
64
|
+
"@typescript-eslint/eslint-plugin": "8.61.1",
|
|
65
|
+
"@typescript-eslint/parser": "8.61.1",
|
|
65
66
|
"c8": "^11.0.0",
|
|
66
67
|
"cross-env": "^10.1.0",
|
|
67
|
-
"eslint": "10.
|
|
68
|
+
"eslint": "10.5.0",
|
|
68
69
|
"eslint-plugin-security": "^4.0.0",
|
|
69
70
|
"fast-check": "^4.8.0",
|
|
70
71
|
"shx": "^0.4.0",
|