Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ For SearXNG deployment, configuration, and troubleshooting, see
- `min_score` (number, optional): Minimum relevance score from 0.0 to 1.0. Results below this score are filtered out.
- `num_results` (number, optional): Maximum number of results to return, from 1 to 20. `SEARXNG_MAX_RESULTS` applies as an operator ceiling.
- `categories` (string, optional): Comma-separated SearXNG categories (e.g. `"news"`, `"it,science"`). Live `/config` capabilities are aggregated across reachable instances; prefer `searxng_instance_info` `categories.common` for consistent multi-instance results. Known values are trimmed and normalized case-insensitively; unknown values are forwarded trimmed so SearXNG can ignore or honor them. If `/config` is unavailable, values are forwarded as-is with a warning. If omitted, each instance uses its server-side default.
- `engines` (string, optional): Comma-separated SearXNG engine names (e.g. `"google,bing,ddg"`, `"semantic scholar"`). Live `/config` capabilities are aggregated across reachable instances; prefer `searxng_instance_info` `engines.common.enabled` for consistent multi-instance results. Known values are trimmed and normalized case-insensitively, including engines disabled by default; unknown values are forwarded trimmed so SearXNG can ignore or honor them. If `/config` is unavailable, values are forwarded as-is with a warning. If omitted, each instance uses its server-side default.
- `engines` (string, optional): Comma-separated SearXNG engine names (e.g. `"google,bing,ddg"`, `"semantic scholar"`). Live `/config` capabilities are aggregated across reachable instances; prefer `searxng_instance_info` `engines.common.enabled` for consistent multi-instance results. Known values are trimmed and normalized case-insensitively, including engines disabled by default; unknown values are forwarded trimmed so SearXNG can ignore or honor them. If `/config` is unavailable, values are forwarded as-is with a warning, except when combined with `time_range`.
- When `engines` and `time_range` are both provided, every configured SearXNG instance must return `/config` successfully and every selected engine must explicitly report `time_range_support=true`. If any instance is unreachable or any engine is unsupported or unknown, the request fails before `/search` to avoid a misleading empty result. Omit `time_range` or use an engine-specific query filter instead.
- `response_format` (string, optional): Response format, either `"text"` for formatted agent-readable output or `"json"` for raw SearXNG JSON with filtered/sliced `results`. If omitted, `SEARXNG_DEFAULT_RESPONSE_FORMAT` applies; if unset or invalid, `text` is used. An explicit `response_format` always takes precedence.
- `result_detail` (string, optional): `"full"` (the default) preserves SearXNG metadata, warnings, provenance, answers, infoboxes, corrections, and suggestions. `"compact"` returns only title, URL, and the description/content snippet for every result; compact JSON uses exactly the `title`, `url`, and `content` keys. Use full when those research signals matter.
- Clients that explicitly send or auto-inject `response_format=text` continue to override the operator default. If omitted calls still return text after configuring JSON, inspect the arguments emitted by the MCP client.
Expand Down
49 changes: 45 additions & 4 deletions __tests__/unit/instance-info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { strict as assert } from 'node:assert';
import { fileURLToPath } from 'node:url';
import { fetchInstanceInfo, clearInstanceInfoCacheForTests } from '../../src/instance-info.js';
import { fetchInstanceInfo, getEngineTimeRangeSupport, clearInstanceInfoCacheForTests } from '../../src/instance-info.js';
import { testFunction, createTestResults, printTestSummary } from '../helpers/test-utils.js';
import { createMockServer, createMockServerWithTracking } from '../helpers/mock-server.js';
import { FetchMocker, createMockFetch, createCapturingMockFetch } from '../helpers/mock-fetch.js';
Expand All @@ -34,9 +34,9 @@ function makeConfig() {
},
},
engines: [
{ name: 'google', categories: ['general'], disabled: false },
{ name: 'bing', categories: ['general'], disabled: true },
{ name: 'brave', categories: ['news'], disabled: false },
{ name: 'google', categories: ['general'], disabled: false, time_range_support: true },
{ name: 'bing', categories: ['general'], disabled: true, time_range_support: true },
{ name: 'brave', categories: ['news'], disabled: false, time_range_support: false },
],
default_locale: 'en',
locales: { en: 'English', fr: 'French' },
Expand Down Expand Up @@ -119,6 +119,47 @@ async function runTests() {
envManager.restore();
}, results);

await testFunction('resolves engine time-range support from cached /config', async () => {
clearInstanceInfoCacheForTests();
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');
const mockServer = createMockServer();
fetchMocker.mock(createMockFetch({ json: makeConfig() }));

const support = await getEngineTimeRangeSupport(mockServer as any, ['google', 'brave', 'missing']);

assert.deepEqual(support, {
supported: ['google'],
unsupported: ['brave'],
unknown: ['missing'],
});

fetchMocker.restore();
envManager.restore();
}, results);

await testFunction('fails closed when any configured instance /config is unavailable', async () => {
clearInstanceInfoCacheForTests();
envManager.set('SEARXNG_URL', 'https://up.example.com;https://down.example.com');
const mockServer = createMockServer();
fetchMocker.mock(async (url, options) => {
if (new URL(url.toString()).origin === 'https://down.example.com') {
throw new Error('temporary outage');
}
return createMockFetch({ json: makeConfig() })(url, options);
});

const support = await getEngineTimeRangeSupport(mockServer as any, ['google', 'brave']);

assert.deepEqual(support, {
supported: [],
unsupported: [],
unknown: ['google', 'brave'],
});

fetchMocker.restore();
envManager.restore();
}, results);

await testFunction('returns category names when /config categories is an array of strings', async () => {
clearInstanceInfoCacheForTests();
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');
Expand Down
116 changes: 112 additions & 4 deletions __tests__/unit/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ function makeConfigWithEngines() {
return {
categories: ['general', 'news', 'social media'],
engines: [
{ name: 'google', disabled: false },
{ name: 'ddg', disabled: false },
{ name: 'bing', disabled: true },
{ name: 'semantic scholar', disabled: false },
{ name: 'google', disabled: false, time_range_support: true },
{ name: 'ddg', disabled: false, time_range_support: true },
{ name: 'github', disabled: false, time_range_support: false },
{ name: 'bing', disabled: true, time_range_support: true },
{ name: 'semantic scholar', disabled: false, time_range_support: false },
],
};
}
Expand Down Expand Up @@ -1621,6 +1622,113 @@ async function runTests() {
envManager.restore();
}, results);

await testFunction('explicit engine with unsupported time_range is rejected before search', async () => {
clearInstanceInfoCacheForTests();
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');

const mockServer = createMockServer();
const requestedUrls: string[] = [];
fetchMocker.mock(async (url) => {
requestedUrls.push(url.toString());
return createMockFetch({ json: makeConfigWithEngines() })(url);
});

try {
await performWebSearch(mockServer as any, 'AI', 1, 'year', undefined, undefined, undefined, undefined, undefined, 'github');
assert.fail('Expected incompatible time_range to be rejected');
} catch (error: any) {
assert.ok(error.message.includes('github'), error.message);
assert.ok(error.message.includes('time_range=year'), error.message);
assert.ok(error.message.includes('misleading empty result'), error.message);
}

assert.equal(requestedUrls.length, 1);
assert.ok(new URL(requestedUrls[0]).pathname.endsWith('/config'));

fetchMocker.restore();
envManager.restore();
}, results);

await testFunction('explicit engine with supported time_range reaches search unchanged', async () => {
clearInstanceInfoCacheForTests();
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');

const mockServer = createMockServer();
const requestedUrls: string[] = [];
fetchMocker.mock(async (url) => {
requestedUrls.push(url.toString());
const parsedUrl = new URL(url.toString());
return createMockFetch({
json: parsedUrl.pathname.endsWith('/config') ? makeConfigWithEngines() : { results: [] },
})(url);
});

await performWebSearch(mockServer as any, 'AI', 1, 'year', undefined, undefined, undefined, undefined, undefined, 'google');

assert.equal(requestedUrls.length, 2);
const searchUrl = new URL(requestedUrls[1]);
assert.equal(searchUrl.searchParams.get('engines'), 'google');
assert.equal(searchUrl.searchParams.get('time_range'), 'year');

fetchMocker.restore();
envManager.restore();
}, results);

await testFunction('unavailable /config rejects explicit engine plus time_range without searching', async () => {
clearInstanceInfoCacheForTests();
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');

const mockServer = createMockServer();
const requestedUrls: string[] = [];
fetchMocker.mock(async (url) => {
requestedUrls.push(url.toString());
return createMockFetch({ ok: false, status: 403, statusText: 'Forbidden' })(url);
});

try {
await performWebSearch(mockServer as any, 'AI', 1, 'year', undefined, undefined, undefined, undefined, undefined, 'github');
assert.fail('Expected unavailable capability discovery to stop the search');
} catch (error: any) {
assert.ok(error.message.includes('/config is unavailable'), error.message);
assert.ok(error.message.includes('omit time_range') || error.message.includes('Omit time_range'), error.message);
}

assert.equal(requestedUrls.length, 1);
assert.ok(new URL(requestedUrls[0]).pathname.endsWith('/config'));

fetchMocker.restore();
envManager.restore();
}, results);

await testFunction('partial multi-instance /config failure rejects time_range before search', async () => {
clearInstanceInfoCacheForTests();
envManager.set('SEARXNG_URL', 'https://up.example.com;https://down.example.com');

const mockServer = createMockServer();
const requestedUrls: string[] = [];
fetchMocker.mock(async (url, options) => {
requestedUrls.push(url.toString());
if (new URL(url.toString()).origin === 'https://down.example.com') {
throw new Error('temporary outage');
}
return createMockFetch({ json: makeConfigWithEngines() })(url, options);
});

try {
await performWebSearch(mockServer as any, 'AI', 1, 'year', undefined, undefined, undefined, undefined, undefined, 'google');
assert.fail('Expected partial capability discovery failure to reject the search');
} catch (error: any) {
assert.ok(error.message.includes('google'), error.message);
assert.ok(error.message.includes('time_range=year'), error.message);
}

assert.equal(requestedUrls.length, 2);
assert.ok(requestedUrls.every((requestedUrl) => new URL(requestedUrl).pathname.endsWith('/config')));

fetchMocker.restore();
envManager.restore();
}, results);

await testFunction('mixed-case engines and categories normalize to canonical /config names', async () => {
clearInstanceInfoCacheForTests();
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');
Expand Down
2 changes: 2 additions & 0 deletions __tests__/unit/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ async function runTests() {
await testFunction('WEB_SEARCH_TOOL schema includes week, min_score, and num_results', () => {
const properties = WEB_SEARCH_TOOL.inputSchema.properties as Record<string, any>;
assert.ok(properties.time_range.enum.includes('week'));
assert.ok(properties.time_range.description.includes('time_range_support=true'));
assert.ok(WEB_SEARCH_TOOL.description.includes('request is rejected before search'));
assert.equal(properties.safesearch.type, 'string');
assert.deepEqual(properties.safesearch.enum, ['0', '1', '2']);
assert.equal(properties.safesearch.default, undefined);
Expand Down
48 changes: 48 additions & 0 deletions src/instance-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ type AggregateConfigResult =
| { available: true; configs: ReachableConfig[]; failures: ConfigFailure[] }
| { available: false; message: string; failures: ConfigFailure[] };
type CachedConfigFailure = { until: number; message: string; status?: number };
export type EngineTimeRangeSupport = {
supported: string[];
unsupported: string[];
unknown: string[];
};

const CONFIG_FAILURE_CACHE_TTL_MS = 60_000;
const cachedConfigs = new Map<string, SearXNGConfig>();
Expand Down Expand Up @@ -396,6 +401,49 @@ export async function getKnownCategories(mcpServer: McpServer, refresh = false):
return getAggregatedCapability(mcpServer, refresh, (config) => new Set(namesFromCategories(config)));
}

export async function getEngineTimeRangeSupport(
mcpServer: McpServer,
requestedEngines: string[],
): Promise<EngineTimeRangeSupport | null> {
const result = await fetchConfigs(mcpServer);
if (!result.available) {
return null;
}

if (result.failures.length > 0) {
return {
supported: [],
unsupported: [],
unknown: [...requestedEngines],
};
}

const supported: string[] = [];
const unsupported: string[] = [];
const unknown: string[] = [];

for (const name of requestedEngines) {
const values = result.configs.map(({ config }) => {
const engine = Array.isArray(config.engines)
? config.engines.find((entry: any) => entry?.name === name)
: undefined;
return typeof engine?.time_range_support === "boolean"
? engine.time_range_support
: undefined;
});

if (values.every((value) => value === true)) {
supported.push(name);
} else if (values.some((value) => value === false)) {
unsupported.push(name);
} else {
unknown.push(name);
}
}

return { supported, unsupported, unknown };
}

export async function fetchInstanceInfo(
mcpServer: McpServer,
includeEngines = false,
Expand Down
31 changes: 30 additions & 1 deletion src/search.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { McpServer } from "@modelcontextprotocol/server";
import { parse } from "node-html-parser";
import { SearXNGWeb, type ResultDetail } from "./types.js";
import { getKnownCategories, getKnownEngines } from "./instance-info.js";
import { getEngineTimeRangeSupport, getKnownCategories, getKnownEngines } from "./instance-info.js";
import { applySearchRequestConfig, fetchSearxng } from "./proxy.js";
import { logMessage } from "./logging.js";
import { searchCache } from "./search-cache.js";
Expand Down Expand Up @@ -349,6 +349,34 @@ type FailedInstanceResult = {
type ResponseFormat = "text" | "json";
const warnedInvalidDefaultResponseFormat = new WeakSet<McpServer>();

async function validateTimeRangeSupport(
mcpServer: McpServer,
engines: string | undefined,
timeRange: string | undefined,
): Promise<void> {
if (!engines || !timeRange) {
return;
}

const requestedEngines = splitCommaSeparated(engines);
const support = await getEngineTimeRangeSupport(mcpServer, requestedEngines);
if (support === null) {
throw new MCPSearXNGError(
`Cannot verify whether the selected engines support time_range=${timeRange} because SearXNG /config is unavailable. ` +
"Retry after capability discovery is available, or omit time_range."
);
}

const incompatible = [...support.unsupported, ...support.unknown];
if (incompatible.length > 0) {
throw new MCPSearXNGError(
`The selected ${incompatible.length === 1 ? "engine does" : "engines do"} not confirm support for ` +
`time_range=${timeRange}: ${incompatible.join(", ")}. SearXNG skips incompatible engines and can return a misleading empty result. ` +
"Omit time_range or use an engine-specific query filter; this adapter will not silently change the requested filter."
);
}
}

async function normalizeSearchFilters(
mcpServer: McpServer,
categories?: string,
Expand Down Expand Up @@ -841,6 +869,7 @@ export async function performWebSearch(
}

const filters = await normalizeSearchFilters(mcpServer, categories, engines);
await validateTimeRangeSupport(mcpServer, filters.engines, time_range);

// Build detailed log message with all parameters
const searchParams = [
Expand Down
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export const WEB_SEARCH_TOOL: Tool = {
"CRITICAL: The required parameter name is exactly `query` (not `prompt`, `q`, or any other name). " +
"Calls an external SearXNG instance; availability depends on the `SEARXNG_URL` configuration. " +
"Use `pageno` to paginate results; combine `time_range` and `language` to narrow scope. " +
"When `engines` and `time_range` are both provided, every selected engine must explicitly advertise time-range support via SearXNG /config; otherwise the request is rejected before search. " +
"To read the full text of a result URL, follow up with `web_url_read`.",
annotations: {
readOnlyHint: true,
Expand All @@ -217,7 +218,7 @@ export const WEB_SEARCH_TOOL: Tool = {
},
time_range: {
type: "string",
description: "Time range of search (day, week, month, year)",
description: "Time range of search (day, week, month, year). With explicit engines, all selected engines must confirm time_range_support=true via /config.",
enum: ["day", "week", "month", "year"],
},
language: {
Expand Down