mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
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.
This commit is contained in:
@@ -195,6 +195,45 @@ Without Developer Mode, CCS falls back to copying directories.
|
||||
|
||||
<br>
|
||||
|
||||
## 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.
|
||||
|
||||
<br>
|
||||
|
||||
## Documentation
|
||||
|
||||
| Topic | Link |
|
||||
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -115,6 +115,13 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string>;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP configuration file structure
|
||||
*/
|
||||
interface McpConfig {
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
[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<string, McpServerConfig>;
|
||||
|
||||
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<string, unknown> {
|
||||
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);
|
||||
}
|
||||
@@ -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<WebSearchConfig>;
|
||||
|
||||
// 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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>): 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+244
-27
@@ -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<WebSearchConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<WebSearchConfig>) => {
|
||||
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 (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
Loading configuration...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
||||
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
|
||||
<Card className="border-yellow-500/50 bg-yellow-500/5">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert className="border-green-500/50 bg-green-500/5">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
<AlertDescription className="text-green-600">
|
||||
Settings saved successfully
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-yellow-600">
|
||||
<Construction className="w-5 h-5" />
|
||||
Page Relocated
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5" />
|
||||
WebSearch Configuration
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure MCP-based web search for third-party profiles (gemini, agy, codex, qwen, etc.)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
The settings editor has been integrated into the <strong>API Profiles</strong> page for
|
||||
a better user experience.
|
||||
</p>
|
||||
<p className="text-muted-foreground">To edit environment variables for a profile:</p>
|
||||
<ol className="list-decimal list-inside text-muted-foreground space-y-1 ml-2">
|
||||
<li>Go to API Profiles page</li>
|
||||
<li>Click the actions menu (...) on any profile</li>
|
||||
<li>Select "Edit Settings"</li>
|
||||
</ol>
|
||||
<div className="pt-2">
|
||||
<Button asChild>
|
||||
<Link to="/api">
|
||||
Go to API Profiles
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Link>
|
||||
<CardContent className="space-y-6">
|
||||
<Alert className="border-blue-500/50 bg-blue-500/5">
|
||||
<Info className="h-4 w-4 text-blue-600" />
|
||||
<AlertDescription className="text-blue-600">
|
||||
Third-party profiles cannot use Anthropic's native WebSearch. CCS automatically
|
||||
configures MCP web search servers as a fallback.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Enable/Disable */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="enabled" className="text-base">
|
||||
Auto-Configure MCP WebSearch
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically add web search MCP servers for third-party profiles
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={config?.enabled ? 'enabled' : 'disabled'}
|
||||
onValueChange={(value) => saveConfig({ enabled: value === 'enabled' })}
|
||||
disabled={saving}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enabled">Enabled</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Provider Selection */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="provider" className="text-base">
|
||||
Preferred Provider
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Primary web search provider to use
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={config?.provider || 'auto'}
|
||||
onValueChange={(value) =>
|
||||
saveConfig({
|
||||
provider: value as WebSearchConfig['provider'],
|
||||
})
|
||||
}
|
||||
disabled={saving || !config?.enabled}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto (Recommended)</SelectItem>
|
||||
<SelectItem value="web-search-prime">web-search-prime</SelectItem>
|
||||
<SelectItem value="brave">Brave Search</SelectItem>
|
||||
<SelectItem value="tavily">Tavily</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Fallback Enable */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="fallback" className="text-base">
|
||||
Enable Fallback Chain
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add backup providers when primary is unavailable
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={config?.fallback ? 'enabled' : 'disabled'}
|
||||
onValueChange={(value) => saveConfig({ fallback: value === 'enabled' })}
|
||||
disabled={saving || !config?.enabled}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enabled">Enabled</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Info */}
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="font-medium mb-3">Available Providers</h4>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 mt-2" />
|
||||
<div>
|
||||
<p className="font-medium">web-search-prime</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Free, no API key required. Primary fallback option.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-500 mt-2" />
|
||||
<div>
|
||||
<p className="font-medium">Brave Search</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Free tier: 15k queries/month. Set <code className="text-xs">BRAVE_API_KEY</code>{' '}
|
||||
env var.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500 mt-2" />
|
||||
<div>
|
||||
<p className="font-medium">Tavily</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
AI-optimized search (paid). Set <code className="text-xs">TAVILY_API_KEY</code>{' '}
|
||||
env var.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" onClick={fetchConfig} disabled={loading || saving}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
Reference in New Issue
Block a user