From e260df7178dd030efe3d490b4cd9421b386c43c2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 30 Mar 2026 16:10:26 -0400 Subject: [PATCH] feat: add first-class CCS WebSearch runtime --- README.md | 7 +- docs/project-overview-pdr.md | 5 +- docs/project-roadmap.md | 3 +- docs/websearch.md | 43 ++- lib/hooks/websearch-transformer.cjs | 155 +++++---- lib/mcp/ccs-websearch-server.cjs | 207 +++++++++++ src/api/services/profile-lifecycle-service.ts | 8 +- src/api/services/profile-writer.ts | 8 +- src/ccs.ts | 29 +- src/cliproxy/executor/index.ts | 30 +- src/cliproxy/services/variant-settings.ts | 10 +- src/commands/install-command.ts | 13 +- src/copilot/copilot-executor.ts | 10 +- src/utils/claude-config-path.ts | 5 + src/utils/websearch-manager.ts | 42 ++- src/utils/websearch/claude-tool-args.ts | 51 +++ src/utils/websearch/hook-installer.ts | 5 +- src/utils/websearch/index.ts | 19 +- src/utils/websearch/mcp-installer.ts | 329 ++++++++++++++++++ src/utils/websearch/profile-hook-injector.ts | 8 +- .../api/profile-lifecycle-service.test.ts | 12 +- .../unit/api/profile-writer-anthropic.test.ts | 4 +- .../hooks/ccs-websearch-mcp-server.test.ts | 203 +++++++++++ .../settings-profile-websearch-launch.test.ts | 8 +- .../utils/websearch/claude-tool-args.test.ts | 24 ++ .../utils/websearch/mcp-installer.test.ts | 161 +++++++++ 26 files changed, 1241 insertions(+), 158 deletions(-) create mode 100644 lib/mcp/ccs-websearch-server.cjs create mode 100644 src/utils/websearch/claude-tool-args.ts create mode 100644 src/utils/websearch/mcp-installer.ts create mode 100644 tests/unit/hooks/ccs-websearch-mcp-server.test.ts create mode 100644 tests/unit/utils/websearch/claude-tool-args.test.ts create mode 100644 tests/unit/utils/websearch/mcp-installer.test.ts diff --git a/README.md b/README.md index 3bc3f421..34c10a75 100644 --- a/README.md +++ b/README.md @@ -630,18 +630,18 @@ Without Developer Mode, CCS falls back to copying directories. ## WebSearch -Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS intercepts those requests and resolves them through real local search backends instead of depending on another model CLI to do the search. +Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS now provisions a first-class local `ccs-websearch` MCP tool, disables native `WebSearch` on third-party launches, and routes search through real local providers instead of surfacing a denied native-tool call. ### How It Works | Profile Type | WebSearch Method | |--------------|------------------| | Claude (native) | Anthropic WebSearch API | -| Third-party profiles | Local Search Backend Chain | +| Third-party profiles | CCS local MCP search tool | ### Local Search Backend Chain -CCS intercepts WebSearch requests and routes them through deterministic search providers: +For third-party profiles, Claude uses the managed `ccs-websearch` MCP tool. CCS then routes that request through deterministic search providers in this order: | Priority | Provider | Setup | Notes | |----------|----------|-------|-------| @@ -673,6 +673,7 @@ websearch: > [!TIP] > **DuckDuckGo** still works out of the box. Add **Exa**, **Tavily**, or **Brave Search** if you want API-backed results, then keep Gemini/OpenCode/Grok only if you explicitly want legacy fallback behavior. +> CCS manages the user-scope MCP entry in `~/.claude.json` and syncs it into isolated account configs when needed. See [docs/websearch.md](./docs/websearch.md) for detailed configuration and troubleshooting. diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index 744de589..5680a2d2 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -35,7 +35,7 @@ CCS provides: 3. **AI Providers**: Dedicated CLIProxy dashboard for Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key families 4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API 5. **Visual Dashboard**: React SPA for configuration management -6. **Automatic WebSearch**: Real backend fallback chain for third-party providers +6. **Automatic WebSearch**: First-class local WebSearch tool with deterministic provider chain for third-party providers 7. **Usage Analytics**: Token tracking, cost analysis, model breakdown 8. **Official Claude Channels**: Runtime auto-enable plus dashboard token/config flow for Telegram, Discord, and macOS-only iMessage @@ -93,7 +93,8 @@ CCS provides: - Validate symlinks and permissions ### FR-007: WebSearch Fallback -- Intercept WebSearch for third-party profiles that cannot reach Anthropic's native tool +- Expose a CCS-managed local WebSearch tool for third-party profiles that cannot reach Anthropic's native tool +- Suppress native `WebSearch` on third-party launches so Claude uses the CCS-owned path - Support Exa, Tavily, Brave, and DuckDuckGo real search backends - Keep Gemini CLI, OpenCode, and Grok as optional legacy fallback - Graceful fallback chain diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index eee18ec2..5ff51ac3 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-03-28 +Last Updated: 2026-03-30 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-03-30**: **#862** Third-party WebSearch now uses a first-class CCS-managed MCP tool path instead of relying on a denied native Anthropic `WebSearch` call as the normal UX. CCS provisions `ccs-websearch` into `~/.claude.json`, syncs it into isolated account configs when needed, suppresses native `WebSearch` on third-party launches, preserves the provider order `Exa -> Tavily -> Brave -> DuckDuckGo -> legacy CLI fallback`, and keeps the old hook runtime only as shared provider plumbing plus compatibility fallback. Uninstall cleanup now also removes the managed WebSearch MCP runtime. - **2026-03-28**: **#773** CCS now ships a dedicated `Compatible -> Codex CLI` dashboard route with a real split-view control center. The page detects the local Codex binary, keeps overview/docs guidance, and adds guided editors for the user-owned `~/.codex/config.toml` layer: top-level runtime defaults, project trust, profiles, model providers, MCP servers, and supported feature flags. Structured saves intentionally normalize TOML formatting and drop comments, so the raw editor remains the fidelity escape hatch. Follow-up fixes added immediate raw snapshot refresh, refresh/discard recovery for stale raw drafts, dirty raw-editor guarding for structured controls, project-trust path validation, read-only handling for unreadable config files, preservation of unsupported upstream values such as granular `approval_policy`, and feature reset-to-default support. CCS still warns that transient runtime overrides such as `codex -c key=value` and `CCS_CODEX_API_KEY` may change effective behavior without persisting into the file. - **2026-03-27**: WebSearch dashboard cards now manage Exa, Tavily, and Brave API keys inline instead of relying on a separate manual env step. CCS stores those secrets through `global_env`, reflects masked key state in `/api/websearch`, and counts dashboard-managed keys as ready in the WebSearch status flow. - **2026-03-27**: **#812** CCS now includes a first-class `ccs docker` command suite for self-hosting the integrated Dashboard + CLIProxy stack. The CLI can stage bundled Docker assets locally or to a remote `--host` over SSH, report compose/supervisor status, stream CCS or CLIProxy logs, and run in-container update flows without relying on ad-hoc deployment scripts. diff --git a/docs/websearch.md b/docs/websearch.md index 914200b1..7c83632b 100644 --- a/docs/websearch.md +++ b/docs/websearch.md @@ -1,6 +1,6 @@ # WebSearch Configuration Guide -Last Updated: 2026-03-27 +Last Updated: 2026-03-30 CCS provides automatic web search for third-party profiles that cannot access Anthropic's native WebSearch API. @@ -12,7 +12,7 @@ Native Claude subscription accounts still use Anthropic's server-side WebSearch ### Third-Party Profiles -Third-party profiles cannot execute Anthropic's server-side WebSearch because the tool never reaches their backend. CCS now solves that by intercepting WebSearch and running real local search providers directly. +Third-party profiles cannot execute Anthropic's server-side WebSearch because the tool never reaches their backend. CCS now solves that by provisioning a first-class local MCP tool, suppressing native `WebSearch` for those launches, and running real local search providers directly. ## Architecture @@ -20,30 +20,37 @@ Third-party profiles cannot execute Anthropic's server-side WebSearch because th ┌──────────────────────────────────────────────────────────────┐ │ Claude Code CLI │ │ │ -│ WebSearch Tool Request │ +│ Search Request │ │ │ │ │ ├── Native Claude Account? → Anthropic WebSearch API │ │ │ │ -│ └── Third-party Profile? → PreToolUse Hook │ +│ └── Third-party Profile? → native WebSearch disabled │ │ │ │ -│ ├── 1. Exa Search API │ -│ ├── 2. Tavily Search API │ -│ ├── 3. Brave Search API │ -│ ├── 4. DuckDuckGo HTML │ -│ └── 5. Legacy CLI fallback │ -│ (Gemini/OpenCode/Grok) │ +│ └── CCS MCP tool │ +│ ccs-websearch.search │ +│ │ │ +│ ├── 1. Exa │ +│ ├── 2. Tavily │ +│ ├── 3. Brave │ +│ ├── 4. DuckDuckGo│ +│ └── 5. Legacy CLI│ +│ fallback │ +│ (Gemini/ │ +│ OpenCode/ │ +│ Grok) │ └──────────────────────────────────────────────────────────────┘ ``` ## Why This Changed -The previous design asked another model CLI to perform web search and summarize the answer. That was brittle: +The previous design asked another model CLI to perform web search and summarize the answer. A later compatibility path also depended on a denied native-tool hook. Both were brittle: - CLI syntax changed upstream - auth state varied per tool - prompt/tool behavior drifted across releases +- hook-shaped denial output produced awkward host UX -The new flow matches the `goclaw` model more closely: web search is treated as a first-class deterministic capability, not an LLM-to-LLM workaround. +The new flow matches the `goclaw` model more closely: web search is treated as a first-class deterministic capability, not an LLM-to-LLM workaround or a denied native tool call. ## Providers @@ -109,8 +116,14 @@ websearch: | `TAVILY_API_KEY` | Enables Tavily when `providers.tavily.enabled: true` | | `BRAVE_API_KEY` | Enables Brave Search when `providers.brave.enabled: true` | | `GROK_API_KEY` | Required only for legacy Grok CLI fallback | -| `CCS_WEBSEARCH_SKIP` | Skip hook entirely | -| `CCS_DEBUG` | Verbose hook logging | +| `CCS_WEBSEARCH_SKIP` | Disable the CCS local WebSearch runtime for the current process | +| `CCS_DEBUG` | Verbose WebSearch runtime logging | + +## Managed Runtime Files + +- `~/.claude.json` → CCS manages `mcpServers.ccs-websearch` +- `~/.ccs/mcp/ccs-websearch-server.cjs` → local MCP server binary +- `~/.ccs/hooks/websearch-transformer.cjs` → shared provider runtime plus legacy compatibility fallback ## Troubleshooting @@ -140,7 +153,7 @@ Those providers remain supported, but they are no longer the primary path. Enabl 1. Check `websearch.enabled: true` 2. Keep DuckDuckGo enabled unless you have a strong reason to disable it 3. If using Exa, Tavily, or Brave, verify the matching API key -4. Run with `CCS_DEBUG=1` for hook logs +4. Run with `CCS_DEBUG=1` for runtime logs ## Security Considerations diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index 95311502..9b7faf42 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -576,6 +576,90 @@ function outputAllFailedMessage(query, errors) { emitHookOutput(buildFailureHookOutput(query, errors)); } +function getConfiguredProviders() { + return [ + { + name: 'Exa', + id: 'exa', + available: () => isProviderEnabled('exa') && Boolean(getProviderApiKey('exa')), + fn: tryExaSearch, + }, + { + name: 'Tavily', + id: 'tavily', + available: () => isProviderEnabled('tavily') && Boolean(getProviderApiKey('tavily')), + fn: tryTavilySearch, + }, + { + name: 'Brave Search', + id: 'brave', + available: () => isProviderEnabled('brave') && Boolean(getProviderApiKey('brave')), + fn: tryBraveSearch, + }, + { + name: 'DuckDuckGo', + id: 'duckduckgo', + available: () => isProviderEnabled('duckduckgo'), + fn: tryDuckDuckGoSearch, + }, + { + name: 'Gemini CLI', + id: 'gemini', + available: () => isProviderEnabled('gemini') && isCliAvailable('gemini'), + fn: tryGeminiSearch, + }, + { + name: 'OpenCode', + id: 'opencode', + available: () => isProviderEnabled('opencode') && isCliAvailable('opencode'), + fn: tryOpenCodeSearch, + }, + { + name: 'Grok CLI', + id: 'grok', + available: () => isProviderEnabled('grok') && isCliAvailable('grok'), + fn: tryGrokSearch, + }, + ]; +} + +function getActiveProviders() { + return getConfiguredProviders().filter((provider) => provider.available()); +} + +function hasAnyActiveProviders() { + return getActiveProviders().length > 0; +} + +async function runLocalWebSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { + const activeProviders = getActiveProviders(); + + debug( + `Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}` + ); + + if (activeProviders.length === 0) { + return { success: false, noActiveProviders: true, errors: [] }; + } + + const errors = []; + for (const provider of activeProviders) { + debug(`Trying ${provider.name}`); + const result = await provider.fn(query, timeoutSec); + if (result.success) { + return { + success: true, + providerId: provider.id, + providerName: provider.name, + content: result.content, + }; + } + errors.push({ provider: provider.name, error: result.error }); + } + + return { success: false, noActiveProviders: false, errors }; +} + async function processHook(input) { try { if (shouldSkipHook()) { @@ -596,72 +680,17 @@ async function processHook(input) { process.env.CCS_WEBSEARCH_TIMEOUT || `${DEFAULT_TIMEOUT_SEC}`, 10 ); - const providers = [ - { - name: 'Exa', - id: 'exa', - available: () => isProviderEnabled('exa') && Boolean(getProviderApiKey('exa')), - fn: tryExaSearch, - }, - { - name: 'Tavily', - id: 'tavily', - available: () => isProviderEnabled('tavily') && Boolean(getProviderApiKey('tavily')), - fn: tryTavilySearch, - }, - { - name: 'Brave Search', - id: 'brave', - available: () => isProviderEnabled('brave') && Boolean(getProviderApiKey('brave')), - fn: tryBraveSearch, - }, - { - name: 'DuckDuckGo', - id: 'duckduckgo', - available: () => isProviderEnabled('duckduckgo'), - fn: tryDuckDuckGoSearch, - }, - { - name: 'Gemini CLI', - id: 'gemini', - available: () => isProviderEnabled('gemini') && isCliAvailable('gemini'), - fn: tryGeminiSearch, - }, - { - name: 'OpenCode', - id: 'opencode', - available: () => isProviderEnabled('opencode') && isCliAvailable('opencode'), - fn: tryOpenCodeSearch, - }, - { - name: 'Grok CLI', - id: 'grok', - available: () => isProviderEnabled('grok') && isCliAvailable('grok'), - fn: tryGrokSearch, - }, - ]; - - const activeProviders = providers.filter((provider) => provider.available()); - debug( - `Enabled providers: ${activeProviders.map((provider) => provider.name).join(', ') || 'none'}` - ); - - if (activeProviders.length === 0) { + const result = await runLocalWebSearch(query, timeout); + if (result.noActiveProviders) { process.exit(0); } - const errors = []; - for (const provider of activeProviders) { - debug(`Trying ${provider.name}`); - const result = await provider.fn(query, timeout); - if (result.success) { - outputSuccess(query, result.content, provider.name); - return; - } - errors.push({ provider: provider.name, error: result.error }); + if (result.success) { + outputSuccess(query, result.content, result.providerName); + return; } - outputAllFailedMessage(query, errors); + outputAllFailedMessage(query, result.errors); } catch (error) { debug(`Hook error: ${error.message}`); process.exit(0); @@ -691,6 +720,10 @@ module.exports = { buildSuccessHookOutput, extractDuckDuckGoResults, formatStructuredSearchResults, + getActiveProviders, + hasAnyActiveProviders, + runLocalWebSearch, + shouldSkipHook, tryExaSearch, tryTavilySearch, tryDuckDuckGoSearch, diff --git a/lib/mcp/ccs-websearch-server.cjs b/lib/mcp/ccs-websearch-server.cjs new file mode 100644 index 00000000..4d891899 --- /dev/null +++ b/lib/mcp/ccs-websearch-server.cjs @@ -0,0 +1,207 @@ +#!/usr/bin/env node + +const { + hasAnyActiveProviders, + runLocalWebSearch, + shouldSkipHook, +} = 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 = 'search'; +const TOOL_DESCRIPTION = + 'Search the web through CCS-managed providers. Provider order: Exa, Tavily, Brave Search, DuckDuckGo, then optional legacy CLI fallback.'; + +let inputBuffer = Buffer.alloc(0); + +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: 'The search query to run against CCS WebSearch providers.', + }, + }, + required: ['query'], + additionalProperties: false, + }, + }, + ]; +} + +function writeMessage(message) { + const body = Buffer.from(JSON.stringify(message), 'utf8'); + process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`); + process.stdout.write(body); +} + +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 || {}; + + if (params.name !== TOOL_NAME) { + writeError(id, -32602, `Unknown tool: ${params.name || ''}`); + return; + } + + if (!shouldExposeTools()) { + writeResponse(id, { + content: [ + { + type: 'text', + text: 'CCS WebSearch is unavailable for this profile or no providers are ready.', + }, + ], + isError: true, + }); + return; + } + + const query = typeof toolArgs.query === 'string' ? toolArgs.query.trim() : ''; + if (!query) { + writeError(id, -32602, 'Tool "search" requires a non-empty string query.'); + return; + } + + const result = await runLocalWebSearch(query); + if (result.success) { + writeResponse(id, { + content: [{ type: 'text', text: result.content }], + }); + return; + } + + 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': + 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': + writeResponse(message.id, { tools: getTools() }); + return; + case 'tools/call': + await handleToolCall(message); + return; + default: + if (message.id !== undefined) { + writeError(message.id, -32601, `Method not found: ${message.method}`); + } + } +} + +function parseMessages() { + while (true) { + 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; + } + + const body = inputBuffer.slice(headerEnd + 4, messageEnd).toString('utf8'); + inputBuffer = inputBuffer.slice(messageEnd); + + 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.stdin.resume(); diff --git a/src/api/services/profile-lifecycle-service.ts b/src/api/services/profile-lifecycle-service.ts index e54a3e92..50f3b86b 100644 --- a/src/api/services/profile-lifecycle-service.ts +++ b/src/api/services/profile-lifecycle-service.ts @@ -10,7 +10,7 @@ import type { Config, Settings } from '../../types'; import type { TargetType } from '../../targets/target-adapter'; import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata'; import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager'; -import { ensureProfileHooksOrThrow } from '../../utils/websearch/profile-hook-injector'; +import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; import { isSensitiveKey } from '../../utils/sensitive-keys'; import { isReservedName } from '../../config/reserved-names'; import { isUnifiedMode, mutateUnifiedConfig } from '../../config/unified-config-loader'; @@ -218,7 +218,7 @@ export function registerApiProfileOrphans(options?: { try { if (orphan.validation.valid) { - ensureProfileHooksOrThrow(orphan.name); + ensureWebSearchMcpOrThrow(); } registerApiProfileInConfig(orphan.name, options?.target || 'claude', options?.force || false); result.registered.push(orphan.name); @@ -268,7 +268,7 @@ export function copyApiProfile( writeJsonObjectAtomically(destinationSettingsPath, sourceSettings); try { - ensureProfileHooksOrThrow(destination); + ensureWebSearchMcpOrThrow(); } catch (hookError) { rollbackSettingsFile(destinationSettingsPath, previousDestinationContent, destinationExisted); throw hookError; @@ -393,7 +393,7 @@ export function importApiProfileBundle( writeJsonObjectAtomically(settingsPath, settings); try { - ensureProfileHooksOrThrow(name); + ensureWebSearchMcpOrThrow(); } catch (hookError) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw hookError; diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 912dc5b3..efb70c32 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -8,7 +8,7 @@ import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-man import { expandPath } from '../../utils/helpers'; import { validateApiName } from './validation-service'; import { mutateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; -import { ensureProfileHooksOrThrow } from '../../utils/websearch/profile-hook-injector'; +import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; import type { TargetType } from '../../targets/target-adapter'; import { resolveDroidProvider } from '../../targets/droid-provider'; import { isReservedName } from '../../config/reserved-names'; @@ -126,8 +126,7 @@ function createSettingsFile( fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); try { - // Inject WebSearch hooks into profile settings - ensureProfileHooksOrThrow(name); + ensureWebSearchMcpOrThrow(); } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; @@ -216,8 +215,7 @@ function createApiProfileUnified( fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8'); try { - // Inject WebSearch hooks into profile settings - ensureProfileHooksOrThrow(name); + ensureWebSearchMcpOrThrow(); } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; diff --git a/src/ccs.ts b/src/ccs.ts index ca2625fb..ff2f99dc 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -24,10 +24,11 @@ import { import { getEffectiveEnvVars, getCompositeEnvVars } from './cliproxy/config/env-builder'; import { CLIPROXY_DEFAULT_PORT } from './cliproxy/config/port-manager'; import { - ensureMcpWebSearch, + ensureWebSearchMcpOrThrow, displayWebSearchStatus, getWebSearchHookEnv, - ensureProfileHooksOrThrow, + syncWebSearchMcpToConfigDir, + appendThirdPartyWebSearchToolArgs, } from './utils/websearch-manager'; import { getGlobalEnvConfig, getOfficialChannelsConfig } from './config/unified-config-loader'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from './utils/hooks/image-analyzer-profile-hook-injector'; @@ -677,8 +678,9 @@ async function main(): Promise { if (profileInfo.type === 'cliproxy') { // CLIPROXY FLOW: OAuth-based profiles (gemini, codex, agy, qwen) or user-defined variants - // Inject WebSearch hook into profile settings before launch - ensureProfileHooksOrThrow(profileInfo.name); + if (resolvedTarget === 'claude') { + ensureWebSearchMcpOrThrow(); + } // Inject Image Analyzer hook into profile settings before launch ensureImageAnalyzerHooks(profileInfo.name); @@ -835,8 +837,7 @@ async function main(): Promise { }); } else if (profileInfo.type === 'copilot') { // COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy - // Inject WebSearch hook into profile settings before launch - ensureProfileHooksOrThrow(profileInfo.name); + ensureWebSearchMcpOrThrow(); // Inject Image Analyzer hook into profile settings before launch ensureImageAnalyzerHooks(profileInfo.name); @@ -858,6 +859,7 @@ async function main(): Promise { ) ); } + syncWebSearchMcpToConfigDir(continuityInheritance.claudeConfigDir); const exitCode = await executeCopilotProfile( copilotConfig, remainingArgs, @@ -867,14 +869,12 @@ async function main(): Promise { process.exit(exitCode); } else if (profileInfo.type === 'settings') { // Settings-based profiles (glm, glmt) are third-party providers - // WebSearch is server-side tool - third-party providers have no access - // Inject WebSearch hook into profile settings before launch - ensureProfileHooksOrThrow(profileInfo.name); + if (resolvedTarget === 'claude') { + ensureWebSearchMcpOrThrow(); + } // Inject Image Analyzer hook into profile settings before launch ensureImageAnalyzerHooks(profileInfo.name); - ensureMcpWebSearch(); - // Display WebSearch status (single line, equilibrium UX) displayWebSearchStatus(); @@ -894,6 +894,7 @@ async function main(): Promise { ); } const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; + syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir); const expandedSettingsPath = resolvedSettingsPath ?? (profileInfo.settingsPath @@ -1053,7 +1054,11 @@ async function main(): Promise { return; } - execClaude(claudeCli, ['--settings', expandedSettingsPath, ...remainingArgs], envVars); + execClaude( + claudeCli, + ['--settings', expandedSettingsPath, ...appendThirdPartyWebSearchToolArgs(remainingArgs)], + envVars + ); } else if (profileInfo.type === 'account') { // NEW FLOW: Account-based profile (work, personal) // All platforms: Use instance isolation with CLAUDE_CONFIG_DIR diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 1715d1bc..e8e07862 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -53,9 +53,9 @@ import { } from '../account-manager'; import { formatAccountDisplayName } from '../accounts/email-account-identity'; import { - ensureMcpWebSearch, - installWebSearchHook, + ensureWebSearchMcpOrThrow, displayWebSearchStatus, + appendThirdPartyWebSearchToolArgs, } from '../../utils/websearch-manager'; import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader'; import { installImageAnalyzerHook } from '../../utils/hooks'; @@ -197,9 +197,8 @@ export async function execClaudeWithCLIProxy( log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`); } - // Setup WebSearch hooks - ensureMcpWebSearch(); - installWebSearchHook(); + // Setup first-class CCS WebSearch runtime + ensureWebSearchMcpOrThrow(); displayWebSearchStatus(); // Sync image analyzer hook from npm package to ~/.ccs/hooks/ @@ -1036,7 +1035,12 @@ export async function execClaudeWithCLIProxy( let claude: ChildProcess; if (needsShell) { - const cmdString = [claudeCli, '--settings', settingsPath, ...claudeArgs] + const cmdString = [ + claudeCli, + '--settings', + settingsPath, + ...appendThirdPartyWebSearchToolArgs(claudeArgs), + ] .map(escapeShellArg) .join(' '); claude = spawn(cmdString, { @@ -1046,11 +1050,15 @@ export async function execClaudeWithCLIProxy( env, }); } else { - claude = spawn(claudeCli, ['--settings', settingsPath, ...claudeArgs], { - stdio: 'inherit', - windowsHide: true, - env, - }); + claude = spawn( + claudeCli, + ['--settings', settingsPath, ...appendThirdPartyWebSearchToolArgs(claudeArgs)], + { + stdio: 'inherit', + windowsHide: true, + env, + } + ); } // 12b. Start runtime quota monitor (adaptive polling during session) diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index e55bb866..6e982528 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -14,7 +14,7 @@ import { expandPath } from '../../utils/helpers'; import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator'; import { CLIProxyProvider } from '../types'; import { CompositeTierConfig } from '../../config/unified-config-types'; -import { ensureProfileHooksOrThrow } from '../../utils/websearch/profile-hook-injector'; +import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager'; import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector'; import { getEffectiveApiKey } from '../auth-token-manager'; import { warn } from '../../utils/ui'; @@ -154,8 +154,7 @@ export function createSettingsFile( writeSettings(settingsPath, settings); try { - // Inject WebSearch hooks into variant settings - ensureProfileHooksOrThrow(`${provider}-${name}`); + ensureWebSearchMcpOrThrow(); } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; @@ -189,8 +188,7 @@ export function createSettingsFileUnified( writeSettings(settingsPath, settings); try { - // Inject WebSearch hooks into variant settings - ensureProfileHooksOrThrow(`${provider}-${name}`); + ensureWebSearchMcpOrThrow(); } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; @@ -289,7 +287,7 @@ export function createCompositeSettingsFile( // Hook injectors target ~/.ccs/.settings.json; only run for default path. if (path.resolve(settingsPath) === path.resolve(defaultSettingsPath)) { try { - ensureProfileHooksOrThrow(`composite-${name}`); + ensureWebSearchMcpOrThrow(); } catch (error) { rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); throw error; diff --git a/src/commands/install-command.ts b/src/commands/install-command.ts index 076e97fb..fb45f770 100644 --- a/src/commands/install-command.ts +++ b/src/commands/install-command.ts @@ -5,7 +5,7 @@ */ import { info, ok, color, box, initUI } from '../utils/ui'; -import { uninstallWebSearchHook } from '../utils/websearch'; +import { uninstallWebSearchHook, uninstallWebSearchMcp } from '../utils/websearch'; import { ClaudeSymlinkManager } from '../utils/claude-symlink-manager'; /** @@ -42,12 +42,19 @@ export async function handleUninstallCommand(): Promise { removed += 1; // Count as 1 item (the hook file) } - // 2. Remove symlinks from ~/.claude/ + // 2. Remove managed WebSearch MCP runtime/config + const mcpRemoved = uninstallWebSearchMcp(); + if (mcpRemoved) { + console.log(ok('Removed WebSearch MCP runtime')); + removed += 1; + } + + // 3. Remove symlinks from ~/.claude/ const symlinkManager = new ClaudeSymlinkManager(); const symlinksRemoved = symlinkManager.uninstall(); removed += symlinksRemoved; // Add actual count of symlinks removed - // 3. Summary + // 4. Summary console.log(''); if (removed > 0) { console.log(ok('Uninstall complete!')); diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 8f885cbd..47a2df5e 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -14,7 +14,11 @@ import { ensureCopilotApi } from './copilot-package-manager'; import { normalizeCopilotConfigWithWarnings } from './copilot-model-normalizer'; import { CopilotStatus } from './types'; import { fail, info, ok, warn } from '../utils/ui'; -import { getWebSearchHookEnv } from '../utils/websearch-manager'; +import { + getWebSearchHookEnv, + appendThirdPartyWebSearchToolArgs, + syncWebSearchMcpToConfigDir, +} from '../utils/websearch-manager'; import { getImageAnalysisHookEnv } from '../utils/hooks'; import { stripClaudeCodeEnv } from '../utils/shell-executor'; @@ -173,9 +177,11 @@ export async function executeCopilotProfile( console.log(info(`Using GitHub Copilot proxy (model: ${normalizedConfig.model})`)); console.log(''); + syncWebSearchMcpToConfigDir(claudeConfigDir); + // Spawn Claude CLI return new Promise((resolve) => { - const proc = spawn(claudeCliPath, claudeArgs, { + const proc = spawn(claudeCliPath, appendThirdPartyWebSearchToolArgs(claudeArgs), { stdio: 'inherit', env, shell: process.platform === 'win32', diff --git a/src/utils/claude-config-path.ts b/src/utils/claude-config-path.ts index 351c5be4..1c840041 100644 --- a/src/utils/claude-config-path.ts +++ b/src/utils/claude-config-path.ts @@ -25,6 +25,11 @@ export function getClaudeConfigDir(): string { return getDefaultClaudeConfigDir(); } +/** Resolve user-scope Claude JSON config path (~/.claude.json). */ +export function getClaudeUserConfigPath(): string { + return path.join(getCcsHome(), '.claude.json'); +} + /** Resolve Claude settings.json path. */ export function getClaudeSettingsPath(): string { return path.join(getClaudeConfigDir(), 'settings.json'); diff --git a/src/utils/websearch-manager.ts b/src/utils/websearch-manager.ts index 9c4a6dc7..1503695a 100644 --- a/src/utils/websearch-manager.ts +++ b/src/utils/websearch-manager.ts @@ -1,15 +1,15 @@ /** - * WebSearch Manager - Manages WebSearch hook for CCS + * WebSearch Manager - Manages CCS WebSearch runtime * * WebSearch is a server-side tool executed by Anthropic's API. * Third-party providers (gemini, agy, codex, qwen) don't have access. - * This manager installs a hook that uses deterministic local search backends, - * with legacy AI CLI tools kept only as optional fallback. + * CCS exposes a first-class local WebSearch tool for those profiles and keeps + * the legacy hook runtime only as a compatibility fallback. * - * Simplified Architecture: - * - No MCP dependency for the default path - * - Uses real search providers first (DuckDuckGo, Brave) - * - Keeps Gemini/OpenCode/Grok as compatibility fallback only + * Runtime Architecture: + * - User-scope MCP server in ~/.claude.json for third-party profiles + * - Real search providers first (Exa, Tavily, Brave Search, DuckDuckGo) + * - Gemini/OpenCode/Grok retained as optional legacy fallback * * @module utils/websearch-manager */ @@ -53,6 +53,23 @@ export { // Re-export hook environment export { getWebSearchHookEnv } from './websearch/hook-env'; +// Re-export MCP runtime helpers +export { + getWebSearchMcpServerName, + getWebSearchMcpServerPath, + installWebSearchMcpServer, + ensureWebSearchMcpConfig, + ensureWebSearchMcp, + uninstallWebSearchMcpServer, + removeWebSearchMcpConfig, + uninstallWebSearchMcp, + syncWebSearchMcpToConfigDir, + ensureWebSearchMcpOrThrow, +} from './websearch/mcp-installer'; + +// Re-export Claude launch arg helpers +export { appendThirdPartyWebSearchToolArgs } from './websearch/claude-tool-args'; + // Re-export status and readiness functions export { getWebSearchCliProviders, @@ -62,7 +79,7 @@ export { displayWebSearchStatus, } from './websearch/status'; -// Re-export profile hook injection +// Re-export profile compatibility hook injection export { ensureProfileHooks, ensureProfileHooksOrThrow } from './websearch/profile-hook-injector'; // Import for local use @@ -77,11 +94,4 @@ export function clearAllCliCaches(): void { clearOpenCodeCliCache(); } -// ========== Backward Compatibility Exports ========== - -/** - * @deprecated Use installWebSearchHook instead - MCP is no longer used - */ -export function ensureMcpWebSearch(): boolean { - return false; -} +export { ensureWebSearchMcp as ensureMcpWebSearch } from './websearch/mcp-installer'; diff --git a/src/utils/websearch/claude-tool-args.ts b/src/utils/websearch/claude-tool-args.ts new file mode 100644 index 00000000..8e9b7584 --- /dev/null +++ b/src/utils/websearch/claude-tool-args.ts @@ -0,0 +1,51 @@ +/** + * Claude launch argument helpers for third-party WebSearch. + */ + +const NATIVE_WEBSEARCH_TOOL = 'WebSearch'; +const DISALLOWED_TOOLS_FLAG = '--disallowedTools'; + +function parseToolValue(rawValue: string): string[] { + return rawValue + .split(',') + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +function hasToolInFlag(args: string[], flag: string, toolName: string): boolean { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === flag) { + for (let cursor = index + 1; cursor < args.length; cursor += 1) { + const value = args[cursor]; + if (value.startsWith('--')) { + break; + } + if (parseToolValue(value).includes(toolName)) { + return true; + } + } + continue; + } + + if (!arg.startsWith(`${flag}=`)) { + continue; + } + + const rawValue = arg.slice(flag.length + 1); + if (parseToolValue(rawValue).includes(toolName)) { + return true; + } + } + + return false; +} + +export function appendThirdPartyWebSearchToolArgs(args: string[]): string[] { + if (hasToolInFlag(args, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL)) { + return args; + } + + return [...args, DISALLOWED_TOOLS_FLAG, NATIVE_WEBSEARCH_TOOL]; +} diff --git a/src/utils/websearch/hook-installer.ts b/src/utils/websearch/hook-installer.ts index a2c7149a..dc5c1f70 100644 --- a/src/utils/websearch/hook-installer.ts +++ b/src/utils/websearch/hook-installer.ts @@ -1,7 +1,7 @@ /** * WebSearch Hook Installer * - * Manages installation and uninstallation of the WebSearch hook. + * Manages installation and uninstallation of the legacy WebSearch hook runtime. * * @module utils/websearch/hook-installer */ @@ -70,7 +70,8 @@ export function hasWebSearchHook(): boolean { /** * Install WebSearch hook to ~/.ccs/hooks/ * - * This hook intercepts WebSearch and executes via Gemini CLI. + * This hook now serves as a compatibility fallback and a shared provider runtime + * for the first-class CCS WebSearch MCP server. * * @returns true if hook installed successfully */ diff --git a/src/utils/websearch/index.ts b/src/utils/websearch/index.ts index 83c240de..5cd4cd27 100644 --- a/src/utils/websearch/index.ts +++ b/src/utils/websearch/index.ts @@ -50,6 +50,23 @@ export { removeHookConfig } from './hook-config'; // Hook Environment export { getWebSearchHookEnv } from './hook-env'; +// MCP Runtime +export { + getWebSearchMcpServerName, + getWebSearchMcpServerPath, + installWebSearchMcpServer, + ensureWebSearchMcpConfig, + ensureWebSearchMcp, + uninstallWebSearchMcpServer, + removeWebSearchMcpConfig, + uninstallWebSearchMcp, + syncWebSearchMcpToConfigDir, + ensureWebSearchMcpOrThrow, +} from './mcp-installer'; + +// Claude launch args +export { appendThirdPartyWebSearchToolArgs } from './claude-tool-args'; + // Status and Readiness export { getWebSearchCliProviders, @@ -61,5 +78,5 @@ export { export { WEBSEARCH_API_KEY_PROVIDERS, getWebSearchApiKeyStates } from './provider-secrets'; -// Profile Hook Injection +// Profile compatibility hook injection export { ensureProfileHooks, ensureProfileHooksOrThrow } from './profile-hook-injector'; diff --git a/src/utils/websearch/mcp-installer.ts b/src/utils/websearch/mcp-installer.ts new file mode 100644 index 00000000..ff45b31f --- /dev/null +++ b/src/utils/websearch/mcp-installer.ts @@ -0,0 +1,329 @@ +/** + * WebSearch MCP installer and ~/.claude.json provisioning. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { getWebSearchConfig } from '../../config/unified-config-loader'; +import { getCcsDir } from '../config-manager'; +import { getClaudeUserConfigPath } from '../claude-config-path'; +import { info, warn } from '../ui'; +import { InstanceManager } from '../../management/instance-manager'; +import { installWebSearchHook } from './hook-installer'; + +const WEBSEARCH_MCP_SERVER = 'ccs-websearch-server.cjs'; +const WEBSEARCH_MCP_SERVER_NAME = 'ccs-websearch'; + +interface ClaudeUserConfig { + mcpServers?: Record; + [key: string]: unknown; +} + +function getCcsMcpDir(): string { + return path.join(getCcsDir(), 'mcp'); +} + +export function getWebSearchMcpServerName(): string { + return WEBSEARCH_MCP_SERVER_NAME; +} + +export function getWebSearchMcpServerPath(): string { + return path.join(getCcsMcpDir(), WEBSEARCH_MCP_SERVER); +} + +function hasMatchingContents(sourcePath: string, destinationPath: string): boolean { + if (!fs.existsSync(destinationPath)) { + return false; + } + + const source = fs.readFileSync(sourcePath); + try { + const destination = fs.readFileSync(destinationPath); + return source.equals(destination); + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error( + warn(`Existing WebSearch MCP server is unreadable: ${(error as Error).message}`) + ); + } + return false; + } +} + +function getTempPath(targetPath: string): string { + const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${targetPath}.${suffix}.tmp`; +} + +function resolveBundledServerSourcePath(): string | null { + const possiblePaths = [ + path.join(__dirname, '..', '..', '..', 'lib', 'mcp', WEBSEARCH_MCP_SERVER), + path.join(__dirname, '..', '..', 'lib', 'mcp', WEBSEARCH_MCP_SERVER), + path.join(__dirname, '..', 'lib', 'mcp', WEBSEARCH_MCP_SERVER), + ]; + + for (const candidate of possiblePaths) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +function readClaudeUserConfig(configPath: string): ClaudeUserConfig | null { + if (!fs.existsSync(configPath)) { + return {}; + } + + try { + const raw = fs.readFileSync(configPath, 'utf8'); + const parsed = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return null; + } + return parsed as ClaudeUserConfig; + } catch { + return null; + } +} + +function writeClaudeUserConfig(configPath: string, config: ClaudeUserConfig): boolean { + const tempPath = getTempPath(configPath); + + try { + fs.writeFileSync(tempPath, JSON.stringify(config, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, configPath); + return true; + } finally { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } +} + +function removeManagedServerConfig(configPath: string): boolean { + if (!fs.existsSync(configPath)) { + return false; + } + + const config = readClaudeUserConfig(configPath); + if (config === null) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`)); + } + return false; + } + + const existingServers = + config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) + ? { ...(config.mcpServers as Record) } + : {}; + + if (!(WEBSEARCH_MCP_SERVER_NAME in existingServers)) { + return false; + } + + delete existingServers[WEBSEARCH_MCP_SERVER_NAME]; + + const nextConfig: ClaudeUserConfig = { ...config }; + if (Object.keys(existingServers).length === 0) { + delete nextConfig.mcpServers; + } else { + nextConfig.mcpServers = existingServers; + } + + try { + writeClaudeUserConfig(configPath, nextConfig); + if (process.env.CCS_DEBUG) { + console.error(info(`Removed WebSearch MCP config from ${configPath}`)); + } + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error( + warn( + `Failed to remove WebSearch MCP config from ${configPath}: ${(error as Error).message}` + ) + ); + } + return false; + } +} + +export function installWebSearchMcpServer(): boolean { + const wsConfig = getWebSearchConfig(); + if (!wsConfig.enabled) { + return false; + } + + if (!installWebSearchHook()) { + if (process.env.CCS_DEBUG) { + console.error( + warn('WebSearch MCP server install skipped because hook runtime is unavailable') + ); + } + return false; + } + + const sourcePath = resolveBundledServerSourcePath(); + if (!sourcePath) { + if (process.env.CCS_DEBUG) { + console.error(warn(`WebSearch MCP server source not found: ${WEBSEARCH_MCP_SERVER}`)); + } + return false; + } + + const mcpDir = getCcsMcpDir(); + if (!fs.existsSync(mcpDir)) { + fs.mkdirSync(mcpDir, { recursive: true, mode: 0o700 }); + } + + const serverPath = getWebSearchMcpServerPath(); + if (hasMatchingContents(sourcePath, serverPath)) { + return true; + } + + const tempPath = getTempPath(serverPath); + + try { + fs.copyFileSync(sourcePath, tempPath); + fs.chmodSync(tempPath, 0o755); + fs.renameSync(tempPath, serverPath); + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to install WebSearch MCP server: ${(error as Error).message}`)); + } + return false; + } finally { + if (fs.existsSync(tempPath)) { + fs.unlinkSync(tempPath); + } + } +} + +export function ensureWebSearchMcpConfig(): boolean { + const wsConfig = getWebSearchConfig(); + if (!wsConfig.enabled) { + return false; + } + + const claudeUserConfigPath = getClaudeUserConfigPath(); + const claudeUserConfigDir = path.dirname(claudeUserConfigPath); + const config = readClaudeUserConfig(claudeUserConfigPath); + + if (config === null) { + if (process.env.CCS_DEBUG) { + console.error(warn('Malformed ~/.claude.json prevents WebSearch MCP provisioning')); + } + return false; + } + + if (!fs.existsSync(claudeUserConfigDir)) { + fs.mkdirSync(claudeUserConfigDir, { recursive: true, mode: 0o700 }); + } + + const existingServers = + config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers) + ? (config.mcpServers as Record) + : {}; + const desiredServerConfig = { + command: 'node', + args: [getWebSearchMcpServerPath()], + }; + + const currentConfig = existingServers[WEBSEARCH_MCP_SERVER_NAME]; + if ( + typeof currentConfig === 'object' && + currentConfig !== null && + JSON.stringify(currentConfig) === JSON.stringify(desiredServerConfig) + ) { + return true; + } + + const nextConfig: ClaudeUserConfig = { + ...config, + mcpServers: { + ...existingServers, + [WEBSEARCH_MCP_SERVER_NAME]: desiredServerConfig, + }, + }; + + try { + writeClaudeUserConfig(claudeUserConfigPath, nextConfig); + if (process.env.CCS_DEBUG) { + console.error(info(`Ensured WebSearch MCP config in ${claudeUserConfigPath}`)); + } + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`)); + } + return false; + } +} + +export function ensureWebSearchMcp(): boolean { + const wsConfig = getWebSearchConfig(); + if (!wsConfig.enabled) { + return false; + } + + return installWebSearchMcpServer() && ensureWebSearchMcpConfig(); +} + +export function syncWebSearchMcpToConfigDir(claudeConfigDir: string | undefined): boolean { + if (!claudeConfigDir) { + return false; + } + + return new InstanceManager().syncMcpServers(claudeConfigDir); +} + +export function uninstallWebSearchMcpServer(): boolean { + const serverPath = getWebSearchMcpServerPath(); + if (!fs.existsSync(serverPath)) { + return false; + } + + try { + fs.unlinkSync(serverPath); + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to remove WebSearch MCP server: ${(error as Error).message}`)); + } + return false; + } +} + +export function removeWebSearchMcpConfig(): boolean { + let removed = removeManagedServerConfig(getClaudeUserConfigPath()); + + const instanceManager = new InstanceManager(); + for (const instanceName of instanceManager.listInstances()) { + const instancePath = instanceManager.getInstancePath(instanceName); + const instanceClaudeConfigPath = path.join(instancePath, '.claude.json'); + removed = removeManagedServerConfig(instanceClaudeConfigPath) || removed; + } + + return removed; +} + +export function uninstallWebSearchMcp(): boolean { + const removedConfig = removeWebSearchMcpConfig(); + const removedServer = uninstallWebSearchMcpServer(); + return removedConfig || removedServer; +} + +export function ensureWebSearchMcpOrThrow(): void { + const wsConfig = getWebSearchConfig(); + if (!wsConfig.enabled) { + return; + } + + if (!ensureWebSearchMcp()) { + throw new Error('WebSearch is enabled, but CCS could not prepare the local WebSearch tool.'); + } +} diff --git a/src/utils/websearch/profile-hook-injector.ts b/src/utils/websearch/profile-hook-injector.ts index cb98412d..62bbed64 100644 --- a/src/utils/websearch/profile-hook-injector.ts +++ b/src/utils/websearch/profile-hook-injector.ts @@ -1,8 +1,9 @@ /** * Profile Hook Injector * - * Injects WebSearch hooks into per-profile settings files. - * This replaces the global ~/.claude/settings.json approach. + * Injects the legacy WebSearch compatibility hook into per-profile settings files. + * The first-class runtime now uses the CCS-managed MCP server; these hooks remain + * for compatibility and migration safety only. * * @module utils/websearch/profile-hook-injector */ @@ -76,7 +77,8 @@ function migrateGlobalHook(): void { } /** - * Ensure WebSearch hook is configured in profile's settings file + * Ensure the legacy WebSearch compatibility hook is configured in a profile's + * settings file when that path is still needed. * * @param profileName - Name of the profile (e.g., 'agy', 'gemini', 'glm') * @returns true if hook is configured (existing or newly added) diff --git a/tests/unit/api/profile-lifecycle-service.test.ts b/tests/unit/api/profile-lifecycle-service.test.ts index 758c9aec..bd832b47 100644 --- a/tests/unit/api/profile-lifecycle-service.test.ts +++ b/tests/unit/api/profile-lifecycle-service.test.ts @@ -128,7 +128,7 @@ describe('profile lifecycle service', () => { expect(result.skipped).toEqual([]); }); - it('does not register orphan profiles when WebSearch hook setup fails', async () => { + it('does not register orphan profiles when local WebSearch tool setup fails', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -152,7 +152,7 @@ describe('profile lifecycle service', () => { expect(copyFileSpy).toHaveBeenCalled(); expect(result.registered).toEqual([]); expect(result.skipped).toHaveLength(1); - expect(result.skipped[0]?.reason).toContain('could not prepare the profile hook'); + expect(result.skipped[0]?.reason).toContain('could not prepare the local WebSearch tool'); expect(config.profiles.extra).toBeUndefined(); }); @@ -240,7 +240,7 @@ describe('profile lifecycle service', () => { expect(result.error).toContain('Invalid source profile name'); }); - it('rolls back copied settings when WebSearch hook setup fails', async () => { + it('rolls back copied settings when local WebSearch tool setup fails', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); fs.writeFileSync( @@ -263,7 +263,7 @@ describe('profile lifecycle service', () => { const result = await runInScopedCcsDir(() => copyApiProfile('source', 'copy-dest')); expect(result.success).toBe(false); - expect(result.error).toContain('could not prepare the profile hook'); + expect(result.error).toContain('could not prepare the local WebSearch tool'); expect(copyFileSpy).toHaveBeenCalled(); expect(fs.existsSync(path.join(ccsDir, 'copy-dest.settings.json'))).toBe(false); }); @@ -287,7 +287,7 @@ describe('profile lifecycle service', () => { expect(result.error).toContain('Invalid bundle profile target'); }); - it('rolls back imported settings when WebSearch hook setup fails', async () => { + it('rolls back imported settings when local WebSearch tool setup fails', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); fs.writeFileSync( @@ -314,7 +314,7 @@ describe('profile lifecycle service', () => { ); expect(result.success).toBe(false); - expect(result.error).toContain('could not prepare the profile hook'); + expect(result.error).toContain('could not prepare the local WebSearch tool'); expect(copyFileSpy).toHaveBeenCalled(); expect(fs.existsSync(path.join(ccsDir, 'import-failure.settings.json'))).toBe(false); }); diff --git a/tests/unit/api/profile-writer-anthropic.test.ts b/tests/unit/api/profile-writer-anthropic.test.ts index fe1d8973..d3c52fa7 100644 --- a/tests/unit/api/profile-writer-anthropic.test.ts +++ b/tests/unit/api/profile-writer-anthropic.test.ts @@ -104,7 +104,7 @@ describe('profile-writer Anthropic direct', () => { expect(settings.env.ANTHROPIC_API_KEY).toBe(''); }); - it('rolls back the created settings file when WebSearch hook installation fails', () => { + it('rolls back the created settings file when local WebSearch tool setup fails', () => { const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => { throw new Error('copy failed'); }); @@ -117,7 +117,7 @@ describe('profile-writer Anthropic direct', () => { ); expect(result.success).toBe(false); - expect(result.error).toContain('could not prepare the profile hook'); + expect(result.error).toContain('could not prepare the local WebSearch tool'); expect(copyFileSpy).toHaveBeenCalled(); expect(fs.existsSync(path.join(tempHome, '.ccs', 'hook-failure.settings.json'))).toBe(false); }); diff --git a/tests/unit/hooks/ccs-websearch-mcp-server.test.ts b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts new file mode 100644 index 00000000..9b0e35df --- /dev/null +++ b/tests/unit/hooks/ccs-websearch-mcp-server.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from 'bun:test'; +import { spawn } from 'child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-websearch-server.cjs'); + +function encodeMessage(message: unknown): string { + const body = JSON.stringify(message); + return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`; +} + +function collectResponses( + child: ReturnType, + expectedCount: number +): Promise>> { + return new Promise((resolve, reject) => { + let buffer = Buffer.alloc(0); + const responses: Array> = []; + const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 5000); + + function tryParse(): void { + while (true) { + const headerEnd = buffer.indexOf('\r\n\r\n'); + if (headerEnd === -1) { + return; + } + + const headerText = buffer.slice(0, headerEnd).toString('utf8'); + const match = headerText.match(/content-length:\s*(\d+)/i); + if (!match) { + reject(new Error('Missing Content-Length header')); + return; + } + + const contentLength = Number.parseInt(match[1], 10); + const messageEnd = headerEnd + 4 + contentLength; + if (buffer.length < messageEnd) { + return; + } + + const body = buffer.slice(headerEnd + 4, messageEnd).toString('utf8'); + buffer = buffer.slice(messageEnd); + responses.push(JSON.parse(body) as Record); + + if (responses.length >= expectedCount) { + clearTimeout(timer); + resolve(responses); + return; + } + } + } + + child.stdout.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + try { + tryParse(); + } catch (error) { + clearTimeout(timer); + reject(error); + } + }); + + child.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + + child.stderr.on('data', () => { + // Ignore debug noise in tests. + }); + }); +} + +describe('ccs-websearch MCP server', () => { + it('lists the CCS search tool and returns provider-backed results', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'ccs-websearch-mcp-server-')); + const preloadPath = join(tempDir, 'mock-fetch.cjs'); + const html = ` + Example title + Example snippet + `.trim(); + writeFileSync( + preloadPath, + `global.fetch = async () => ({ ok: true, text: async () => ${JSON.stringify(html)} });\n`, + 'utf8' + ); + + const child = spawn('node', ['-r', preloadPath, serverPath], { + env: { + ...process.env, + CCS_PROFILE_TYPE: 'settings', + CCS_WEBSEARCH_ENABLED: '1', + CCS_WEBSEARCH_SKIP: '0', + CCS_WEBSEARCH_BRAVE: '0', + CCS_WEBSEARCH_DUCKDUCKGO: '1', + CCS_WEBSEARCH_EXA: '0', + CCS_WEBSEARCH_GEMINI: '0', + CCS_WEBSEARCH_GROK: '0', + CCS_WEBSEARCH_OPENCODE: '0', + CCS_WEBSEARCH_TAVILY: '0', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + try { + const responsesPromise = collectResponses(child, 3); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bun-test', version: '1.0.0' }, + }, + }) + ); + child.stdin.write(encodeMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' })); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'search', arguments: { query: 'btc price' } }, + }) + ); + + const responses = await responsesPromise; + const toolsList = responses.find((message) => message.id === 2); + const toolCall = responses.find((message) => message.id === 3); + + expect(toolsList?.result).toEqual({ + tools: [ + { + name: 'search', + description: + 'Search the web through CCS-managed providers. Provider order: Exa, Tavily, Brave Search, DuckDuckGo, then optional legacy CLI fallback.', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The search query to run against CCS WebSearch providers.', + }, + }, + required: ['query'], + additionalProperties: false, + }, + }, + ], + }); + expect(toolCall?.result).toBeDefined(); + expect( + ((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text + ).toContain('CCS local WebSearch evidence'); + expect( + ((toolCall?.result as { content: Array<{ text: string }> }).content[0] || {}).text + ).toContain('Provider: DuckDuckGo'); + } finally { + child.kill(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('hides the tool for native account profiles', async () => { + const child = spawn('node', [serverPath], { + env: { + ...process.env, + CCS_PROFILE_TYPE: 'account', + CCS_WEBSEARCH_ENABLED: '1', + CCS_WEBSEARCH_SKIP: '1', + CCS_WEBSEARCH_DUCKDUCKGO: '1', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + try { + const responsesPromise = collectResponses(child, 2); + child.stdin.write( + encodeMessage({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'bun-test', version: '1.0.0' }, + }, + }) + ); + child.stdin.write(encodeMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' })); + + const responses = await responsesPromise; + const toolsList = responses.find((message) => message.id === 2); + expect(toolsList?.result).toEqual({ tools: [] }); + } finally { + child.kill(); + } + }); +}); diff --git a/tests/unit/targets/settings-profile-websearch-launch.test.ts b/tests/unit/targets/settings-profile-websearch-launch.test.ts index 3753a218..e0ab84f4 100644 --- a/tests/unit/targets/settings-profile-websearch-launch.test.ts +++ b/tests/unit/targets/settings-profile-websearch-launch.test.ts @@ -92,7 +92,7 @@ exit 0 fs.rmSync(tmpHome, { recursive: true, force: true }); }); - it('fails before Claude launch when an enabled WebSearch hook cannot be prepared', () => { + it('fails before Claude launch when the local WebSearch tool runtime cannot be prepared', () => { if (process.platform === 'win32') return; fs.writeFileSync(path.join(ccsDir, 'hooks'), 'not-a-directory', 'utf8'); @@ -100,7 +100,7 @@ exit 0 const result = runCcs(['glm', 'smoke'], baseEnv); expect(result.status).toBe(1); - expect(result.stderr).toContain('could not prepare the profile hook for "glm"'); + expect(result.stderr).toContain('could not prepare the local WebSearch tool'); expect(fs.existsSync(claudeArgsLogPath)).toBe(false); }); @@ -117,7 +117,9 @@ exit 0 const result = runCcs(['glm', 'smoke'], baseEnv); expect(result.status).toBe(0); - expect(result.stderr).not.toContain('could not prepare the profile hook for "glm"'); + expect(result.stderr).not.toContain('could not prepare the local WebSearch tool'); expect(fs.existsSync(claudeArgsLogPath)).toBe(true); + expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).toContain('--disallowedTools'); + expect(fs.readFileSync(claudeArgsLogPath, 'utf8')).toContain('WebSearch'); }); }); diff --git a/tests/unit/utils/websearch/claude-tool-args.test.ts b/tests/unit/utils/websearch/claude-tool-args.test.ts new file mode 100644 index 00000000..e96d4de0 --- /dev/null +++ b/tests/unit/utils/websearch/claude-tool-args.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'bun:test'; +import { appendThirdPartyWebSearchToolArgs } from '../../../../src/utils/websearch/claude-tool-args'; + +describe('appendThirdPartyWebSearchToolArgs', () => { + it('appends native WebSearch suppression when no tool flags are present', () => { + expect(appendThirdPartyWebSearchToolArgs(['smoke'])).toEqual([ + 'smoke', + '--disallowedTools', + 'WebSearch', + ]); + }); + + it('does not append duplicate suppression when WebSearch is already disallowed', () => { + expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools', 'WebSearch'])).toEqual( + ['smoke', '--disallowedTools', 'WebSearch'] + ); + }); + + it('detects comma-separated disallowed tool values', () => { + expect(appendThirdPartyWebSearchToolArgs(['smoke', '--disallowedTools=Read,WebSearch'])).toEqual( + ['smoke', '--disallowedTools=Read,WebSearch'] + ); + }); +}); diff --git a/tests/unit/utils/websearch/mcp-installer.test.ts b/tests/unit/utils/websearch/mcp-installer.test.ts new file mode 100644 index 00000000..53b3b10b --- /dev/null +++ b/tests/unit/utils/websearch/mcp-installer.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getHookPath } from '../../../../src/utils/websearch/hook-config'; +import { + ensureWebSearchMcp, + getWebSearchMcpServerName, + getWebSearchMcpServerPath, + uninstallWebSearchMcp, +} from '../../../../src/utils/websearch/mcp-installer'; + +describe('ensureWebSearchMcp', () => { + let tempHome: string | undefined; + let originalCcsHome: string | undefined; + + function setupTempHome(): string { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-mcp-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + return tempHome; + } + + function getCcsDir(): string { + if (!tempHome) { + throw new Error('tempHome not initialized'); + } + return path.join(tempHome, '.ccs'); + } + + function writeEnabledConfig(): void { + const ccsDir = getCcsDir(); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + ['version: 12', 'websearch:', ' enabled: true', ' providers:', ' duckduckgo:', ' enabled: true', ''].join('\n'), + 'utf8' + ); + } + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + + tempHome = undefined; + originalCcsHome = undefined; + }); + + it('installs the MCP server and preserves existing user mcpServers entries', () => { + setupTempHome(); + writeEnabledConfig(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync( + claudeUserConfigPath, + JSON.stringify( + { + mcpServers: { + existing: { command: 'uvx', args: ['some-server'] }, + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + expect(ensureWebSearchMcp()).toBe(true); + expect(fs.existsSync(getHookPath())).toBe(true); + expect(fs.existsSync(getWebSearchMcpServerPath())).toBe(true); + + const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as { + mcpServers: Record; + }; + + expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] }); + expect(config.mcpServers[getWebSearchMcpServerName()]).toEqual({ + command: 'node', + args: [getWebSearchMcpServerPath()], + }); + }); + + it('returns false and preserves malformed ~/.claude.json', () => { + setupTempHome(); + writeEnabledConfig(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync(claudeUserConfigPath, '{ invalid json', 'utf8'); + + expect(ensureWebSearchMcp()).toBe(false); + expect(fs.readFileSync(claudeUserConfigPath, 'utf8')).toBe('{ invalid json'); + }); + + it('removes the managed MCP runtime while preserving unrelated server entries', () => { + setupTempHome(); + writeEnabledConfig(); + + const claudeUserConfigPath = path.join(tempHome as string, '.claude.json'); + fs.writeFileSync( + claudeUserConfigPath, + JSON.stringify( + { + mcpServers: { + existing: { command: 'uvx', args: ['some-server'] }, + }, + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + expect(ensureWebSearchMcp()).toBe(true); + + const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work'); + fs.mkdirSync(instancePath, { recursive: true }); + fs.writeFileSync( + path.join(instancePath, '.claude.json'), + JSON.stringify( + { + mcpServers: { + existing: { command: 'uvx', args: ['instance-server'] }, + [getWebSearchMcpServerName()]: { command: 'node', args: ['/tmp/override.cjs'] }, + }, + otherKey: 'keep-me', + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + expect(uninstallWebSearchMcp()).toBe(true); + expect(fs.existsSync(getWebSearchMcpServerPath())).toBe(false); + + const globalConfig = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as { + mcpServers: Record; + }; + expect(globalConfig.mcpServers).toEqual({ + existing: { command: 'uvx', args: ['some-server'] }, + }); + + const instanceConfig = JSON.parse( + fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8') + ) as { + otherKey: string; + mcpServers: Record; + }; + expect(instanceConfig.otherKey).toBe('keep-me'); + expect(instanceConfig.mcpServers).toEqual({ + existing: { command: 'uvx', args: ['instance-server'] }, + }); + }); +});