diff --git a/admin-routes.mjs b/admin-routes.mjs index 0a9b58f..debe42d 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -207,6 +207,15 @@ export function createAdminApi({ if (!mindSearchConfigService?.getRuntimeState) return res.status(503).json({ message: 'MindSearch 配置服务未启用' }); return res.json(await mindSearchConfigService.getRuntimeState()); }); + adminApi.post('/mindsearch/services/:serviceId/test', requireAdmin, async (req, res) => { + if (!mindSearchConfigService?.testService) return res.status(503).json({ message: 'MindSearch 服务测试未启用' }); + try { + return res.json(await mindSearchConfigService.testService(req.params.serviceId)); + } catch (error) { + if (error?.code === 'SEARCH_SERVICE_NOT_FOUND') return res.status(404).json({ message: error.message }); + throw error; + } + }); adminApi.get('/memory-v2/runtime', requireAdmin, async (_req, res) => { if (!memoryV2ConfigService?.getRuntimeState) { diff --git a/admin-routes.test.mjs b/admin-routes.test.mjs index c6b8f3b..18c0b90 100644 --- a/admin-routes.test.mjs +++ b/admin-routes.test.mjs @@ -165,6 +165,7 @@ test('admin system disclosure policy routes version config through the injected test('admin MindSearch routes persist only through injected control-plane service', async () => { const updates = []; + const testedServices = []; const router = createAdminApi({ jsonBody: express.json(), getToken() { return 'token-admin'; }, userAuth: { async getMe() { return { id: 'admin-1', role: 'admin' }; } }, @@ -173,6 +174,7 @@ test('admin MindSearch routes persist only through injected control-plane servic async getAdminConfig() { return { config: { enabled: false, mode: 'off', providers: { searxng: false, github: false, reader: false } }, source: 'env' }; }, async updateAdminConfig(patch, context) { updates.push({ patch, context }); return { config: { enabled: true, mode: 'shadow', providers: { searxng: true, github: false, reader: false } }, source: 'admin' }; }, async getRuntimeState() { return { effective: false, mode: 'off' }; }, + async testService(serviceId) { testedServices.push(serviceId); return { ok: true, serviceId, latencyMs: 3 }; }, }, plazaPosts: null, plazaOps: null, wechatAdmin: null, subscriptionService: null, }); @@ -185,6 +187,9 @@ test('admin MindSearch routes persist only through injected control-plane servic assert.deepEqual(updates, [{ patch: { enabled: true, mode: 'shadow' }, context: { updatedBy: 'admin-1' } }]); const runtime = await fetch(`${server.baseUrl}/admin-api/mindsearch/runtime`, { headers: { cookie: 'h5_user_session=token-admin' } }); assert.deepEqual(await runtime.json(), { effective: false, mode: 'off' }); + const serviceTest = await fetch(`${server.baseUrl}/admin-api/mindsearch/services/searxng/test`, { method: 'POST', headers: { cookie: 'h5_user_session=token-admin' } }); + assert.deepEqual(await serviceTest.json(), { ok: true, serviceId: 'searxng', latencyMs: 3 }); + assert.deepEqual(testedServices, ['searxng']); } finally { await server.close(); } }); diff --git a/capabilities.mjs b/capabilities.mjs index 187a91f..42ba737 100644 --- a/capabilities.mjs +++ b/capabilities.mjs @@ -496,7 +496,31 @@ export function buildAgentExtensionPolicy( } const searchEnabled = capabilities.search_external && mindSearchConfig?.enabled && mindSearchConfig.mode !== 'off'; if (searchEnabled && (!policies || policies.network_egress !== 'deny')) { - extensions.push({ type: 'stdio', name: 'tkmind-search', description: 'MindSearch 外部搜索增强(补充能力)', display_name: 'tkmind-search', bundled: false, cmd: resolveSandboxMcpNodeExecPath(process.env.GOOSED_MCP_NODE_PATH), args: [resolveMindSearchMcpServerPath(process.env.TKMIND_SEARCH_MCP_SERVER_PATH)], envs: { TKMIND_SEARCH_ENABLED: '1', TKMIND_SEARCH_MODE: mindSearchConfig.mode, TKMIND_SEARCH_PROVIDER_SEARXNG_ENABLED: mindSearchConfig.providers?.searxng ? '1' : '0', TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED: mindSearchConfig.providers?.github ? '1' : '0', TKMIND_SEARCH_READER_ENABLED: mindSearchConfig.providers?.reader ? '1' : '0', TKMIND_SEARCH_SEARXNG_URL: mindSearchConfig.settings?.searxngEndpoint ?? '', TKMIND_SEARCH_MAX_RESULTS: String(mindSearchConfig.settings?.maxResults ?? 10), TKMIND_SEARCH_TIMEOUT_MS: String(mindSearchConfig.settings?.timeoutMs ?? 8000), TKMIND_SEARCH_READER_MAX_CHARS: String(mindSearchConfig.settings?.readerMaxChars ?? 12000) }, available_tools: ['tkmind_search', 'tkmind_read'] }); + const hasResearchService = mindSearchConfig.services?.some((service) => + service.enabled && service.id === mindSearchConfig.routes?.research && service.adapter === 'research-http'); + extensions.push({ + type: 'stdio', + name: 'tkmind-search', + description: 'MindSearch 外部搜索增强(补充能力)', + display_name: 'tkmind-search', + bundled: false, + cmd: resolveSandboxMcpNodeExecPath(process.env.GOOSED_MCP_NODE_PATH), + args: [resolveMindSearchMcpServerPath(process.env.TKMIND_SEARCH_MCP_SERVER_PATH)], + envs: { + TKMIND_SEARCH_ENABLED: '1', + TKMIND_SEARCH_MODE: mindSearchConfig.mode, + TKMIND_SEARCH_PROVIDER_SEARXNG_ENABLED: mindSearchConfig.providers?.searxng ? '1' : '0', + TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED: mindSearchConfig.providers?.github ? '1' : '0', + TKMIND_SEARCH_READER_ENABLED: mindSearchConfig.providers?.reader ? '1' : '0', + TKMIND_SEARCH_SEARXNG_URL: mindSearchConfig.settings?.searxngEndpoint ?? '', + TKMIND_SEARCH_MAX_RESULTS: String(mindSearchConfig.settings?.maxResults ?? 10), + TKMIND_SEARCH_TIMEOUT_MS: String(mindSearchConfig.settings?.timeoutMs ?? 8000), + TKMIND_SEARCH_READER_MAX_CHARS: String(mindSearchConfig.settings?.readerMaxChars ?? 12000), + TKMIND_SEARCH_SERVICES_JSON: JSON.stringify(mindSearchConfig.services ?? []), + TKMIND_SEARCH_ROUTES_JSON: JSON.stringify(mindSearchConfig.routes ?? {}), + }, + available_tools: ['tkmind_search', 'tkmind_read', ...(hasResearchService ? ['tkmind_research'] : [])], + }); } if (capabilities.excel_analysis) { const excelWorkspaceRoot = resolveSandboxMcpLocalRoot(sandboxMcp); diff --git a/mindsearch-config.mjs b/mindsearch-config.mjs index 3585043..841c1d2 100644 --- a/mindsearch-config.mjs +++ b/mindsearch-config.mjs @@ -1,11 +1,56 @@ const CONFIG_TABLE = 'h5_mindsearch_config'; const CONFIG_SCOPE = 'global'; +const SERVICE_KINDS = new Set(['provider', 'reader', 'orchestrator']); +const SERVICE_ADAPTERS = new Set(['searxng', 'github', 'reader', 'research-http']); +const ROUTE_KEYS = ['web', 'news', 'code', 'read', 'research']; + +const BUILTIN_SERVICES = Object.freeze([ + { + id: 'searxng', + name: 'SearXNG', + kind: 'provider', + adapter: 'searxng', + capabilities: ['search.web', 'search.news'], + endpoint: '', + healthPath: '/config', + enabled: false, + timeoutMs: 8000, + priority: 100, + }, + { + id: 'github', + name: 'GitHub Code', + kind: 'provider', + adapter: 'github', + capabilities: ['search.code'], + endpoint: 'https://api.github.com/search/code', + healthPath: '', + enabled: false, + timeoutMs: 8000, + priority: 90, + }, + { + id: 'reader', + name: 'Safe Reader', + kind: 'reader', + adapter: 'reader', + capabilities: ['search.read'], + endpoint: '', + healthPath: '', + enabled: false, + timeoutMs: 8000, + priority: 80, + }, +]); + export const MINDSEARCH_DEFAULT_CONFIG = Object.freeze({ enabled: false, mode: 'off', providers: { searxng: false, github: false, reader: false }, settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 }, + services: BUILTIN_SERVICES, + routes: { web: 'searxng', news: 'searxng', code: 'github', read: 'reader', research: '' }, }); const clone = (value) => JSON.parse(JSON.stringify(value)); @@ -15,22 +60,182 @@ const bool = (value, fallback = false) => { return /^(1|true|yes|on)$/i.test(String(value)); }; +const boundedNumber = (value, fallback, min, max) => + Math.max(min, Math.min(max, Number(value ?? fallback) || fallback)); + +export function isValidSearchServiceEndpoint(value, { allowEmpty = true } = {}) { + const endpoint = String(value ?? '').trim(); + if (!endpoint) return allowEmpty; + try { + const url = new URL(endpoint); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return false; + return !['0.0.0.0', '::', '[::]', '169.254.169.254'].includes(url.hostname); + } catch { + return false; + } +} + +function normalizeCapabilities(value, fallback = []) { + const capabilities = Array.isArray(value) ? value : fallback; + return [...new Set(capabilities + .map((item) => String(item ?? '').trim()) + .filter((item) => /^[a-z][a-z0-9_.-]{1,63}$/.test(item)))] + .slice(0, 20); +} + +function normalizeService(input = {}, fallback = {}) { + const id = String(input.id ?? fallback.id ?? '').trim().toLowerCase(); + if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(id)) return null; + const kind = SERVICE_KINDS.has(input.kind) ? input.kind : (fallback.kind ?? 'provider'); + const adapter = SERVICE_ADAPTERS.has(input.adapter) ? input.adapter : (fallback.adapter ?? 'searxng'); + const endpoint = String(input.endpoint ?? fallback.endpoint ?? '').trim(); + if (!isValidSearchServiceEndpoint(endpoint)) return null; + const healthPathValue = String(input.healthPath ?? fallback.healthPath ?? '').trim(); + const healthPath = healthPathValue && /^\/[^\s]{0,255}$/.test(healthPathValue) ? healthPathValue : ''; + return { + id, + name: String(input.name ?? fallback.name ?? id).trim().slice(0, 80) || id, + kind, + adapter, + capabilities: normalizeCapabilities(input.capabilities, fallback.capabilities), + endpoint, + healthPath, + enabled: bool(input.enabled, bool(fallback.enabled)), + timeoutMs: boundedNumber(input.timeoutMs, fallback.timeoutMs ?? 8000, 1000, 120000), + priority: boundedNumber(input.priority, fallback.priority ?? 100, 1, 1000), + }; +} + +function builtinsFromLegacy(input = {}) { + const providers = input.providers ?? {}; + const settings = input.settings ?? {}; + return BUILTIN_SERVICES.map((service) => normalizeService({ + ...service, + enabled: bool(providers[service.adapter], service.enabled), + endpoint: service.adapter === 'searxng' + ? String(settings.searxngEndpoint ?? service.endpoint) + : service.endpoint, + timeoutMs: settings.timeoutMs ?? service.timeoutMs, + }, service)); +} + +function normalizeServices(input = {}) { + const source = Array.isArray(input.services) ? input.services : builtinsFromLegacy(input); + const seen = new Set(); + const services = []; + for (const item of source) { + const fallback = BUILTIN_SERVICES.find((candidate) => candidate.id === item?.id) ?? {}; + const service = normalizeService(item, fallback); + if (!service || seen.has(service.id)) continue; + seen.add(service.id); + services.push(service); + } + for (const builtin of builtinsFromLegacy(input)) { + if (!seen.has(builtin.id)) services.push(builtin); + } + return services.slice(0, 30); +} + +function normalizeRoutes(value, services) { + const ids = new Set(services.map((service) => service.id)); + const defaults = MINDSEARCH_DEFAULT_CONFIG.routes; + return Object.fromEntries(ROUTE_KEYS.map((key) => { + const requested = String(value?.[key] ?? defaults[key] ?? '').trim(); + return [key, !requested || ids.has(requested) ? requested : '']; + })); +} + +export function resolveMindSearchService(config, route) { + const normalized = normalizeMindSearchConfig(config); + const serviceId = normalized.routes?.[route] ?? ''; + return normalized.services.find((service) => service.id === serviceId && service.enabled) ?? null; +} + +export async function probeMindSearchService(service, { fetchImpl = fetch } = {}) { + const normalized = normalizeService(service); + if (!normalized) throw new Error('Invalid search service configuration'); + if (!normalized.endpoint) { + return { + ok: normalized.adapter === 'reader', + serviceId: normalized.id, + adapter: normalized.adapter, + status: null, + latencyMs: 0, + message: normalized.adapter === 'reader' ? 'Built-in reader is available' : 'Service endpoint is not configured', + }; + } + const startedAt = Date.now(); + let url; + if (normalized.adapter === 'searxng') { + url = new URL(normalized.endpoint); + url.searchParams.set('q', 'OpenAI'); + url.searchParams.set('format', 'json'); + } else if (normalized.healthPath) { + url = new URL(normalized.healthPath, normalized.endpoint); + } else { + return { + ok: true, + serviceId: normalized.id, + adapter: normalized.adapter, + status: null, + latencyMs: 0, + message: 'Configuration is valid; no health path is configured', + }; + } + try { + const response = await fetchImpl(url, { + signal: AbortSignal.timeout(normalized.timeoutMs), + headers: { accept: 'application/json,text/plain' }, + }); + let resultCount = null; + if (normalized.adapter === 'searxng' && response.ok) { + const body = await response.json(); + resultCount = Array.isArray(body.results) ? body.results.length : 0; + } + return { + ok: response.ok && (resultCount == null || resultCount > 0), + serviceId: normalized.id, + adapter: normalized.adapter, + status: response.status, + latencyMs: Date.now() - startedAt, + resultCount, + message: response.ok ? 'Health check completed' : `Service returned HTTP ${response.status}`, + }; + } catch (error) { + return { + ok: false, + serviceId: normalized.id, + adapter: normalized.adapter, + status: null, + latencyMs: Date.now() - startedAt, + message: error instanceof Error ? error.message : 'Service health check failed', + }; + } +} + export function normalizeMindSearchConfig(input = {}) { const mode = ['off', 'shadow', 'assist'].includes(input.mode) ? input.mode : 'off'; + const services = normalizeServices(input); + const serviceByAdapter = (adapter) => services.find((service) => service.adapter === adapter); + const searxng = serviceByAdapter('searxng'); + const github = serviceByAdapter('github'); + const reader = serviceByAdapter('reader'); const config = { enabled: bool(input.enabled), mode, providers: { - searxng: bool(input.providers?.searxng), - github: bool(input.providers?.github), - reader: bool(input.providers?.reader), + searxng: bool(searxng?.enabled), + github: bool(github?.enabled), + reader: bool(reader?.enabled), }, settings: { - searxngEndpoint: String(input.settings?.searxngEndpoint ?? '').trim(), - maxResults: Math.max(1, Math.min(20, Number(input.settings?.maxResults ?? 10) || 10)), - timeoutMs: Math.max(1000, Math.min(30000, Number(input.settings?.timeoutMs ?? 8000) || 8000)), - readerMaxChars: Math.max(1000, Math.min(50000, Number(input.settings?.readerMaxChars ?? 12000) || 12000)), + searxngEndpoint: String(searxng?.endpoint ?? input.settings?.searxngEndpoint ?? '').trim(), + maxResults: boundedNumber(input.settings?.maxResults, 10, 1, 20), + timeoutMs: boundedNumber(input.settings?.timeoutMs, 8000, 1000, 30000), + readerMaxChars: boundedNumber(input.settings?.readerMaxChars, 12000, 1000, 50000), }, + services, + routes: normalizeRoutes(input.routes, services), }; if (!config.enabled || config.mode === 'off') { config.enabled = false; @@ -49,6 +254,57 @@ function envConfig(env = process.env) { github: env.TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED, reader: env.TKMIND_SEARCH_READER_ENABLED, }, + settings: { + searxngEndpoint: env.TKMIND_SEARCH_SEARXNG_URL, + maxResults: env.TKMIND_SEARCH_MAX_RESULTS, + timeoutMs: env.TKMIND_SEARCH_TIMEOUT_MS, + readerMaxChars: env.TKMIND_SEARCH_READER_MAX_CHARS, + }, + services: parseJsonArray(env.TKMIND_SEARCH_SERVICES_JSON), + routes: parseJsonObject(env.TKMIND_SEARCH_ROUTES_JSON), + }); +} + +function parseJsonArray(value) { + if (!value) return undefined; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function parseJsonObject(value) { + if (!value) return undefined; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function applyLegacyPatchToServices(current, patch) { + if (Array.isArray(patch?.services)) return patch.services; + const providerPatch = patch?.providers ?? {}; + const settingPatch = patch?.settings ?? {}; + return current.services.map((service) => { + if (service.adapter === 'searxng') { + return { + ...service, + ...(providerPatch.searxng == null ? {} : { enabled: providerPatch.searxng }), + ...(settingPatch.searxngEndpoint == null ? {} : { endpoint: settingPatch.searxngEndpoint }), + ...(settingPatch.timeoutMs == null ? {} : { timeoutMs: settingPatch.timeoutMs }), + }; + } + if (service.adapter === 'github' && providerPatch.github != null) { + return { ...service, enabled: providerPatch.github }; + } + if (service.adapter === 'reader' && providerPatch.reader != null) { + return { ...service, enabled: providerPatch.reader }; + } + return service; }); } @@ -61,7 +317,7 @@ export async function ensureMindSearchConfigSchema(pool) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); } -export function createMindSearchConfigService(pool, { env = process.env } = {}) { +export function createMindSearchConfigService(pool, { env = process.env, fetchImpl = fetch } = {}) { let ready = null; const ensure = async () => { if (!pool) return; @@ -82,9 +338,22 @@ export function createMindSearchConfigService(pool, { env = process.env } = {}) ensureSchema: ensure, getEffectiveConfig: async () => (await load()).config, getAdminConfig: async () => load(), + testService: async (serviceId) => { + const current = await load(); + const service = current.config.services.find((item) => item.id === String(serviceId ?? '').trim()); + if (!service) throw Object.assign(new Error('Search service not found'), { code: 'SEARCH_SERVICE_NOT_FOUND' }); + return probeMindSearchService(service, { fetchImpl }); + }, updateAdminConfig: async (patch, { updatedBy = null } = {}) => { const current = await load(); - const next = normalizeMindSearchConfig({ ...current.config, ...patch, providers: { ...current.config.providers, ...(patch?.providers ?? {}) }, settings: { ...current.config.settings, ...(patch?.settings ?? {}) } }); + const next = normalizeMindSearchConfig({ + ...current.config, + ...patch, + providers: { ...current.config.providers, ...(patch?.providers ?? {}) }, + settings: { ...current.config.settings, ...(patch?.settings ?? {}) }, + services: applyLegacyPatchToServices(current.config, patch), + routes: { ...current.config.routes, ...(patch?.routes ?? {}) }, + }); if (!pool) return { config: next, source: 'env' }; await ensure(); const now = Date.now(); @@ -93,7 +362,16 @@ export function createMindSearchConfigService(pool, { env = process.env } = {}) }, getRuntimeState: async () => { const config = await (async () => (await load()).config)(); - return { enabled: config.enabled, mode: config.mode, providers: config.providers, effective: config.enabled && config.mode !== 'off' }; + return { + enabled: config.enabled, + mode: config.mode, + providers: config.providers, + services: config.services.map(({ id, name, kind, adapter, capabilities, enabled, timeoutMs, priority }) => ({ + id, name, kind, adapter, capabilities, enabled, timeoutMs, priority, + })), + routes: config.routes, + effective: config.enabled && config.mode !== 'off', + }; }, }; } diff --git a/mindsearch-providers.mjs b/mindsearch-providers.mjs index 3ef66b3..dfa53ef 100644 --- a/mindsearch-providers.mjs +++ b/mindsearch-providers.mjs @@ -1,4 +1,5 @@ import { isSafeHttpUrl, normalizeSearchResult } from './search-capability.mjs'; +import { isValidSearchServiceEndpoint } from './mindsearch-config.mjs'; export async function searchSearxng(query, { limit = 10, endpoint = process.env.TKMIND_SEARCH_SEARXNG_URL, fetchImpl = fetch } = {}) { if (!endpoint) throw new Error('SearXNG endpoint is not configured'); @@ -29,3 +30,48 @@ export async function searchGithubCode(query, { limit = 10, token = process.env. const body = await response.json(); return (Array.isArray(body.items) ? body.items : []).slice(0, limit).map((item, index) => normalizeSearchResult({ title: `${item.repository?.full_name ?? ''}:${item.path ?? item.name ?? ''}`, url: item.html_url, snippet: item.repository?.description ?? '', source: 'github' }, index)); } + +function serviceUrl(endpoint, path) { + if (!isValidSearchServiceEndpoint(endpoint, { allowEmpty: false })) throw new Error('Search service endpoint is not configured safely'); + const base = endpoint.endsWith('/') ? endpoint : `${endpoint}/`; + return new URL(path.replace(/^\//, ''), base); +} + +async function postServiceJson(endpoint, path, payload, { timeoutMs = 30000, fetchImpl = fetch } = {}) { + const response = await fetchImpl(serviceUrl(endpoint, path), { + method: 'POST', + signal: AbortSignal.timeout(timeoutMs), + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error(`Search service returned ${response.status}`); + return response.json(); +} + +export async function searchResearchService(query, { + endpoint, + type = 'web', + limit = 10, + timeoutMs = 30000, + fetchImpl = fetch, +} = {}) { + const body = await postServiceJson(endpoint, '/v1/search', { query, type, limit }, { timeoutMs, fetchImpl }); + return (Array.isArray(body.results) ? body.results : []) + .slice(0, limit) + .map((item, index) => normalizeSearchResult({ ...item, source: item.source || 'research-service' }, index)); +} + +export async function startResearchTask(question, { + endpoint, + depth = 'standard', + timeoutMs = 30000, + fetchImpl = fetch, +} = {}) { + const body = await postServiceJson(endpoint, '/v1/research', { question, depth }, { timeoutMs, fetchImpl }); + if (!body || typeof body !== 'object' || !body.task_id) throw new Error('Research service returned an invalid task'); + return { + taskId: String(body.task_id), + status: String(body.status ?? 'running'), + service: String(body.service ?? 'research-service'), + }; +} diff --git a/mindsearch.test.mjs b/mindsearch.test.mjs index 18dcae1..41301c3 100644 --- a/mindsearch.test.mjs +++ b/mindsearch.test.mjs @@ -1,13 +1,74 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { buildCitations, isSafeHttpUrl, validateSearchRequest } from './search-capability.mjs'; -import { MINDSEARCH_DEFAULT_CONFIG, normalizeMindSearchConfig } from './mindsearch-config.mjs'; +import { + MINDSEARCH_DEFAULT_CONFIG, + normalizeMindSearchConfig, + probeMindSearchService, + resolveMindSearchService, +} from './mindsearch-config.mjs'; import { buildAgentExtensionPolicy, DEFAULT_USER_CAPABILITIES } from './capabilities.mjs'; -import { searchGithubCode, searchSearxng } from './mindsearch-providers.mjs'; +import { + searchGithubCode, + searchResearchService, + searchSearxng, + startResearchTask, +} from './mindsearch-providers.mjs'; test('MindSearch defaults to disabled and normalizes unsafe modes', () => { assert.equal(MINDSEARCH_DEFAULT_CONFIG.enabled, false); - assert.deepEqual(normalizeMindSearchConfig({ enabled: true, mode: 'invalid', providers: { github: true } }), { enabled: false, mode: 'off', providers: { searxng: false, github: true, reader: false }, settings: { searxngEndpoint: '', maxResults: 10, timeoutMs: 8000, readerMaxChars: 12000 } }); + const config = normalizeMindSearchConfig({ enabled: true, mode: 'invalid', providers: { github: true } }); + assert.equal(config.enabled, false); + assert.equal(config.mode, 'off'); + assert.deepEqual(config.providers, { searxng: false, github: true, reader: false }); + assert.equal(config.services.find((service) => service.id === 'github').enabled, true); + assert.deepEqual(config.routes, { web: 'searxng', news: 'searxng', code: 'github', read: 'reader', research: '' }); +}); + +test('MindSearch service registry accepts research orchestrators and rejects unsafe endpoints', () => { + const config = normalizeMindSearchConfig({ + enabled: true, + mode: 'assist', + services: [ + { + id: 'research-main', + name: 'Research', + kind: 'orchestrator', + adapter: 'research-http', + endpoint: 'http://127.0.0.1:20100', + healthPath: '/health', + capabilities: ['search.web', 'research.execute', 'research.execute', 'INVALID CAPABILITY'], + enabled: true, + timeoutMs: 500000, + }, + { id: 'metadata', adapter: 'research-http', endpoint: 'http://169.254.169.254/latest', enabled: true }, + ], + routes: { research: 'research-main', web: 'research-main' }, + }); + assert.equal(config.services.some((service) => service.id === 'metadata'), false); + assert.deepEqual(config.services.find((service) => service.id === 'research-main').capabilities, ['search.web', 'research.execute']); + assert.equal(config.services.find((service) => service.id === 'research-main').timeoutMs, 120000); + assert.equal(resolveMindSearchService(config, 'research').id, 'research-main'); +}); + +test('MindSearch service probe supports loopback SearXNG without exposing arbitrary response data', async () => { + let requested; + const result = await probeMindSearchService({ + id: 'searxng-local', + name: 'Local', + kind: 'provider', + adapter: 'searxng', + endpoint: 'http://127.0.0.1:8080/search', + enabled: true, + }, { + fetchImpl: async (url) => { + requested = String(url); + return { ok: true, status: 200, json: async () => ({ results: [{ title: 'A' }] }) }; + }, + }); + assert.match(requested, /q=OpenAI/); + assert.equal(result.ok, true); + assert.equal(result.resultCount, 1); }); test('search request validation and citations are deterministic', () => { @@ -42,3 +103,20 @@ test('GitHub code adapter sends bounded queries and normalizes results', async ( assert.equal(requested.options.headers.accept, 'application/vnd.github+json'); assert.equal(result[0].source, 'github'); }); + +test('Research service adapter uses the stable HTTP contract', async () => { + const requests = []; + const fetchImpl = async (url, options) => { + requests.push({ url: String(url), options }); + if (String(url).endsWith('/v1/research')) { + return { ok: true, json: async () => ({ task_id: 'task-1', status: 'running' }) }; + } + return { ok: true, json: async () => ({ results: [{ title: 'Report', url: 'https://example.com/report', snippet: 'evidence' }] }) }; + }; + const results = await searchResearchService('AI Agent', { endpoint: 'http://research.local', limit: 5, fetchImpl }); + const task = await startResearchTask('Analyze AI Agent', { endpoint: 'http://research.local', depth: 'deep', fetchImpl }); + assert.equal(results[0].source, 'research-service'); + assert.deepEqual(task, { taskId: 'task-1', status: 'running', service: 'research-service' }); + assert.equal(JSON.parse(requests[0].options.body).query, 'AI Agent'); + assert.deepEqual(JSON.parse(requests[1].options.body), { question: 'Analyze AI Agent', depth: 'deep' }); +}); diff --git a/tkmind-search-mcp.mjs b/tkmind-search-mcp.mjs index 4c01b16..553a4bd 100644 --- a/tkmind-search-mcp.mjs +++ b/tkmind-search-mcp.mjs @@ -1,7 +1,18 @@ import readline from 'node:readline'; -import { normalizeMindSearchConfig } from './mindsearch-config.mjs'; +import { normalizeMindSearchConfig, resolveMindSearchService } from './mindsearch-config.mjs'; import { SEARCH_ERROR_CODES, validateSearchRequest } from './search-capability.mjs'; -import { readSafeUrl, searchGithubCode, searchSearxng } from './mindsearch-providers.mjs'; +import { + readSafeUrl, + searchGithubCode, + searchResearchService, + searchSearxng, + startResearchTask, +} from './mindsearch-providers.mjs'; + +function parseJson(value, fallback) { + if (!value) return fallback; + try { return JSON.parse(value); } catch { return fallback; } +} const config = normalizeMindSearchConfig({ enabled: process.env.TKMIND_SEARCH_ENABLED, @@ -11,12 +22,31 @@ const config = normalizeMindSearchConfig({ github: process.env.TKMIND_SEARCH_PROVIDER_GITHUB_ENABLED, reader: process.env.TKMIND_SEARCH_READER_ENABLED, }, + settings: { + searxngEndpoint: process.env.TKMIND_SEARCH_SEARXNG_URL, + maxResults: process.env.TKMIND_SEARCH_MAX_RESULTS, + timeoutMs: process.env.TKMIND_SEARCH_TIMEOUT_MS, + readerMaxChars: process.env.TKMIND_SEARCH_READER_MAX_CHARS, + }, + services: parseJson(process.env.TKMIND_SEARCH_SERVICES_JSON, undefined), + routes: parseJson(process.env.TKMIND_SEARCH_ROUTES_JSON, undefined), }); const tools = [ { name: 'tkmind_search', description: 'Optional external search enhancement; existing web search remains primary.', inputSchema: { type: 'object', properties: { query: { type: 'string' }, type: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] } }, { name: 'tkmind_read', description: 'Optional safe URL reader for search citations.', inputSchema: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } }, ]; +if (resolveMindSearchService(config, 'research')) { + tools.push({ + name: 'tkmind_research', + description: 'Start an asynchronous deep-research task and return its task identifier.', + inputSchema: { + type: 'object', + properties: { question: { type: 'string' }, depth: { type: 'string', enum: ['quick', 'standard', 'deep'] } }, + required: ['question'], + }, + }); +} function response(id, result) { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); } function error(id, code, message) { response(id, { isError: true, content: [{ type: 'text', text: `${code}: ${message}` }], structuredContent: { code, message } }); } @@ -31,15 +61,30 @@ function handle(message) { if (name === 'tkmind_search') { const checked = validateSearchRequest(params.arguments ?? {}); if (!checked.ok) return error(id, checked.code, checked.message); - if (checked.value.type === 'code' && config.providers.github) { - return searchGithubCode(checked.value.query, { limit: checked.value.limit }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: 'github' }) }], structuredContent: { results, query: checked.value.query, provider: 'github' } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message)); + const route = checked.value.type === 'code' ? 'code' : checked.value.type === 'news' ? 'news' : 'web'; + const service = resolveMindSearchService(config, route); + if (service?.adapter === 'github') { + return searchGithubCode(checked.value.query, { limit: checked.value.limit, endpoint: service.endpoint || undefined }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: service.id }) }], structuredContent: { results, query: checked.value.query, provider: service.id } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message)); } - if (config.providers.searxng) { - return searchSearxng(checked.value.query, { limit: checked.value.limit }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: 'searxng' }) }], structuredContent: { results, query: checked.value.query, provider: 'searxng' } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message)); + if (service?.adapter === 'searxng') { + return searchSearxng(checked.value.query, { limit: checked.value.limit, endpoint: service.endpoint }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: service.id }) }], structuredContent: { results, query: checked.value.query, provider: service.id } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message)); + } + if (service?.adapter === 'research-http') { + return searchResearchService(checked.value.query, { endpoint: service.endpoint, type: checked.value.type, limit: checked.value.limit, timeoutMs: service.timeoutMs }).then((results) => response(id, { content: [{ type: 'text', text: JSON.stringify({ results, query: checked.value.query, provider: service.id }) }], structuredContent: { results, query: checked.value.query, provider: service.id } })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message)); } return error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, 'No search provider is configured.'); } - if (name === 'tkmind_read' && config.providers.reader) return readSafeUrl(params.arguments?.url).then((result) => response(id, { content: [{ type: 'text', text: result.content }], structuredContent: result })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message)); + if (name === 'tkmind_read' && resolveMindSearchService(config, 'read')?.adapter === 'reader') return readSafeUrl(params.arguments?.url).then((result) => response(id, { content: [{ type: 'text', text: result.content }], structuredContent: result })).catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message)); + if (name === 'tkmind_research') { + const question = String(params.arguments?.question ?? '').trim(); + if (!question || question.length > 2000) return error(id, SEARCH_ERROR_CODES.INVALID_REQUEST, 'question must be 1-2000 characters'); + const depth = ['quick', 'standard', 'deep'].includes(params.arguments?.depth) ? params.arguments.depth : 'standard'; + const service = resolveMindSearchService(config, 'research'); + if (service?.adapter !== 'research-http') return error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, 'No research service is configured.'); + return startResearchTask(question, { endpoint: service.endpoint, depth, timeoutMs: service.timeoutMs }) + .then((result) => response(id, { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result })) + .catch((err) => error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, err.message)); + } return error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, 'No reader provider is configured.'); }