Skip to content

Commit 4b1fb49

Browse files
ihor-sokoliukclaudecodex
committed
feat(search): validate and normalize categories/engines against live /config (FEAT-043)
Normalize comma-separated `categories` and `engines` case-insensitively to the SearXNG instance's canonical names from cached `/config`, so miscased input (e.g. `Google`, `Social Media`) no longer silently degrades to a default search. Unknown values are rejected with a helpful error listing available values; a stale cache is refreshed once before failing; when `/config` is unavailable the values are forwarded as-is with a non-fatal warning. Also fixes category extraction for instances whose `/config.categories` is an array of names. Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Codex <noreply@openai.com>
1 parent 9afcb26 commit 4b1fb49

8 files changed

Lines changed: 424 additions & 64 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ AI Assistant (e.g. Claude)
9191
- `safesearch` (number, optional): Safe search filter level (0: None, 1: Moderate, 2: Strict) (default: instance setting)
9292
- `min_score` (number, optional): Minimum relevance score from 0.0 to 1.0. Results below this score are filtered out.
9393
- `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`).
95-
- `engines` (string, optional): Comma-separated SearXNG engine names (e.g. `"google,bing,ddg"`). Names are matched exactly when live `/config` validation is available.
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.
9696
- `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"`)
9797

9898
- **searxng_search_suggestions**

__tests__/unit/instance-info.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,17 @@ function makeConfig() {
4747
return config;
4848
}
4949

50+
function makeConfigWithCategoryArray() {
51+
const config: any = makeConfig();
52+
config.categories = ['general', 'social media', 'science'];
53+
config.engines = [
54+
{ name: 'google', categories: ['general'], disabled: false },
55+
{ name: 'semantic scholar', categories: ['science'], disabled: false },
56+
{ name: 'mastodon', category: 'social media', disabled: false },
57+
];
58+
return config;
59+
}
60+
5061
async function runTests() {
5162
console.log('🧪 Testing: instance-info.ts\n');
5263

@@ -71,6 +82,38 @@ async function runTests() {
7182
envManager.restore();
7283
}, results);
7384

85+
await testFunction('returns category names when /config categories is an array of strings', async () => {
86+
clearInstanceInfoCacheForTests();
87+
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');
88+
const mockServer = createMockServer();
89+
fetchMocker.mock(createMockFetch({ json: makeConfigWithCategoryArray() }));
90+
91+
const result = await fetchInstanceInfo(mockServer as any, true, false);
92+
const payload = JSON.parse(result);
93+
94+
assert.equal(payload.available, true);
95+
assert.deepEqual(payload.categories, ['general', 'science', 'social media']);
96+
assert.deepEqual(payload.engines.enabled, ['google', 'mastodon', 'semantic scholar']);
97+
98+
fetchMocker.restore();
99+
envManager.restore();
100+
}, results);
101+
102+
await testFunction('category filter works when /config categories is an array of strings', async () => {
103+
clearInstanceInfoCacheForTests();
104+
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');
105+
const mockServer = createMockServer();
106+
fetchMocker.mock(createMockFetch({ json: makeConfigWithCategoryArray() }));
107+
108+
const result = JSON.parse(await fetchInstanceInfo(mockServer as any, true, false, 'social media'));
109+
110+
assert.deepEqual(result.categories, ['social media']);
111+
assert.deepEqual(result.engines.enabled, ['mastodon']);
112+
113+
fetchMocker.restore();
114+
envManager.restore();
115+
}, results);
116+
74117
await testFunction('second call returns cached result without fetching again', async () => {
75118
clearInstanceInfoCacheForTests();
76119
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');

__tests__/unit/search.test.ts

Lines changed: 154 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ function makeMockSearchResults(count: number) {
3030

3131
function makeConfigWithEngines() {
3232
return {
33+
categories: ['general', 'news', 'social media'],
3334
engines: [
3435
{ name: 'google', disabled: false },
3536
{ name: 'ddg', disabled: false },
3637
{ name: 'bing', disabled: true },
38+
{ name: 'semantic scholar', disabled: false },
3739
],
3840
};
3941
}
@@ -856,6 +858,43 @@ async function runTests() {
856858
envManager.restore();
857859
}, results);
858860

861+
await testFunction('mixed-case engines and categories normalize to canonical /config names', async () => {
862+
clearInstanceInfoCacheForTests();
863+
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');
864+
865+
const mockServer = createMockServer();
866+
const requestedUrls: string[] = [];
867+
868+
fetchMocker.mock(async (url) => {
869+
requestedUrls.push(url.toString());
870+
const parsedUrl = new URL(url.toString());
871+
if (parsedUrl.pathname.endsWith('/config')) {
872+
return createMockFetch({ json: makeConfigWithEngines() })(url);
873+
}
874+
return createMockFetch({ json: { results: [] } })(url);
875+
});
876+
877+
await performWebSearch(
878+
mockServer as any,
879+
'test query',
880+
1,
881+
undefined,
882+
undefined,
883+
undefined,
884+
undefined,
885+
undefined,
886+
' News , SOCIAL MEDIA ',
887+
' Google , Semantic Scholar ',
888+
);
889+
890+
const searchUrl = new URL(requestedUrls[1]);
891+
assert.equal(searchUrl.searchParams.get('categories'), 'news,social media');
892+
assert.equal(searchUrl.searchParams.get('engines'), 'google,semantic scholar');
893+
894+
fetchMocker.restore();
895+
envManager.restore();
896+
}, results);
897+
859898
await testFunction('invalid engine names from live /config throw helpful validation error', async () => {
860899
clearInstanceInfoCacheForTests();
861900
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');
@@ -885,7 +924,114 @@ async function runTests() {
885924
envManager.restore();
886925
}, results);
887926

888-
await testFunction('unavailable /config forwards engines and prepends text warning', async () => {
927+
await testFunction('unknown category from live /config throws validation error with available categories', async () => {
928+
clearInstanceInfoCacheForTests();
929+
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');
930+
931+
const mockServer = createMockServer();
932+
let searchCalled = false;
933+
934+
fetchMocker.mock(async (url) => {
935+
const parsedUrl = new URL(url.toString());
936+
if (parsedUrl.pathname.endsWith('/config')) {
937+
return createMockFetch({ json: makeConfigWithEngines() })(url);
938+
}
939+
searchCalled = true;
940+
return createMockFetch({ json: { results: [] } })(url);
941+
});
942+
943+
try {
944+
await performWebSearch(mockServer as any, 'test query', 1, undefined, undefined, undefined, undefined, undefined, 'unknown');
945+
assert.fail('Expected invalid category validation error');
946+
} catch (error: any) {
947+
assert.ok(error.message.includes('Invalid SearXNG category name(s): unknown'), error.message);
948+
assert.ok(error.message.includes('Available categories: general, news, social media'), error.message);
949+
assert.ok(error.message.includes('searxng_instance_info'), error.message);
950+
}
951+
assert.equal(searchCalled, false, 'Search should not run after validation failure');
952+
953+
fetchMocker.restore();
954+
envManager.restore();
955+
}, results);
956+
957+
await testFunction('unknown engine from live /config throws validation error with available engines', async () => {
958+
clearInstanceInfoCacheForTests();
959+
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');
960+
961+
const mockServer = createMockServer();
962+
let searchCalled = false;
963+
964+
fetchMocker.mock(async (url) => {
965+
const parsedUrl = new URL(url.toString());
966+
if (parsedUrl.pathname.endsWith('/config')) {
967+
return createMockFetch({ json: makeConfigWithEngines() })(url);
968+
}
969+
searchCalled = true;
970+
return createMockFetch({ json: { results: [] } })(url);
971+
});
972+
973+
try {
974+
await performWebSearch(mockServer as any, 'test query', 1, undefined, undefined, undefined, undefined, undefined, undefined, 'missing');
975+
assert.fail('Expected invalid engine validation error');
976+
} catch (error: any) {
977+
assert.ok(error.message.includes('Invalid SearXNG engine name(s): missing'), error.message);
978+
assert.ok(error.message.includes('Available engines:'), error.message);
979+
assert.ok(error.message.includes('semantic scholar'), error.message);
980+
}
981+
assert.equal(searchCalled, false, 'Search should not run after validation failure');
982+
983+
fetchMocker.restore();
984+
envManager.restore();
985+
}, results);
986+
987+
await testFunction('stale config refreshes once and then normalizes newly available value', async () => {
988+
clearInstanceInfoCacheForTests();
989+
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');
990+
991+
const mockServer = createMockServer();
992+
const requestedUrls: string[] = [];
993+
let configFetchCount = 0;
994+
995+
fetchMocker.mock(async (url) => {
996+
requestedUrls.push(url.toString());
997+
const parsedUrl = new URL(url.toString());
998+
if (parsedUrl.pathname.endsWith('/config')) {
999+
configFetchCount++;
1000+
const config = makeConfigWithEngines();
1001+
if (configFetchCount === 2) {
1002+
config.categories.push('software wikis');
1003+
config.engines.push({ name: 'annas archive', disabled: false });
1004+
}
1005+
return createMockFetch({ json: config })(url);
1006+
}
1007+
return createMockFetch({ json: { results: [] } })(url);
1008+
});
1009+
1010+
await performWebSearch(
1011+
mockServer as any,
1012+
'test query',
1013+
1,
1014+
undefined,
1015+
undefined,
1016+
undefined,
1017+
undefined,
1018+
undefined,
1019+
'Software Wikis',
1020+
'Annas Archive',
1021+
);
1022+
1023+
assert.equal(configFetchCount, 2, 'Expected cached config plus one refresh');
1024+
const configRequests = requestedUrls.filter((url) => new URL(url).pathname.endsWith('/config'));
1025+
assert.equal(configRequests.length, 2, 'Expected exactly one refresh request');
1026+
const searchUrl = new URL(requestedUrls[2]);
1027+
assert.equal(searchUrl.searchParams.get('categories'), 'software wikis');
1028+
assert.equal(searchUrl.searchParams.get('engines'), 'annas archive');
1029+
1030+
fetchMocker.restore();
1031+
envManager.restore();
1032+
}, results);
1033+
1034+
await testFunction('unavailable /config forwards engines and categories and prepends text warning', async () => {
8891035
clearInstanceInfoCacheForTests();
8901036
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');
8911037

@@ -912,18 +1058,19 @@ async function runTests() {
9121058
})(url);
9131059
});
9141060

915-
const result = await performWebSearch(mockServer as any, 'test query', 1, undefined, undefined, undefined, undefined, undefined, undefined, 'unknown');
1061+
const result = await performWebSearch(mockServer as any, 'test query', 1, undefined, undefined, undefined, undefined, undefined, 'Unknown Category', 'Unknown Engine');
9161062

917-
assert.ok(result.startsWith('Note: engine names were not validated'), result);
1063+
assert.ok(result.startsWith('Note: categories and engines were not validated or normalized'), result);
9181064
assert.ok(result.includes('Forwarded Result'), result);
9191065
const searchUrl = requestedUrls[1];
920-
assert.equal(new URL(searchUrl).searchParams.get('engines'), 'unknown');
1066+
assert.equal(new URL(searchUrl).searchParams.get('categories'), 'Unknown Category');
1067+
assert.equal(new URL(searchUrl).searchParams.get('engines'), 'Unknown Engine');
9211068

9221069
fetchMocker.restore();
9231070
envManager.restore();
9241071
}, results);
9251072

926-
await testFunction('unavailable /config includes warnings in JSON response when engines are provided', async () => {
1073+
await testFunction('unavailable /config includes warnings in JSON response when categories and engines are provided', async () => {
9271074
clearInstanceInfoCacheForTests();
9281075
envManager.set('SEARXNG_URL', 'https://test-searx.example.com');
9291076

@@ -937,10 +1084,10 @@ async function runTests() {
9371084
return createMockFetch({ json: { query: 'test query', results: [] } })(url);
9381085
});
9391086

940-
const result = await performWebSearch(mockServer as any, 'test query', 1, undefined, undefined, undefined, undefined, undefined, undefined, 'unknown', 'json');
1087+
const result = await performWebSearch(mockServer as any, 'test query', 1, undefined, undefined, undefined, undefined, undefined, 'Unknown Category', 'Unknown Engine', 'json');
9411088
const payload = JSON.parse(result);
9421089

943-
assert.deepEqual(payload.warnings, ['Engine names were not validated because SearXNG /config is unavailable.']);
1090+
assert.deepEqual(payload.warnings, ['Categories and engines were not validated or normalized because SearXNG /config is unavailable.']);
9441091

9451092
fetchMocker.restore();
9461093
envManager.restore();

__tests__/unit/types.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,12 +254,16 @@ async function runTests() {
254254
const properties = WEB_SEARCH_TOOL.inputSchema.properties as Record<string, any>;
255255
assert.ok(properties.categories, 'WEB_SEARCH_TOOL must expose categories parameter');
256256
assert.equal(properties.categories.type, 'string');
257+
assert.ok(properties.categories.description.includes('case-insensitively'), properties.categories.description);
258+
assert.ok(properties.categories.description.includes('/config'), properties.categories.description);
257259
}, results);
258260

259261
await testFunction('WEB_SEARCH_TOOL schema includes engines property', () => {
260262
const properties = WEB_SEARCH_TOOL.inputSchema.properties as Record<string, any>;
261263
assert.ok(properties.engines, 'WEB_SEARCH_TOOL must expose engines parameter');
262264
assert.equal(properties.engines.type, 'string');
265+
assert.ok(properties.engines.description.includes('case-insensitively'), properties.engines.description);
266+
assert.ok(!properties.engines.description.includes('matched exactly'), properties.engines.description);
263267
}, results);
264268

265269
await testFunction('WEB_SEARCH_TOOL schema includes response_format enum', () => {

0 commit comments

Comments
 (0)