mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 18:16:08 +00:00
- switch ccs-websearch MCP to stdio-first framing and keep legacy compatibility - add cooldown persistence and bounded retries for provider failures - surface cooldown status in readiness output and regression tests
340 lines
8.6 KiB
JavaScript
340 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const {
|
|
getActiveProviderIds,
|
|
getQueryFingerprint,
|
|
getSkipReason,
|
|
hasAnyActiveProviders,
|
|
runLocalWebSearch,
|
|
shouldSkipHook,
|
|
traceWebSearchEvent,
|
|
} = require('../hooks/websearch-transformer.cjs');
|
|
|
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
const SERVER_NAME = 'ccs-websearch';
|
|
const SERVER_VERSION = '1.0.0';
|
|
const TOOL_NAME = 'WebSearch';
|
|
const TOOL_ALIASES = ['search'];
|
|
const TOOL_DESCRIPTION =
|
|
'Third-party WebSearch replacement for CCS-managed Claude launches. Use this instead of Bash/curl/http fetches for web lookups. Provider order: Exa, Tavily, Brave Search, DuckDuckGo, then optional legacy CLI fallback.';
|
|
|
|
function isSupportedToolName(name) {
|
|
return name === TOOL_NAME || TOOL_ALIASES.includes(name);
|
|
}
|
|
|
|
let inputBuffer = Buffer.alloc(0);
|
|
const sessionState = {
|
|
initializeCount: 0,
|
|
toolsListCount: 0,
|
|
exposed: false,
|
|
toolCalls: 0,
|
|
};
|
|
let sessionSummaryWritten = false;
|
|
|
|
function shouldExposeTools() {
|
|
return !shouldSkipHook() && hasAnyActiveProviders();
|
|
}
|
|
|
|
function getTools() {
|
|
if (!shouldExposeTools()) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
{
|
|
name: TOOL_NAME,
|
|
description: TOOL_DESCRIPTION,
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
query: {
|
|
type: 'string',
|
|
description:
|
|
'Web query to resolve through CCS providers. Prefer this tool over ad hoc Bash/curl lookups when you need current web information.',
|
|
},
|
|
},
|
|
required: ['query'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
function writeMessage(message) {
|
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
}
|
|
|
|
function writeResponse(id, result) {
|
|
writeMessage({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
result,
|
|
});
|
|
}
|
|
|
|
function writeError(id, code, message) {
|
|
writeMessage({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code,
|
|
message,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function handleToolCall(message) {
|
|
const id = message.id;
|
|
const params = message.params || {};
|
|
const toolArgs = params.arguments || {};
|
|
const toolName = params.name || '<missing>';
|
|
const query = typeof toolArgs.query === 'string' ? toolArgs.query.trim() : '';
|
|
const fingerprint = getQueryFingerprint(query);
|
|
|
|
if (!isSupportedToolName(toolName)) {
|
|
traceWebSearchEvent('mcp_tool_call_rejected', {
|
|
source: 'mcp',
|
|
reason: 'unknown_tool',
|
|
toolName,
|
|
});
|
|
writeError(id, -32602, `Unknown tool: ${toolName}`);
|
|
return;
|
|
}
|
|
|
|
sessionState.toolCalls += 1;
|
|
traceWebSearchEvent('mcp_tool_call_received', {
|
|
source: 'mcp',
|
|
toolName,
|
|
...fingerprint,
|
|
});
|
|
|
|
if (!shouldExposeTools()) {
|
|
traceWebSearchEvent('mcp_tool_call_unavailable', {
|
|
source: 'mcp',
|
|
toolName,
|
|
exposed: false,
|
|
skipReason: getSkipReason(),
|
|
activeProviderIds: getActiveProviderIds(),
|
|
...fingerprint,
|
|
});
|
|
writeResponse(id, {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'CCS WebSearch is unavailable for this profile or no providers are ready.',
|
|
},
|
|
],
|
|
isError: true,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!query) {
|
|
traceWebSearchEvent('mcp_tool_call_rejected', {
|
|
source: 'mcp',
|
|
reason: 'empty_query',
|
|
toolName,
|
|
});
|
|
writeError(id, -32602, `Tool "${TOOL_NAME}" requires a non-empty string query.`);
|
|
return;
|
|
}
|
|
|
|
const result = await runLocalWebSearch(query);
|
|
if (result.success) {
|
|
traceWebSearchEvent('mcp_tool_call_result', {
|
|
source: 'mcp',
|
|
toolName,
|
|
success: true,
|
|
providerId: result.providerId,
|
|
providerName: result.providerName,
|
|
...fingerprint,
|
|
});
|
|
writeResponse(id, {
|
|
content: [{ type: 'text', text: result.content }],
|
|
});
|
|
return;
|
|
}
|
|
|
|
traceWebSearchEvent('mcp_tool_call_result', {
|
|
source: 'mcp',
|
|
toolName,
|
|
success: false,
|
|
noActiveProviders: Boolean(result.noActiveProviders),
|
|
errorCount: result.errors.length,
|
|
...fingerprint,
|
|
});
|
|
|
|
const errorDetail =
|
|
result.noActiveProviders || result.errors.length === 0
|
|
? 'No active WebSearch providers are ready.'
|
|
: result.errors.map((entry) => `${entry.provider}: ${entry.error}`).join(' | ');
|
|
|
|
writeResponse(id, {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: `CCS local WebSearch failed for "${query}". ${errorDetail}`,
|
|
},
|
|
],
|
|
isError: true,
|
|
});
|
|
}
|
|
|
|
async function handleMessage(message) {
|
|
if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
|
|
return;
|
|
}
|
|
|
|
switch (message.method) {
|
|
case 'initialize':
|
|
sessionState.initializeCount += 1;
|
|
sessionState.exposed = sessionState.exposed || shouldExposeTools();
|
|
traceWebSearchEvent('mcp_initialize', {
|
|
source: 'mcp',
|
|
exposed: shouldExposeTools(),
|
|
skipReason: getSkipReason(),
|
|
activeProviderIds: getActiveProviderIds(),
|
|
});
|
|
writeResponse(message.id, {
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
capabilities: {
|
|
tools: {},
|
|
},
|
|
serverInfo: {
|
|
name: SERVER_NAME,
|
|
version: SERVER_VERSION,
|
|
},
|
|
});
|
|
return;
|
|
case 'notifications/initialized':
|
|
return;
|
|
case 'ping':
|
|
writeResponse(message.id, {});
|
|
return;
|
|
case 'tools/list':
|
|
sessionState.toolsListCount += 1;
|
|
{
|
|
const tools = getTools();
|
|
const exposed = tools.length > 0;
|
|
sessionState.exposed = sessionState.exposed || exposed;
|
|
traceWebSearchEvent('mcp_tools_list', {
|
|
source: 'mcp',
|
|
exposed,
|
|
toolNames: tools.map((tool) => tool.name),
|
|
activeProviderIds: getActiveProviderIds(),
|
|
skipReason: getSkipReason(),
|
|
});
|
|
writeResponse(message.id, { tools });
|
|
}
|
|
return;
|
|
case 'tools/call':
|
|
await handleToolCall(message);
|
|
return;
|
|
default:
|
|
if (message.id !== undefined) {
|
|
writeError(message.id, -32601, `Method not found: ${message.method}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function writeSessionSummary(exitCodeOrSignal) {
|
|
if (sessionSummaryWritten) {
|
|
return;
|
|
}
|
|
|
|
sessionSummaryWritten = true;
|
|
traceWebSearchEvent('mcp_session_summary', {
|
|
source: 'mcp',
|
|
initializeCount: sessionState.initializeCount,
|
|
toolsListCount: sessionState.toolsListCount,
|
|
exposed: sessionState.exposed,
|
|
toolCalls: sessionState.toolCalls,
|
|
calledWebSearch: sessionState.toolCalls > 0,
|
|
likelyBypassed: sessionState.exposed && sessionState.toolCalls === 0 ? 'unknown' : false,
|
|
activeProviderIds: getActiveProviderIds(),
|
|
skipReason: getSkipReason(),
|
|
exitCode: typeof exitCodeOrSignal === 'number' ? exitCodeOrSignal : null,
|
|
exitSignal: typeof exitCodeOrSignal === 'string' ? exitCodeOrSignal : null,
|
|
});
|
|
}
|
|
|
|
function parseMessages() {
|
|
while (true) {
|
|
let body;
|
|
const startsWithLegacyHeaders = inputBuffer
|
|
.slice(0, Math.min(inputBuffer.length, 32))
|
|
.toString('utf8')
|
|
.toLowerCase()
|
|
.startsWith('content-length:');
|
|
|
|
if (startsWithLegacyHeaders) {
|
|
const headerEnd = inputBuffer.indexOf('\r\n\r\n');
|
|
if (headerEnd === -1) {
|
|
return;
|
|
}
|
|
|
|
const headerText = inputBuffer.slice(0, headerEnd).toString('utf8');
|
|
const contentLengthMatch = headerText.match(/content-length:\s*(\d+)/i);
|
|
if (!contentLengthMatch) {
|
|
inputBuffer = Buffer.alloc(0);
|
|
return;
|
|
}
|
|
|
|
const contentLength = Number.parseInt(contentLengthMatch[1], 10);
|
|
const messageEnd = headerEnd + 4 + contentLength;
|
|
if (inputBuffer.length < messageEnd) {
|
|
return;
|
|
}
|
|
|
|
body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8');
|
|
inputBuffer = inputBuffer.slice(messageEnd);
|
|
} else {
|
|
const newlineIndex = inputBuffer.indexOf('\n');
|
|
if (newlineIndex === -1) {
|
|
return;
|
|
}
|
|
|
|
body = inputBuffer.slice(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim();
|
|
inputBuffer = inputBuffer.slice(newlineIndex + 1);
|
|
if (!body) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
let message;
|
|
try {
|
|
message = JSON.parse(body);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
Promise.resolve(handleMessage(message)).catch((error) => {
|
|
if (message && message.id !== undefined) {
|
|
writeError(message.id, -32603, (error && error.message) || 'Internal error');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
process.stdin.on('data', (chunk) => {
|
|
inputBuffer = Buffer.concat([inputBuffer, chunk]);
|
|
parseMessages();
|
|
});
|
|
|
|
process.stdin.on('error', () => {
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on('exit', (code) => {
|
|
writeSessionSummary(code);
|
|
});
|
|
|
|
['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((signal) => {
|
|
process.on(signal, () => {
|
|
writeSessionSummary(signal);
|
|
process.exit(0);
|
|
});
|
|
});
|
|
|
|
process.stdin.resume();
|