Files
memind/tkmind-search-mcp.mjs
T
2026-07-24 09:40:58 +08:00

139 lines
8.2 KiB
JavaScript

import readline from 'node:readline';
import { normalizeMindSearchConfig, resolveMindSearchService } from './mindsearch-config.mjs';
import { SEARCH_ERROR_CODES, validateSearchRequest } from './search-capability.mjs';
import {
cancelResearchTask,
getResearchTask,
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,
mode: process.env.TKMIND_SEARCH_MODE || (/^(1|true|yes|on)$/i.test(process.env.TKMIND_SEARCH_ENABLED ?? '') ? 'assist' : 'off'),
providers: {
searxng: process.env.TKMIND_SEARCH_PROVIDER_SEARXNG_ENABLED,
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'],
},
});
tools.push(
{
name: 'tkmind_research_status',
description: 'Get Deep Search progress, evidence sources, and the completed report.',
inputSchema: {
type: 'object',
properties: { task_id: { type: 'string' } },
required: ['task_id'],
},
},
{
name: 'tkmind_research_cancel',
description: 'Cancel a queued or running Deep Search task.',
inputSchema: {
type: 'object',
properties: { task_id: { type: 'string' } },
required: ['task_id'],
},
},
);
}
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 } }); }
function handle(message) {
const { id, method, params = {} } = message;
if (method === 'initialize') return response(id, { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'tkmind-search', version: '0.1.0' } });
if (method === 'notifications/initialized') return;
if (method === 'tools/list') return response(id, { tools });
if (method !== 'tools/call') return error(id, 'METHOD_NOT_FOUND', `unsupported method: ${method}`);
if (!config.enabled || config.mode === 'off') return error(id, SEARCH_ERROR_CODES.CAPABILITY_DISABLED, 'MindSearch is disabled; existing search capabilities are unchanged.');
const name = params.name;
if (name === 'tkmind_search') {
const checked = validateSearchRequest(params.arguments ?? {});
if (!checked.ok) return error(id, checked.code, checked.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 (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' && 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,
userId: process.env.TKMIND_SEARCH_USER_ID || null,
llmProviderKeyId: service.llmProviderKeyId || '',
llmModel: service.llmModel || '',
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));
}
if (name === 'tkmind_research_status' || name === 'tkmind_research_cancel') {
const taskId = String(params.arguments?.task_id ?? '').trim();
if (!taskId || taskId.length > 200) return error(id, SEARCH_ERROR_CODES.INVALID_REQUEST, 'task_id must be 1-200 characters');
const service = resolveMindSearchService(config, 'research');
if (service?.adapter !== 'research-http') return error(id, SEARCH_ERROR_CODES.PROVIDER_UNAVAILABLE, 'No research service is configured.');
const operation = name === 'tkmind_research_cancel' ? cancelResearchTask : getResearchTask;
return operation(taskId, {
endpoint: service.endpoint,
userId: process.env.TKMIND_SEARCH_USER_ID || null,
timeoutMs: service.timeoutMs,
})
.then((result) => response(id, {
content: [{ type: 'text', text: result.report || 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.');
}
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', (line) => { try { handle(JSON.parse(line)); } catch { error(null, SEARCH_ERROR_CODES.INVALID_REQUEST, 'invalid JSON-RPC request'); } });