Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ For SearXNG deployment, configuration, and troubleshooting, see
- `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, 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.
- When SearXNG returns zero results and reports one or more `unresponsive_engines`, the request fails with an engine error naming each failed engine and its reason instead of returning the "No results found" message. An empty result set alongside failing engines cannot be read as "nothing matched", so the caller can retry, select different engines, or fall back. Rows that engines did return and that `min_score` or `num_results` then filtered away are a filtering outcome and still return the normal no-results message.
- `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
61 changes: 61 additions & 0 deletions __tests__/unit/error-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
createJSONError,
createDataError,
createNoResultsMessage,
createEngineFailureError,
createURLFormatError,
createContentError,
createConversionError,
Expand Down Expand Up @@ -125,6 +126,66 @@ async function runTests() {
assert.ok(warning.includes('Content Warning'));
}, results);

await testFunction('createEngineFailureError names every failed engine and its reason', () => {
const error = createEngineFailureError('ada lovelace', [
['startpage', 'Suspended: CAPTCHA'],
['brave', 'too many requests'],
]);

assert.ok(error instanceof MCPSearXNGError);
assert.ok(error.message.includes('SearXNG Engine Error'), error.message);
assert.ok(error.message.includes('"ada lovelace"'), error.message);
assert.ok(error.message.includes('startpage (Suspended: CAPTCHA)'), error.message);
assert.ok(error.message.includes('brave (too many requests)'), error.message);
assert.ok(!error.message.includes('No results found'), error.message);
}, results);

await testFunction('createEngineFailureError normalizes malformed engine entries', () => {
const error = createEngineFailureError('malformed', [
['yahoo', 'HTTP protocol\nerror'],
['duckduckgo', ''],
[undefined as any, undefined as any],
]);

assert.ok(
error.message.includes('yahoo (HTTP protocol error), duckduckgo, unknown engine'),
error.message,
);
assert.ok(!error.message.includes('\n'), error.message);
}, results);

await testFunction('createEngineFailureError survives entries that are not tuples', () => {
// unresponsive_engines is external data that is only cast to SearXNGWeb, so
// an entry may be anything. None of these may throw a raw TypeError.
const error = createEngineFailureError('untrusted', [
null,
['yahoo'],
[],
42,
['startpage', 'Suspended: CAPTCHA'],
] as any);

assert.ok(error instanceof MCPSearXNGError);
assert.ok(error.message.includes('startpage (Suspended: CAPTCHA)'), error.message);
assert.ok(error.message.includes('yahoo'), error.message);
assert.ok(error.message.includes('unknown engine'), error.message);
}, results);

await testFunction('createEngineFailureError reads a bare string entry as the engine name', () => {
// Indexing a string entry positionally would render "b (r)" instead.
const error = createEngineFailureError('bare string', ['brave'] as any);

assert.ok(error.message.includes('these engines failed: brave.'), error.message);
assert.ok(!error.message.includes('b (r)'), error.message);
}, results);

await testFunction('createEngineFailureError bounds an oversized failure reason', () => {
const error = createEngineFailureError('bounded', [['brave', 'x'.repeat(500)]]);

assert.ok(error.message.includes(`brave (${'x'.repeat(120)})`), 'reason should survive up to the cap');
assert.ok(!error.message.includes('x'.repeat(121)), 'reason should be truncated at the cap');
}, results);

await testFunction('createEmptyContentWarning includes the URL', () => {
const warning = createEmptyContentWarning('https://test.com');
// Exact-match the full message (not url.includes) β€” a substring URL check
Expand Down
Loading