Files
ccs/lib/mcp/ccs-websearch-server.cjs
T
Tam Nhu Tran 7f83e041b7 feat(websearch): add managed mcp runtime and provider cooldowns
- 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
2026-03-31 14:22:11 -04:00

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();