mcp-searxng 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -239,6 +239,8 @@ By default the server uses STDIO. Set `MCP_HTTP_PORT` to enable HTTP mode:
239
239
 
240
240
  **Endpoints:** `POST/GET/DELETE /mcp` (MCP protocol), `GET /health` (health check)
241
241
 
242
+ For reverse-proxy deployments, see [CONFIGURATION.md](CONFIGURATION.md) for `MCP_HTTP_TRUST_PROXY` so rate limiting and logs use the correct client IP.
243
+
242
244
  **Test it:**
243
245
 
244
246
  ```bash
@@ -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, context: ErrorContext): MCPSearXNGError;
20
- export declare function createDataError(data: any, context: ErrorContext): MCPSearXNGError;
19
+ export declare function createJSONError(responseText: string): MCPSearXNGError;
20
+ export declare function createDataError(): MCPSearXNGError;
21
21
  export declare function createNoResultsMessage(query: string): string;
22
22
  export declare function createURLFormatError(url: string): MCPSearXNGError;
23
23
  export declare function createURLSecurityPolicyError(url: string): MCPSearXNGError;
24
24
  export declare function createContentError(message: string, url: string): MCPSearXNGError;
25
- export declare function createConversionError(error: any, url: string, htmlContent: string): MCPSearXNGError;
25
+ export declare function createConversionError(url: string): MCPSearXNGError;
26
26
  export declare function createTimeoutError(timeout: number, url: string): MCPSearXNGError;
27
- export declare function createEmptyContentWarning(url: string, htmlLength: number, htmlPreview: string): string;
27
+ export declare function createEmptyContentWarning(url: string): string;
28
28
  export declare function createUnexpectedError(error: any, context: ErrorContext): MCPSearXNGError;
29
29
  /**
30
30
  * Process-level crash handlers, registered by the CLI entrypoint (cli.ts).
@@ -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, context) {
87
+ export function createJSONError(responseText) {
88
88
  const preview = responseText.substring(0, 100).replace(/\n/g, ' ');
89
89
  return new MCPSearXNGError(`🔍 SearXNG Response Error: Invalid JSON format. Response: "${preview}..."`);
90
90
  }
91
- export function createDataError(data, context) {
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(error, url, htmlContent) {
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, htmlLength, htmlPreview) {
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) {
@@ -6,6 +6,7 @@ export interface HttpSecurityConfig {
6
6
  allowedOrigins: string[];
7
7
  enableDnsRebindingProtection: boolean;
8
8
  allowedHosts: string[];
9
+ trustProxy: boolean | number | string;
9
10
  exposeFullConfig: boolean;
10
11
  allowPrivateUrls: boolean;
11
12
  }
@@ -7,6 +7,21 @@ function parseCsv(value) {
7
7
  .map((item) => item.trim())
8
8
  .filter(Boolean);
9
9
  }
10
+ function parseTrustProxy(value) {
11
+ const trimmed = value?.trim();
12
+ // Treat "0" as disabled (false): operators use 0 to turn numeric knobs off,
13
+ // and Express would otherwise mis-parse the string "0" as a bogus trust subnet.
14
+ if (!trimmed || trimmed === "false" || trimmed === "0") {
15
+ return false;
16
+ }
17
+ if (trimmed === "true") {
18
+ return true;
19
+ }
20
+ if (/^[1-9]\d*$/.test(trimmed)) {
21
+ return Number(trimmed);
22
+ }
23
+ return trimmed;
24
+ }
10
25
  export function getHttpSecurityConfig() {
11
26
  const harden = isEnabled(process.env.MCP_HTTP_HARDEN);
12
27
  const authToken = process.env.MCP_HTTP_AUTH_TOKEN;
@@ -20,6 +35,7 @@ export function getHttpSecurityConfig() {
20
35
  allowedOrigins,
21
36
  enableDnsRebindingProtection: harden,
22
37
  allowedHosts: allowedHosts.length > 0 ? allowedHosts : ["127.0.0.1", "localhost"],
38
+ trustProxy: parseTrustProxy(process.env.MCP_HTTP_TRUST_PROXY),
23
39
  exposeFullConfig: isEnabled(process.env.MCP_HTTP_EXPOSE_FULL_CONFIG),
24
40
  allowPrivateUrls: isEnabled(process.env.MCP_HTTP_ALLOW_PRIVATE_URLS),
25
41
  };
@@ -55,6 +55,9 @@ export async function createHttpServer(createMcpServer) {
55
55
  const app = express();
56
56
  const security = getHttpSecurityConfig();
57
57
  validateHttpSecurityConfig(security);
58
+ if (security.trustProxy !== false) {
59
+ app.set('trust proxy', security.trustProxy);
60
+ }
58
61
  app.use(express.json());
59
62
  // Add CORS support for web clients
60
63
  app.use(cors({
@@ -97,7 +100,7 @@ export async function createHttpServer(createMcpServer) {
97
100
  mcpServer = session.mcpServer;
98
101
  logMessage(mcpServer, "debug", `Reusing session: ${sessionId}`);
99
102
  }
100
- else if (!sessionId && isInitializeRequest(req.body)) {
103
+ else if (isInitializeRequest(req.body)) {
101
104
  // New initialization request — create fresh McpServer and transport
102
105
  mcpServer = createMcpServer();
103
106
  transport = new StreamableHTTPServerTransport({
@@ -129,11 +132,12 @@ export async function createHttpServer(createMcpServer) {
129
132
  contentType: req.headers['content-type'],
130
133
  accept: req.headers['accept']
131
134
  });
132
- res.status(400).json({
135
+ const sessionNotFound = Boolean(sessionId);
136
+ res.status(sessionNotFound ? 404 : 400).json({
133
137
  jsonrpc: '2.0',
134
138
  error: {
135
- code: -32000,
136
- message: 'Bad Request: No valid session ID provided',
139
+ code: sessionNotFound ? -32001 : -32000,
140
+ message: sessionNotFound ? 'Session not found' : 'Bad Request: No valid session ID provided',
137
141
  },
138
142
  id: null,
139
143
  });
@@ -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: redactInstanceUrl(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 categoryNamesFromArray(categories) {
34
+ function categoryNamesFromList(values) {
49
35
  const names = new Set();
50
- for (const category of categories) {
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 categoryNamesFromArray(config.categories);
45
+ return categoryNamesFromList(config.categories);
69
46
  }
70
47
  if (config.categories && typeof config.categories === "object") {
71
- return categoryNamesFromObject(config.categories);
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 }) => redactInstanceUrl(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
- logMessage(mcpServer, "warning", `SearXNG /config fetch failed for ${redactInstanceUrl(base)}: ${error instanceof Error ? error.message : String(error)}`);
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: ${url.toString()}`);
93
- return await fetch(url.toString(), {
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
- logMessage(mcpServer, "error", `Network error during search request: ${error.message}`, { query, url: url.toString() });
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: url.toString(),
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(error, context);
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
- data = (await response.json());
353
+ responseText = await response.text();
342
354
  }
343
- catch (error) {
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
- let responseText;
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
- const context = { url: url.toString(), query: request.query };
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 ${servedBy.length === 1 ? "instance" : "instances"}: ${servedBy.join(", ")}`
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
  }
@@ -326,13 +326,13 @@ export async function fetchAndConvertToMarkdown(mcpServer, url, timeoutMs = 1000
326
326
  try {
327
327
  markdownContent = NodeHtmlMarkdown.translate(htmlContent);
328
328
  }
329
- catch (error) {
330
- throw createConversionError(error, url, htmlContent);
329
+ catch {
330
+ throw createConversionError(url);
331
331
  }
332
332
  if (!markdownContent || markdownContent.trim().length === 0) {
333
333
  logMessage(mcpServer, "warning", `Empty content after conversion: ${url}`);
334
334
  // DON'T cache empty/failed conversions - return warning directly
335
- return createEmptyContentWarning(url, htmlContent.length, htmlContent);
335
+ return createEmptyContentWarning(url);
336
336
  }
337
337
  // Only cache successful markdown conversion
338
338
  urlCache.set(url, htmlContent, markdownContent);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const packageVersion = "1.8.0";
1
+ export declare const packageVersion = "1.9.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "1.8.0";
1
+ export const packageVersion = "1.9.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-searxng",
3
- "version": "1.8.0",
3
+ "version": "1.9.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.61.1",
65
- "@typescript-eslint/parser": "8.61.1",
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.5.0",
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",