mcp-searxng 1.1.0 → 1.2.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
@@ -1,12 +1,19 @@
1
- # SearXNG MCP Server
1
+ <div align="center">
2
2
 
3
- An [MCP server](https://modelcontextprotocol.io/introduction) that integrates the [SearXNG](https://docs.searxng.org) API, giving AI assistants web search capabilities.
3
+ # 🔍 SearXNG MCP Server
4
+
5
+ **Private web search for AI assistants — connect any SearXNG instance to Claude, Cursor, and more.**
4
6
 
5
- [![https://nodei.co/npm/mcp-searxng.png?downloads=true&downloadRank=true&stars=true](https://nodei.co/npm/mcp-searxng.png?downloads=true&downloadRank=true&stars=true)](https://www.npmjs.com/package/mcp-searxng)
7
+ [![GitHub Stars](https://img.shields.io/github/stars/ihor-sokoliuk/mcp-searxng?style=flat-square&logo=github&label=stars)](https://github.com/ihor-sokoliuk/mcp-searxng/stargazers)
8
+ [![npm version](https://img.shields.io/npm/v/mcp-searxng?style=flat-square&logo=npm)](https://www.npmjs.com/package/mcp-searxng)
9
+ [![npm downloads](https://img.shields.io/npm/dm/mcp-searxng?style=flat-square&logo=npm&label=downloads%2Fmo)](https://www.npmjs.com/package/mcp-searxng)
10
+ [![Docker Pulls](https://img.shields.io/docker/pulls/isokoliuk/mcp-searxng?style=flat-square&logo=docker)](https://hub.docker.com/r/isokoliuk/mcp-searxng)
11
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE)
12
+ [![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)
6
13
 
7
- [![https://badgen.net/docker/pulls/isokoliuk/mcp-searxng](https://badgen.net/docker/pulls/isokoliuk/mcp-searxng)](https://hub.docker.com/r/isokoliuk/mcp-searxng)
14
+ An [MCP server](https://modelcontextprotocol.io/introduction) that integrates the [SearXNG](https://docs.searxng.org) API, giving AI assistants web search capabilities.
8
15
 
9
- <a href="https://glama.ai/mcp/servers/0j7jjyt7m9"><img width="380" height="200" src="https://glama.ai/mcp/servers/0j7jjyt7m9/badge" alt="SearXNG Server MCP server" /></a>
16
+ </div>
10
17
 
11
18
  ## Quick Start
12
19
 
@@ -26,7 +33,7 @@ Add to your MCP client configuration (e.g. `claude_desktop_config.json`):
26
33
  }
27
34
  ```
28
35
 
29
- Replace `YOUR_SEARXNG_INSTANCE_URL` with the URL of your SearXNG instance (e.g. `https://search.example.com`).
36
+ Replace `YOUR_SEARXNG_INSTANCE_URL` with the URL of your SearXNG instance (e.g. `https://searxng.example.com`).
30
37
 
31
38
  ## Features
32
39
 
@@ -34,9 +41,10 @@ Replace `YOUR_SEARXNG_INSTANCE_URL` with the URL of your SearXNG instance (e.g.
34
41
  - **URL Content Reading**: Advanced content extraction with pagination, section filtering, and heading extraction.
35
42
  - **Intelligent Caching**: URL content is cached with TTL (Time-To-Live) to improve performance and reduce redundant requests.
36
43
  - **Pagination**: Control which page of results to retrieve.
37
- - **Time Filtering**: Filter results by time range (day, month, year).
44
+ - **Time Filtering**: Filter results by time range (day, week, month, year).
38
45
  - **Language Selection**: Filter results by preferred language.
39
46
  - **Safe Search**: Control content filtering level for search results.
47
+ - **Relevance Filtering**: Filter out low-scoring search results with `min_score`.
40
48
 
41
49
  ## How It Works
42
50
 
@@ -61,9 +69,10 @@ AI Assistant (e.g. Claude)
61
69
  - Inputs:
62
70
  - `query` (string): The search query. This string is passed to external search services.
63
71
  - `pageno` (number, optional): Search page number, starts at 1 (default 1)
64
- - `time_range` (string, optional): Filter results by time range - one of: "day", "month", "year" (default: none)
72
+ - `time_range` (string, optional): Filter results by time range - one of: "day", "week", "month", "year" (default: none)
65
73
  - `language` (string, optional): Language code for results (e.g., "en", "fr", "de") or "all" (default: "all")
66
74
  - `safesearch` (number, optional): Safe search filter level (0: None, 1: Moderate, 2: Strict) (default: instance setting)
75
+ - `min_score` (number, optional): Minimum relevance score from 0.0 to 1.0. Results below this score are filtered out.
67
76
 
68
77
  - **web_url_read**
69
78
  - Read and convert the content from a URL to markdown with advanced content extraction options
package/dist/cache.js CHANGED
@@ -11,6 +11,7 @@ class SimpleCache {
11
11
  this.cleanupInterval = setInterval(() => {
12
12
  this.cleanupExpired();
13
13
  }, cleanupIntervalMs);
14
+ this.cleanupInterval.unref();
14
15
  }
15
16
  cleanupExpired() {
16
17
  const now = Date.now();
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- declare const packageVersion = "1.1.0";
3
+ declare const packageVersion = "1.2.0";
4
4
  export { packageVersion };
5
5
  export declare function isWebUrlReadArgs(args: unknown): args is {
6
6
  url: string;
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { fileURLToPath } from "node:url";
2
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
5
  import { CallToolRequestSchema, ListToolsRequestSchema, SetLevelRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
@@ -10,7 +11,8 @@ import { fetchAndConvertToMarkdown } from "./url-reader.js";
10
11
  import { createConfigResource, createHelpResource } from "./resources.js";
11
12
  import { createHttpServer, resolveBindHost } from "./http-server.js";
12
13
  // Use a static version string that will be updated by the version script
13
- const packageVersion = "1.1.0";
14
+ const packageVersion = "1.2.0";
15
+ const isMainModule = process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1];
14
16
  // Export the version for use in other modules
15
17
  export { packageVersion };
16
18
  // Type guard for URL reading args
@@ -77,7 +79,7 @@ export function createMcpServer() {
77
79
  if (!isSearXNGWebSearchArgs(args)) {
78
80
  throw new Error("Invalid arguments for web search");
79
81
  }
80
- const result = await performWebSearch(mcpServer, args.query, args.pageno, args.time_range, args.language, args.safesearch);
82
+ const result = await performWebSearch(mcpServer, args.query, args.pageno, args.time_range, args.language, args.safesearch, args.min_score);
81
83
  return {
82
84
  content: [
83
85
  {
@@ -238,17 +240,18 @@ async function main() {
238
240
  logMessage(mcpServer, "info", `SearXNG URL: ${process.env.SEARXNG_URL || 'not configured'}`);
239
241
  }
240
242
  }
241
- // Handle uncaught errors
242
- process.on('uncaughtException', (error) => {
243
- console.error('Uncaught Exception:', error);
244
- process.exit(1);
245
- });
246
- process.on('unhandledRejection', (reason, promise) => {
247
- console.error('Unhandled Rejection at:', promise, 'reason:', reason);
248
- process.exit(1);
249
- });
250
- // Start the server (CLI entrypoint)
251
- main().catch((error) => {
252
- console.error("Failed to start server:", error);
253
- process.exit(1);
254
- });
243
+ if (isMainModule) {
244
+ // Handle uncaught errors for the CLI entrypoint.
245
+ process.on('uncaughtException', (error) => {
246
+ console.error('Uncaught Exception:', error);
247
+ process.exit(1);
248
+ });
249
+ process.on('unhandledRejection', (reason, promise) => {
250
+ console.error('Unhandled Rejection at:', promise, 'reason:', reason);
251
+ process.exit(1);
252
+ });
253
+ main().catch((error) => {
254
+ console.error("Failed to start server:", error);
255
+ process.exit(1);
256
+ });
257
+ }
package/dist/resources.js CHANGED
@@ -43,9 +43,10 @@ Performs web searches using the configured SearXNG instance.
43
43
  **Parameters:**
44
44
  - \`query\` (required): The search query string
45
45
  - \`pageno\` (optional): Page number (default: 1)
46
- - \`time_range\` (optional): Filter by time - "day", "month", or "year"
46
+ - \`time_range\` (optional): Filter by time - "day", "week", "month", or "year"
47
47
  - \`language\` (optional): Language code like "en", "fr", "de" (default: "all")
48
48
  - \`safesearch\` (optional): Safe search level - 0 (none), 1 (moderate), 2 (strict)
49
+ - \`min_score\` (optional): Minimum relevance score from 0.0 to 1.0
49
50
 
50
51
  ### 2. web_url_read
51
52
  Reads and converts web page content to Markdown format.
@@ -63,6 +64,10 @@ Reads and converts web page content to Markdown format.
63
64
  - \`HTTP_PROXY\` / \`HTTPS_PROXY\`: Proxy server configuration
64
65
  - \`NO_PROXY\` / \`no_proxy\`: Comma-separated list of hosts to bypass proxy
65
66
  - \`MCP_HTTP_PORT\`: Enable HTTP transport on specified port
67
+ - \`MCP_HTTP_ALLOW_PRIVATE_URLS\`: Allow \`web_url_read\` to fetch private/internal URLs. Disabled by default in all modes.
68
+
69
+ ### URL Reader Security
70
+ \`web_url_read\` blocks private/internal URLs and redirects to private/internal URLs by default. Set \`MCP_HTTP_ALLOW_PRIVATE_URLS=true\` only when internal URL reads are intentional.
66
71
 
67
72
  ## Transport Modes
68
73
 
package/dist/search.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- export declare function performWebSearch(mcpServer: McpServer, query: string, pageno?: number, time_range?: string, language?: string, safesearch?: number): Promise<string>;
2
+ export declare function performWebSearch(mcpServer: McpServer, query: string, pageno?: number, time_range?: string, language?: string, safesearch?: number, min_score?: number): Promise<string>;
package/dist/search.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createProxyAgent, createDefaultAgent, ProxyType } from "./proxy.js";
2
2
  import { logMessage } from "./logging.js";
3
3
  import { MCPSearXNGError, validateEnvironment, createNetworkError, createServerError, createJSONError, createDataError, createNoResultsMessage } from "./error-handler.js";
4
- export async function performWebSearch(mcpServer, query, pageno = 1, time_range, language = "all", safesearch) {
4
+ export async function performWebSearch(mcpServer, query, pageno = 1, time_range, language = "all", safesearch, min_score) {
5
5
  const startTime = Date.now();
6
6
  // Build detailed log message with all parameters
7
7
  const searchParams = [
@@ -23,7 +23,7 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
23
23
  url.searchParams.set("format", "json");
24
24
  url.searchParams.set("pageno", pageno.toString());
25
25
  if (time_range !== undefined &&
26
- ["day", "month", "year"].includes(time_range)) {
26
+ ["day", "week", "month", "year"].includes(time_range)) {
27
27
  url.searchParams.set("time_range", time_range);
28
28
  }
29
29
  if (language && language !== "all") {
@@ -110,14 +110,17 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
110
110
  const context = { url: url.toString(), query };
111
111
  throw createDataError(data, context);
112
112
  }
113
- const results = data.results.map((result) => ({
113
+ const results = data.results
114
+ .map((result) => ({
114
115
  title: result.title || "",
115
116
  content: result.content || "",
116
117
  url: result.url || "",
117
118
  score: result.score || 0,
118
- }));
119
+ }))
120
+ .filter((result) => min_score === undefined || result.score >= min_score);
119
121
  if (results.length === 0) {
120
- logMessage(mcpServer, "info", `No results found for query: "${query}"`);
122
+ const filterNote = min_score === undefined ? "" : ` after applying min_score=${min_score}`;
123
+ logMessage(mcpServer, "info", `No results found for query: "${query}"${filterNote}`);
121
124
  return createNoResultsMessage(query);
122
125
  }
123
126
  const duration = Date.now() - startTime;
@@ -22,8 +22,10 @@ export function getSystemCACerts() {
22
22
  return null;
23
23
  }
24
24
  for (const caPath of CA_BUNDLE_PATHS) {
25
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
25
26
  if (existsSync(caPath)) {
26
27
  try {
28
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
27
29
  return readFileSync(caPath, "utf8");
28
30
  }
29
31
  catch {
package/dist/types.d.ts CHANGED
@@ -13,6 +13,7 @@ export declare function isSearXNGWebSearchArgs(args: unknown): args is {
13
13
  time_range?: string;
14
14
  language?: string;
15
15
  safesearch?: number;
16
+ min_score?: number;
16
17
  };
17
18
  export declare const WEB_SEARCH_TOOL: Tool;
18
19
  export declare const READ_URL_TOOL: Tool;
package/dist/types.js CHANGED
@@ -1,14 +1,43 @@
1
+ const VALID_TIME_RANGES = ["day", "week", "month", "year"];
2
+ const VALID_SAFESEARCH_VALUES = [0, 1, 2];
1
3
  export function isSearXNGWebSearchArgs(args) {
2
- return (typeof args === "object" &&
3
- args !== null &&
4
- "query" in args &&
5
- typeof args.query === "string");
4
+ if (typeof args !== "object" ||
5
+ args === null ||
6
+ !("query" in args) ||
7
+ typeof args.query !== "string") {
8
+ return false;
9
+ }
10
+ const searchArgs = args;
11
+ if (searchArgs.pageno !== undefined && (typeof searchArgs.pageno !== "number" || searchArgs.pageno < 1)) {
12
+ return false;
13
+ }
14
+ if (searchArgs.time_range !== undefined &&
15
+ (typeof searchArgs.time_range !== "string" || !VALID_TIME_RANGES.includes(searchArgs.time_range))) {
16
+ return false;
17
+ }
18
+ if (searchArgs.language !== undefined && typeof searchArgs.language !== "string") {
19
+ return false;
20
+ }
21
+ if (searchArgs.safesearch !== undefined &&
22
+ (typeof searchArgs.safesearch !== "number" || !VALID_SAFESEARCH_VALUES.includes(searchArgs.safesearch))) {
23
+ return false;
24
+ }
25
+ if (searchArgs.min_score !== undefined &&
26
+ (typeof searchArgs.min_score !== "number" ||
27
+ Number.isNaN(searchArgs.min_score) ||
28
+ searchArgs.min_score < 0 ||
29
+ searchArgs.min_score > 1)) {
30
+ return false;
31
+ }
32
+ return true;
6
33
  }
7
34
  export const WEB_SEARCH_TOOL = {
8
35
  name: "searxng_web_search",
9
- description: "Searches the web using SearXNG. " +
10
- "CRITICAL: The parameter name MUST be exactly `query` (not `prompt`, `q`, or any other name). " +
11
- "Pass your search terms as the value of the `query` parameter.",
36
+ description: "Searches the web using SearXNG and returns a list of results, each with a title, URL, and content snippet. " +
37
+ "CRITICAL: The required parameter name is exactly `query` (not `prompt`, `q`, or any other name). " +
38
+ "Calls an external SearXNG instance; availability depends on the `SEARXNG_URL` configuration. " +
39
+ "Use `pageno` to paginate results; combine `time_range` and `language` to narrow scope. " +
40
+ "To read the full text of a result URL, follow up with `web_url_read`.",
12
41
  annotations: {
13
42
  readOnlyHint: true,
14
43
  openWorldHint: true,
@@ -27,8 +56,8 @@ export const WEB_SEARCH_TOOL = {
27
56
  },
28
57
  time_range: {
29
58
  type: "string",
30
- description: "Time range of search (day, month, year)",
31
- enum: ["day", "month", "year"],
59
+ description: "Time range of search (day, week, month, year)",
60
+ enum: ["day", "week", "month", "year"],
32
61
  },
33
62
  language: {
34
63
  type: "string",
@@ -41,14 +70,25 @@ export const WEB_SEARCH_TOOL = {
41
70
  enum: [0, 1, 2],
42
71
  default: 0,
43
72
  },
73
+ min_score: {
74
+ type: "number",
75
+ description: "Minimum relevance score threshold from 0.0 to 1.0. Results below this score are filtered out.",
76
+ minimum: 0,
77
+ maximum: 1,
78
+ },
44
79
  },
45
80
  required: ["query"],
46
81
  },
47
82
  };
48
83
  export const READ_URL_TOOL = {
49
84
  name: "web_url_read",
50
- description: "Read the content from an URL. " +
51
- "Use this for further information retrieving to understand the content of each URL.",
85
+ description: "Fetches a URL and returns its text content converted to markdown. " +
86
+ "Three modes: " +
87
+ "(1) Full content — omit filtering params; use `startChar`/`maxLength` to paginate large pages. " +
88
+ "(2) Section extraction — set `section` to return content under a specific heading. " +
89
+ "(3) Headings only — set `readHeadings: true` to list all headings (mutually exclusive with other filtering params). " +
90
+ "Returns an error string if the URL is unreachable or content cannot be extracted. " +
91
+ "Use after `searxng_web_search` to read the full content of individual result URLs.",
52
92
  annotations: {
53
93
  readOnlyHint: true,
54
94
  openWorldHint: true,
@@ -6,6 +6,8 @@ import { logMessage } from "./logging.js";
6
6
  import { urlCache } from "./cache.js";
7
7
  import { getHttpSecurityConfig } from "./http-security.js";
8
8
  import { createURLFormatError, createURLSecurityPolicyError, createNetworkError, createServerError, createContentError, createConversionError, createTimeoutError, createEmptyContentWarning, createUnexpectedError } from "./error-handler.js";
9
+ const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
10
+ const MAX_REDIRECTS = 5;
9
11
  function isPrivateHostname(hostname) {
10
12
  const lower = hostname.toLowerCase().replace(/\.+$/, "");
11
13
  return lower === "localhost" || lower.endsWith(".localhost");
@@ -14,7 +16,8 @@ function isPrivateIpv4(hostname) {
14
16
  if (isIP(hostname) !== 4) {
15
17
  return false;
16
18
  }
17
- return (hostname.startsWith("10.") ||
19
+ return (hostname.startsWith("0.") ||
20
+ hostname.startsWith("10.") ||
18
21
  hostname.startsWith("127.") ||
19
22
  hostname.startsWith("192.168.") ||
20
23
  /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname) ||
@@ -39,17 +42,28 @@ function isPrivateIPv6(hostname) {
39
42
  const mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
40
43
  if (mapped)
41
44
  return isPrivateIpv4(mapped[1]);
45
+ // IPv4-mapped ::ffff:<hhhh>:<hhhh> — convert the hex segments to dotted decimal
46
+ const hexMapped = addr.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
47
+ if (hexMapped) {
48
+ const high = parseInt(hexMapped[1], 16);
49
+ const low = parseInt(hexMapped[2], 16);
50
+ const ipv4 = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
51
+ return isPrivateIpv4(ipv4);
52
+ }
42
53
  return false;
43
54
  }
44
55
  function assertUrlAllowed(url) {
45
56
  const security = getHttpSecurityConfig();
46
- if (!security.harden || security.allowPrivateUrls) {
57
+ if (security.allowPrivateUrls) {
47
58
  return;
48
59
  }
49
60
  if (isPrivateHostname(url.hostname) || isPrivateIpv4(url.hostname) || isPrivateIPv6(url.hostname)) {
50
61
  throw createURLSecurityPolicyError(url.toString());
51
62
  }
52
63
  }
64
+ function isRedirectResponse(response) {
65
+ return REDIRECT_STATUS_CODES.has(response.status);
66
+ }
53
67
  function applyCharacterPagination(content, startChar = 0, maxLength) {
54
68
  if (startChar >= content.length) {
55
69
  return "";
@@ -58,18 +72,15 @@ function applyCharacterPagination(content, startChar = 0, maxLength) {
58
72
  const end = maxLength ? Math.min(content.length, start + maxLength) : content.length;
59
73
  return content.slice(start, end);
60
74
  }
61
- function escapeRegExp(str) {
62
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
63
- }
64
75
  function extractSection(markdownContent, sectionHeading) {
65
76
  const lines = markdownContent.split('\n');
66
- const sectionRegex = new RegExp(`^#{1,6}\\s*.*${escapeRegExp(sectionHeading)}.*$`, 'i');
77
+ const normalizedHeading = sectionHeading.toLowerCase();
67
78
  let startIndex = -1;
68
79
  let currentLevel = 0;
69
- // Find the section start
80
+ // Find the section start — string match avoids RegExp constructor with user input
70
81
  for (let i = 0; i < lines.length; i++) {
71
82
  const line = lines[i];
72
- if (sectionRegex.test(line)) {
83
+ if (/^#{1,6}\s/.test(line) && line.toLowerCase().includes(normalizedHeading)) {
73
84
  startIndex = i;
74
85
  currentLevel = (line.match(/^#+/) || [''])[0].length;
75
86
  break;
@@ -93,6 +104,7 @@ function extractSection(markdownContent, sectionHeading) {
93
104
  function extractParagraphRange(markdownContent, range) {
94
105
  const paragraphs = markdownContent.split('\n\n').filter(p => p.trim().length > 0);
95
106
  // Parse range (e.g., "1-5", "3", "10-")
107
+ // eslint-disable-next-line security/detect-unsafe-regex
96
108
  const rangeMatch = range.match(/^(\d+)(?:-(\d*))?$/);
97
109
  if (!rangeMatch) {
98
110
  return "";
@@ -176,16 +188,11 @@ export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 1000
176
188
  const controller = new AbortController();
177
189
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
178
190
  try {
179
- // Prepare request options with proxy support
191
+ // Prepare base request options with proxy support
180
192
  const requestOptions = {
181
193
  signal: controller.signal,
194
+ redirect: "manual",
182
195
  };
183
- // Add proxy or default dispatcher (includes system CA certs for TLS)
184
- const proxyAgent = createProxyAgent(url, ProxyType.URL_READER);
185
- const dispatcher = proxyAgent ?? createDefaultAgent();
186
- if (dispatcher) {
187
- requestOptions.dispatcher = dispatcher;
188
- }
189
196
  // Add User-Agent header if configured (URL_READER_USER_AGENT takes priority over USER_AGENT)
190
197
  const userAgent = process.env.URL_READER_USER_AGENT || process.env.USER_AGENT;
191
198
  if (userAgent) {
@@ -195,17 +202,47 @@ export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 1000
195
202
  };
196
203
  }
197
204
  let response;
205
+ let currentUrl = parsedUrl;
206
+ let usedDispatcher = false;
198
207
  try {
199
- // Fetch the URL with the abort signal.
200
- // Use undici's own fetch so it shares the same internal version as the
201
- // Agent/ProxyAgent dispatcher avoids the Node.js bundled-undici vs
202
- // npm-undici version mismatch that breaks Content-Encoding decompression.
203
- response = await undiciFetch(url, requestOptions);
208
+ for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
209
+ // Add proxy or default dispatcher (includes system CA certs for TLS)
210
+ const proxyAgent = createProxyAgent(currentUrl.toString(), ProxyType.URL_READER);
211
+ const dispatcher = proxyAgent ?? createDefaultAgent();
212
+ usedDispatcher = !!dispatcher;
213
+ const currentRequestOptions = {
214
+ ...requestOptions,
215
+ };
216
+ if (dispatcher) {
217
+ currentRequestOptions.dispatcher = dispatcher;
218
+ }
219
+ // Fetch the URL with the abort signal.
220
+ // Use undici's own fetch so it shares the same internal version as the
221
+ // Agent/ProxyAgent dispatcher — avoids the Node.js bundled-undici vs
222
+ // npm-undici version mismatch that breaks Content-Encoding decompression.
223
+ response = await undiciFetch(currentUrl.toString(), currentRequestOptions);
224
+ if (!isRedirectResponse(response)) {
225
+ break;
226
+ }
227
+ const location = response.headers.get("location");
228
+ if (!location) {
229
+ break;
230
+ }
231
+ if (redirects === MAX_REDIRECTS) {
232
+ throw createContentError(`Too many redirects while fetching URL: ${url}`, url);
233
+ }
234
+ const nextUrl = new URL(location, currentUrl);
235
+ assertUrlAllowed(nextUrl);
236
+ currentUrl = nextUrl;
237
+ }
204
238
  }
205
239
  catch (error) {
240
+ if (error.name === 'MCPSearXNGError') {
241
+ throw error;
242
+ }
206
243
  const context = {
207
- url,
208
- proxyAgent: !!dispatcher,
244
+ url: currentUrl.toString(),
245
+ proxyAgent: usedDispatcher,
209
246
  timeout: timeoutMs
210
247
  };
211
248
  throw createNetworkError(error, context);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-searxng",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "mcpName": "io.github.ihor-sokoliuk/mcp-searxng",
5
5
  "description": "MCP server for SearXNG integration",
6
6
  "license": "MIT",
@@ -42,6 +42,8 @@
42
42
  "bootstrap": "npm install && npm run build",
43
43
  "inspector": "DANGEROUSLY_OMIT_AUTH=true npx @modelcontextprotocol/inspector node dist/index.js",
44
44
  "lint": "eslint src __tests__",
45
+ "audit:deps": "npm audit --audit-level=moderate",
46
+ "security": "npm run lint && npm run audit:deps",
45
47
  "postversion": "TAG=$(node scripts/update-version.js | tail -1) && git add src/index.ts .mcp/server.json && git commit --amend --no-edit && git tag -f $TAG"
46
48
  },
47
49
  "dependencies": {
@@ -61,6 +63,7 @@
61
63
  "c8": "^11.0.0",
62
64
  "cross-env": "^10.1.0",
63
65
  "eslint": "^10.1.0",
66
+ "eslint-plugin-security": "^4.0.0",
64
67
  "shx": "^0.4.0",
65
68
  "supertest": "^7.2.2",
66
69
  "tsx": "^4.21.0",