mcp-searxng 1.5.0 → 1.7.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
@@ -91,7 +91,8 @@ AI Assistant (e.g. Claude)
91
91
  - `safesearch` (number, optional): Safe search filter level (0: None, 1: Moderate, 2: Strict) (default: instance setting)
92
92
  - `min_score` (number, optional): Minimum relevance score from 0.0 to 1.0. Results below this score are filtered out.
93
93
  - `num_results` (number, optional): Maximum number of results to return, from 1 to 20. `SEARXNG_MAX_RESULTS` applies as an operator ceiling.
94
- - `categories` (string, optional): Comma-separated SearXNG categories (e.g. `"news"`, `"it,science"`). Supported values: `general`, `news`, `images`, `videos`, `it`, `science`, `files`, `social media`. Default: SearXNG instance default (usually `general`).
94
+ - `categories` (string, optional): Comma-separated SearXNG categories (e.g. `"news"`, `"it,science"`). When live `/config` is available, values are trimmed and normalized case-insensitively to the instance's canonical category names; unknown values are rejected with available categories listed. If `/config` is unavailable, values are forwarded as-is with a warning. Default: SearXNG instance default.
95
+ - `engines` (string, optional): Comma-separated SearXNG engine names (e.g. `"google,bing,ddg"`, `"semantic scholar"`). When live `/config` is available, values are trimmed and normalized case-insensitively to canonical engine names, including engines disabled by default; unknown values are rejected with available engines listed. If `/config` is unavailable, values are forwarded as-is with a warning.
95
96
  - `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"`)
96
97
 
97
98
  - **searxng_search_suggestions**
@@ -272,6 +273,21 @@ You should receive a JSON response. If not, confirm the file is correctly mounte
272
273
 
273
274
  See also: [SearXNG settings docs](https://docs.searxng.org/admin/settings/settings.html) · [discussion](https://github.com/searxng/searxng/discussions/1789)
274
275
 
276
+ ### Can't enable JSON? (HTML fallback)
277
+
278
+ If you must use a public instance you don't control and it rejects `format=json` (the 403 above), set the opt-in flag instead of editing the server:
279
+
280
+ ```json
281
+ "SEARXNG_HTML_FALLBACK": "true"
282
+ ```
283
+
284
+ A search that gets a `403`/`404` or a non-JSON response is then retried automatically **without** `format=json` and parsed from the regular HTML results page.
285
+
286
+ - **On success:** you get normal results (title, URL, snippet). They are marked `sourceFormat: "html"` in JSON mode, and text mode adds the line *"Note: Results parsed from SearXNG HTML fallback; metadata is limited."* Relevance scores and engine names are not available from HTML.
287
+ - **On failure:** parsing is best-effort and varies by the instance's theme/version, so some results may be missed or sparse. If the HTML page itself also fails — still blocked, rate-limited (`429`), auth (`401`), or `5xx` — the **original error is surfaced unchanged**. The fallback only triggers on `403`/`404`/non-JSON, never on auth or network errors.
288
+
289
+ Enabling JSON on an instance you control (above) remains the recommended setup — the fallback is a compatibility aid, not a replacement.
290
+
275
291
  ## Contributing
276
292
 
277
293
  See [CONTRIBUTING.md](CONTRIBUTING.md)
package/dist/cache.d.ts CHANGED
@@ -2,14 +2,17 @@ interface CacheEntry {
2
2
  htmlContent: string;
3
3
  markdownContent: string;
4
4
  timestamp: number;
5
+ hitCount: number;
5
6
  }
6
7
  declare class SimpleCache {
7
8
  private cache;
8
9
  private readonly ttlMs;
10
+ private readonly maxEntries;
9
11
  private cleanupInterval;
10
- constructor(ttlMs?: number, cleanupIntervalMs?: number);
12
+ constructor(ttlMs?: number, maxEntries?: number, cleanupIntervalMs?: number);
11
13
  private startCleanup;
12
14
  private cleanupExpired;
15
+ private evictIfNeeded;
13
16
  get(url: string): CacheEntry | null;
14
17
  set(url: string, htmlContent: string, markdownContent: string): void;
15
18
  clear(): void;
@@ -19,6 +22,7 @@ declare class SimpleCache {
19
22
  entries: Array<{
20
23
  url: string;
21
24
  age: number;
25
+ hitCount: number;
22
26
  }>;
23
27
  };
24
28
  }
package/dist/cache.js CHANGED
@@ -1,13 +1,28 @@
1
+ const DEFAULT_CACHE_TTL_MS = 86400000;
2
+ const DEFAULT_CACHE_MAX_ENTRIES = 500;
3
+ const DEFAULT_CLEANUP_INTERVAL_MS = 60000;
4
+ function parsePositiveInteger(value, fallback) {
5
+ if (value === undefined || value.trim() === "") {
6
+ return fallback;
7
+ }
8
+ const parsed = parseInt(value, 10);
9
+ return Number.isNaN(parsed) || parsed <= 0 ? fallback : parsed;
10
+ }
11
+ function normalizePositiveInteger(value, fallback) {
12
+ return !Number.isFinite(value) || !Number.isInteger(value) || value <= 0 ? fallback : value;
13
+ }
1
14
  class SimpleCache {
2
15
  cache = new Map();
3
16
  ttlMs;
17
+ maxEntries;
4
18
  cleanupInterval = null;
5
- constructor(ttlMs = 60000, cleanupIntervalMs = 30000) {
6
- this.ttlMs = ttlMs;
7
- this.startCleanup(cleanupIntervalMs);
19
+ constructor(ttlMs = parsePositiveInteger(process.env.CACHE_TTL_MS, DEFAULT_CACHE_TTL_MS), maxEntries = parsePositiveInteger(process.env.CACHE_MAX_ENTRIES, DEFAULT_CACHE_MAX_ENTRIES), cleanupIntervalMs = DEFAULT_CLEANUP_INTERVAL_MS) {
20
+ this.ttlMs = normalizePositiveInteger(ttlMs, DEFAULT_CACHE_TTL_MS);
21
+ this.maxEntries = normalizePositiveInteger(maxEntries, DEFAULT_CACHE_MAX_ENTRIES);
22
+ this.startCleanup(normalizePositiveInteger(cleanupIntervalMs, DEFAULT_CLEANUP_INTERVAL_MS));
8
23
  }
9
24
  startCleanup(cleanupIntervalMs) {
10
- // Clean up expired entries every cleanupIntervalMs milliseconds (default 30s)
25
+ // Clean up expired entries every cleanupIntervalMs milliseconds (default 60s)
11
26
  this.cleanupInterval = setInterval(() => {
12
27
  this.cleanupExpired();
13
28
  }, cleanupIntervalMs);
@@ -21,6 +36,25 @@ class SimpleCache {
21
36
  }
22
37
  }
23
38
  }
39
+ evictIfNeeded() {
40
+ this.cleanupExpired();
41
+ while (this.cache.size > this.maxEntries) {
42
+ let evictionKey = null;
43
+ let evictionEntry = null;
44
+ for (const [key, entry] of this.cache.entries()) {
45
+ if (evictionEntry === null ||
46
+ entry.hitCount < evictionEntry.hitCount ||
47
+ (entry.hitCount === evictionEntry.hitCount && entry.timestamp < evictionEntry.timestamp)) {
48
+ evictionKey = key;
49
+ evictionEntry = entry;
50
+ }
51
+ }
52
+ if (evictionKey === null) {
53
+ return;
54
+ }
55
+ this.cache.delete(evictionKey);
56
+ }
57
+ }
24
58
  get(url) {
25
59
  const entry = this.cache.get(url);
26
60
  if (!entry) {
@@ -31,14 +65,17 @@ class SimpleCache {
31
65
  this.cache.delete(url);
32
66
  return null;
33
67
  }
68
+ entry.hitCount++;
34
69
  return entry;
35
70
  }
36
71
  set(url, htmlContent, markdownContent) {
37
72
  this.cache.set(url, {
38
73
  htmlContent,
39
74
  markdownContent,
40
- timestamp: Date.now()
75
+ timestamp: Date.now(),
76
+ hitCount: 0
41
77
  });
78
+ this.evictIfNeeded();
42
79
  }
43
80
  clear() {
44
81
  this.cache.clear();
@@ -55,7 +92,8 @@ class SimpleCache {
55
92
  const now = Date.now();
56
93
  const entries = Array.from(this.cache.entries()).map(([url, entry]) => ({
57
94
  url,
58
- age: now - entry.timestamp
95
+ age: now - entry.timestamp,
96
+ hitCount: entry.hitCount
59
97
  }));
60
98
  return {
61
99
  size: this.cache.size,
package/dist/index.js CHANGED
@@ -104,7 +104,7 @@ export function createMcpServer() {
104
104
  if (!isSearXNGWebSearchArgs(args)) {
105
105
  throw new Error("Invalid arguments for web search");
106
106
  }
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.response_format);
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
108
  return {
109
109
  content: [
110
110
  {
@@ -1,3 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  export declare function clearInstanceInfoCacheForTests(): void;
3
+ export declare function getKnownEngines(mcpServer: McpServer, refresh?: boolean): Promise<Set<string> | null>;
4
+ export declare function getKnownCategories(mcpServer: McpServer, refresh?: boolean): Promise<Set<string> | null>;
3
5
  export declare function fetchInstanceInfo(mcpServer: McpServer, includeEngines?: boolean, includeDisabled?: boolean, category?: string, refresh?: boolean): Promise<string>;
@@ -9,11 +9,39 @@ function unavailable(message, status) {
9
9
  ...(status !== undefined ? { status } : {}),
10
10
  }, null, 2);
11
11
  }
12
+ function categoryNamesFromEngines(config) {
13
+ const names = new Set();
14
+ if (Array.isArray(config.engines)) {
15
+ for (const engine of config.engines) {
16
+ for (const category of engineCategories(engine)) {
17
+ if (typeof category === "string" && category.trim() !== "") {
18
+ names.add(category);
19
+ }
20
+ }
21
+ }
22
+ }
23
+ return [...names];
24
+ }
12
25
  function namesFromCategories(config) {
13
- if (!config.categories || typeof config.categories !== "object") {
14
- return [];
26
+ const names = new Set();
27
+ if (Array.isArray(config.categories)) {
28
+ for (const category of config.categories) {
29
+ if (typeof category === "string" && category.trim() !== "") {
30
+ names.add(category);
31
+ }
32
+ }
33
+ }
34
+ else if (config.categories && typeof config.categories === "object") {
35
+ for (const category of Object.keys(config.categories)) {
36
+ if (category.trim() !== "") {
37
+ names.add(category);
38
+ }
39
+ }
15
40
  }
16
- return Object.keys(config.categories).sort();
41
+ for (const category of categoryNamesFromEngines(config)) {
42
+ names.add(category);
43
+ }
44
+ return [...names].sort();
17
45
  }
18
46
  function engineCategories(engine) {
19
47
  if (Array.isArray(engine.categories)) {
@@ -51,6 +79,17 @@ function collectEngines(config, includeDisabled, category) {
51
79
  ...(includeDisabled ? { disabled: [...disabled].sort() } : {}),
52
80
  };
53
81
  }
82
+ function allEngineNames(config) {
83
+ const names = new Set();
84
+ if (Array.isArray(config.engines)) {
85
+ for (const engine of config.engines) {
86
+ if (engine && typeof engine.name === "string") {
87
+ names.add(engine.name);
88
+ }
89
+ }
90
+ }
91
+ return names;
92
+ }
54
93
  function formatInstanceInfo(config, includeEngines, includeDisabled, category) {
55
94
  const categories = category
56
95
  ? namesFromCategories(config).filter((name) => name === category)
@@ -76,16 +115,19 @@ export function clearInstanceInfoCacheForTests() {
76
115
  cachedConfig = null;
77
116
  cachedBaseUrl = null;
78
117
  }
79
- export async function fetchInstanceInfo(mcpServer, includeEngines = false, includeDisabled = false, category, refresh = false) {
118
+ async function fetchConfig(mcpServer, refresh = false) {
80
119
  const base = process.env.SEARXNG_URL;
81
120
  if (!base) {
82
- return unavailable("SEARXNG_URL is not configured; cannot fetch SearXNG /config.");
121
+ return {
122
+ available: false,
123
+ message: "SEARXNG_URL is not configured; cannot fetch SearXNG /config.",
124
+ };
83
125
  }
84
126
  if (refresh) {
85
127
  cachedConfig = null;
86
128
  }
87
129
  if (cachedConfig && cachedBaseUrl === base) {
88
- return formatInstanceInfo(cachedConfig, includeEngines, includeDisabled, category);
130
+ return { available: true, config: cachedConfig };
89
131
  }
90
132
  const parsedBase = new URL(base.endsWith("/") ? base : `${base}/`);
91
133
  const url = new URL("config", parsedBase);
@@ -100,14 +142,42 @@ export async function fetchInstanceInfo(mcpServer, includeEngines = false, inclu
100
142
  }
101
143
  const response = await fetch(url.toString(), requestOptions);
102
144
  if (!response.ok) {
103
- return unavailable(`SearXNG /config is unavailable: HTTP ${response.status} ${response.statusText}`, response.status);
145
+ return {
146
+ available: false,
147
+ message: `SearXNG /config is unavailable: HTTP ${response.status} ${response.statusText}`,
148
+ status: response.status,
149
+ };
104
150
  }
105
151
  cachedConfig = await response.json();
106
152
  cachedBaseUrl = base;
107
- return formatInstanceInfo(cachedConfig, includeEngines, includeDisabled, category);
153
+ return { available: true, config: cachedConfig };
108
154
  }
109
155
  catch (error) {
110
156
  logMessage(mcpServer, "warning", `SearXNG /config fetch failed: ${error instanceof Error ? error.message : String(error)}`);
111
- return unavailable("SearXNG /config is unavailable; instance capability discovery could not complete.");
157
+ return {
158
+ available: false,
159
+ message: "SearXNG /config is unavailable; instance capability discovery could not complete.",
160
+ };
161
+ }
162
+ }
163
+ export async function getKnownEngines(mcpServer, refresh = false) {
164
+ const result = await fetchConfig(mcpServer, refresh);
165
+ if (!result.available) {
166
+ return null;
167
+ }
168
+ return allEngineNames(result.config);
169
+ }
170
+ export async function getKnownCategories(mcpServer, refresh = false) {
171
+ const result = await fetchConfig(mcpServer, refresh);
172
+ if (!result.available) {
173
+ return null;
174
+ }
175
+ return new Set(namesFromCategories(result.config));
176
+ }
177
+ export async function fetchInstanceInfo(mcpServer, includeEngines = false, includeDisabled = false, category, refresh = false) {
178
+ const result = await fetchConfig(mcpServer, refresh);
179
+ if (!result.available) {
180
+ return unavailable(result.message, result.status);
112
181
  }
182
+ return formatInstanceInfo(result.config, includeEngines, includeDisabled, category);
113
183
  }
package/dist/resources.js CHANGED
@@ -53,10 +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"
57
- - \`response_format\` (optional): "text" for formatted output or "json" for raw SearXNG-shaped JSON
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
58
+ - \`response_format\` (optional): "text" for formatted output or "json" for raw SearXNG-shaped JSON (may include a \`warnings\` array for non-fatal issues)
58
59
 
59
- 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\`.
60
+ Text output can include metadata sections for direct answers, spelling corrections, suggestions, and infoboxes before the result list. JSON output preserves the SearXNG response shape with filtered and sliced \`results\`, and may include a \`warnings\` array for non-fatal issues. Unknown categories or engines are rejected when live \`/config\` validation is available; if \`/config\` is unavailable, the search proceeds with the supplied values and emits a warning.
60
61
 
61
62
  ### 2. searxng_search_suggestions
62
63
  Returns autocomplete suggestions from the configured SearXNG instance.
package/dist/search.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
- export declare function performWebSearch(mcpServer: McpServer, query: string, pageno?: number, time_range?: string, language?: string, safesearch?: number, min_score?: number, num_results?: number, categories?: string, response_format?: "text" | "json"): Promise<string>;
2
+ export declare function performWebSearch(mcpServer: McpServer, query: string, pageno?: number, time_range?: string, language?: string, safesearch?: number, min_score?: number, num_results?: number, categories?: string, engines?: string, response_format?: "text" | "json"): Promise<string>;
package/dist/search.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { parse } from "node-html-parser";
2
+ import { getKnownCategories, getKnownEngines } from "./instance-info.js";
1
3
  import { createProxyAgent, createDefaultAgent, ProxyType } from "./proxy.js";
2
4
  import { logMessage } from "./logging.js";
3
5
  import { MCPSearXNGError, validateEnvironment, createNetworkError, createServerError, createJSONError, createDataError, createNoResultsMessage } from "./error-handler.js";
@@ -31,9 +33,226 @@ function truncateResultContent(content, maxResultChars) {
31
33
  }
32
34
  return `${content.slice(0, maxResultChars)}…`;
33
35
  }
36
+ function normalizeHtmlText(text) {
37
+ return text.replace(/\s+/g, " ").trim();
38
+ }
39
+ function isHtmlFallbackEnabled() {
40
+ return process.env.SEARXNG_HTML_FALLBACK === "true";
41
+ }
42
+ function shouldFallbackForStatus(status) {
43
+ return status === 403 || status === 404;
44
+ }
45
+ function buildHtmlFallbackUrl(jsonUrl) {
46
+ const htmlUrl = new URL(jsonUrl.toString());
47
+ htmlUrl.searchParams.delete("format");
48
+ return htmlUrl;
49
+ }
50
+ function parseHtmlSearchResults(html, query) {
51
+ const root = parse(html);
52
+ const articles = root.querySelectorAll("article.result");
53
+ const candidates = articles.length > 0 ? articles : root.querySelectorAll(".result");
54
+ const results = candidates
55
+ .map((entry) => {
56
+ const link = entry.querySelector("h3 > a") ?? entry.querySelector("h3 a") ?? entry.querySelector("a[href]");
57
+ if (!link) {
58
+ return undefined;
59
+ }
60
+ const href = link?.getAttribute("href")?.trim();
61
+ if (!href) {
62
+ return undefined;
63
+ }
64
+ try {
65
+ new URL(href);
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ const title = normalizeHtmlText(link.text);
71
+ const snippetNode = entry.querySelector("p.content") ?? entry.querySelector(".content");
72
+ const content = snippetNode ? normalizeHtmlText(snippetNode.text) : "";
73
+ return {
74
+ title,
75
+ url: href,
76
+ content,
77
+ };
78
+ })
79
+ .filter((result) => result !== undefined);
80
+ return {
81
+ query,
82
+ number_of_results: results.length,
83
+ results,
84
+ sourceFormat: "html",
85
+ };
86
+ }
87
+ async function fetchWithSearchTimeout(mcpServer, url, requestOptions, timeoutMs, query, searxngUrl) {
88
+ const controller = new AbortController();
89
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
90
+ try {
91
+ logMessage(mcpServer, "info", `Making request to: ${url.toString()}`);
92
+ return await fetch(url.toString(), {
93
+ ...requestOptions,
94
+ signal: controller.signal,
95
+ });
96
+ }
97
+ catch (error) {
98
+ logMessage(mcpServer, "error", `Network error during search request: ${error.message}`, { query, url: url.toString() });
99
+ const context = {
100
+ url: url.toString(),
101
+ searxngUrl,
102
+ proxyAgent: !!requestOptions.dispatcher,
103
+ username: process.env.AUTH_USERNAME,
104
+ };
105
+ throw createNetworkError(error, context);
106
+ }
107
+ finally {
108
+ clearTimeout(timeoutId);
109
+ }
110
+ }
111
+ async function fetchHtmlFallbackSearch(mcpServer, jsonUrl, requestOptions, timeoutMs, query, searxngUrl) {
112
+ const htmlUrl = buildHtmlFallbackUrl(jsonUrl);
113
+ logMessage(mcpServer, "info", `Retrying search with HTML fallback: ${htmlUrl.toString()}`);
114
+ const response = await fetchWithSearchTimeout(mcpServer, htmlUrl, requestOptions, timeoutMs, query, searxngUrl);
115
+ if (!response.ok) {
116
+ let responseBody;
117
+ try {
118
+ responseBody = await response.text();
119
+ }
120
+ catch {
121
+ responseBody = '[Could not read response body]';
122
+ }
123
+ const context = {
124
+ url: htmlUrl.toString(),
125
+ searxngUrl,
126
+ };
127
+ throw createServerError(response.status, response.statusText, responseBody, context);
128
+ }
129
+ const html = await response.text();
130
+ return parseHtmlSearchResults(html, query);
131
+ }
34
132
  function hasItems(items) {
35
133
  return Array.isArray(items) && items.length > 0;
36
134
  }
135
+ function splitCommaSeparated(value) {
136
+ return value
137
+ .split(",")
138
+ .map((entry) => entry.trim())
139
+ .filter((entry) => entry !== "");
140
+ }
141
+ function buildCanonicalLookup(knownValues) {
142
+ const lookup = new Map();
143
+ for (const value of knownValues) {
144
+ lookup.set(value.trim().toLowerCase(), value);
145
+ }
146
+ return lookup;
147
+ }
148
+ function normalizeCommaSeparated(value, knownValues) {
149
+ const lookup = buildCanonicalLookup(knownValues);
150
+ const normalized = [];
151
+ const invalid = [];
152
+ for (const requested of splitCommaSeparated(value)) {
153
+ const canonical = lookup.get(requested.toLowerCase());
154
+ if (canonical === undefined) {
155
+ invalid.push(requested);
156
+ }
157
+ else {
158
+ normalized.push(canonical);
159
+ }
160
+ }
161
+ return {
162
+ normalized: normalized.join(","),
163
+ invalid,
164
+ };
165
+ }
166
+ function formatAvailableValues(label, values) {
167
+ const available = [...values].sort().join(", ");
168
+ return available === "" ? "" : ` Available ${label}: ${available}.`;
169
+ }
170
+ function createValidationError(kind, invalid, available) {
171
+ const label = kind === "category" ? "categories" : "engines";
172
+ return new MCPSearXNGError(`🔍 Invalid SearXNG ${kind} name(s): ${invalid.join(", ")}.` +
173
+ formatAvailableValues(label, available) +
174
+ ` Use the searxng_instance_info tool to discover available ${label}.`);
175
+ }
176
+ async function normalizeSearchFilters(mcpServer, categories, engines) {
177
+ const effectiveCategories = categories !== undefined && categories.trim() !== "" ? categories : undefined;
178
+ const effectiveEngines = engines !== undefined && engines.trim() !== "" ? engines : undefined;
179
+ if (!effectiveCategories && !effectiveEngines) {
180
+ return {};
181
+ }
182
+ const unavailableFilterLabel = effectiveCategories && effectiveEngines
183
+ ? "categories and engines"
184
+ : effectiveCategories
185
+ ? "categories"
186
+ : "engines";
187
+ const unavailableWarning = `${unavailableFilterLabel[0].toUpperCase()}${unavailableFilterLabel.slice(1)} were not validated or normalized because SearXNG /config is unavailable.`;
188
+ const unavailableNote = `Note: ${unavailableFilterLabel} were not validated or normalized (SearXNG /config unavailable).`;
189
+ let knownCategories;
190
+ let knownEngines;
191
+ if (effectiveCategories) {
192
+ knownCategories = await getKnownCategories(mcpServer);
193
+ if (knownCategories === null) {
194
+ return {
195
+ categories: effectiveCategories,
196
+ engines: effectiveEngines,
197
+ validationWarning: unavailableWarning,
198
+ validationNote: unavailableNote,
199
+ };
200
+ }
201
+ }
202
+ if (effectiveEngines) {
203
+ knownEngines = await getKnownEngines(mcpServer);
204
+ if (knownEngines === null) {
205
+ return {
206
+ categories: effectiveCategories,
207
+ engines: effectiveEngines,
208
+ validationWarning: unavailableWarning,
209
+ validationNote: unavailableNote,
210
+ };
211
+ }
212
+ }
213
+ let normalizedCategories = effectiveCategories && knownCategories
214
+ ? normalizeCommaSeparated(effectiveCategories, knownCategories)
215
+ : undefined;
216
+ let normalizedEngines = effectiveEngines && knownEngines
217
+ ? normalizeCommaSeparated(effectiveEngines, knownEngines)
218
+ : undefined;
219
+ if ((normalizedCategories && normalizedCategories.invalid.length > 0) ||
220
+ (normalizedEngines && normalizedEngines.invalid.length > 0)) {
221
+ if (effectiveCategories) {
222
+ knownCategories = await getKnownCategories(mcpServer, true);
223
+ knownEngines = effectiveEngines && knownCategories !== null
224
+ ? await getKnownEngines(mcpServer)
225
+ : knownEngines;
226
+ }
227
+ else if (effectiveEngines) {
228
+ knownEngines = await getKnownEngines(mcpServer, true);
229
+ }
230
+ if (knownCategories === null || knownEngines === null) {
231
+ return {
232
+ categories: effectiveCategories,
233
+ engines: effectiveEngines,
234
+ validationWarning: unavailableWarning,
235
+ validationNote: unavailableNote,
236
+ };
237
+ }
238
+ normalizedCategories = effectiveCategories && knownCategories
239
+ ? normalizeCommaSeparated(effectiveCategories, knownCategories)
240
+ : undefined;
241
+ normalizedEngines = effectiveEngines && knownEngines
242
+ ? normalizeCommaSeparated(effectiveEngines, knownEngines)
243
+ : undefined;
244
+ }
245
+ if (normalizedCategories && normalizedCategories.invalid.length > 0 && knownCategories) {
246
+ throw createValidationError("category", normalizedCategories.invalid, knownCategories);
247
+ }
248
+ if (normalizedEngines && normalizedEngines.invalid.length > 0 && knownEngines) {
249
+ throw createValidationError("engine", normalizedEngines.invalid, knownEngines);
250
+ }
251
+ return {
252
+ categories: normalizedCategories ? normalizedCategories.normalized : undefined,
253
+ engines: normalizedEngines ? normalizedEngines.normalized : undefined,
254
+ };
255
+ }
37
256
  function formatSearchMetadata(data) {
38
257
  const sections = [];
39
258
  if (hasItems(data.answers)) {
@@ -77,7 +296,7 @@ function getDefaultSafesearch(mcpServer) {
77
296
  }
78
297
  return parsed;
79
298
  }
80
- export async function performWebSearch(mcpServer, query, pageno = 1, time_range, language, safesearch, min_score, num_results, categories, response_format = "text") {
299
+ export async function performWebSearch(mcpServer, query, pageno = 1, time_range, language, safesearch, min_score, num_results, categories, engines, response_format = "text") {
81
300
  const startTime = Date.now();
82
301
  const operatorMax = getOperatorMaxResults(mcpServer);
83
302
  const effectiveMax = operatorMax !== undefined
@@ -86,6 +305,12 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
86
305
  const maxResultChars = getMaxResultChars(mcpServer);
87
306
  const effectiveLanguage = language ?? getDefaultLanguage();
88
307
  const effectiveSafesearch = safesearch !== undefined ? safesearch : getDefaultSafesearch(mcpServer);
308
+ const validationError = validateEnvironment();
309
+ if (validationError) {
310
+ logMessage(mcpServer, "error", "Configuration invalid");
311
+ throw new MCPSearXNGError(validationError);
312
+ }
313
+ const filters = await normalizeSearchFilters(mcpServer, categories, engines);
89
314
  // Build detailed log message with all parameters
90
315
  const searchParams = [
91
316
  `page ${pageno}`,
@@ -94,14 +319,10 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
94
319
  effectiveSafesearch !== undefined ? `safesearch: ${effectiveSafesearch}` : null,
95
320
  min_score !== undefined ? `min_score: ${min_score}` : null,
96
321
  effectiveMax !== undefined ? `num_results: ${effectiveMax}` : null,
97
- categories ? `categories: ${categories}` : null,
322
+ filters.categories ? `categories: ${filters.categories}` : null,
323
+ filters.engines ? `engines: ${filters.engines}` : null,
98
324
  ].filter(Boolean).join(", ");
99
325
  logMessage(mcpServer, "info", `Starting web search: "${query}" (${searchParams})`);
100
- const validationError = validateEnvironment();
101
- if (validationError) {
102
- logMessage(mcpServer, "error", "Configuration invalid");
103
- throw new MCPSearXNGError(validationError);
104
- }
105
326
  const searxngUrl = process.env.SEARXNG_URL;
106
327
  const parsedUrl = new URL(searxngUrl.endsWith('/') ? searxngUrl : searxngUrl + '/');
107
328
  const url = new URL('search', parsedUrl);
@@ -118,8 +339,11 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
118
339
  if (effectiveSafesearch !== undefined && [0, 1, 2].includes(effectiveSafesearch)) {
119
340
  url.searchParams.set("safesearch", effectiveSafesearch.toString());
120
341
  }
121
- if (categories) {
122
- url.searchParams.set("categories", categories);
342
+ if (filters.categories) {
343
+ url.searchParams.set("categories", filters.categories);
344
+ }
345
+ if (filters.engines) {
346
+ url.searchParams.set("engines", filters.engines);
123
347
  }
124
348
  // Prepare request options with headers
125
349
  const requestOptions = {
@@ -151,57 +375,49 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
151
375
  }
152
376
  // Fetch with AbortController timeout and enhanced error handling
153
377
  const SEARCH_TIMEOUT_MS = parseInt(process.env.SEARXNG_TIMEOUT_MS ?? "10000", 10);
154
- const controller = new AbortController();
155
- const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS);
156
378
  let response;
157
- try {
158
- logMessage(mcpServer, "info", `Making request to: ${url.toString()}`);
159
- response = await fetch(url.toString(), {
160
- ...requestOptions,
161
- signal: controller.signal,
162
- });
163
- }
164
- catch (error) {
165
- clearTimeout(timeoutId);
166
- logMessage(mcpServer, "error", `Network error during search request: ${error.message}`, { query, url: url.toString() });
167
- const context = {
168
- url: url.toString(),
169
- searxngUrl,
170
- proxyAgent: !!dispatcher,
171
- username
172
- };
173
- throw createNetworkError(error, context);
174
- }
175
- clearTimeout(timeoutId);
379
+ response = await fetchWithSearchTimeout(mcpServer, url, requestOptions, SEARCH_TIMEOUT_MS, query, searxngUrl);
380
+ let data;
176
381
  if (!response.ok) {
177
- let responseBody;
178
- try {
179
- responseBody = await response.text();
382
+ if (isHtmlFallbackEnabled() && shouldFallbackForStatus(response.status)) {
383
+ data = await fetchHtmlFallbackSearch(mcpServer, url, requestOptions, SEARCH_TIMEOUT_MS, query, searxngUrl);
180
384
  }
181
- catch {
182
- responseBody = '[Could not read response body]';
385
+ else {
386
+ let responseBody;
387
+ try {
388
+ responseBody = await response.text();
389
+ }
390
+ catch {
391
+ responseBody = '[Could not read response body]';
392
+ }
393
+ const context = {
394
+ url: url.toString(),
395
+ searxngUrl
396
+ };
397
+ throw createServerError(response.status, response.statusText, responseBody, context);
183
398
  }
184
- const context = {
185
- url: url.toString(),
186
- searxngUrl
187
- };
188
- throw createServerError(response.status, response.statusText, responseBody, context);
189
- }
190
- // Parse JSON response
191
- let data;
192
- try {
193
- data = (await response.json());
194
399
  }
195
- catch (error) {
196
- let responseText;
400
+ else {
401
+ // Parse JSON response
197
402
  try {
198
- responseText = await response.text();
403
+ data = (await response.json());
199
404
  }
200
- catch {
201
- responseText = '[Could not read response text]';
405
+ catch (error) {
406
+ if (isHtmlFallbackEnabled()) {
407
+ data = await fetchHtmlFallbackSearch(mcpServer, url, requestOptions, SEARCH_TIMEOUT_MS, query, searxngUrl);
408
+ }
409
+ else {
410
+ let responseText;
411
+ try {
412
+ responseText = await response.text();
413
+ }
414
+ catch {
415
+ responseText = '[Could not read response text]';
416
+ }
417
+ const context = { url: url.toString() };
418
+ throw createJSONError(responseText, context);
419
+ }
202
420
  }
203
- const context = { url: url.toString() };
204
- throw createJSONError(responseText, context);
205
421
  }
206
422
  if (!data.results) {
207
423
  const context = { url: url.toString(), query };
@@ -213,9 +429,18 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
213
429
  ? results.slice(0, effectiveMax)
214
430
  : results;
215
431
  if (response_format === "json") {
216
- return JSON.stringify({ ...data, results: slicedResults }, null, 2);
432
+ return JSON.stringify({
433
+ ...data,
434
+ results: slicedResults,
435
+ ...(filters.validationWarning ? { warnings: [filters.validationWarning] } : {}),
436
+ }, null, 2);
217
437
  }
218
438
  const metadata = formatSearchMetadata(data);
439
+ const leadingSections = [
440
+ filters.validationNote ?? null,
441
+ data.sourceFormat === "html" ? "Note: Results parsed from SearXNG HTML fallback; metadata is limited." : null,
442
+ metadata || null,
443
+ ].filter(Boolean).join("\n\n");
219
444
  if (slicedResults.length === 0) {
220
445
  const appliedFilters = [
221
446
  min_score === undefined ? null : `min_score=${min_score}`,
@@ -224,15 +449,22 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
224
449
  const filterNote = appliedFilters ? ` after applying ${appliedFilters}` : "";
225
450
  logMessage(mcpServer, "info", `No results found for query: "${query}"${filterNote}`);
226
451
  const noResultsMessage = createNoResultsMessage(query);
227
- return metadata ? `${metadata}\n\n---\n\n${noResultsMessage}` : noResultsMessage;
452
+ return leadingSections ? `${leadingSections}\n\n---\n\n${noResultsMessage}` : noResultsMessage;
228
453
  }
229
454
  const duration = Date.now() - startTime;
230
455
  logMessage(mcpServer, "info", `Search completed: "${query}" (${searchParams}) - ${slicedResults.length} results in ${duration}ms`);
231
456
  const formattedResults = slicedResults
232
457
  .map((r) => {
233
- const score = r.score || 0;
234
- return `Title: ${r.title || ""}\nDescription: ${truncateResultContent(r.content || "", maxResultChars)}\nURL: ${r.url || ""}\nRelevance Score: ${score.toFixed(3)}`;
458
+ const lines = [
459
+ `Title: ${r.title || ""}`,
460
+ `Description: ${truncateResultContent(r.content || "", maxResultChars)}`,
461
+ `URL: ${r.url || ""}`,
462
+ ];
463
+ if (r.score !== undefined) {
464
+ lines.push(`Relevance Score: ${r.score.toFixed(3)}`);
465
+ }
466
+ return lines.join("\n");
235
467
  })
236
468
  .join("\n\n");
237
- return metadata ? `${metadata}\n\n---\n\n${formattedResults}` : formattedResults;
469
+ return leadingSections ? `${leadingSections}\n\n---\n\n${formattedResults}` : formattedResults;
238
470
  }
package/dist/types.d.ts CHANGED
@@ -3,7 +3,7 @@ export interface SearXNGWebResult {
3
3
  title: string;
4
4
  content: string;
5
5
  url: string;
6
- score: number;
6
+ score?: number;
7
7
  engine?: string;
8
8
  engines?: string[];
9
9
  category?: string;
@@ -23,6 +23,7 @@ export interface SearXNGWeb {
23
23
  query: string;
24
24
  number_of_results: number;
25
25
  results: SearXNGWebResult[];
26
+ sourceFormat?: "json" | "html";
26
27
  suggestions?: string[];
27
28
  corrections?: string[];
28
29
  answers?: string[];
@@ -38,6 +39,7 @@ export declare function isSearXNGWebSearchArgs(args: unknown): args is {
38
39
  min_score?: number;
39
40
  num_results?: number;
40
41
  categories?: string;
42
+ engines?: string;
41
43
  response_format?: "text" | "json";
42
44
  };
43
45
  export declare function isSearXNGSearchSuggestionsArgs(args: unknown): args is {
package/dist/types.js CHANGED
@@ -41,6 +41,9 @@ export function isSearXNGWebSearchArgs(args) {
41
41
  if (searchArgs.categories !== undefined && typeof searchArgs.categories !== "string") {
42
42
  return false;
43
43
  }
44
+ if (searchArgs.engines !== undefined && typeof searchArgs.engines !== "string") {
45
+ return false;
46
+ }
44
47
  if (searchArgs.response_format !== undefined &&
45
48
  (typeof searchArgs.response_format !== "string" || !VALID_RESPONSE_FORMATS.includes(searchArgs.response_format))) {
46
49
  return false;
@@ -132,7 +135,11 @@ export const WEB_SEARCH_TOOL = {
132
135
  },
133
136
  categories: {
134
137
  type: "string",
135
- description: "Comma-separated SearXNG categories. Supported: general, news, images, videos, it, science, files, social media. Default: general (SearXNG instance default).",
138
+ description: "Comma-separated SearXNG categories. Values are normalized case-insensitively to canonical names from live /config; unknown values are rejected with available categories listed. If /config is unavailable, values are forwarded as-is with a warning.",
139
+ },
140
+ engines: {
141
+ type: "string",
142
+ description: "Comma-separated SearXNG engine names to query (e.g. 'google,bing,ddg'). Values are normalized case-insensitively to canonical names from live /config; unknown values are rejected with available engines listed. If /config is unavailable, values are forwarded as-is with a warning.",
136
143
  },
137
144
  response_format: {
138
145
  type: "string",
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const packageVersion = "1.5.0";
1
+ export declare const packageVersion = "1.7.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "1.5.0";
1
+ export const packageVersion = "1.7.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-searxng",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "mcpName": "io.github.ihor-sokoliuk/mcp-searxng",
5
5
  "description": "MCP server for SearXNG integration",
6
6
  "license": "MIT",
@@ -55,21 +55,25 @@
55
55
  "express": "^5.2.1",
56
56
  "express-rate-limit": "^8.5.2",
57
57
  "node-html-markdown": "^2.0.0",
58
- "undici": "7.27.2"
58
+ "node-html-parser": "^6.1.13",
59
+ "undici": "7.28.0"
59
60
  },
60
61
  "devDependencies": {
61
- "@types/node": "22.19.20",
62
+ "@types/node": "22.19.21",
62
63
  "@types/supertest": "^7.2.0",
63
- "@typescript-eslint/eslint-plugin": "8.61.0",
64
- "@typescript-eslint/parser": "8.61.0",
64
+ "@typescript-eslint/eslint-plugin": "8.61.1",
65
+ "@typescript-eslint/parser": "8.61.1",
65
66
  "c8": "^11.0.0",
66
67
  "cross-env": "^10.1.0",
67
- "eslint": "10.4.1",
68
+ "eslint": "10.5.0",
68
69
  "eslint-plugin-security": "^4.0.0",
69
70
  "fast-check": "^4.8.0",
70
71
  "shx": "^0.4.0",
71
72
  "supertest": "^7.2.2",
72
73
  "tsx": "4.22.4",
73
74
  "typescript": "^5.8.3"
75
+ },
76
+ "overrides": {
77
+ "hono": ">=4.12.25"
74
78
  }
75
79
  }