mcp-searxng 1.7.2 → 1.9.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
@@ -38,11 +38,12 @@ Add to your MCP client configuration (e.g. `claude_desktop_config.json`):
38
38
  }
39
39
  ```
40
40
 
41
- Replace `YOUR_SEARXNG_INSTANCE_URL` with the URL of your SearXNG instance (e.g. `https://searxng.example.com`).
41
+ Replace `YOUR_SEARXNG_INSTANCE_URL` with the URL of your SearXNG instance (e.g. `https://searxng.example.com`). You can also provide interchangeable replicas as a semicolon-separated list, e.g. `https://one.example.com;https://two.example.com`.
42
42
 
43
43
  ## Features
44
44
 
45
45
  - **Web Search**: General queries, news, articles, with pagination.
46
+ - **Instance Failover**: Configure multiple interchangeable SearXNG replicas in `SEARXNG_URL`; searches fail over by default and can optionally fan out in parallel.
46
47
  - **Structured Search Output**: Choose formatted text or raw SearXNG-shaped JSON with `response_format`.
47
48
  - **Direct Answers & Metadata**: Text results surface SearXNG answers, corrections, suggestions, and infoboxes before result lists.
48
49
  - **Search Suggestions**: Query autocomplete via SearXNG's `/autocompleter` endpoint.
@@ -68,9 +69,9 @@ Replace `YOUR_SEARXNG_INSTANCE_URL` with the URL of your SearXNG instance (e.g.
68
69
 
69
70
  ## How It Works
70
71
 
71
- `mcp-searxng` is a standalone MCP server — a separate Node.js process that your AI assistant connects to for web search. It queries any SearXNG instance via its HTTP JSON API.
72
+ `mcp-searxng` is a standalone MCP server — a separate Node.js process that your AI assistant connects to for web search. It queries one SearXNG instance, or a semicolon-separated list of interchangeable SearXNG replicas, via the HTTP JSON API.
72
73
 
73
- > **Not a SearXNG plugin:** This project cannot be installed as a native SearXNG plugin. Point it at any existing SearXNG instance by setting `SEARXNG_URL`.
74
+ > **Not a SearXNG plugin:** This project cannot be installed as a native SearXNG plugin. Point it at any existing SearXNG instance, or interchangeable replica list, by setting `SEARXNG_URL`.
74
75
 
75
76
  ```
76
77
  AI Assistant (e.g. Claude)
@@ -79,7 +80,7 @@ AI Assistant (e.g. Claude)
79
80
  mcp-searxng (this project — Node.js process)
80
81
  │ HTTP JSON API (SEARXNG_URL)
81
82
 
82
- SearXNG instance
83
+ SearXNG instance(s)
83
84
  ```
84
85
 
85
86
  ## Tools
@@ -91,11 +92,11 @@ AI Assistant (e.g. Claude)
91
92
  - `pageno` (number, optional): Search page number, starts at 1 (default 1)
92
93
  - `time_range` (string, optional): Filter results by time range - one of: "day", "week", "month", "year" (default: none)
93
94
  - `language` (string, optional): Language code for results (e.g., "en", "fr", "de") or "all" (default: "all")
94
- - `safesearch` (number, optional): Safe search filter level (0: None, 1: Moderate, 2: Strict) (default: instance setting)
95
+ - `safesearch` (string enum, optional): Safe search filter level, one of `"0"` (None), `"1"` (Moderate), or `"2"` (Strict). Legacy numeric values `0`, `1`, and `2` are still accepted for backward compatibility. (default: instance setting)
95
96
  - `min_score` (number, optional): Minimum relevance score from 0.0 to 1.0. Results below this score are filtered out.
96
97
  - `num_results` (number, optional): Maximum number of results to return, from 1 to 20. `SEARXNG_MAX_RESULTS` applies as an operator ceiling.
97
- - `categories` (string, optional): Comma-separated SearXNG categories (e.g. `"news"`, `"it,science"`). When live `/config` is available, values are trimmed and normalized case-insensitively to the instance's canonical category names; unknown values are rejected with available categories listed. If `/config` is unavailable, values are forwarded as-is with a warning. Default: SearXNG instance default.
98
- - `engines` (string, optional): Comma-separated SearXNG engine names (e.g. `"google,bing,ddg"`, `"semantic scholar"`). When live `/config` is available, values are trimmed and normalized case-insensitively to canonical engine names, including engines disabled by default; unknown values are rejected with available engines listed. If `/config` is unavailable, values are forwarded as-is with a warning.
98
+ - `categories` (string, optional): Comma-separated SearXNG categories (e.g. `"news"`, `"it,science"`). Live `/config` capabilities are aggregated across reachable instances; prefer `searxng_instance_info` `categories.common` for consistent multi-instance results. Known values are trimmed and normalized case-insensitively; unknown values are forwarded trimmed so SearXNG can ignore or honor them. If `/config` is unavailable, values are forwarded as-is with a warning. If omitted, each instance uses its server-side default.
99
+ - `engines` (string, optional): Comma-separated SearXNG engine names (e.g. `"google,bing,ddg"`, `"semantic scholar"`). Live `/config` capabilities are aggregated across reachable instances; prefer `searxng_instance_info` `engines.common.enabled` for consistent multi-instance results. Known values are trimmed and normalized case-insensitively, including engines disabled by default; unknown values are forwarded trimmed so SearXNG can ignore or honor them. If `/config` is unavailable, values are forwarded as-is with a warning. If omitted, each instance uses its server-side default.
99
100
  - `response_format` (string, optional): Response format, either `"text"` for formatted agent-readable output or `"json"` for raw SearXNG JSON with filtered/sliced `results`. (default: `"text"`)
100
101
 
101
102
  - **searxng_search_suggestions**
@@ -105,7 +106,7 @@ AI Assistant (e.g. Claude)
105
106
  - `language` (string, optional): Language code for suggestions (e.g., "en", "fr", "de") or "all" (default: "all")
106
107
 
107
108
  - **searxng_instance_info**
108
- - Discover categories, engines, defaults, locales, and plugins exposed by the configured SearXNG instance
109
+ - Discover categories, engines, defaults, locales, and plugins exposed by all reachable configured SearXNG instances. The response reports `common` values present on every reachable instance and `available` values present on at least one reachable instance.
109
110
  - Inputs:
110
111
  - `includeEngines` (boolean, optional): Include enabled engine names in the response. (default: false)
111
112
  - `includeDisabled` (boolean, optional): Include disabled engine names when `includeEngines` is true. (default: false)
@@ -238,6 +239,8 @@ By default the server uses STDIO. Set `MCP_HTTP_PORT` to enable HTTP mode:
238
239
 
239
240
  **Endpoints:** `POST/GET/DELETE /mcp` (MCP protocol), `GET /health` (health check)
240
241
 
242
+ For reverse-proxy deployments, see [CONFIGURATION.md](CONFIGURATION.md) for `MCP_HTTP_TRUST_PROXY` so rate limiting and logs use the correct client IP.
243
+
241
244
  **Test it:**
242
245
 
243
246
  ```bash
@@ -249,7 +252,7 @@ curl http://localhost:3000/health
249
252
 
250
253
  ## Configuration
251
254
 
252
- Set `SEARXNG_URL` to your SearXNG instance URL. All other variables are optional.
255
+ Set `SEARXNG_URL` to your SearXNG instance URL. For failover, set it to semicolon-separated interchangeable replica URLs. Set `SEARXNG_FANOUT=true` to query all healthy replicas in parallel and merge results. All other variables are optional.
253
256
 
254
257
  Full environment variable reference: [CONFIGURATION.md](CONFIGURATION.md)
255
258
 
package/dist/cli.js CHANGED
@@ -1,13 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { main } from "./index.js";
3
- process.on('uncaughtException', (error) => {
4
- console.error('Uncaught Exception:', error);
5
- process.exit(1);
6
- });
7
- process.on('unhandledRejection', (reason, promise) => {
8
- console.error('Unhandled Rejection at:', promise, 'reason:', reason);
9
- process.exit(1);
10
- });
3
+ import { handleUncaughtException, handleUnhandledRejection } from "./error-handler.js";
4
+ process.on('uncaughtException', handleUncaughtException);
5
+ process.on('unhandledRejection', handleUnhandledRejection);
11
6
  main().catch((error) => {
12
7
  console.error("Failed to start server:", error);
13
8
  process.exit(1);
@@ -16,14 +16,23 @@ export declare class MCPSearXNGError extends Error {
16
16
  export declare function createConfigurationError(message: string): MCPSearXNGError;
17
17
  export declare function createNetworkError(error: any, context: ErrorContext): MCPSearXNGError;
18
18
  export declare function createServerError(status: number, statusText: string, responseBody: string, context: ErrorContext): MCPSearXNGError;
19
- export declare function createJSONError(responseText: string, context: ErrorContext): MCPSearXNGError;
20
- export declare function createDataError(data: any, context: ErrorContext): MCPSearXNGError;
19
+ export declare function createJSONError(responseText: string): MCPSearXNGError;
20
+ export declare function createDataError(): MCPSearXNGError;
21
21
  export declare function createNoResultsMessage(query: string): string;
22
22
  export declare function createURLFormatError(url: string): MCPSearXNGError;
23
23
  export declare function createURLSecurityPolicyError(url: string): MCPSearXNGError;
24
24
  export declare function createContentError(message: string, url: string): MCPSearXNGError;
25
- export declare function createConversionError(error: any, url: string, htmlContent: string): MCPSearXNGError;
25
+ export declare function createConversionError(url: string): MCPSearXNGError;
26
26
  export declare function createTimeoutError(timeout: number, url: string): MCPSearXNGError;
27
- export declare function createEmptyContentWarning(url: string, htmlLength: number, htmlPreview: string): string;
27
+ export declare function createEmptyContentWarning(url: string): string;
28
28
  export declare function createUnexpectedError(error: any, context: ErrorContext): MCPSearXNGError;
29
+ /**
30
+ * Process-level crash handlers, registered by the CLI entrypoint (cli.ts).
31
+ *
32
+ * Extracted here so the logic is unit-testable: cli.ts calls main() at import
33
+ * time (it must always start the server — see issue #91), so it cannot be
34
+ * imported to test these in place.
35
+ */
36
+ export declare function handleUncaughtException(error: unknown): void;
37
+ export declare function handleUnhandledRejection(reason: unknown, promise: Promise<unknown>): void;
29
38
  export declare function validateEnvironment(): string | null;
@@ -2,6 +2,7 @@
2
2
  * Concise error handling for MCP SearXNG server
3
3
  * Provides clear, focused error messages that identify the root cause
4
4
  */
5
+ import { parseSearxngUrls, validateSearxngInstanceUrl } from "./searxng-instances.js";
5
6
  export class MCPSearXNGError extends Error {
6
7
  constructor(message) {
7
8
  super(message);
@@ -83,11 +84,11 @@ export function createServerError(status, statusText, responseBody, context) {
83
84
  }
84
85
  return new MCPSearXNGError(`🚫 ${target} Error (${status}): ${statusText}`);
85
86
  }
86
- export function createJSONError(responseText, context) {
87
+ export function createJSONError(responseText) {
87
88
  const preview = responseText.substring(0, 100).replace(/\n/g, ' ');
88
89
  return new MCPSearXNGError(`🔍 SearXNG Response Error: Invalid JSON format. Response: "${preview}..."`);
89
90
  }
90
- export function createDataError(data, context) {
91
+ export function createDataError() {
91
92
  return new MCPSearXNGError(`🔍 SearXNG Data Error: Missing results array in response`);
92
93
  }
93
94
  export function createNoResultsMessage(query) {
@@ -103,35 +104,47 @@ export function createURLSecurityPolicyError(url) {
103
104
  export function createContentError(message, url) {
104
105
  return new MCPSearXNGError(`📄 Content Error: ${message} (${url})`);
105
106
  }
106
- export function createConversionError(error, url, htmlContent) {
107
+ export function createConversionError(url) {
107
108
  return new MCPSearXNGError(`🔄 Conversion Error: Cannot convert HTML to Markdown (${url})`);
108
109
  }
109
110
  export function createTimeoutError(timeout, url) {
110
111
  const hostname = new URL(url).hostname;
111
112
  return new MCPSearXNGError(`⏱️ Timeout Error: ${hostname} took longer than ${timeout}ms to respond`);
112
113
  }
113
- export function createEmptyContentWarning(url, htmlLength, htmlPreview) {
114
+ export function createEmptyContentWarning(url) {
114
115
  return `📄 Content Warning: Page fetched but appears empty after conversion (${url}). May contain only media or require JavaScript.`;
115
116
  }
116
117
  export function createUnexpectedError(error, context) {
117
118
  return new MCPSearXNGError(`❓ Unexpected Error: ${error.message || String(error)}`);
118
119
  }
120
+ /**
121
+ * Process-level crash handlers, registered by the CLI entrypoint (cli.ts).
122
+ *
123
+ * Extracted here so the logic is unit-testable: cli.ts calls main() at import
124
+ * time (it must always start the server — see issue #91), so it cannot be
125
+ * imported to test these in place.
126
+ */
127
+ export function handleUncaughtException(error) {
128
+ console.error('Uncaught Exception:', error);
129
+ process.exit(1);
130
+ }
131
+ export function handleUnhandledRejection(reason, promise) {
132
+ console.error('Unhandled Rejection at:', promise, 'reason:', reason);
133
+ process.exit(1);
134
+ }
119
135
  export function validateEnvironment() {
120
136
  const issues = [];
121
- const searxngUrl = process.env.SEARXNG_URL;
122
- if (!searxngUrl) {
137
+ const searxngUrls = parseSearxngUrls();
138
+ if (searxngUrls.length === 0) {
123
139
  issues.push("SEARXNG_URL not set");
124
140
  }
125
141
  else {
126
- try {
127
- const url = new URL(searxngUrl);
128
- if (!['http:', 'https:'].includes(url.protocol)) {
129
- issues.push(`SEARXNG_URL invalid protocol: ${url.protocol}`);
142
+ for (const searxngUrl of searxngUrls) {
143
+ const validationError = validateSearxngInstanceUrl(searxngUrl);
144
+ if (validationError) {
145
+ issues.push(validationError);
130
146
  }
131
147
  }
132
- catch (error) {
133
- issues.push(`SEARXNG_URL invalid format: ${searxngUrl}`);
134
- }
135
148
  }
136
149
  const authUsername = process.env.AUTH_USERNAME;
137
150
  const authPassword = process.env.AUTH_PASSWORD;
@@ -6,6 +6,7 @@ export interface HttpSecurityConfig {
6
6
  allowedOrigins: string[];
7
7
  enableDnsRebindingProtection: boolean;
8
8
  allowedHosts: string[];
9
+ trustProxy: boolean | number | string;
9
10
  exposeFullConfig: boolean;
10
11
  allowPrivateUrls: boolean;
11
12
  }
@@ -7,6 +7,21 @@ function parseCsv(value) {
7
7
  .map((item) => item.trim())
8
8
  .filter(Boolean);
9
9
  }
10
+ function parseTrustProxy(value) {
11
+ const trimmed = value?.trim();
12
+ // Treat "0" as disabled (false): operators use 0 to turn numeric knobs off,
13
+ // and Express would otherwise mis-parse the string "0" as a bogus trust subnet.
14
+ if (!trimmed || trimmed === "false" || trimmed === "0") {
15
+ return false;
16
+ }
17
+ if (trimmed === "true") {
18
+ return true;
19
+ }
20
+ if (/^[1-9]\d*$/.test(trimmed)) {
21
+ return Number(trimmed);
22
+ }
23
+ return trimmed;
24
+ }
10
25
  export function getHttpSecurityConfig() {
11
26
  const harden = isEnabled(process.env.MCP_HTTP_HARDEN);
12
27
  const authToken = process.env.MCP_HTTP_AUTH_TOKEN;
@@ -20,6 +35,7 @@ export function getHttpSecurityConfig() {
20
35
  allowedOrigins,
21
36
  enableDnsRebindingProtection: harden,
22
37
  allowedHosts: allowedHosts.length > 0 ? allowedHosts : ["127.0.0.1", "localhost"],
38
+ trustProxy: parseTrustProxy(process.env.MCP_HTTP_TRUST_PROXY),
23
39
  exposeFullConfig: isEnabled(process.env.MCP_HTTP_EXPOSE_FULL_CONFIG),
24
40
  allowPrivateUrls: isEnabled(process.env.MCP_HTTP_ALLOW_PRIVATE_URLS),
25
41
  };
@@ -55,6 +55,9 @@ export async function createHttpServer(createMcpServer) {
55
55
  const app = express();
56
56
  const security = getHttpSecurityConfig();
57
57
  validateHttpSecurityConfig(security);
58
+ if (security.trustProxy !== false) {
59
+ app.set('trust proxy', security.trustProxy);
60
+ }
58
61
  app.use(express.json());
59
62
  // Add CORS support for web clients
60
63
  app.use(cors({
@@ -97,7 +100,7 @@ export async function createHttpServer(createMcpServer) {
97
100
  mcpServer = session.mcpServer;
98
101
  logMessage(mcpServer, "debug", `Reusing session: ${sessionId}`);
99
102
  }
100
- else if (!sessionId && isInitializeRequest(req.body)) {
103
+ else if (isInitializeRequest(req.body)) {
101
104
  // New initialization request — create fresh McpServer and transport
102
105
  mcpServer = createMcpServer();
103
106
  transport = new StreamableHTTPServerTransport({
@@ -129,11 +132,12 @@ export async function createHttpServer(createMcpServer) {
129
132
  contentType: req.headers['content-type'],
130
133
  accept: req.headers['accept']
131
134
  });
132
- res.status(400).json({
135
+ const sessionNotFound = Boolean(sessionId);
136
+ res.status(sessionNotFound ? 404 : 400).json({
133
137
  jsonrpc: '2.0',
134
138
  error: {
135
- code: -32000,
136
- message: 'Bad Request: No valid session ID provided',
139
+ code: sessionNotFound ? -32001 : -32000,
140
+ message: sessionNotFound ? 'Session not found' : 'Bad Request: No valid session ID provided',
137
141
  },
138
142
  id: null,
139
143
  });
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { fetchInstanceInfo } from "./instance-info.js";
10
10
  import { fetchAndConvertToMarkdown } from "./url-reader.js";
11
11
  import { createConfigResource, createHelpResource } from "./resources.js";
12
12
  import { createHttpServer, resolveBindHost } from "./http-server.js";
13
+ import { getSearxngInstances } from "./searxng-instances.js";
13
14
  import { packageVersion } from "./version.js";
14
15
  // Type guard for URL reading args
15
16
  export function isWebUrlReadArgs(args) {
@@ -104,7 +105,7 @@ export function createMcpServer() {
104
105
  if (!isSearXNGWebSearchArgs(args)) {
105
106
  throw new Error("Invalid arguments for web search");
106
107
  }
107
- const result = await performWebSearch(mcpServer, args.query, args.pageno, args.time_range, args.language, args.safesearch, args.min_score, args.num_results, args.categories, args.engines, args.response_format);
108
+ const result = await performWebSearch(mcpServer, args.query, args.pageno, args.time_range, args.language, args.safesearch === undefined ? undefined : Number(args.safesearch), args.min_score, args.num_results, args.categories, args.engines, args.response_format);
108
109
  return {
109
110
  content: [
110
111
  {
@@ -277,8 +278,9 @@ export async function main() {
277
278
  // Show helpful message when running in terminal
278
279
  if (process.stdin.isTTY) {
279
280
  console.error(`🔍 MCP SearXNG Server v${packageVersion} - Ready`);
280
- if (process.env.SEARXNG_URL) {
281
- console.error(`🌐 SearXNG URL: ${process.env.SEARXNG_URL}`);
281
+ const searxngInstances = getSearxngInstances();
282
+ if (searxngInstances.length > 0) {
283
+ console.error(`🌐 SearXNG URLs: ${searxngInstances.join("; ")}`);
282
284
  }
283
285
  else {
284
286
  console.error("⚠️ SEARXNG_URL not set — configure it before using search tools");
@@ -291,6 +293,7 @@ export async function main() {
291
293
  logMessage(mcpServer, "info", `MCP SearXNG Server v${packageVersion} connected via STDIO`);
292
294
  logMessage(mcpServer, "info", `Log level: ${getCurrentLogLevel()}`);
293
295
  logMessage(mcpServer, "info", `Environment: ${process.env.NODE_ENV || 'development'}`);
294
- logMessage(mcpServer, "info", `SearXNG URL: ${process.env.SEARXNG_URL || 'not configured'}`);
296
+ const searxngInstances = getSearxngInstances();
297
+ logMessage(mcpServer, "info", `SearXNG URLs: ${searxngInstances.length > 0 ? searxngInstances.join("; ") : 'not configured'}`);
295
298
  }
296
299
  }