#!/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, SearXNG, 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 || ''; 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();