mcp-searxng 1.7.2 → 1.8.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)
@@ -249,7 +250,7 @@ curl http://localhost:3000/health
249
250
 
250
251
  ## Configuration
251
252
 
252
- Set `SEARXNG_URL` to your SearXNG instance URL. All other variables are optional.
253
+ 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
254
 
254
255
  Full environment variable reference: [CONFIGURATION.md](CONFIGURATION.md)
255
256
 
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);
@@ -26,4 +26,13 @@ export declare function createConversionError(error: any, url: string, htmlConte
26
26
  export declare function createTimeoutError(timeout: number, url: string): MCPSearXNGError;
27
27
  export declare function createEmptyContentWarning(url: string, htmlLength: number, htmlPreview: 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);
@@ -116,22 +117,34 @@ export function createEmptyContentWarning(url, htmlLength, htmlPreview) {
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;
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
  }
@@ -1,12 +1,35 @@
1
1
  import { logMessage } from "./logging.js";
2
2
  import { createDefaultAgent, createProxyAgent, ProxyType } from "./proxy.js";
3
- let cachedConfig = null;
4
- let cachedBaseUrl = null;
5
- function unavailable(message, status) {
3
+ import { getSearxngInstances } from "./searxng-instances.js";
4
+ const CONFIG_FAILURE_CACHE_TTL_MS = 60_000;
5
+ const cachedConfigs = new Map();
6
+ const cachedConfigFailures = new Map();
7
+ function redactInstanceUrl(raw) {
8
+ try {
9
+ const url = new URL(raw);
10
+ if (!url.username && !url.password) {
11
+ return raw;
12
+ }
13
+ url.username = "";
14
+ url.password = "";
15
+ return url.toString();
16
+ }
17
+ catch {
18
+ return raw;
19
+ }
20
+ }
21
+ function redactFailures(failures) {
22
+ return failures.map(({ sourceUrl, message, status }) => ({
23
+ sourceUrl: redactInstanceUrl(sourceUrl),
24
+ message,
25
+ ...(status !== undefined ? { status } : {}),
26
+ }));
27
+ }
28
+ function unavailable(message, failures = []) {
6
29
  return JSON.stringify({
7
30
  available: false,
8
31
  message,
9
- ...(status !== undefined ? { status } : {}),
32
+ ...(failures.length > 0 ? { instancesUnreachable: redactFailures(failures) } : {}),
10
33
  }, null, 2);
11
34
  }
12
35
  function categoryNamesFromEngines(config) {
@@ -22,22 +45,35 @@ function categoryNamesFromEngines(config) {
22
45
  }
23
46
  return [...names];
24
47
  }
25
- function namesFromCategories(config) {
48
+ function categoryNamesFromArray(categories) {
26
49
  const names = new Set();
27
- if (Array.isArray(config.categories)) {
28
- for (const category of config.categories) {
29
- if (typeof category === "string" && category.trim() !== "") {
30
- names.add(category);
31
- }
50
+ for (const category of categories) {
51
+ if (typeof category === "string" && category.trim() !== "") {
52
+ names.add(category);
32
53
  }
33
54
  }
34
- else if (config.categories && typeof config.categories === "object") {
35
- for (const category of Object.keys(config.categories)) {
36
- if (category.trim() !== "") {
37
- names.add(category);
38
- }
55
+ return [...names];
56
+ }
57
+ function categoryNamesFromObject(categories) {
58
+ const names = new Set();
59
+ for (const category of Object.keys(categories)) {
60
+ if (category.trim() !== "") {
61
+ names.add(category);
39
62
  }
40
63
  }
64
+ return [...names];
65
+ }
66
+ function configuredCategoryNames(config) {
67
+ if (Array.isArray(config.categories)) {
68
+ return categoryNamesFromArray(config.categories);
69
+ }
70
+ if (config.categories && typeof config.categories === "object") {
71
+ return categoryNamesFromObject(config.categories);
72
+ }
73
+ return [];
74
+ }
75
+ function namesFromCategories(config) {
76
+ const names = new Set(configuredCategoryNames(config));
41
77
  for (const category of categoryNamesFromEngines(config)) {
42
78
  names.add(category);
43
79
  }
@@ -52,7 +88,7 @@ function engineCategories(engine) {
52
88
  }
53
89
  return [];
54
90
  }
55
- function collectEngines(config, includeDisabled, category) {
91
+ function engineSets(config, category) {
56
92
  const enabled = new Set();
57
93
  const disabled = new Set();
58
94
  if (Array.isArray(config.engines)) {
@@ -65,19 +101,14 @@ function collectEngines(config, includeDisabled, category) {
65
101
  continue;
66
102
  }
67
103
  if (engine.disabled) {
68
- if (includeDisabled) {
69
- disabled.add(engine.name);
70
- }
104
+ disabled.add(engine.name);
71
105
  }
72
106
  else {
73
107
  enabled.add(engine.name);
74
108
  }
75
109
  }
76
110
  }
77
- return {
78
- enabled: [...enabled].sort(),
79
- ...(includeDisabled ? { disabled: [...disabled].sort() } : {}),
80
- };
111
+ return { enabled, disabled };
81
112
  }
82
113
  function allEngineNames(config) {
83
114
  const names = new Set();
@@ -90,48 +121,114 @@ function allEngineNames(config) {
90
121
  }
91
122
  return names;
92
123
  }
93
- function formatInstanceInfo(config, includeEngines, includeDisabled, category) {
94
- const categories = category
124
+ function sorted(values) {
125
+ return [...values].sort();
126
+ }
127
+ function union(sets) {
128
+ const result = new Set();
129
+ for (const set of sets) {
130
+ for (const value of set) {
131
+ result.add(value);
132
+ }
133
+ }
134
+ return result;
135
+ }
136
+ function intersection(sets) {
137
+ if (sets.length === 0) {
138
+ return new Set();
139
+ }
140
+ const result = new Set(sets[0]);
141
+ for (const set of sets.slice(1)) {
142
+ for (const value of [...result]) {
143
+ if (!set.has(value)) {
144
+ result.delete(value);
145
+ }
146
+ }
147
+ }
148
+ return result;
149
+ }
150
+ function categoriesForConfig(config, category) {
151
+ const names = category
95
152
  ? namesFromCategories(config).filter((name) => name === category)
96
153
  : namesFromCategories(config);
154
+ return new Set(names);
155
+ }
156
+ function aggregateCategories(configs, category) {
157
+ const sets = configs.map(({ config }) => categoriesForConfig(config, category));
158
+ return {
159
+ common: sorted(intersection(sets)),
160
+ available: sorted(union(sets)),
161
+ };
162
+ }
163
+ function aggregateEngines(configs, includeDisabled, category) {
164
+ const perInstance = configs.map(({ config }) => engineSets(config, category));
165
+ const payload = {
166
+ common: {
167
+ enabled: sorted(intersection(perInstance.map(({ enabled }) => enabled))),
168
+ },
169
+ available: {
170
+ enabled: sorted(union(perInstance.map(({ enabled }) => enabled))),
171
+ },
172
+ };
173
+ if (includeDisabled) {
174
+ payload.common.disabled = sorted(intersection(perInstance.map(({ disabled }) => disabled)));
175
+ payload.available.disabled = sorted(union(perInstance.map(({ disabled }) => disabled)));
176
+ }
177
+ return payload;
178
+ }
179
+ function formatInstanceInfo(configs, failures, includeEngines, includeDisabled, category) {
180
+ const primary = configs[0].config;
97
181
  const payload = {
98
182
  available: true,
99
- categories,
183
+ instancesReachable: configs.map(({ sourceUrl }) => redactInstanceUrl(sourceUrl)),
184
+ ...(failures.length > 0 ? { instancesUnreachable: redactFailures(failures) } : {}),
185
+ categories: aggregateCategories(configs, category),
100
186
  defaults: {
101
- safesearch: config.search?.safe_search ?? config.default_safe_search,
102
- locale: config.default_locale,
103
- language: config.default_language,
104
- theme: config.default_theme,
187
+ safesearch: primary.search?.safe_search ?? primary.default_safe_search,
188
+ locale: primary.default_locale,
189
+ language: primary.default_language,
190
+ theme: primary.default_theme,
105
191
  },
106
- locales: config.locales,
107
- plugins: config.plugins ?? [],
192
+ defaultsNote: "Defaults, locales, and plugins are reported from the primary reachable instance and may vary across configured instances.",
193
+ locales: primary.locales,
194
+ plugins: primary.plugins ?? [],
108
195
  };
109
196
  if (includeEngines) {
110
- payload.engines = collectEngines(config, includeDisabled, category);
197
+ payload.engines = aggregateEngines(configs, includeDisabled, category);
111
198
  }
112
199
  return JSON.stringify(payload, null, 2);
113
200
  }
114
201
  export function clearInstanceInfoCacheForTests() {
115
- cachedConfig = null;
116
- cachedBaseUrl = null;
202
+ cachedConfigs.clear();
203
+ cachedConfigFailures.clear();
117
204
  }
118
- async function fetchConfig(mcpServer, refresh = false) {
119
- const base = process.env.SEARXNG_URL;
120
- if (!base) {
121
- return {
122
- available: false,
123
- message: "SEARXNG_URL is not configured; cannot fetch SearXNG /config.",
124
- };
125
- }
126
- if (refresh) {
127
- cachedConfig = null;
205
+ function getCachedFailure(base, now = Date.now()) {
206
+ const cached = cachedConfigFailures.get(base);
207
+ if (!cached) {
208
+ return null;
128
209
  }
129
- if (cachedConfig && cachedBaseUrl === base) {
130
- return { available: true, config: cachedConfig };
210
+ if (cached.until <= now) {
211
+ cachedConfigFailures.delete(base);
212
+ return null;
131
213
  }
132
- const parsedBase = new URL(base.endsWith("/") ? base : `${base}/`);
133
- const url = new URL("config", parsedBase);
214
+ return {
215
+ available: false,
216
+ sourceUrl: base,
217
+ message: cached.message,
218
+ ...(cached.status !== undefined ? { status: cached.status } : {}),
219
+ };
220
+ }
221
+ function cacheFailure(base, message, status) {
222
+ cachedConfigFailures.set(base, {
223
+ until: Date.now() + CONFIG_FAILURE_CACHE_TTL_MS,
224
+ message,
225
+ ...(status !== undefined ? { status } : {}),
226
+ });
227
+ }
228
+ async function requestInstanceConfig(mcpServer, base) {
134
229
  try {
230
+ const parsedBase = new URL(base.endsWith("/") ? base : `${base}/`);
231
+ const url = new URL("config", parsedBase);
135
232
  const requestOptions = {
136
233
  signal: AbortSignal.timeout(5000),
137
234
  };
@@ -142,42 +239,96 @@ async function fetchConfig(mcpServer, refresh = false) {
142
239
  }
143
240
  const response = await fetch(url.toString(), requestOptions);
144
241
  if (!response.ok) {
242
+ const message = `SearXNG /config is unavailable: HTTP ${response.status} ${response.statusText}`;
145
243
  return {
146
244
  available: false,
147
- message: `SearXNG /config is unavailable: HTTP ${response.status} ${response.statusText}`,
245
+ message,
148
246
  status: response.status,
247
+ sourceUrl: base,
149
248
  };
150
249
  }
151
- cachedConfig = await response.json();
152
- cachedBaseUrl = base;
153
- return { available: true, config: cachedConfig };
250
+ const config = await response.json();
251
+ return { available: true, config, sourceUrl: base };
154
252
  }
155
253
  catch (error) {
156
- logMessage(mcpServer, "warning", `SearXNG /config fetch failed: ${error instanceof Error ? error.message : String(error)}`);
254
+ logMessage(mcpServer, "warning", `SearXNG /config fetch failed for ${redactInstanceUrl(base)}: ${error instanceof Error ? error.message : String(error)}`);
255
+ const message = "SearXNG /config is unavailable; instance capability discovery could not complete.";
157
256
  return {
158
257
  available: false,
159
- message: "SearXNG /config is unavailable; instance capability discovery could not complete.",
258
+ message,
259
+ sourceUrl: base,
160
260
  };
161
261
  }
162
262
  }
163
- export async function getKnownEngines(mcpServer, refresh = false) {
164
- const result = await fetchConfig(mcpServer, refresh);
165
- if (!result.available) {
166
- return null;
263
+ async function fetchConfigFromInstance(mcpServer, base) {
264
+ const cached = cachedConfigs.get(base);
265
+ if (cached) {
266
+ return { available: true, config: cached, sourceUrl: base };
267
+ }
268
+ const cachedFailure = getCachedFailure(base);
269
+ if (cachedFailure) {
270
+ return cachedFailure;
167
271
  }
168
- return allEngineNames(result.config);
272
+ const result = await requestInstanceConfig(mcpServer, base);
273
+ if (result.available) {
274
+ cachedConfigs.set(base, result.config);
275
+ cachedConfigFailures.delete(base);
276
+ }
277
+ else {
278
+ cacheFailure(base, result.message, result.status);
279
+ }
280
+ return result;
169
281
  }
170
- export async function getKnownCategories(mcpServer, refresh = false) {
171
- const result = await fetchConfig(mcpServer, refresh);
282
+ async function fetchConfigs(mcpServer, refresh = false) {
283
+ const instances = getSearxngInstances();
284
+ if (instances.length === 0) {
285
+ return {
286
+ available: false,
287
+ message: "SEARXNG_URL is not configured; cannot fetch SearXNG /config.",
288
+ failures: [],
289
+ };
290
+ }
291
+ if (refresh) {
292
+ cachedConfigs.clear();
293
+ cachedConfigFailures.clear();
294
+ }
295
+ const results = await Promise.all(instances.map((instance) => fetchConfigFromInstance(mcpServer, instance)));
296
+ const configs = results
297
+ .filter((result) => result.available)
298
+ .map(({ config, sourceUrl }) => ({ config, sourceUrl }));
299
+ const failures = results
300
+ .filter((result) => !result.available)
301
+ .map(({ sourceUrl, message, status }) => ({
302
+ sourceUrl,
303
+ message,
304
+ ...(status !== undefined ? { status } : {}),
305
+ }));
306
+ if (configs.length === 0) {
307
+ return {
308
+ available: false,
309
+ message: "SearXNG /config is unavailable; no configured instances answered capability discovery.",
310
+ failures,
311
+ };
312
+ }
313
+ return { available: true, configs, failures };
314
+ }
315
+ async function getAggregatedCapability(mcpServer, refresh, extractor) {
316
+ const result = await fetchConfigs(mcpServer, refresh);
172
317
  if (!result.available) {
173
318
  return null;
174
319
  }
175
- return new Set(namesFromCategories(result.config));
320
+ return union(result.configs.map(({ config }) => extractor(config)));
321
+ }
322
+ export async function getKnownEngines(mcpServer, refresh = false) {
323
+ return getAggregatedCapability(mcpServer, refresh, allEngineNames);
324
+ }
325
+ export async function getKnownCategories(mcpServer, refresh = false) {
326
+ return getAggregatedCapability(mcpServer, refresh, (config) => new Set(namesFromCategories(config)));
176
327
  }
177
328
  export async function fetchInstanceInfo(mcpServer, includeEngines = false, includeDisabled = false, category, refresh = false) {
178
- const result = await fetchConfig(mcpServer, refresh);
329
+ const result = await fetchConfigs(mcpServer, refresh);
179
330
  if (!result.available) {
180
- return unavailable(result.message, result.status);
331
+ return unavailable(result.message, result.failures);
181
332
  }
182
- return formatInstanceInfo(result.config, includeEngines, includeDisabled, category);
333
+ return formatInstanceInfo(result.configs, result.failures, includeEngines, includeDisabled, category);
183
334
  }
package/dist/resources.js CHANGED
@@ -43,7 +43,7 @@ This is a Model Context Protocol (MCP) server that provides web search, autocomp
43
43
  ## Available Tools
44
44
 
45
45
  ### 1. searxng_web_search
46
- Performs web searches using the configured SearXNG instance.
46
+ Performs web searches using the configured SearXNG instance or replica list, with failover/fanout when multiple instances are configured.
47
47
 
48
48
  **Parameters:**
49
49
  - \`query\` (required): The search query string
@@ -53,11 +53,11 @@ Performs web searches using the configured SearXNG instance.
53
53
  - \`safesearch\` (optional): Safe search level - 0 (none), 1 (moderate), 2 (strict)
54
54
  - \`min_score\` (optional): Minimum relevance score from 0.0 to 1.0
55
55
  - \`num_results\` (optional): Maximum result count from 1 to 20
56
- - \`categories\` (optional): Comma-separated SearXNG categories such as "news" or "it,science"; live \`/config\` values are normalized case-insensitively when available
57
- - \`engines\` (optional): Comma-separated SearXNG engine names such as "google,bing,ddg" or "semantic scholar"; live \`/config\` values are normalized case-insensitively when available
56
+ - \`categories\` (optional): Comma-separated SearXNG categories such as "news" or "it,science"; live \`/config\` values are aggregated across reachable instances and normalized case-insensitively when available
57
+ - \`engines\` (optional): Comma-separated SearXNG engine names such as "google,bing,ddg" or "semantic scholar"; live \`/config\` values are aggregated across reachable instances and normalized case-insensitively when available
58
58
  - \`response_format\` (optional): "text" for formatted output or "json" for raw SearXNG-shaped JSON (may include a \`warnings\` array for non-fatal issues)
59
59
 
60
- Text output can include metadata sections for direct answers, spelling corrections, suggestions, and infoboxes before the result list. JSON output preserves the SearXNG response shape with filtered and sliced \`results\`, and may include a \`warnings\` array for non-fatal issues. Unknown categories or engines are rejected when live \`/config\` validation is available; if \`/config\` is unavailable, the search proceeds with the supplied values and emits a warning.
60
+ Text output can include metadata sections for direct answers, spelling corrections, suggestions, and infoboxes before the result list. JSON output preserves the SearXNG response shape with filtered and sliced \`results\`, and may include a \`warnings\` array for non-fatal issues. Use \`searxng_instance_info\` and prefer \`common\` categories/engines for consistent multi-instance results; \`available\`-only filters are best-effort. Unknown categories or engines are forwarded trimmed so SearXNG can ignore or honor them; if \`/config\` is unavailable, the search proceeds with the supplied values and emits a warning.
61
61
 
62
62
  ### 2. searxng_search_suggestions
63
63
  Returns autocomplete suggestions from the configured SearXNG instance.
@@ -67,7 +67,7 @@ Returns autocomplete suggestions from the configured SearXNG instance.
67
67
  - \`language\` (optional): Language code like "en", "fr", "de" or "all" (default: "all")
68
68
 
69
69
  ### 3. searxng_instance_info
70
- Discovers categories, engines, defaults, locales, and plugins exposed by the configured SearXNG instance.
70
+ Discovers 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.
71
71
 
72
72
  **Parameters:**
73
73
  - \`includeEngines\` (optional): Include enabled engine names