mcp-searxng 1.7.0 → 1.7.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.md CHANGED
@@ -12,9 +12,12 @@
12
12
  [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/ihor-sokoliuk/mcp-searxng/badge)](https://scorecard.dev/viewer/?uri=github.com/ihor-sokoliuk/mcp-searxng)
13
13
  [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13143/badge)](https://www.bestpractices.dev/projects/13143)
14
14
  [![mcp-searxng MCP server](https://glama.ai/mcp/servers/ihor-sokoliuk/mcp-searxng/badges/score.svg)](https://glama.ai/mcp/servers/ihor-sokoliuk/mcp-searxng)
15
+ [![GitHub MCP Registry](https://img.shields.io/badge/GitHub_MCP_Registry-listed-2da44e?style=flat-square&logo=github&logoColor=white)](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
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
+ }
@@ -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, createDefaultAgent, ProxyType } from "./proxy.js";
3
+ import { createProxyAgent, createUrlReaderAgent, ProxyType } from "./proxy.js";
5
4
  import { logMessage } from "./logging.js";
6
5
  import { urlCache } from "./cache.js";
7
- import { getHttpSecurityConfig } from "./http-security.js";
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 ?? createDefaultAgent();
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
- responseBody = await response.text();
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
- htmlContent = await response.text();
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.7.0";
1
+ export declare const packageVersion = "1.7.2";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "1.7.0";
1
+ export const packageVersion = "1.7.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-searxng",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "mcpName": "io.github.ihor-sokoliuk/mcp-searxng",
5
5
  "description": "MCP server for SearXNG integration",
6
6
  "license": "MIT",