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 +10 -9
- package/dist/cli.js +3 -8
- package/dist/error-handler.d.ts +9 -0
- package/dist/error-handler.js +22 -9
- package/dist/index.js +7 -4
- package/dist/instance-info.js +217 -66
- package/dist/resources.js +5 -5
- package/dist/search.js +228 -112
- package/dist/searxng-instances.d.ts +10 -0
- package/dist/searxng-instances.js +68 -0
- package/dist/suggestions.js +2 -1
- package/dist/tls-config.d.ts +15 -2
- package/dist/tls-config.js +10 -9
- package/dist/types.d.ts +1 -1
- package/dist/types.js +8 -8
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
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
|
|
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` (
|
|
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"`).
|
|
98
|
-
- `engines` (string, optional): Comma-separated SearXNG engine names (e.g. `"google,bing,ddg"`, `"semantic scholar"`).
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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);
|
package/dist/error-handler.d.ts
CHANGED
|
@@ -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;
|
package/dist/error-handler.js
CHANGED
|
@@ -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
|
|
122
|
-
if (
|
|
137
|
+
const searxngUrls = parseSearxngUrls();
|
|
138
|
+
if (searxngUrls.length === 0) {
|
|
123
139
|
issues.push("SEARXNG_URL not set");
|
|
124
140
|
}
|
|
125
141
|
else {
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
if (
|
|
129
|
-
issues.push(
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
296
|
+
const searxngInstances = getSearxngInstances();
|
|
297
|
+
logMessage(mcpServer, "info", `SearXNG URLs: ${searxngInstances.length > 0 ? searxngInstances.join("; ") : 'not configured'}`);
|
|
295
298
|
}
|
|
296
299
|
}
|
package/dist/instance-info.js
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
import { logMessage } from "./logging.js";
|
|
2
2
|
import { createDefaultAgent, createProxyAgent, ProxyType } from "./proxy.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
...(
|
|
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
|
|
48
|
+
function categoryNamesFromArray(categories) {
|
|
26
49
|
const names = new Set();
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
94
|
-
|
|
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
|
-
|
|
183
|
+
instancesReachable: configs.map(({ sourceUrl }) => redactInstanceUrl(sourceUrl)),
|
|
184
|
+
...(failures.length > 0 ? { instancesUnreachable: redactFailures(failures) } : {}),
|
|
185
|
+
categories: aggregateCategories(configs, category),
|
|
100
186
|
defaults: {
|
|
101
|
-
safesearch:
|
|
102
|
-
locale:
|
|
103
|
-
language:
|
|
104
|
-
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
|
-
|
|
107
|
-
|
|
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 =
|
|
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
|
-
|
|
116
|
-
|
|
202
|
+
cachedConfigs.clear();
|
|
203
|
+
cachedConfigFailures.clear();
|
|
117
204
|
}
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
if (!
|
|
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 (
|
|
130
|
-
|
|
210
|
+
if (cached.until <= now) {
|
|
211
|
+
cachedConfigFailures.delete(base);
|
|
212
|
+
return null;
|
|
131
213
|
}
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
245
|
+
message,
|
|
148
246
|
status: response.status,
|
|
247
|
+
sourceUrl: base,
|
|
149
248
|
};
|
|
150
249
|
}
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
258
|
+
message,
|
|
259
|
+
sourceUrl: base,
|
|
160
260
|
};
|
|
161
261
|
}
|
|
162
262
|
}
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
if (
|
|
166
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
const
|
|
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
|
|
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
|
|
329
|
+
const result = await fetchConfigs(mcpServer, refresh);
|
|
179
330
|
if (!result.available) {
|
|
180
|
-
return unavailable(result.message, result.
|
|
331
|
+
return unavailable(result.message, result.failures);
|
|
181
332
|
}
|
|
182
|
-
return formatInstanceInfo(result.
|
|
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
|
|
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
|
|
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
|