From 071ec041ed7e2cfa21cadb7f55bfa93d9fe8cb1b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 16 Dec 2025 00:42:13 -0500 Subject: [PATCH] feat(websearch): add multi-tier MCP fallback for third-party profiles Implements CCS WebSearch Native Infrastructure (Phases 1-3): Phase 1 - Multi-tier MCP Fallback: - Add mcp-manager.ts with ensureMcpWebSearch() for auto-configuration - Support web-search-prime (primary), Brave Search, and Tavily MCPs - Case-insensitive detection of existing web search MCPs - Block-websearch hook for optional native WebSearch blocking Phase 2 - User Configuration: - Add WebSearchConfig interface to unified-config-types.ts - Add getWebSearchConfig() to unified-config-loader.ts - Support configurable webSearchPrimeUrl for security - Add GET/PUT /api/websearch endpoints in routes.ts - Add WebSearch settings UI in settings.tsx dashboard Phase 3 - Documentation & Testing: - Add WebSearch section to README.md - Create comprehensive docs/websearch.md guide - Add 23 unit tests for MCP manager logic Enables web search for gemini, codex, agy, qwen, glm, kimi profiles that cannot access Anthropic's native WebSearch API. --- README.md | 39 +++ docs/websearch.md | 148 +++++++++++ lib/hooks/block-websearch.cjs | 75 ++++++ src/ccs.ts | 5 + src/cliproxy/cliproxy-executor.ts | 6 + src/config/unified-config-loader.ts | 46 +++- src/config/unified-config-types.ts | 22 ++ src/utils/mcp-manager.ts | 384 ++++++++++++++++++++++++++++ src/web-server/routes.ts | 78 ++++++ tests/unit/mcp-manager.test.ts | 269 +++++++++++++++++++ ui/src/pages/settings.tsx | 271 ++++++++++++++++++-- 11 files changed, 1314 insertions(+), 29 deletions(-) create mode 100644 docs/websearch.md create mode 100644 lib/hooks/block-websearch.cjs create mode 100644 src/utils/mcp-manager.ts create mode 100644 tests/unit/mcp-manager.test.ts diff --git a/README.md b/README.md index f1693ba8..bb69dc9c 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,45 @@ Without Developer Mode, CCS falls back to copying directories.
+## WebSearch + +Third-party profiles (Gemini, Codex, GLM, etc.) cannot use Anthropic's native WebSearch. CCS automatically configures MCP-based web search as a fallback. + +### How It Works + +| Profile Type | WebSearch Method | +|--------------|------------------| +| Claude (native) | Anthropic WebSearch API | +| OAuth providers | MCP web-search-prime (auto-configured) | +| API profiles | MCP web-search-prime (auto-configured) | + +### Configuration + +Configure via dashboard (**Settings** page) or `~/.ccs/config.yaml`: + +```yaml +websearch: + enabled: true # Enable/disable auto-config + provider: auto # auto | web-search-prime | brave | tavily + fallback: true # Enable fallback chain +``` + +### Optional API Keys + +For additional search providers, set environment variables: + +```bash +export BRAVE_API_KEY="your-key" # Free tier: 15k queries/month +export TAVILY_API_KEY="your-key" # AI-optimized search (paid) +``` + +> [!NOTE] +> `web-search-prime` works without API keys. Brave/Tavily are optional fallbacks. + +See [docs/websearch.md](./docs/websearch.md) for detailed configuration and troubleshooting. + +
+ ## Documentation | Topic | Link | diff --git a/docs/websearch.md b/docs/websearch.md new file mode 100644 index 00000000..c1a02392 --- /dev/null +++ b/docs/websearch.md @@ -0,0 +1,148 @@ +# WebSearch Configuration Guide + +CCS provides automatic web search capability for all profiles, including third-party providers that cannot access Anthropic's native WebSearch API. + +## How WebSearch Works + +### Native Claude Accounts + +When using a native Claude subscription account, WebSearch is handled by Anthropic's server-side API ($10/1000 searches, usage-based billing). + +### Third-Party Profiles + +Third-party profiles (OAuth and API-based) cannot use Anthropic's WebSearch because: +- Claude Code CLI executes tools locally +- CLIProxyAPI only receives conversation messages +- Tool execution never reaches the third-party backend + +CCS solves this by automatically configuring MCP (Model Context Protocol) web search servers. + +## Architecture + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Claude Code CLI │ +│ │ +│ WebSearch Tool Request │ +│ │ │ +│ ├── Native Claude Account? → Anthropic WebSearch API │ +│ │ ($10/1000 searches) │ +│ │ │ +│ └── Third-party Profile? → MCP Fallback Chain │ +│ │ │ +│ ├── 1. web-search-prime │ +│ ├── 2. Brave Search (free) │ +│ └── 3. Tavily (paid) │ +└──────────────────────────────────────────────────────────────┘ +``` + +## MCP Providers + +| Provider | Type | Cost | API Key Required | Notes | +|----------|------|------|------------------|-------| +| web-search-prime | HTTP MCP | Free | No | Primary, always available | +| Brave Search | stdio MCP | Free tier | `BRAVE_API_KEY` | 15k queries/month | +| Tavily | stdio MCP | Paid | `TAVILY_API_KEY` | AI-optimized search | + +## Configuration + +### Via Dashboard + +1. Open dashboard: `ccs config` +2. Navigate to **Settings** page +3. Configure WebSearch options: + - **Enable/Disable**: Toggle auto-configuration + - **Provider**: Choose preferred provider + - **Fallback**: Enable/disable fallback chain + +### Via Config File + +Edit `~/.ccs/config.yaml`: + +```yaml +websearch: + enabled: true # Enable auto-config (default: true) + provider: auto # auto | web-search-prime | brave | tavily + fallback: true # Enable fallback chain (default: true) + webSearchPrimeUrl: "https://..." # Optional: custom endpoint +``` + +### Provider Options + +- **auto** (default): Uses web-search-prime, adds Brave/Tavily if API keys available +- **web-search-prime**: Free, no API key needed +- **brave**: Requires `BRAVE_API_KEY` env var +- **tavily**: Requires `TAVILY_API_KEY` env var + +## Setting Up Optional Providers + +### Brave Search (Free Tier) + +1. Get API key: [brave.com/search/api](https://brave.com/search/api) +2. Set environment variable: + ```bash + export BRAVE_API_KEY="your-api-key" + ``` +3. Restart CCS - Brave will be added to fallback chain + +**Free tier limits**: 15,000 queries/month, 1 query/second + +### Tavily (AI-Optimized) + +1. Get API key: [tavily.com](https://tavily.com) +2. Set environment variable: + ```bash + export TAVILY_API_KEY="your-api-key" + ``` +3. Restart CCS - Tavily will be added to fallback chain + +## MCP Configuration + +CCS writes MCP configuration to `~/.claude/.mcp.json`. Example: + +```json +{ + "mcpServers": { + "web-search-prime": { + "type": "http", + "url": "https://api.z.ai/api/mcp/web_search_prime/mcp", + "headers": {} + }, + "brave-search": { + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-brave-search"], + "env": { "BRAVE_API_KEY": "..." } + } + } +} +``` + +## Troubleshooting + +### WebSearch Not Working + +1. **Check config**: Ensure `websearch.enabled: true` in config +2. **Verify MCP**: Check `~/.claude/.mcp.json` exists +3. **Debug mode**: Run with `CCS_DEBUG=1 ccs gemini` for verbose output + +### MCP Server Errors + +1. **Network issues**: web-search-prime requires internet access +2. **npx failures**: Brave/Tavily require Node.js and npx +3. **API key issues**: Verify env vars are set correctly + +### Existing MCP Config + +CCS respects existing web search MCP configuration. If you have manually configured web search MCPs, CCS will not overwrite them. + +To reset: +1. Remove web search entries from `~/.claude/.mcp.json` +2. Run any CCS third-party profile to regenerate + +## Security Considerations + +- API keys are stored in environment variables only +- Never commit API keys to version control +- Use `.env` files with proper permissions (chmod 600) +- Dashboard settings are stored in `~/.ccs/config.yaml` (no API keys) diff --git a/lib/hooks/block-websearch.cjs b/lib/hooks/block-websearch.cjs new file mode 100644 index 00000000..0c2de5e8 --- /dev/null +++ b/lib/hooks/block-websearch.cjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/** + * CCS WebSearch Blocking Hook + * + * Blocks Claude's native WebSearch tool and redirects to MCP alternative. + * This is a PreToolUse hook that runs BEFORE the tool is executed. + * + * WebSearch is a server-side tool executed by Anthropic's API. + * Third-party providers (gemini, agy, codex, qwen) don't have access. + * + * Usage: + * Configured in ~/.claude/settings.json: + * { + * "hooks": { + * "PreToolUse": [{ + * "matcher": "WebSearch", + * "hooks": [{ + * "type": "command", + * "command": "node ~/.ccs/hooks/block-websearch.cjs", + * "timeout": 5 + * }] + * }] + * } + * } + * + * Exit codes: + * 0 - Allow tool (pass-through) + * 2 - Block tool (deny with message) + * + * @module hooks/block-websearch + */ + +// Read input from stdin +let input = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (chunk) => { + input += chunk; +}); +process.stdin.on('end', () => { + try { + const data = JSON.parse(input); + + // Only block WebSearch tool + if (data.tool_name === 'WebSearch') { + const query = data.tool_input?.query || ''; + + const output = { + decision: 'block', + reason: 'WebSearch unavailable with current provider', + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: `WebSearch is not available with third-party providers. Use mcp__web-search-prime__webSearchPrime tool instead with the same query: "${query}"`, + }, + }; + + console.log(JSON.stringify(output)); + process.exit(2); // Exit code 2 = block + } + + // Allow all other tools + process.exit(0); + } catch (err) { + // Don't block on parse errors - allow tool to proceed + if (process.env.CCS_DEBUG) { + console.error('[CCS Hook] Parse error:', err.message); + } + process.exit(0); + } +}); + +// Handle stdin not being available +process.stdin.on('error', () => { + process.exit(0); +}); diff --git a/src/ccs.ts b/src/ccs.ts index 15c57273..0ad69fe5 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -14,6 +14,7 @@ import { detectClaudeCli } from './utils/claude-detector'; import { getSettingsPath } from './utils/config-manager'; import { ErrorManager } from './utils/error-manager'; import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy'; +import { ensureMcpWebSearch } from './utils/mcp-manager'; // Import extracted command handlers import { handleVersionCommand } from './commands/version-command'; @@ -378,6 +379,10 @@ async function main(): Promise { const customSettingsPath = profileInfo.settingsPath; // undefined for hardcoded profiles await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { customSettingsPath }); } else if (profileInfo.type === 'settings') { + // Settings-based profiles (glm, glmt, kimi) are third-party providers + // WebSearch is server-side tool - third-party providers have no access + ensureMcpWebSearch(); + // Check if this is GLMT profile (requires proxy) if (profileInfo.name === 'glmt') { // GLMT FLOW: Settings-based with embedded proxy for thinking support diff --git a/src/cliproxy/cliproxy-executor.ts b/src/cliproxy/cliproxy-executor.ts index a14ac2c8..a7ae91ac 100644 --- a/src/cliproxy/cliproxy-executor.ts +++ b/src/cliproxy/cliproxy-executor.ts @@ -39,6 +39,7 @@ import { } from './account-manager'; import { getPortCheckCommand, getCatCommand, killProcessOnPort } from '../utils/platform-commands'; import { getPortProcess, isCLIProxyProcess } from '../utils/port-utils'; +import { ensureMcpWebSearch } from '../utils/mcp-manager'; /** Default executor configuration */ const DEFAULT_CONFIG: ExecutorConfig = { @@ -112,6 +113,11 @@ export async function execClaudeWithCLIProxy( } }; + // Ensure MCP web-search is configured for third-party profiles + // WebSearch is a server-side tool executed by Anthropic's API + // Third-party providers don't have access, so we use MCP fallback + ensureMcpWebSearch(); + // Validate provider const providerConfig = getProviderConfig(provider); log(`Provider: ${providerConfig.displayName}`); diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 0b4152bb..2b181ba0 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -115,6 +115,13 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { ...defaults.preferences, ...partial.preferences, }, + websearch: { + enabled: partial.websearch?.enabled ?? defaults.websearch?.enabled ?? true, + provider: partial.websearch?.provider ?? defaults.websearch?.provider ?? 'auto', + fallback: partial.websearch?.fallback ?? defaults.websearch?.fallback ?? true, + webSearchPrimeUrl: + partial.websearch?.webSearchPrimeUrl ?? defaults.websearch?.webSearchPrimeUrl, + }, }; } @@ -149,8 +156,7 @@ function generateYamlHeader(): string { # # To customize a profile: # 1. Edit the *.settings.json file directly (e.g., ~/.ccs/glm.settings.json) -# 2. The file format matches Claude's settings.json: { "env": { ... } } -# +# 2. The file format matches Claude's settings.json: { "env": { ... } }\n# # Structure: # ┌─────────────────────────────────────────────────────────────────────────────┐ # │ profiles - References to *.settings.json files for API providers │ @@ -228,6 +234,23 @@ function generateYamlWithComments(config: UnifiedConfig): string { ); lines.push(''); + // WebSearch section + if (config.websearch) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# WebSearch: MCP auto-configuration for third-party profiles'); + lines.push('# enabled: true/false - Enable/disable MCP web-search auto-config'); + lines.push('# provider: auto | web-search-prime | brave | tavily'); + lines.push('# fallback: true/false - Enable fallback chain when provider fails'); + lines.push('# API keys: Set BRAVE_API_KEY or TAVILY_API_KEY env vars'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml + .dump({ websearch: config.websearch }, { indent: 2, lineWidth: -1, quotingType: '"' }) + .trim() + ); + lines.push(''); + } + return lines.join('\n'); } @@ -292,3 +315,22 @@ export function getDefaultProfile(): string | undefined { export function setDefaultProfile(name: string): void { updateUnifiedConfig({ default: name }); } + +/** + * Get websearch configuration. + * Returns defaults if not configured. + */ +export function getWebSearchConfig(): { + enabled: boolean; + provider: 'auto' | 'web-search-prime' | 'brave' | 'tavily'; + fallback: boolean; + webSearchPrimeUrl?: string; +} { + const config = loadOrCreateUnifiedConfig(); + return { + enabled: config.websearch?.enabled ?? true, + provider: config.websearch?.provider ?? 'auto', + fallback: config.websearch?.fallback ?? true, + webSearchPrimeUrl: config.websearch?.webSearchPrimeUrl, + }; +} diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 12e62eea..3d9fa5ce 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -100,6 +100,21 @@ export interface PreferencesConfig { auto_update?: boolean; } +/** + * WebSearch configuration. + * Controls MCP web-search auto-configuration for third-party profiles. + */ +export interface WebSearchConfig { + /** Enable auto-configuration of MCP web-search (default: true) */ + enabled?: boolean; + /** Preferred provider: auto uses fallback chain, or specify one */ + provider?: 'auto' | 'web-search-prime' | 'brave' | 'tavily'; + /** Enable fallback chain when preferred provider fails (default: true) */ + fallback?: boolean; + /** Custom URL for web-search-prime provider (optional, overrides default) */ + webSearchPrimeUrl?: string; +} + /** * Main unified configuration structure. * Stored in ~/.ccs/config.yaml @@ -117,6 +132,8 @@ export interface UnifiedConfig { cliproxy: CLIProxyConfig; /** User preferences */ preferences: PreferencesConfig; + /** WebSearch configuration */ + websearch?: WebSearchConfig; } /** @@ -154,6 +171,11 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { telemetry: false, auto_update: true, }, + websearch: { + enabled: true, + provider: 'auto', + fallback: true, + }, }; } diff --git a/src/utils/mcp-manager.ts b/src/utils/mcp-manager.ts new file mode 100644 index 00000000..8e85cf00 --- /dev/null +++ b/src/utils/mcp-manager.ts @@ -0,0 +1,384 @@ +/** + * MCP Manager - Manages MCP server configuration for CCS + * + * Ensures web-search-prime MCP is available for third-party profiles + * that cannot use Claude's native WebSearch tool. + * + * WebSearch is a server-side tool executed by Anthropic's API. + * Third-party providers (gemini, agy, codex, qwen) don't have access. + * This manager auto-configures MCP web-search as a fallback. + * + * @module utils/mcp-manager + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { info, warn } from './ui'; +import { getWebSearchConfig } from '../config/unified-config-loader'; + +// MCP configuration file path +const MCP_CONFIG_PATH = path.join(os.homedir(), '.claude', '.mcp.json'); + +/** + * MCP server configuration interface + */ +interface McpServerConfig { + type: 'http' | 'stdio'; + url?: string; + headers?: Record; + command?: string; + args?: string[]; + env?: Record; +} + +/** + * MCP configuration file structure + */ +interface McpConfig { + mcpServers?: Record; + [key: string]: unknown; +} + +/** + * Default web-search-prime MCP configuration (primary) + * HTTP-based, no API key needed + */ +const WEB_SEARCH_PRIME_CONFIG: McpServerConfig = { + type: 'http', + url: 'https://api.z.ai/api/mcp/web_search_prime/mcp', + headers: {}, +}; + +/** + * Brave Search MCP configuration (secondary fallback) + * Requires BRAVE_API_KEY env var + * Free tier: 15k queries/month, 1 query/sec + * Package: @modelcontextprotocol/server-brave-search + */ +const BRAVE_SEARCH_CONFIG: McpServerConfig = { + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-brave-search'], + env: {}, +}; + +/** + * Tavily MCP configuration (tertiary fallback) + * Requires TAVILY_API_KEY env var + * AI-optimized search, paid service + * Package: @tavily/mcp-server (official) + */ +const TAVILY_CONFIG: McpServerConfig = { + type: 'stdio', + command: 'npx', + args: ['-y', '@tavily/mcp-server'], + env: {}, +}; + +/** + * Ensure MCP web-search is configured for third-party profiles + * + * Called before spawning Claude CLI for CLIProxy profiles. + * Respects user configuration from ~/.ccs/config.yaml: + * - enabled: false → skip auto-config + * - provider: specific → add only that provider + * - fallback: false → don't add fallback providers + * + * Multi-tier MCP fallback chain: + * 1. web-search-prime (primary, no API key needed) + * 2. brave-search (if BRAVE_API_KEY set) + * 3. tavily (if TAVILY_API_KEY set) + * + * Only adds MCPs if no web search MCP is already configured. + * + * @returns true if web search MCP is available, false on error + */ +export function ensureMcpWebSearch(): boolean { + try { + // Check user configuration + const wsConfig = getWebSearchConfig(); + + // If disabled by user, skip auto-configuration + if (!wsConfig.enabled) { + if (process.env.CCS_DEBUG) { + console.error(info('WebSearch auto-config disabled by user')); + } + return false; + } + + let config: McpConfig = { mcpServers: {} }; + + // Read existing config if present + if (fs.existsSync(MCP_CONFIG_PATH)) { + const content = fs.readFileSync(MCP_CONFIG_PATH, 'utf8'); + try { + config = JSON.parse(content); + } catch { + // Malformed JSON - start fresh but preserve file as backup + if (process.env.CCS_DEBUG) { + console.error(warn('Malformed .mcp.json - starting fresh')); + } + config = { mcpServers: {} }; + } + } + + // Initialize mcpServers if missing + if (!config.mcpServers) { + config.mcpServers = {}; + } + + // Check if any web search MCP already configured (case-insensitive) + const hasWebSearch = Object.keys(config.mcpServers).some((key) => { + const lowerKey = key.toLowerCase(); + return ( + lowerKey.includes('web-search') || + lowerKey.includes('websearch') || + lowerKey.includes('tavily') || + lowerKey.includes('brave') + ); + }); + + if (hasWebSearch) { + if (process.env.CCS_DEBUG) { + console.error(info('MCP web-search already configured')); + } + return true; + } + + // Track what we add for logging + const addedMcps: string[] = []; + + // Helper to add a specific provider + const addProvider = (provider: 'web-search-prime' | 'brave' | 'tavily'): boolean => { + // mcpServers is guaranteed to exist here (initialized above) + const servers = config.mcpServers as Record; + + if (provider === 'web-search-prime') { + const webSearchPrimeConfig = { ...WEB_SEARCH_PRIME_CONFIG }; + // Use configurable URL if provided + if (wsConfig.webSearchPrimeUrl) { + webSearchPrimeConfig.url = wsConfig.webSearchPrimeUrl; + } + servers['web-search-prime'] = webSearchPrimeConfig; + addedMcps.push('web-search-prime'); + return true; + } + + if (provider === 'brave') { + const braveApiKey = process.env.BRAVE_API_KEY; + if (braveApiKey) { + const braveConfig = { ...BRAVE_SEARCH_CONFIG }; + braveConfig.env = { BRAVE_API_KEY: braveApiKey }; + servers['brave-search'] = braveConfig; + addedMcps.push('brave-search'); + return true; + } + return false; + } + + if (provider === 'tavily') { + const tavilyApiKey = process.env.TAVILY_API_KEY; + if (tavilyApiKey) { + const tavilyConfig = { ...TAVILY_CONFIG }; + tavilyConfig.env = { TAVILY_API_KEY: tavilyApiKey }; + servers['tavily'] = tavilyConfig; + addedMcps.push('tavily'); + return true; + } + return false; + } + + return false; + }; + + // Apply user's provider preference + if (wsConfig.provider === 'auto') { + // Auto mode: add all available providers + addProvider('web-search-prime'); + if (wsConfig.fallback) { + addProvider('brave'); + addProvider('tavily'); + } + } else { + // Specific provider requested + const added = addProvider(wsConfig.provider); + if (!added && wsConfig.fallback) { + // Fallback if preferred provider not available + if (process.env.CCS_DEBUG) { + console.error( + warn(`Preferred provider ${wsConfig.provider} not available, using fallback`) + ); + } + addProvider('web-search-prime'); + addProvider('brave'); + addProvider('tavily'); + } + } + + // If nothing was added, return false + if (addedMcps.length === 0) { + if (process.env.CCS_DEBUG) { + console.error(warn('No web search MCP could be configured')); + } + return false; + } + + // Ensure ~/.claude directory exists + const claudeDir = path.dirname(MCP_CONFIG_PATH); + if (!fs.existsSync(claudeDir)) { + fs.mkdirSync(claudeDir, { recursive: true, mode: 0o700 }); + } + + // Write config with proper formatting + fs.writeFileSync(MCP_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); + + if (process.env.CCS_DEBUG) { + console.error(info(`Added MCP servers for web search: ${addedMcps.join(', ')}`)); + } + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to configure MCP: ${(error as Error).message}`)); + } + return false; + } +} + +/** + * Check if MCP web-search is configured + * + * @returns true if any web search MCP is present + */ +export function hasMcpWebSearch(): boolean { + try { + if (!fs.existsSync(MCP_CONFIG_PATH)) { + return false; + } + + const content = fs.readFileSync(MCP_CONFIG_PATH, 'utf8'); + const config: McpConfig = JSON.parse(content); + + if (!config.mcpServers) { + return false; + } + + // Case-insensitive check for web search MCP + return Object.keys(config.mcpServers).some((key) => { + const lowerKey = key.toLowerCase(); + return ( + lowerKey.includes('web-search') || + lowerKey.includes('websearch') || + lowerKey.includes('tavily') || + lowerKey.includes('brave') + ); + }); + } catch { + return false; + } +} + +/** + * Get path to MCP config file + * + * @returns absolute path to .mcp.json + */ +export function getMcpConfigPath(): string { + return MCP_CONFIG_PATH; +} + +// CCS hooks directory +const CCS_HOOKS_DIR = path.join(os.homedir(), '.ccs', 'hooks'); +const BLOCK_WEBSEARCH_HOOK = 'block-websearch.cjs'; + +/** + * Install WebSearch blocking hook to ~/.ccs/hooks/ + * + * This hook blocks native WebSearch and directs Claude to use MCP. + * Optional feature - must be explicitly enabled. + * + * @returns true if hook installed successfully + */ +export function installWebSearchHook(): boolean { + try { + // Ensure hooks directory exists + if (!fs.existsSync(CCS_HOOKS_DIR)) { + fs.mkdirSync(CCS_HOOKS_DIR, { recursive: true, mode: 0o700 }); + } + + const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); + + // Find the bundled hook script + // In npm package: node_modules/ccs/lib/hooks/block-websearch.cjs + // In development: lib/hooks/block-websearch.cjs + const possiblePaths = [ + path.join(__dirname, '..', '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK), + path.join(__dirname, '..', 'lib', 'hooks', BLOCK_WEBSEARCH_HOOK), + ]; + + let sourcePath: string | null = null; + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + sourcePath = p; + break; + } + } + + if (!sourcePath) { + if (process.env.CCS_DEBUG) { + console.error(warn('WebSearch hook source not found')); + } + return false; + } + + // Copy hook to ~/.ccs/hooks/ + fs.copyFileSync(sourcePath, hookPath); + fs.chmodSync(hookPath, 0o755); + + if (process.env.CCS_DEBUG) { + console.error(info(`Installed WebSearch blocking hook to ${hookPath}`)); + } + + return true; + } catch (error) { + if (process.env.CCS_DEBUG) { + console.error(warn(`Failed to install WebSearch hook: ${(error as Error).message}`)); + } + return false; + } +} + +/** + * Get WebSearch hook configuration for settings.json + * + * @returns hook configuration object + */ +export function getWebSearchHookConfig(): Record { + const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); + + return { + PreToolUse: [ + { + matcher: 'WebSearch', + hooks: [ + { + type: 'command', + command: `node "${hookPath}"`, + timeout: 5, + }, + ], + }, + ], + }; +} + +/** + * Check if WebSearch blocking hook is installed + * + * @returns true if hook exists + */ +export function hasWebSearchHook(): boolean { + const hookPath = path.join(CCS_HOOKS_DIR, BLOCK_WEBSEARCH_HOOK); + return fs.existsSync(hookPath); +} diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 8108563e..df30f5cf 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -53,6 +53,8 @@ import { getBackupDirectories, } from '../config/migration-manager'; import { getProfileSecrets, setProfileSecrets } from '../config/secrets-manager'; +import { getWebSearchConfig } from '../config/unified-config-loader'; +import type { WebSearchConfig } from '../config/unified-config-types'; import { isUnifiedConfig } from '../config/unified-config-types'; import { isSensitiveKey, maskSensitiveValue } from '../utils/sensitive-keys'; @@ -1397,3 +1399,79 @@ apiRoutes.delete('/cliproxy/openai-compat/:name', (req: Request, res: Response): res.status(500).json({ error: (error as Error).message }); } }); + +// ==================== WebSearch Configuration ==================== + +/** + * GET /api/websearch - Get WebSearch configuration + * Returns: WebSearchConfig with enabled, provider, fallback + */ +apiRoutes.get('/websearch', (_req: Request, res: Response): void => { + try { + const config = getWebSearchConfig(); + res.json(config); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * PUT /api/websearch - Update WebSearch configuration + * Body: { enabled?: boolean, provider?: string, fallback?: boolean } + */ +apiRoutes.put('/websearch', (req: Request, res: Response): void => { + const { enabled, provider, fallback, webSearchPrimeUrl } = req.body as Partial; + + // Validate enabled + if (enabled !== undefined && typeof enabled !== 'boolean') { + res.status(400).json({ error: 'Invalid value for enabled. Must be a boolean.' }); + return; + } + + // Validate fallback + if (fallback !== undefined && typeof fallback !== 'boolean') { + res.status(400).json({ error: 'Invalid value for fallback. Must be a boolean.' }); + return; + } + + // Validate provider if specified + const validProviders = ['auto', 'web-search-prime', 'brave', 'tavily']; + if (provider && !validProviders.includes(provider)) { + res.status(400).json({ + error: `Invalid provider. Must be one of: ${validProviders.join(', ')}`, + }); + return; + } + + // Validate webSearchPrimeUrl if specified + if (webSearchPrimeUrl !== undefined && typeof webSearchPrimeUrl !== 'string') { + res.status(400).json({ error: 'Invalid value for webSearchPrimeUrl. Must be a string.' }); + return; + } + + try { + // Load existing config and update websearch section + const existingConfig = loadUnifiedConfig(); + if (!existingConfig) { + res.status(500).json({ error: 'Failed to load config' }); + return; + } + + // Merge updates + existingConfig.websearch = { + enabled: enabled ?? existingConfig.websearch?.enabled ?? true, + provider: provider ?? existingConfig.websearch?.provider ?? 'auto', + fallback: fallback ?? existingConfig.websearch?.fallback ?? true, + webSearchPrimeUrl: webSearchPrimeUrl ?? existingConfig.websearch?.webSearchPrimeUrl, + }; + + saveUnifiedConfig(existingConfig); + + res.json({ + success: true, + websearch: existingConfig.websearch, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); diff --git a/tests/unit/mcp-manager.test.ts b/tests/unit/mcp-manager.test.ts new file mode 100644 index 00000000..e82362e8 --- /dev/null +++ b/tests/unit/mcp-manager.test.ts @@ -0,0 +1,269 @@ +/** + * Unit tests for MCP Manager module + * + * Tests web search MCP configuration logic without filesystem dependencies. + * These tests focus on the pure logic functions that can be tested without + * modifying the actual config files. + */ +import { describe, it, expect } from 'bun:test'; + +/** + * Test helper: Simulate hasWebSearch detection logic + * This matches the logic in mcp-manager.ts hasMcpWebSearch() + */ +function detectWebSearchMcp(mcpServers: Record): boolean { + return Object.keys(mcpServers).some((key) => { + const lowerKey = key.toLowerCase(); + return ( + lowerKey.includes('web-search') || + lowerKey.includes('websearch') || + lowerKey.includes('tavily') || + lowerKey.includes('brave') + ); + }); +} + +/** + * Test helper: Simulate provider configuration logic + * Returns which providers would be added given config and env + */ +function getProvidersToAdd( + wsConfig: { enabled: boolean; provider: string; fallback: boolean }, + hasExistingWebSearch: boolean, + apiKeys: { brave?: string; tavily?: string } +): string[] { + if (!wsConfig.enabled) return []; + if (hasExistingWebSearch) return []; + + const providers: string[] = []; + + if (wsConfig.provider === 'auto') { + providers.push('web-search-prime'); + if (wsConfig.fallback) { + if (apiKeys.brave) providers.push('brave-search'); + if (apiKeys.tavily) providers.push('tavily'); + } + } else if (wsConfig.provider === 'web-search-prime') { + providers.push('web-search-prime'); + } else if (wsConfig.provider === 'brave') { + if (apiKeys.brave) { + providers.push('brave-search'); + } else if (wsConfig.fallback) { + // Fallback chain + providers.push('web-search-prime'); + if (apiKeys.tavily) providers.push('tavily'); + } + } else if (wsConfig.provider === 'tavily') { + if (apiKeys.tavily) { + providers.push('tavily'); + } else if (wsConfig.fallback) { + // Fallback chain + providers.push('web-search-prime'); + if (apiKeys.brave) providers.push('brave-search'); + } + } + + return providers; +} + +describe('mcp-manager logic', () => { + describe('web search detection', () => { + it('should detect web-search-prime', () => { + const servers = { 'web-search-prime': { type: 'http' } }; + expect(detectWebSearchMcp(servers)).toBe(true); + }); + + it('should detect brave-search', () => { + const servers = { 'brave-search': { type: 'stdio' } }; + expect(detectWebSearchMcp(servers)).toBe(true); + }); + + it('should detect tavily', () => { + const servers = { tavily: { type: 'stdio' } }; + expect(detectWebSearchMcp(servers)).toBe(true); + }); + + it('should be case-insensitive', () => { + expect(detectWebSearchMcp({ 'WebSearch-Custom': {} })).toBe(true); + expect(detectWebSearchMcp({ 'WEB-SEARCH': {} })).toBe(true); + expect(detectWebSearchMcp({ TAVILY: {} })).toBe(true); + expect(detectWebSearchMcp({ 'Brave-Search': {} })).toBe(true); + }); + + it('should not detect unrelated MCPs', () => { + expect(detectWebSearchMcp({ 'my-custom-mcp': {} })).toBe(false); + expect(detectWebSearchMcp({ 'filesystem': {} })).toBe(false); + expect(detectWebSearchMcp({ 'github-copilot': {} })).toBe(false); + }); + + it('should return false for empty servers', () => { + expect(detectWebSearchMcp({})).toBe(false); + }); + }); + + describe('provider selection logic', () => { + const defaultConfig = { enabled: true, provider: 'auto', fallback: true }; + const noApiKeys = {}; + const braveOnly = { brave: 'test-key' }; + const tavilyOnly = { tavily: 'test-key' }; + const bothKeys = { brave: 'brave-key', tavily: 'tavily-key' }; + + it('should add web-search-prime in auto mode', () => { + const providers = getProvidersToAdd(defaultConfig, false, noApiKeys); + expect(providers).toContain('web-search-prime'); + }); + + it('should add brave-search when API key available in auto mode', () => { + const providers = getProvidersToAdd(defaultConfig, false, braveOnly); + expect(providers).toContain('web-search-prime'); + expect(providers).toContain('brave-search'); + }); + + it('should add tavily when API key available in auto mode', () => { + const providers = getProvidersToAdd(defaultConfig, false, tavilyOnly); + expect(providers).toContain('web-search-prime'); + expect(providers).toContain('tavily'); + }); + + it('should add all providers when both API keys available', () => { + const providers = getProvidersToAdd(defaultConfig, false, bothKeys); + expect(providers).toContain('web-search-prime'); + expect(providers).toContain('brave-search'); + expect(providers).toContain('tavily'); + }); + + it('should not add fallbacks when fallback=false', () => { + const config = { enabled: true, provider: 'auto', fallback: false }; + const providers = getProvidersToAdd(config, false, bothKeys); + expect(providers).toEqual(['web-search-prime']); + }); + + it('should skip when disabled', () => { + const config = { enabled: false, provider: 'auto', fallback: true }; + const providers = getProvidersToAdd(config, false, bothKeys); + expect(providers).toEqual([]); + }); + + it('should skip when web search already exists', () => { + const providers = getProvidersToAdd(defaultConfig, true, bothKeys); + expect(providers).toEqual([]); + }); + + it('should use specific provider when configured', () => { + const config = { enabled: true, provider: 'brave', fallback: false }; + const providers = getProvidersToAdd(config, false, braveOnly); + expect(providers).toEqual(['brave-search']); + }); + + it('should fallback when specific provider not available', () => { + const config = { enabled: true, provider: 'tavily', fallback: true }; + // No tavily key, should fallback + const providers = getProvidersToAdd(config, false, braveOnly); + expect(providers).toContain('web-search-prime'); + expect(providers).toContain('brave-search'); + expect(providers).not.toContain('tavily'); + }); + + it('should return empty when provider unavailable and fallback=false', () => { + const config = { enabled: true, provider: 'brave', fallback: false }; + // No brave key and no fallback + const providers = getProvidersToAdd(config, false, noApiKeys); + expect(providers).toEqual([]); + }); + }); + + describe('MCP server config structures', () => { + it('should define correct web-search-prime structure', () => { + const webSearchPrimeConfig = { + type: 'http', + url: 'https://api.z.ai/api/mcp/web_search_prime/mcp', + headers: {}, + }; + + expect(webSearchPrimeConfig.type).toBe('http'); + expect(webSearchPrimeConfig.url).toContain('web_search_prime'); + }); + + it('should define correct brave-search structure', () => { + const braveConfig = { + type: 'stdio', + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-brave-search'], + env: { BRAVE_API_KEY: 'test-key' }, + }; + + expect(braveConfig.type).toBe('stdio'); + expect(braveConfig.command).toBe('npx'); + expect(braveConfig.args).toContain('@modelcontextprotocol/server-brave-search'); + expect(braveConfig.env.BRAVE_API_KEY).toBe('test-key'); + }); + + it('should define correct tavily structure', () => { + const tavilyConfig = { + type: 'stdio', + command: 'npx', + args: ['-y', '@tavily/mcp-server'], + env: { TAVILY_API_KEY: 'test-key' }, + }; + + expect(tavilyConfig.type).toBe('stdio'); + expect(tavilyConfig.args).toContain('@tavily/mcp-server'); + expect(tavilyConfig.env.TAVILY_API_KEY).toBe('test-key'); + }); + }); + + describe('hook configuration', () => { + it('should define correct PreToolUse hook structure', () => { + const hookConfig = { + PreToolUse: [ + { + matcher: 'WebSearch', + hooks: [ + { + type: 'command', + command: 'node "/path/to/hook.cjs"', + timeout: 5, + }, + ], + }, + ], + }; + + expect(hookConfig.PreToolUse).toBeDefined(); + expect(hookConfig.PreToolUse[0].matcher).toBe('WebSearch'); + expect(hookConfig.PreToolUse[0].hooks[0].type).toBe('command'); + expect(hookConfig.PreToolUse[0].hooks[0].timeout).toBe(5); + }); + }); + + describe('MCP config file path', () => { + it('should be located in .claude directory', () => { + // The MCP config path should follow this pattern + const expectedPathPattern = '.claude/.mcp.json'; + const testPath = '/home/user/.claude/.mcp.json'; + expect(testPath).toContain(expectedPathPattern); + }); + }); + + describe('WebSearch config defaults', () => { + it('should have correct default values', () => { + const defaults = { + enabled: true, + provider: 'auto', + fallback: true, + }; + + expect(defaults.enabled).toBe(true); + expect(defaults.provider).toBe('auto'); + expect(defaults.fallback).toBe(true); + }); + + it('should validate provider options', () => { + const validProviders = ['auto', 'web-search-prime', 'brave', 'tavily']; + expect(validProviders).toContain('auto'); + expect(validProviders).toContain('web-search-prime'); + expect(validProviders).toContain('brave'); + expect(validProviders).toContain('tavily'); + }); + }); +}); diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx index 98d5c4e9..f1971496 100644 --- a/ui/src/pages/settings.tsx +++ b/ui/src/pages/settings.tsx @@ -1,42 +1,259 @@ /** - * Settings Page - Deprecated - * Settings functionality has been moved to API Profiles page + * Settings Page - WebSearch Configuration + * Configure MCP-based web search for third-party profiles */ -import { Link } from 'react-router-dom'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Button } from '@/components/ui/button'; -import { Construction, ArrowRight } from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Globe, RefreshCw, CheckCircle2, AlertCircle, Info } from 'lucide-react'; + +interface WebSearchConfig { + enabled: boolean; + provider: 'auto' | 'web-search-prime' | 'brave' | 'tavily'; + fallback: boolean; +} export function SettingsPage() { + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + // Load config on mount + useEffect(() => { + fetchConfig(); + }, []); + + const fetchConfig = async () => { + try { + setLoading(true); + setError(null); + const res = await fetch('/api/websearch'); + if (!res.ok) throw new Error('Failed to load WebSearch config'); + const data = await res.json(); + setConfig(data); + } catch (err) { + setError((err as Error).message); + } finally { + setLoading(false); + } + }; + + const saveConfig = async (updates: Partial) => { + if (!config) return; + + try { + setSaving(true); + setError(null); + setSuccess(false); + + const res = await fetch('/api/websearch', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...config, ...updates }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Failed to save'); + } + + const data = await res.json(); + setConfig(data.websearch); + setSuccess(true); + setTimeout(() => setSuccess(false), 3000); + } catch (err) { + setError((err as Error).message); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
+

Settings

+
+ + Loading configuration... +
+
+ ); + } + return ( -
+

Settings

- + {error && ( + + + {error} + + )} + + {success && ( + + + + Settings saved successfully + + + )} + + - - - Page Relocated + + + WebSearch Configuration + + Configure MCP-based web search for third-party profiles (gemini, agy, codex, qwen, etc.) + - -

- The settings editor has been integrated into the API Profiles page for - a better user experience. -

-

To edit environment variables for a profile:

-
    -
  1. Go to API Profiles page
  2. -
  3. Click the actions menu (...) on any profile
  4. -
  5. Select "Edit Settings"
  6. -
-
-