mcp-searxng 1.5.0 → 1.6.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**
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,4 @@
1
+ import { getKnownCategories, getKnownEngines } from "./instance-info.js";
1
2
  import { createProxyAgent, createDefaultAgent, ProxyType } from "./proxy.js";
2
3
  import { logMessage } from "./logging.js";
3
4
  import { MCPSearXNGError, validateEnvironment, createNetworkError, createServerError, createJSONError, createDataError, createNoResultsMessage } from "./error-handler.js";
@@ -34,6 +35,127 @@ function truncateResultContent(content, maxResultChars) {
34
35
  function hasItems(items) {
35
36
  return Array.isArray(items) && items.length > 0;
36
37
  }
38
+ function splitCommaSeparated(value) {
39
+ return value
40
+ .split(",")
41
+ .map((entry) => entry.trim())
42
+ .filter((entry) => entry !== "");
43
+ }
44
+ function buildCanonicalLookup(knownValues) {
45
+ const lookup = new Map();
46
+ for (const value of knownValues) {
47
+ lookup.set(value.trim().toLowerCase(), value);
48
+ }
49
+ return lookup;
50
+ }
51
+ function normalizeCommaSeparated(value, knownValues) {
52
+ const lookup = buildCanonicalLookup(knownValues);
53
+ const normalized = [];
54
+ const invalid = [];
55
+ for (const requested of splitCommaSeparated(value)) {
56
+ const canonical = lookup.get(requested.toLowerCase());
57
+ if (canonical === undefined) {
58
+ invalid.push(requested);
59
+ }
60
+ else {
61
+ normalized.push(canonical);
62
+ }
63
+ }
64
+ return {
65
+ normalized: normalized.join(","),
66
+ invalid,
67
+ };
68
+ }
69
+ function formatAvailableValues(label, values) {
70
+ const available = [...values].sort().join(", ");
71
+ return available === "" ? "" : ` Available ${label}: ${available}.`;
72
+ }
73
+ function createValidationError(kind, invalid, available) {
74
+ const label = kind === "category" ? "categories" : "engines";
75
+ return new MCPSearXNGError(`🔍 Invalid SearXNG ${kind} name(s): ${invalid.join(", ")}.` +
76
+ formatAvailableValues(label, available) +
77
+ ` Use the searxng_instance_info tool to discover available ${label}.`);
78
+ }
79
+ async function normalizeSearchFilters(mcpServer, categories, engines) {
80
+ const effectiveCategories = categories !== undefined && categories.trim() !== "" ? categories : undefined;
81
+ const effectiveEngines = engines !== undefined && engines.trim() !== "" ? engines : undefined;
82
+ if (!effectiveCategories && !effectiveEngines) {
83
+ return {};
84
+ }
85
+ const unavailableFilterLabel = effectiveCategories && effectiveEngines
86
+ ? "categories and engines"
87
+ : effectiveCategories
88
+ ? "categories"
89
+ : "engines";
90
+ const unavailableWarning = `${unavailableFilterLabel[0].toUpperCase()}${unavailableFilterLabel.slice(1)} were not validated or normalized because SearXNG /config is unavailable.`;
91
+ const unavailableNote = `Note: ${unavailableFilterLabel} were not validated or normalized (SearXNG /config unavailable).`;
92
+ let knownCategories;
93
+ let knownEngines;
94
+ if (effectiveCategories) {
95
+ knownCategories = await getKnownCategories(mcpServer);
96
+ if (knownCategories === null) {
97
+ return {
98
+ categories: effectiveCategories,
99
+ engines: effectiveEngines,
100
+ validationWarning: unavailableWarning,
101
+ validationNote: unavailableNote,
102
+ };
103
+ }
104
+ }
105
+ if (effectiveEngines) {
106
+ knownEngines = await getKnownEngines(mcpServer);
107
+ if (knownEngines === null) {
108
+ return {
109
+ categories: effectiveCategories,
110
+ engines: effectiveEngines,
111
+ validationWarning: unavailableWarning,
112
+ validationNote: unavailableNote,
113
+ };
114
+ }
115
+ }
116
+ let normalizedCategories = effectiveCategories && knownCategories
117
+ ? normalizeCommaSeparated(effectiveCategories, knownCategories)
118
+ : undefined;
119
+ let normalizedEngines = effectiveEngines && knownEngines
120
+ ? normalizeCommaSeparated(effectiveEngines, knownEngines)
121
+ : undefined;
122
+ if ((normalizedCategories && normalizedCategories.invalid.length > 0) ||
123
+ (normalizedEngines && normalizedEngines.invalid.length > 0)) {
124
+ if (effectiveCategories) {
125
+ knownCategories = await getKnownCategories(mcpServer, true);
126
+ knownEngines = effectiveEngines && knownCategories !== null
127
+ ? await getKnownEngines(mcpServer)
128
+ : knownEngines;
129
+ }
130
+ else if (effectiveEngines) {
131
+ knownEngines = await getKnownEngines(mcpServer, true);
132
+ }
133
+ if (knownCategories === null || knownEngines === null) {
134
+ return {
135
+ categories: effectiveCategories,
136
+ engines: effectiveEngines,
137
+ validationWarning: unavailableWarning,
138
+ validationNote: unavailableNote,
139
+ };
140
+ }
141
+ normalizedCategories = effectiveCategories && knownCategories
142
+ ? normalizeCommaSeparated(effectiveCategories, knownCategories)
143
+ : undefined;
144
+ normalizedEngines = effectiveEngines && knownEngines
145
+ ? normalizeCommaSeparated(effectiveEngines, knownEngines)
146
+ : undefined;
147
+ }
148
+ if (normalizedCategories && normalizedCategories.invalid.length > 0 && knownCategories) {
149
+ throw createValidationError("category", normalizedCategories.invalid, knownCategories);
150
+ }
151
+ if (normalizedEngines && normalizedEngines.invalid.length > 0 && knownEngines) {
152
+ throw createValidationError("engine", normalizedEngines.invalid, knownEngines);
153
+ }
154
+ return {
155
+ categories: normalizedCategories ? normalizedCategories.normalized : undefined,
156
+ engines: normalizedEngines ? normalizedEngines.normalized : undefined,
157
+ };
158
+ }
37
159
  function formatSearchMetadata(data) {
38
160
  const sections = [];
39
161
  if (hasItems(data.answers)) {
@@ -77,7 +199,7 @@ function getDefaultSafesearch(mcpServer) {
77
199
  }
78
200
  return parsed;
79
201
  }
80
- export async function performWebSearch(mcpServer, query, pageno = 1, time_range, language, safesearch, min_score, num_results, categories, response_format = "text") {
202
+ export async function performWebSearch(mcpServer, query, pageno = 1, time_range, language, safesearch, min_score, num_results, categories, engines, response_format = "text") {
81
203
  const startTime = Date.now();
82
204
  const operatorMax = getOperatorMaxResults(mcpServer);
83
205
  const effectiveMax = operatorMax !== undefined
@@ -86,6 +208,12 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
86
208
  const maxResultChars = getMaxResultChars(mcpServer);
87
209
  const effectiveLanguage = language ?? getDefaultLanguage();
88
210
  const effectiveSafesearch = safesearch !== undefined ? safesearch : getDefaultSafesearch(mcpServer);
211
+ const validationError = validateEnvironment();
212
+ if (validationError) {
213
+ logMessage(mcpServer, "error", "Configuration invalid");
214
+ throw new MCPSearXNGError(validationError);
215
+ }
216
+ const filters = await normalizeSearchFilters(mcpServer, categories, engines);
89
217
  // Build detailed log message with all parameters
90
218
  const searchParams = [
91
219
  `page ${pageno}`,
@@ -94,14 +222,10 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
94
222
  effectiveSafesearch !== undefined ? `safesearch: ${effectiveSafesearch}` : null,
95
223
  min_score !== undefined ? `min_score: ${min_score}` : null,
96
224
  effectiveMax !== undefined ? `num_results: ${effectiveMax}` : null,
97
- categories ? `categories: ${categories}` : null,
225
+ filters.categories ? `categories: ${filters.categories}` : null,
226
+ filters.engines ? `engines: ${filters.engines}` : null,
98
227
  ].filter(Boolean).join(", ");
99
228
  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
229
  const searxngUrl = process.env.SEARXNG_URL;
106
230
  const parsedUrl = new URL(searxngUrl.endsWith('/') ? searxngUrl : searxngUrl + '/');
107
231
  const url = new URL('search', parsedUrl);
@@ -118,8 +242,11 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
118
242
  if (effectiveSafesearch !== undefined && [0, 1, 2].includes(effectiveSafesearch)) {
119
243
  url.searchParams.set("safesearch", effectiveSafesearch.toString());
120
244
  }
121
- if (categories) {
122
- url.searchParams.set("categories", categories);
245
+ if (filters.categories) {
246
+ url.searchParams.set("categories", filters.categories);
247
+ }
248
+ if (filters.engines) {
249
+ url.searchParams.set("engines", filters.engines);
123
250
  }
124
251
  // Prepare request options with headers
125
252
  const requestOptions = {
@@ -213,9 +340,17 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
213
340
  ? results.slice(0, effectiveMax)
214
341
  : results;
215
342
  if (response_format === "json") {
216
- return JSON.stringify({ ...data, results: slicedResults }, null, 2);
343
+ return JSON.stringify({
344
+ ...data,
345
+ results: slicedResults,
346
+ ...(filters.validationWarning ? { warnings: [filters.validationWarning] } : {}),
347
+ }, null, 2);
217
348
  }
218
349
  const metadata = formatSearchMetadata(data);
350
+ const leadingSections = [
351
+ filters.validationNote ?? null,
352
+ metadata || null,
353
+ ].filter(Boolean).join("\n\n");
219
354
  if (slicedResults.length === 0) {
220
355
  const appliedFilters = [
221
356
  min_score === undefined ? null : `min_score=${min_score}`,
@@ -224,7 +359,7 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
224
359
  const filterNote = appliedFilters ? ` after applying ${appliedFilters}` : "";
225
360
  logMessage(mcpServer, "info", `No results found for query: "${query}"${filterNote}`);
226
361
  const noResultsMessage = createNoResultsMessage(query);
227
- return metadata ? `${metadata}\n\n---\n\n${noResultsMessage}` : noResultsMessage;
362
+ return leadingSections ? `${leadingSections}\n\n---\n\n${noResultsMessage}` : noResultsMessage;
228
363
  }
229
364
  const duration = Date.now() - startTime;
230
365
  logMessage(mcpServer, "info", `Search completed: "${query}" (${searchParams}) - ${slicedResults.length} results in ${duration}ms`);
@@ -234,5 +369,5 @@ export async function performWebSearch(mcpServer, query, pageno = 1, time_range,
234
369
  return `Title: ${r.title || ""}\nDescription: ${truncateResultContent(r.content || "", maxResultChars)}\nURL: ${r.url || ""}\nRelevance Score: ${score.toFixed(3)}`;
235
370
  })
236
371
  .join("\n\n");
237
- return metadata ? `${metadata}\n\n---\n\n${formattedResults}` : formattedResults;
372
+ return leadingSections ? `${leadingSections}\n\n---\n\n${formattedResults}` : formattedResults;
238
373
  }
package/dist/types.d.ts CHANGED
@@ -38,6 +38,7 @@ export declare function isSearXNGWebSearchArgs(args: unknown): args is {
38
38
  min_score?: number;
39
39
  num_results?: number;
40
40
  categories?: string;
41
+ engines?: string;
41
42
  response_format?: "text" | "json";
42
43
  };
43
44
  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.6.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const packageVersion = "1.5.0";
1
+ export const packageVersion = "1.6.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-searxng",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "mcpName": "io.github.ihor-sokoliuk/mcp-searxng",
5
5
  "description": "MCP server for SearXNG integration",
6
6
  "license": "MIT",
@@ -71,5 +71,8 @@
71
71
  "supertest": "^7.2.2",
72
72
  "tsx": "4.22.4",
73
73
  "typescript": "^5.8.3"
74
+ },
75
+ "overrides": {
76
+ "hono": ">=4.12.25"
74
77
  }
75
78
  }