mcp-searxng 1.8.0 → 1.10.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 +11 -1
- package/dist/error-handler.d.ts +4 -4
- package/dist/error-handler.js +5 -5
- package/dist/http-security.d.ts +1 -0
- package/dist/http-security.js +16 -0
- package/dist/http-server.js +8 -4
- package/dist/instance-info.js +10 -31
- package/dist/search.js +40 -30
- package/dist/searxng-instances.d.ts +1 -0
- package/dist/searxng-instances.js +14 -0
- package/dist/types.js +4 -2
- package/dist/url-reader.js +182 -17
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -114,7 +114,13 @@ AI Assistant (e.g. Claude)
|
|
|
114
114
|
- `refresh` (boolean, optional): Bypass the process cache and fetch fresh `/config` data. (default: false)
|
|
115
115
|
|
|
116
116
|
- **web_url_read**
|
|
117
|
-
- Read
|
|
117
|
+
- Read URL content as markdown with content-type-aware handling and advanced extraction options
|
|
118
|
+
- Supported readable content:
|
|
119
|
+
- HTML (`text/html`, `application/xhtml+xml`) is converted to markdown
|
|
120
|
+
- JSON (`application/json`, `*+json`) is pretty-printed in a fenced block
|
|
121
|
+
- Plain text, YAML, TOML, XML, and other safe explicit `text/*` responses are returned as readable fenced text
|
|
122
|
+
- Missing or generic content types are read under the existing size cap; non-binary bodies continue through the HTML-to-markdown path for compatibility
|
|
123
|
+
- Binary, media, archive, PDF, and octet-stream downloads are intentionally rejected with a short hint instead of returning raw bytes
|
|
118
124
|
- Inputs:
|
|
119
125
|
- `url` (string): The URL to fetch and process
|
|
120
126
|
- `startChar` (number, optional): Starting character position for content extraction (default: 0)
|
|
@@ -239,6 +245,8 @@ By default the server uses STDIO. Set `MCP_HTTP_PORT` to enable HTTP mode:
|
|
|
239
245
|
|
|
240
246
|
**Endpoints:** `POST/GET/DELETE /mcp` (MCP protocol), `GET /health` (health check)
|
|
241
247
|
|
|
248
|
+
For reverse-proxy deployments, see [CONFIGURATION.md](CONFIGURATION.md) for `MCP_HTTP_TRUST_PROXY` so rate limiting and logs use the correct client IP.
|
|
249
|
+
|
|
242
250
|
**Test it:**
|
|
243
251
|
|
|
244
252
|
```bash
|
|
@@ -256,6 +264,8 @@ Full environment variable reference: [CONFIGURATION.md](CONFIGURATION.md)
|
|
|
256
264
|
|
|
257
265
|
## Troubleshooting
|
|
258
266
|
|
|
267
|
+
If HTTPS requests fail behind a TLS-inspecting corporate proxy with certificate errors, see [TLS / Corporate CA](CONFIGURATION.md#tls--corporate-ca).
|
|
268
|
+
|
|
259
269
|
### 403 Forbidden from SearXNG
|
|
260
270
|
|
|
261
271
|
Your SearXNG instance likely has JSON format disabled. Edit `settings.yml` (usually `/etc/searxng/settings.yml`):
|
package/dist/error-handler.d.ts
CHANGED
|
@@ -16,15 +16,15 @@ 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
|
|
20
|
-
export declare function createDataError(
|
|
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(
|
|
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
|
|
27
|
+
export declare function createEmptyContentWarning(url: string): string;
|
|
28
28
|
export declare function createUnexpectedError(error: any, context: ErrorContext): MCPSearXNGError;
|
|
29
29
|
/**
|
|
30
30
|
* Process-level crash handlers, registered by the CLI entrypoint (cli.ts).
|
package/dist/error-handler.js
CHANGED
|
@@ -84,11 +84,11 @@ export function createServerError(status, statusText, responseBody, context) {
|
|
|
84
84
|
}
|
|
85
85
|
return new MCPSearXNGError(`🚫 ${target} Error (${status}): ${statusText}`);
|
|
86
86
|
}
|
|
87
|
-
export function createJSONError(responseText
|
|
87
|
+
export function createJSONError(responseText) {
|
|
88
88
|
const preview = responseText.substring(0, 100).replace(/\n/g, ' ');
|
|
89
|
-
return new MCPSearXNGError(`🔍 SearXNG Response Error: Invalid JSON format. Response: "${preview}..."
|
|
89
|
+
return new MCPSearXNGError(`🔍 SearXNG Response Error: Invalid JSON format. Response: "${preview}...". Enable - json under search.formats in your SearXNG settings.yml, or set SEARXNG_HTML_FALLBACK=true.`);
|
|
90
90
|
}
|
|
91
|
-
export function createDataError(
|
|
91
|
+
export function createDataError() {
|
|
92
92
|
return new MCPSearXNGError(`🔍 SearXNG Data Error: Missing results array in response`);
|
|
93
93
|
}
|
|
94
94
|
export function createNoResultsMessage(query) {
|
|
@@ -104,14 +104,14 @@ export function createURLSecurityPolicyError(url) {
|
|
|
104
104
|
export function createContentError(message, url) {
|
|
105
105
|
return new MCPSearXNGError(`📄 Content Error: ${message} (${url})`);
|
|
106
106
|
}
|
|
107
|
-
export function createConversionError(
|
|
107
|
+
export function createConversionError(url) {
|
|
108
108
|
return new MCPSearXNGError(`🔄 Conversion Error: Cannot convert HTML to Markdown (${url})`);
|
|
109
109
|
}
|
|
110
110
|
export function createTimeoutError(timeout, url) {
|
|
111
111
|
const hostname = new URL(url).hostname;
|
|
112
112
|
return new MCPSearXNGError(`⏱️ Timeout Error: ${hostname} took longer than ${timeout}ms to respond`);
|
|
113
113
|
}
|
|
114
|
-
export function createEmptyContentWarning(url
|
|
114
|
+
export function createEmptyContentWarning(url) {
|
|
115
115
|
return `📄 Content Warning: Page fetched but appears empty after conversion (${url}). May contain only media or require JavaScript.`;
|
|
116
116
|
}
|
|
117
117
|
export function createUnexpectedError(error, context) {
|
package/dist/http-security.d.ts
CHANGED
package/dist/http-security.js
CHANGED
|
@@ -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
|
};
|
package/dist/http-server.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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/instance-info.js
CHANGED
|
@@ -1,26 +1,12 @@
|
|
|
1
1
|
import { logMessage } from "./logging.js";
|
|
2
2
|
import { createDefaultAgent, createProxyAgent, ProxyType } from "./proxy.js";
|
|
3
|
-
import { getSearxngInstances } from "./searxng-instances.js";
|
|
3
|
+
import { getSearxngInstances, redactSearxngInstanceUrl } from "./searxng-instances.js";
|
|
4
4
|
const CONFIG_FAILURE_CACHE_TTL_MS = 60_000;
|
|
5
5
|
const cachedConfigs = new Map();
|
|
6
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
7
|
function redactFailures(failures) {
|
|
22
8
|
return failures.map(({ sourceUrl, message, status }) => ({
|
|
23
|
-
sourceUrl:
|
|
9
|
+
sourceUrl: redactSearxngInstanceUrl(sourceUrl),
|
|
24
10
|
message,
|
|
25
11
|
...(status !== undefined ? { status } : {}),
|
|
26
12
|
}));
|
|
@@ -45,30 +31,21 @@ function categoryNamesFromEngines(config) {
|
|
|
45
31
|
}
|
|
46
32
|
return [...names];
|
|
47
33
|
}
|
|
48
|
-
function
|
|
34
|
+
function categoryNamesFromList(values) {
|
|
49
35
|
const names = new Set();
|
|
50
|
-
for (const category of
|
|
36
|
+
for (const category of values) {
|
|
51
37
|
if (typeof category === "string" && category.trim() !== "") {
|
|
52
38
|
names.add(category);
|
|
53
39
|
}
|
|
54
40
|
}
|
|
55
41
|
return [...names];
|
|
56
42
|
}
|
|
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);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return [...names];
|
|
65
|
-
}
|
|
66
43
|
function configuredCategoryNames(config) {
|
|
67
44
|
if (Array.isArray(config.categories)) {
|
|
68
|
-
return
|
|
45
|
+
return categoryNamesFromList(config.categories);
|
|
69
46
|
}
|
|
70
47
|
if (config.categories && typeof config.categories === "object") {
|
|
71
|
-
return
|
|
48
|
+
return categoryNamesFromList(Object.keys(config.categories));
|
|
72
49
|
}
|
|
73
50
|
return [];
|
|
74
51
|
}
|
|
@@ -180,7 +157,7 @@ function formatInstanceInfo(configs, failures, includeEngines, includeDisabled,
|
|
|
180
157
|
const primary = configs[0].config;
|
|
181
158
|
const payload = {
|
|
182
159
|
available: true,
|
|
183
|
-
instancesReachable: configs.map(({ sourceUrl }) =>
|
|
160
|
+
instancesReachable: configs.map(({ sourceUrl }) => redactSearxngInstanceUrl(sourceUrl)),
|
|
184
161
|
...(failures.length > 0 ? { instancesUnreachable: redactFailures(failures) } : {}),
|
|
185
162
|
categories: aggregateCategories(configs, category),
|
|
186
163
|
defaults: {
|
|
@@ -251,7 +228,9 @@ async function requestInstanceConfig(mcpServer, base) {
|
|
|
251
228
|
return { available: true, config, sourceUrl: base };
|
|
252
229
|
}
|
|
253
230
|
catch (error) {
|
|
254
|
-
|
|
231
|
+
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
232
|
+
const safeMessage = rawMessage.replaceAll(base, redactSearxngInstanceUrl(base));
|
|
233
|
+
logMessage(mcpServer, "warning", `SearXNG /config fetch failed for ${redactSearxngInstanceUrl(base)}: ${safeMessage}`);
|
|
255
234
|
const message = "SearXNG /config is unavailable; instance capability discovery could not complete.";
|
|
256
235
|
return {
|
|
257
236
|
available: false,
|
package/dist/search.js
CHANGED
|
@@ -2,7 +2,7 @@ import { parse } from "node-html-parser";
|
|
|
2
2
|
import { getKnownCategories, getKnownEngines } from "./instance-info.js";
|
|
3
3
|
import { createProxyAgent, createDefaultAgent, ProxyType } from "./proxy.js";
|
|
4
4
|
import { logMessage } from "./logging.js";
|
|
5
|
-
import { getHealthySearxngInstances, getSearxngInstances, isSearxngFanoutEnabled, recordSearxngInstanceFailure, recordSearxngInstanceSuccess, } from "./searxng-instances.js";
|
|
5
|
+
import { getHealthySearxngInstances, getSearxngInstances, isSearxngFanoutEnabled, recordSearxngInstanceFailure, recordSearxngInstanceSuccess, redactSearxngInstanceUrl, } from "./searxng-instances.js";
|
|
6
6
|
import { MCPSearXNGError, validateEnvironment, createNetworkError, createServerError, createJSONError, createDataError, createNoResultsMessage } from "./error-handler.js";
|
|
7
7
|
function getOperatorMaxResults(mcpServer) {
|
|
8
8
|
const rawValue = process.env.SEARXNG_MAX_RESULTS;
|
|
@@ -88,22 +88,30 @@ function parseHtmlSearchResults(html, query) {
|
|
|
88
88
|
async function fetchWithSearchTimeout(mcpServer, url, requestOptions, timeoutMs, query, searxngUrl) {
|
|
89
89
|
const controller = new AbortController();
|
|
90
90
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
91
|
+
const rawUrl = url.toString();
|
|
92
|
+
const redactedUrl = redactSearxngInstanceUrl(rawUrl);
|
|
91
93
|
try {
|
|
92
|
-
logMessage(mcpServer, "info", `Making request to: ${
|
|
93
|
-
return await fetch(
|
|
94
|
+
logMessage(mcpServer, "info", `Making request to: ${redactedUrl}`);
|
|
95
|
+
return await fetch(rawUrl, {
|
|
94
96
|
...requestOptions,
|
|
95
97
|
signal: controller.signal,
|
|
96
98
|
});
|
|
97
99
|
}
|
|
98
100
|
catch (error) {
|
|
99
|
-
|
|
101
|
+
const safeMessage = typeof error?.message === "string"
|
|
102
|
+
? error.message.replaceAll(rawUrl, redactedUrl)
|
|
103
|
+
: error?.message;
|
|
104
|
+
const safeError = new Error(safeMessage);
|
|
105
|
+
safeError.code = error?.code;
|
|
106
|
+
safeError.cause = error?.cause;
|
|
107
|
+
logMessage(mcpServer, "error", `Network error during search request: ${safeMessage}`, { query, url: redactedUrl });
|
|
100
108
|
const context = {
|
|
101
|
-
url:
|
|
102
|
-
searxngUrl,
|
|
109
|
+
url: redactedUrl,
|
|
110
|
+
searxngUrl: redactSearxngInstanceUrl(searxngUrl),
|
|
103
111
|
proxyAgent: !!requestOptions.dispatcher,
|
|
104
112
|
username: process.env.AUTH_USERNAME,
|
|
105
113
|
};
|
|
106
|
-
throw createNetworkError(
|
|
114
|
+
throw createNetworkError(safeError, context);
|
|
107
115
|
}
|
|
108
116
|
finally {
|
|
109
117
|
clearTimeout(timeoutId);
|
|
@@ -111,7 +119,7 @@ async function fetchWithSearchTimeout(mcpServer, url, requestOptions, timeoutMs,
|
|
|
111
119
|
}
|
|
112
120
|
async function fetchHtmlFallbackSearch(mcpServer, jsonUrl, requestOptions, timeoutMs, query, searxngUrl) {
|
|
113
121
|
const htmlUrl = buildHtmlFallbackUrl(jsonUrl);
|
|
114
|
-
logMessage(mcpServer, "info", `Retrying search with HTML fallback: ${htmlUrl.toString()}`);
|
|
122
|
+
logMessage(mcpServer, "info", `Retrying search with HTML fallback: ${redactSearxngInstanceUrl(htmlUrl.toString())}`);
|
|
115
123
|
const response = await fetchWithSearchTimeout(mcpServer, htmlUrl, requestOptions, timeoutMs, query, searxngUrl);
|
|
116
124
|
if (!response.ok) {
|
|
117
125
|
let responseBody;
|
|
@@ -122,8 +130,8 @@ async function fetchHtmlFallbackSearch(mcpServer, jsonUrl, requestOptions, timeo
|
|
|
122
130
|
responseBody = '[Could not read response body]';
|
|
123
131
|
}
|
|
124
132
|
const context = {
|
|
125
|
-
url: htmlUrl.toString(),
|
|
126
|
-
searxngUrl,
|
|
133
|
+
url: redactSearxngInstanceUrl(htmlUrl.toString()),
|
|
134
|
+
searxngUrl: redactSearxngInstanceUrl(searxngUrl),
|
|
127
135
|
};
|
|
128
136
|
throw createServerError(response.status, response.statusText, responseBody, context);
|
|
129
137
|
}
|
|
@@ -330,36 +338,37 @@ async function fetchSearchFromInstance(mcpServer, instanceUrl, request) {
|
|
|
330
338
|
responseBody = '[Could not read response body]';
|
|
331
339
|
}
|
|
332
340
|
const context = {
|
|
333
|
-
url: url.toString(),
|
|
334
|
-
searxngUrl: instanceUrl
|
|
341
|
+
url: redactSearxngInstanceUrl(url.toString()),
|
|
342
|
+
searxngUrl: redactSearxngInstanceUrl(instanceUrl)
|
|
335
343
|
};
|
|
336
344
|
throw createServerError(response.status, response.statusText, responseBody, context);
|
|
337
345
|
}
|
|
338
346
|
}
|
|
339
347
|
else {
|
|
348
|
+
// Read the body as text once, then parse — a Response body is single-use, so
|
|
349
|
+
// calling response.text() after response.json() consumed it always throws and
|
|
350
|
+
// would leave the error preview empty.
|
|
351
|
+
let responseText;
|
|
340
352
|
try {
|
|
341
|
-
|
|
353
|
+
responseText = await response.text();
|
|
342
354
|
}
|
|
343
|
-
catch
|
|
355
|
+
catch {
|
|
356
|
+
responseText = '[Could not read response text]';
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
data = JSON.parse(responseText);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
344
362
|
if (isHtmlFallbackEnabled()) {
|
|
345
363
|
data = await fetchHtmlFallbackSearch(mcpServer, url, requestOptions, request.timeoutMs, request.query, instanceUrl);
|
|
346
364
|
}
|
|
347
365
|
else {
|
|
348
|
-
|
|
349
|
-
try {
|
|
350
|
-
responseText = await response.text();
|
|
351
|
-
}
|
|
352
|
-
catch {
|
|
353
|
-
responseText = '[Could not read response text]';
|
|
354
|
-
}
|
|
355
|
-
const context = { url: url.toString() };
|
|
356
|
-
throw createJSONError(responseText, context);
|
|
366
|
+
throw createJSONError(responseText);
|
|
357
367
|
}
|
|
358
368
|
}
|
|
359
369
|
}
|
|
360
370
|
if (!data.results) {
|
|
361
|
-
|
|
362
|
-
throw createDataError(data, context);
|
|
371
|
+
throw createDataError();
|
|
363
372
|
}
|
|
364
373
|
return {
|
|
365
374
|
instanceUrl,
|
|
@@ -371,13 +380,13 @@ function hasSearchResults(data) {
|
|
|
371
380
|
}
|
|
372
381
|
function createAllInstancesFailedError(failures, skippedInstances) {
|
|
373
382
|
if (failures.length === 0 && skippedInstances.length > 0) {
|
|
374
|
-
return new MCPSearXNGError(`All configured SearXNG instances are in cooldown after repeated failures: ${skippedInstances.join(", ")}.`);
|
|
383
|
+
return new MCPSearXNGError(`All configured SearXNG instances are in cooldown after repeated failures: ${skippedInstances.map(redactSearxngInstanceUrl).join(", ")}.`);
|
|
375
384
|
}
|
|
376
385
|
const failureDetails = failures
|
|
377
|
-
.map(({ instanceUrl, error }) => `${instanceUrl}: ${error instanceof Error ? error.message : String(error)}`)
|
|
386
|
+
.map(({ instanceUrl, error }) => `${redactSearxngInstanceUrl(instanceUrl)}: ${error instanceof Error ? error.message : String(error)}`)
|
|
378
387
|
.join("; ");
|
|
379
388
|
const skippedDetails = skippedInstances.length > 0
|
|
380
|
-
? ` Skipped cooled-down instances: ${skippedInstances.join(", ")}.`
|
|
389
|
+
? ` Skipped cooled-down instances: ${skippedInstances.map(redactSearxngInstanceUrl).join(", ")}.`
|
|
381
390
|
: "";
|
|
382
391
|
return new MCPSearXNGError(`All configured SearXNG instances failed. ${failureDetails}${skippedDetails}`);
|
|
383
392
|
}
|
|
@@ -535,6 +544,7 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
|
|
|
535
544
|
data = multiResult.data;
|
|
536
545
|
servedBy = multiResult.servedBy;
|
|
537
546
|
}
|
|
547
|
+
const redactedServedBy = servedBy.map(redactSearxngInstanceUrl);
|
|
538
548
|
const results = data.results
|
|
539
549
|
.filter((result) => min_score === undefined || (result.score || 0) >= min_score);
|
|
540
550
|
const slicedResults = effectiveMax !== undefined
|
|
@@ -545,13 +555,13 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
|
|
|
545
555
|
...data,
|
|
546
556
|
results: slicedResults,
|
|
547
557
|
...(filters.validationWarning ? { warnings: [filters.validationWarning] } : {}),
|
|
548
|
-
...(includeProvenance ? { servedBy } : {}),
|
|
558
|
+
...(includeProvenance ? { servedBy: redactedServedBy } : {}),
|
|
549
559
|
}, null, 2);
|
|
550
560
|
}
|
|
551
561
|
const metadata = formatSearchMetadata(data);
|
|
552
562
|
const leadingSections = [
|
|
553
563
|
includeProvenance
|
|
554
|
-
? `Served by SearXNG ${
|
|
564
|
+
? `Served by SearXNG ${redactedServedBy.length === 1 ? "instance" : "instances"}: ${redactedServedBy.join(", ")}`
|
|
555
565
|
: null,
|
|
556
566
|
filters.validationNote ?? null,
|
|
557
567
|
data.sourceFormat === "html" ? "Note: Results parsed from SearXNG HTML fallback; metadata is limited." : null,
|
|
@@ -2,6 +2,7 @@ export declare function parseSearxngUrls(raw?: string | undefined): string[];
|
|
|
2
2
|
export declare function getSearxngInstances(): string[];
|
|
3
3
|
export declare function getPrimarySearxngInstance(): string | undefined;
|
|
4
4
|
export declare function validateSearxngInstanceUrl(value: string): string | null;
|
|
5
|
+
export declare function redactSearxngInstanceUrl(raw: string): string;
|
|
5
6
|
export declare function isSearxngFanoutEnabled(): boolean;
|
|
6
7
|
export declare function recordSearxngInstanceFailure(instanceUrl: string, now?: number): void;
|
|
7
8
|
export declare function recordSearxngInstanceSuccess(instanceUrl: string): void;
|
|
@@ -39,6 +39,20 @@ export function validateSearxngInstanceUrl(value) {
|
|
|
39
39
|
}
|
|
40
40
|
return null;
|
|
41
41
|
}
|
|
42
|
+
export function redactSearxngInstanceUrl(raw) {
|
|
43
|
+
try {
|
|
44
|
+
const url = new URL(raw);
|
|
45
|
+
if (!url.username && !url.password) {
|
|
46
|
+
return raw;
|
|
47
|
+
}
|
|
48
|
+
url.username = "";
|
|
49
|
+
url.password = "";
|
|
50
|
+
return url.toString();
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return raw.replace(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/]*@/, "$1");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
42
56
|
export function isSearxngFanoutEnabled() {
|
|
43
57
|
return process.env.SEARXNG_FANOUT === "true";
|
|
44
58
|
}
|
package/dist/types.js
CHANGED
|
@@ -237,7 +237,7 @@ export const LITE_INSTANCE_INFO_TOOL = {
|
|
|
237
237
|
};
|
|
238
238
|
export const LITE_READ_URL_TOOL = {
|
|
239
239
|
name: "web_url_read",
|
|
240
|
-
description: "Fetch URL.
|
|
240
|
+
description: "Fetch URL. Converts HTML to markdown; returns explicit JSON, plain text, YAML, TOML, and XML as readable markdown; binary/media/archive downloads are rejected.",
|
|
241
241
|
inputSchema: {
|
|
242
242
|
type: "object",
|
|
243
243
|
properties: { url: { type: "string", description: "URL to fetch." } },
|
|
@@ -246,7 +246,9 @@ export const LITE_READ_URL_TOOL = {
|
|
|
246
246
|
};
|
|
247
247
|
export const READ_URL_TOOL = {
|
|
248
248
|
name: "web_url_read",
|
|
249
|
-
description: "Fetches a URL and returns
|
|
249
|
+
description: "Fetches a URL and returns readable content as markdown. " +
|
|
250
|
+
"Content-type aware: HTML is converted to markdown; JSON is pretty-printed; plain text, YAML, TOML, and XML are returned as fenced readable text. " +
|
|
251
|
+
"Binary, media, archive, PDF, and octet-stream downloads are intentionally rejected instead of being returned as raw bytes. " +
|
|
250
252
|
"Three modes: " +
|
|
251
253
|
"(1) Full content — omit filtering params; use `startChar`/`maxLength` to paginate large pages. " +
|
|
252
254
|
"(2) Section extraction — set `section` to return content under a specific heading. " +
|
package/dist/url-reader.js
CHANGED
|
@@ -9,6 +9,40 @@ const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
|
|
|
9
9
|
const MAX_REDIRECTS = 5;
|
|
10
10
|
export const DEFAULT_MAX_CONTENT_LENGTH_BYTES = 5 * 1024 * 1024;
|
|
11
11
|
const HEAD_TIMEOUT_CAP_MS = 3000;
|
|
12
|
+
const BINARY_SNIFF_PREFIX_BYTES = 1024;
|
|
13
|
+
const EXACT_READABLE_CONTENT_TYPES = new Map([
|
|
14
|
+
["text/html", (mediaType) => ({ kind: "html", mediaType, language: "html" })],
|
|
15
|
+
["application/xhtml+xml", (mediaType) => ({ kind: "html", mediaType, language: "html" })],
|
|
16
|
+
["application/json", (mediaType) => ({ kind: "json", mediaType, language: "json" })],
|
|
17
|
+
["application/xml", (mediaType) => ({ kind: "text", mediaType, language: "xml" })],
|
|
18
|
+
["text/xml", (mediaType) => ({ kind: "text", mediaType, language: "xml" })],
|
|
19
|
+
["application/yaml", (mediaType) => ({ kind: "text", mediaType, language: "yaml" })],
|
|
20
|
+
["application/x-yaml", (mediaType) => ({ kind: "text", mediaType, language: "yaml" })],
|
|
21
|
+
["text/yaml", (mediaType) => ({ kind: "text", mediaType, language: "yaml" })],
|
|
22
|
+
["text/x-yaml", (mediaType) => ({ kind: "text", mediaType, language: "yaml" })],
|
|
23
|
+
["application/toml", (mediaType) => ({ kind: "text", mediaType, language: "toml" })],
|
|
24
|
+
["application/x-toml", (mediaType) => ({ kind: "text", mediaType, language: "toml" })],
|
|
25
|
+
["text/toml", (mediaType) => ({ kind: "text", mediaType, language: "toml" })],
|
|
26
|
+
]);
|
|
27
|
+
const EXACT_BINARY_CONTENT_TYPES = new Set([
|
|
28
|
+
"application/pdf",
|
|
29
|
+
"application/octet-stream",
|
|
30
|
+
"binary/octet-stream",
|
|
31
|
+
"application/zip",
|
|
32
|
+
"application/x-zip",
|
|
33
|
+
"application/x-zip-compressed",
|
|
34
|
+
"application/gzip",
|
|
35
|
+
"application/x-gzip",
|
|
36
|
+
"application/x-tar",
|
|
37
|
+
"application/tar",
|
|
38
|
+
"application/x-7z-compressed",
|
|
39
|
+
"application/x-rar-compressed",
|
|
40
|
+
"application/vnd.rar",
|
|
41
|
+
"application/x-bzip",
|
|
42
|
+
"application/x-bzip2",
|
|
43
|
+
"application/x-xz",
|
|
44
|
+
"application/zstd",
|
|
45
|
+
]);
|
|
12
46
|
function isRedirectResponse(response) {
|
|
13
47
|
return REDIRECT_STATUS_CODES.has(response.status);
|
|
14
48
|
}
|
|
@@ -160,6 +194,93 @@ function createContentTooLargeMessage(contentLength, maxBytes) {
|
|
|
160
194
|
return (`Content too large: server reports Content-Length of ${sizeMB} MB (limit: ${limitMB} MB). ` +
|
|
161
195
|
`Try using readHeadings or section to fetch only the relevant parts.`);
|
|
162
196
|
}
|
|
197
|
+
function normalizeMediaType(contentType) {
|
|
198
|
+
if (!contentType) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const mediaType = contentType.split(";")[0].trim().toLowerCase();
|
|
202
|
+
return mediaType === "" ? null : mediaType;
|
|
203
|
+
}
|
|
204
|
+
function isBinaryMediaType(mediaType) {
|
|
205
|
+
if (mediaType.startsWith("image/") ||
|
|
206
|
+
mediaType.startsWith("audio/") ||
|
|
207
|
+
mediaType.startsWith("video/") ||
|
|
208
|
+
mediaType.startsWith("font/")) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
return EXACT_BINARY_CONTENT_TYPES.has(mediaType);
|
|
212
|
+
}
|
|
213
|
+
function classifyContentType(contentType) {
|
|
214
|
+
const mediaType = normalizeMediaType(contentType);
|
|
215
|
+
if (mediaType === null) {
|
|
216
|
+
return { kind: "generic", mediaType };
|
|
217
|
+
}
|
|
218
|
+
const exactReadable = EXACT_READABLE_CONTENT_TYPES.get(mediaType);
|
|
219
|
+
if (exactReadable) {
|
|
220
|
+
return exactReadable(mediaType);
|
|
221
|
+
}
|
|
222
|
+
if (mediaType.endsWith("+json")) {
|
|
223
|
+
return { kind: "json", mediaType, language: "json" };
|
|
224
|
+
}
|
|
225
|
+
else if (isBinaryMediaType(mediaType)) {
|
|
226
|
+
return { kind: "binary", mediaType };
|
|
227
|
+
}
|
|
228
|
+
else if (mediaType.endsWith("+xml")) {
|
|
229
|
+
return { kind: "text", mediaType, language: "xml" };
|
|
230
|
+
}
|
|
231
|
+
else if (mediaType.startsWith("text/")) {
|
|
232
|
+
return { kind: "text", mediaType, language: "text" };
|
|
233
|
+
}
|
|
234
|
+
return { kind: "generic", mediaType };
|
|
235
|
+
}
|
|
236
|
+
function createUnsupportedContentTypeMessage(classification, reason) {
|
|
237
|
+
const contentType = classification.mediaType ?? "missing";
|
|
238
|
+
const reasonText = reason ? ` ${reason}` : "";
|
|
239
|
+
return (`Unsupported content type: ${contentType}.${reasonText} ` +
|
|
240
|
+
"Binary, media, archive, and PDF downloads are intentionally not read by web_url_read.");
|
|
241
|
+
}
|
|
242
|
+
function createNulRejectedContentMessage(classification) {
|
|
243
|
+
if (classification.kind !== "generic" && classification.mediaType !== null) {
|
|
244
|
+
return (`Body was declared ${classification.mediaType} but appears binary (NUL byte in first 1KB); not read. ` +
|
|
245
|
+
"Binary, media, archive, and PDF downloads are intentionally not read by web_url_read.");
|
|
246
|
+
}
|
|
247
|
+
return createUnsupportedContentTypeMessage(classification, `Body appears binary: NUL byte found in the first ${BINARY_SNIFF_PREFIX_BYTES} bytes.`);
|
|
248
|
+
}
|
|
249
|
+
async function cancelResponseBody(response) {
|
|
250
|
+
try {
|
|
251
|
+
await response.body?.cancel();
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
// Best-effort cancellation: returning the unsupported hint is more useful than surfacing cancellation noise.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function getLongestBacktickRun(text) {
|
|
258
|
+
let longestRun = 0;
|
|
259
|
+
let currentRun = 0;
|
|
260
|
+
for (const char of text) {
|
|
261
|
+
if (char === "`") {
|
|
262
|
+
currentRun++;
|
|
263
|
+
longestRun = Math.max(longestRun, currentRun);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
currentRun = 0;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return longestRun;
|
|
270
|
+
}
|
|
271
|
+
function renderFencedMarkdown(language, text) {
|
|
272
|
+
const fence = "`".repeat(Math.max(3, getLongestBacktickRun(text) + 1));
|
|
273
|
+
return `${fence}${language}\n${text}\n${fence}`;
|
|
274
|
+
}
|
|
275
|
+
function renderJsonMarkdown(text) {
|
|
276
|
+
try {
|
|
277
|
+
const parsed = JSON.parse(text);
|
|
278
|
+
return renderFencedMarkdown("json", JSON.stringify(parsed, null, 2));
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return `Note: Response declared JSON but could not be parsed.\n\n${renderFencedMarkdown("text", text)}`;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
163
284
|
function concatenateChunks(chunks, totalBytes) {
|
|
164
285
|
const result = new Uint8Array(totalBytes);
|
|
165
286
|
let offset = 0;
|
|
@@ -169,13 +290,35 @@ function concatenateChunks(chunks, totalBytes) {
|
|
|
169
290
|
}
|
|
170
291
|
return result;
|
|
171
292
|
}
|
|
172
|
-
|
|
293
|
+
function scanPrefixForNul(value, prefixBytesChecked) {
|
|
294
|
+
const remainingPrefixBytes = BINARY_SNIFF_PREFIX_BYTES - prefixBytesChecked;
|
|
295
|
+
const bytesToCheck = Math.min(value.byteLength, remainingPrefixBytes);
|
|
296
|
+
if (bytesToCheck <= 0) {
|
|
297
|
+
return { hasNul: false, prefixBytesChecked };
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
hasNul: value.subarray(0, bytesToCheck).includes(0),
|
|
301
|
+
prefixBytesChecked: prefixBytesChecked + bytesToCheck,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function evaluateChunkLimits(bytesRead, maxBytes, hasNulInPrefix, abortOnNulInPrefix) {
|
|
305
|
+
if (hasNulInPrefix && abortOnNulInPrefix) {
|
|
306
|
+
return { exceeded: false, text: "", bytesRead, hasNulInPrefix };
|
|
307
|
+
}
|
|
308
|
+
if (bytesRead > maxBytes) {
|
|
309
|
+
return { exceeded: true, bytesRead };
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
async function readResponseBodyWithLimit(response, maxBytes, abortOnNulInPrefix = false) {
|
|
173
314
|
if (response.body === null) {
|
|
174
|
-
return { exceeded: false, text: "", bytesRead: 0 };
|
|
315
|
+
return { exceeded: false, text: "", bytesRead: 0, hasNulInPrefix: false };
|
|
175
316
|
}
|
|
176
317
|
const reader = response.body.getReader();
|
|
177
318
|
const chunks = [];
|
|
178
319
|
let bytesRead = 0;
|
|
320
|
+
let prefixBytesChecked = 0;
|
|
321
|
+
let hasNulInPrefix = false;
|
|
179
322
|
try {
|
|
180
323
|
while (true) {
|
|
181
324
|
const { done, value } = await reader.read();
|
|
@@ -185,10 +328,14 @@ async function readResponseBodyWithLimit(response, maxBytes) {
|
|
|
185
328
|
if (!value) {
|
|
186
329
|
continue;
|
|
187
330
|
}
|
|
331
|
+
const nulScan = scanPrefixForNul(value, prefixBytesChecked);
|
|
332
|
+
hasNulInPrefix = hasNulInPrefix || nulScan.hasNul;
|
|
333
|
+
prefixBytesChecked = nulScan.prefixBytesChecked;
|
|
188
334
|
bytesRead += value.byteLength;
|
|
189
|
-
|
|
335
|
+
const limitResult = evaluateChunkLimits(bytesRead, maxBytes, hasNulInPrefix, abortOnNulInPrefix);
|
|
336
|
+
if (limitResult) {
|
|
190
337
|
await reader.cancel();
|
|
191
|
-
return
|
|
338
|
+
return limitResult;
|
|
192
339
|
}
|
|
193
340
|
chunks.push(value);
|
|
194
341
|
}
|
|
@@ -197,7 +344,7 @@ async function readResponseBodyWithLimit(response, maxBytes) {
|
|
|
197
344
|
reader.releaseLock();
|
|
198
345
|
}
|
|
199
346
|
const bodyBytes = concatenateChunks(chunks, bytesRead);
|
|
200
|
-
return { exceeded: false, text: new TextDecoder("utf-8").decode(bodyBytes), bytesRead };
|
|
347
|
+
return { exceeded: false, text: new TextDecoder("utf-8").decode(bodyBytes), bytesRead, hasNulInPrefix };
|
|
201
348
|
}
|
|
202
349
|
export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 10000, paginationOptions = {}) {
|
|
203
350
|
const startTime = Date.now();
|
|
@@ -306,36 +453,54 @@ export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 1000
|
|
|
306
453
|
const context = { url };
|
|
307
454
|
throw createServerError(response.status, response.statusText, responseBody, context);
|
|
308
455
|
}
|
|
309
|
-
|
|
310
|
-
|
|
456
|
+
const contentType = classifyContentType(response.headers.get("content-type"));
|
|
457
|
+
if (contentType.kind === "binary") {
|
|
458
|
+
await cancelResponseBody(response);
|
|
459
|
+
return createUnsupportedContentTypeMessage(contentType);
|
|
460
|
+
}
|
|
461
|
+
// Retrieve readable content
|
|
462
|
+
let rawContent;
|
|
463
|
+
let hasNulInPrefix = false;
|
|
311
464
|
try {
|
|
312
|
-
const bodyRead = await readResponseBodyWithLimit(response, maxContentLengthBytes);
|
|
465
|
+
const bodyRead = await readResponseBodyWithLimit(response, maxContentLengthBytes, true);
|
|
313
466
|
if (bodyRead.exceeded) {
|
|
314
467
|
return createContentTooLargeMessage(bodyRead.bytesRead, maxContentLengthBytes);
|
|
315
468
|
}
|
|
316
|
-
|
|
469
|
+
rawContent = bodyRead.text;
|
|
470
|
+
hasNulInPrefix = bodyRead.hasNulInPrefix;
|
|
317
471
|
}
|
|
318
472
|
catch (error) {
|
|
319
473
|
throw createContentError(`Failed to read website content: ${error.message || 'Unknown error reading content'}`, url);
|
|
320
474
|
}
|
|
321
|
-
if (
|
|
475
|
+
if (hasNulInPrefix) {
|
|
476
|
+
return createNulRejectedContentMessage(contentType);
|
|
477
|
+
}
|
|
478
|
+
if (!rawContent || rawContent.trim().length === 0) {
|
|
322
479
|
throw createContentError("Website returned empty content.", url);
|
|
323
480
|
}
|
|
324
|
-
// Convert
|
|
481
|
+
// Convert readable content to Markdown
|
|
325
482
|
let markdownContent;
|
|
326
|
-
|
|
327
|
-
markdownContent =
|
|
483
|
+
if (contentType.kind === "json") {
|
|
484
|
+
markdownContent = renderJsonMarkdown(rawContent);
|
|
328
485
|
}
|
|
329
|
-
|
|
330
|
-
|
|
486
|
+
else if (contentType.kind === "text") {
|
|
487
|
+
markdownContent = renderFencedMarkdown(contentType.language, rawContent);
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
try {
|
|
491
|
+
markdownContent = NodeHtmlMarkdown.translate(rawContent);
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
throw createConversionError(url);
|
|
495
|
+
}
|
|
331
496
|
}
|
|
332
497
|
if (!markdownContent || markdownContent.trim().length === 0) {
|
|
333
498
|
logMessage(mcpServer, "warning", `Empty content after conversion: ${url}`);
|
|
334
499
|
// DON'T cache empty/failed conversions - return warning directly
|
|
335
|
-
return createEmptyContentWarning(url
|
|
500
|
+
return createEmptyContentWarning(url);
|
|
336
501
|
}
|
|
337
502
|
// Only cache successful markdown conversion
|
|
338
|
-
urlCache.set(url,
|
|
503
|
+
urlCache.set(url, rawContent, markdownContent);
|
|
339
504
|
// Apply pagination options
|
|
340
505
|
const result = applyPaginationOptions(markdownContent, paginationOptions);
|
|
341
506
|
const duration = Date.now() - startTime;
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const packageVersion = "1.
|
|
1
|
+
export declare const packageVersion = "1.10.0";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const packageVersion = "1.
|
|
1
|
+
export const packageVersion = "1.10.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-searxng",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"mcpName": "io.github.ihor-sokoliuk/mcp-searxng",
|
|
5
5
|
"description": "MCP server for SearXNG integration",
|
|
6
6
|
"license": "MIT",
|
|
@@ -61,11 +61,11 @@
|
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/node": "22.20.0",
|
|
63
63
|
"@types/supertest": "^7.2.0",
|
|
64
|
-
"@typescript-eslint/eslint-plugin": "8.
|
|
65
|
-
"@typescript-eslint/parser": "8.
|
|
64
|
+
"@typescript-eslint/eslint-plugin": "8.62.0",
|
|
65
|
+
"@typescript-eslint/parser": "8.62.0",
|
|
66
66
|
"c8": "^11.0.0",
|
|
67
67
|
"cross-env": "^10.1.0",
|
|
68
|
-
"eslint": "10.
|
|
68
|
+
"eslint": "10.6.0",
|
|
69
69
|
"eslint-plugin-security": "^4.0.0",
|
|
70
70
|
"fast-check": "^4.8.0",
|
|
71
71
|
"shx": "^0.4.0",
|