diff --git a/ccw/frontend/src/components/mcp/CrossCliSyncPanel.tsx b/ccw/frontend/src/components/mcp/CrossCliSyncPanel.tsx index cd37040e..641d60f2 100644 --- a/ccw/frontend/src/components/mcp/CrossCliSyncPanel.tsx +++ b/ccw/frontend/src/components/mcp/CrossCliSyncPanel.tsx @@ -324,7 +324,9 @@ export function CrossCliSyncPanel({ onSuccess, className }: CrossCliSyncPanelPro )}

- {server.displayText} + {isHttpMcpServer(server) + ? server.url + : (isStdioMcpServer(server) ? server.command : '')}

diff --git a/ccw/frontend/src/lib/api.mcp.test.ts b/ccw/frontend/src/lib/api.mcp.test.ts index 708b3632..6f978130 100644 --- a/ccw/frontend/src/lib/api.mcp.test.ts +++ b/ccw/frontend/src/lib/api.mcp.test.ts @@ -9,6 +9,7 @@ import { crossCliCopy, fetchAllProjects, fetchOtherProjectsServers, + isStdioMcpServer, type McpServer, } from './api'; @@ -64,11 +65,14 @@ describe('MCP API (frontend ↔ backend contract)', () => { expect(global1?.scope).toBe('global'); const projOnly = result.project[0]; - expect(projOnly?.command).toBe('node'); + expect(isStdioMcpServer(projOnly)).toBe(true); + if (isStdioMcpServer(projOnly)) { + expect(projOnly.command).toBe('node'); + expect(projOnly.env).toEqual({ A: '1' }); + expect(projOnly.args).toEqual(['x']); + } expect(projOnly?.enabled).toBe(true); expect(projOnly?.scope).toBe('project'); - expect(projOnly?.env).toEqual({ A: '1' }); - expect(projOnly?.args).toEqual(['x']); }); it('toggleMcpServer uses /api/mcp-toggle with { projectPath, serverName, enable }', async () => { @@ -150,6 +154,7 @@ describe('MCP API (frontend ↔ backend contract)', () => { const inputServer: McpServer = { name: 's1', + transport: 'stdio', command: 'node', args: ['a'], env: { K: 'V' }, @@ -290,4 +295,3 @@ describe('MCP API (frontend ↔ backend contract)', () => { expect(res.servers['D:/a']?.[0]?.enabled).toBe(false); }); }); - diff --git a/ccw/frontend/src/lib/api.ts b/ccw/frontend/src/lib/api.ts index 3c301beb..3407492d 100644 --- a/ccw/frontend/src/lib/api.ts +++ b/ccw/frontend/src/lib/api.ts @@ -3507,7 +3507,8 @@ function requireProjectPath(projectPath: string | undefined, ctx: string): strin function toServerConfig(server: Partial): UnknownRecord { // Check if this is an HTTP server if (server.transport === 'http') { - const config: UnknownRecord = { url: server.url }; + const url = 'url' in server && typeof server.url === 'string' ? server.url : ''; + const config: UnknownRecord = { url }; // Claude format: type field config.type = 'http'; @@ -3538,25 +3539,62 @@ function toServerConfig(server: Partial): UnknownRecord { // STDIO server (default) const config: UnknownRecord = {}; - if (typeof server.command === 'string') { - config.command = server.command; - } - - if (server.args && server.args.length > 0) { - config.args = server.args; - } - - if (server.env && Object.keys(server.env).length > 0) { - config.env = server.env; - } - - if (server.cwd) { - config.cwd = server.cwd; - } + if ('command' in server && typeof server.command === 'string') config.command = server.command; + if ('args' in server && Array.isArray(server.args) && server.args.length > 0) config.args = server.args; + if ('env' in server && server.env && Object.keys(server.env).length > 0) config.env = server.env; + if ('cwd' in server && typeof server.cwd === 'string' && server.cwd.trim()) config.cwd = server.cwd; return config; } +function _buildFallbackServer(serverName: string, config: Partial): McpServer { + const transport = config.transport ?? 'stdio'; + const enabled = config.enabled ?? true; + const scope = config.scope ?? 'project'; + + if (transport === 'http') { + const url = 'url' in config && typeof config.url === 'string' ? config.url : ''; + return { + name: serverName, + transport: 'http', + url, + enabled, + scope, + }; + } + + const command = + 'command' in config && typeof config.command === 'string' + ? config.command + : ''; + + const args = + 'args' in config && Array.isArray(config.args) + ? config.args + : undefined; + + const env = + 'env' in config && config.env && typeof config.env === 'object' + ? (config.env as Record) + : undefined; + + const cwd = + 'cwd' in config && typeof config.cwd === 'string' + ? config.cwd + : undefined; + + return { + name: serverName, + transport: 'stdio', + command, + args, + env, + cwd, + enabled, + scope, + }; +} + /** * Update MCP server configuration * Supports both STDIO and HTTP server types @@ -3572,12 +3610,14 @@ export async function updateMcpServer( // Validate based on transport type if (config.transport === 'http') { - if (typeof config.url !== 'string' || !config.url.trim()) { + const url = 'url' in config ? config.url : undefined; + if (typeof url !== 'string' || !url.trim()) { throw new Error('updateMcpServer: url is required for HTTP servers'); } } else { // STDIO server (default) - if (typeof config.command !== 'string' || !config.command.trim()) { + const command = 'command' in config ? config.command : undefined; + if (typeof command !== 'string' || !command.trim()) { throw new Error('updateMcpServer: command is required for STDIO servers'); } } @@ -3619,26 +3659,13 @@ export async function updateMcpServer( if (options.projectPath) { const servers = await fetchMcpServers(options.projectPath); - return [...servers.project, ...servers.global].find((s) => s.name === serverName) ?? { - name: serverName, - transport: config.transport ?? 'stdio', - ...(config.transport === 'http' ? { url: config.url! } : { command: config.command! }), - args: config.args, - env: config.env, - enabled: config.enabled ?? true, - scope: config.scope, - } as McpServer; + return ( + [...servers.project, ...servers.global].find((s) => s.name === serverName) ?? + _buildFallbackServer(serverName, config) + ); } - return { - name: serverName, - transport: config.transport ?? 'stdio', - ...(config.transport === 'http' ? { url: config.url! } : { command: config.command! }), - args: config.args, - env: config.env, - enabled: config.enabled ?? true, - scope: config.scope, - } as McpServer; + return _buildFallbackServer(serverName, config); } /** @@ -3756,12 +3783,15 @@ export async function toggleMcpServer( } const servers = await fetchMcpServers(projectPath); - return [...servers.project, ...servers.global].find((s) => s.name === serverName) ?? { - name: serverName, - command: '', - enabled, - scope: 'project', - }; + return ( + [...servers.project, ...servers.global].find((s) => s.name === serverName) ?? { + name: serverName, + transport: 'stdio', + command: '', + enabled, + scope: 'project', + } + ); } // ========== Codex MCP API ========== @@ -3769,9 +3799,7 @@ export async function toggleMcpServer( * Codex MCP Server - Read-only server with config path * Extends McpServer with optional configPath field */ -export interface CodexMcpServer extends McpServer { - configPath?: string; -} +export type CodexMcpServer = McpServer & { configPath?: string }; export interface CodexMcpServersResponse { servers: CodexMcpServer[]; @@ -3958,13 +3986,16 @@ export async function fetchOtherProjectsServers( servers[path] = Object.entries(projectServersRecord) // Exclude globally-defined servers; this section is meant for project-local discovery .filter(([name]) => !(name in userServers) && !(name in enterpriseServers)) - .map(([name, raw]) => { + .flatMap(([name, raw]) => { const normalized = normalizeServerConfig(raw); - return { + if (normalized.transport !== 'stdio') return []; + return [{ name, - ...normalized, + command: normalized.command, + args: normalized.args, + env: normalized.env, enabled: !disabledSet.has(name), - }; + }]; }); } @@ -4552,58 +4583,6 @@ export interface CcwMcpConfig { installedScopes: ('global' | 'project')[]; } -/** - * Platform detection for cross-platform MCP config - */ -const isWindows = typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('win'); - -/** - * Build CCW MCP server config - */ -function buildCcwMcpServerConfig(config: { - enabledTools?: string[]; - projectRoot?: string; - allowedDirs?: string; - enableSandbox?: boolean; -}): { command: string; args: string[]; env: Record } { - const env: Record = {}; - - // Only use default when enabledTools is undefined (not provided) - // When enabledTools is an empty array, set to empty string to disable all tools - console.log('[buildCcwMcpServerConfig] config.enabledTools:', config.enabledTools); - if (config.enabledTools !== undefined) { - env.CCW_ENABLED_TOOLS = config.enabledTools.join(','); - console.log('[buildCcwMcpServerConfig] Set CCW_ENABLED_TOOLS to:', env.CCW_ENABLED_TOOLS); - } else { - env.CCW_ENABLED_TOOLS = 'write_file,edit_file,read_file,core_memory,ask_question,smart_search'; - console.log('[buildCcwMcpServerConfig] Using default CCW_ENABLED_TOOLS'); - } - - if (config.projectRoot) { - env.CCW_PROJECT_ROOT = config.projectRoot; - } - if (config.allowedDirs) { - env.CCW_ALLOWED_DIRS = config.allowedDirs; - } - if (config.enableSandbox) { - env.CCW_ENABLE_SANDBOX = '1'; - } - - // Cross-platform config - if (isWindows) { - return { - command: 'cmd', - args: ['/c', 'npx', '-y', 'ccw-mcp'], - env - }; - } - return { - command: 'npx', - args: ['-y', 'ccw-mcp'], - env - }; -} - /** * Fetch CCW Tools MCP configuration by checking if ccw-tools server exists */ @@ -4698,13 +4677,14 @@ export async function updateCcwConfig(config: { allowedDirs?: string; enableSandbox?: boolean; }): Promise { - const serverConfig = buildCcwMcpServerConfig(config); - - // Install/update to global config - const result = await addGlobalMcpServer('ccw-tools', serverConfig); - if (!result.success) { - throw new Error(result.error || 'Failed to update CCW config'); - } + const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-install-ccw', { + method: 'POST', + body: JSON.stringify({ + scope: 'global', + env: config, + }), + }); + if (result?.error) throw new Error(result.error || 'Failed to update CCW config'); return fetchCcwMcpConfig(); } @@ -4716,31 +4696,21 @@ export async function installCcwMcp( scope: 'global' | 'project' = 'global', projectPath?: string ): Promise { - const serverConfig = buildCcwMcpServerConfig({ - enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'], + const path = scope === 'project' ? requireProjectPath(projectPath, 'installCcwMcp') : undefined; + + const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-install-ccw', { + method: 'POST', + body: JSON.stringify({ + scope, + projectPath: path, + env: { + enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'], + }, + }), }); + if (result?.error) throw new Error(result.error || `Failed to install CCW MCP (${scope})`); - if (scope === 'project' && projectPath) { - const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-copy-server', { - method: 'POST', - body: JSON.stringify({ - projectPath, - serverName: 'ccw-tools', - serverConfig, - configType: 'mcp', - }), - }); - if (result?.error) { - throw new Error(result.error || 'Failed to install CCW MCP to project'); - } - } else { - const result = await addGlobalMcpServer('ccw-tools', serverConfig); - if (!result.success) { - throw new Error(result.error || 'Failed to install CCW MCP'); - } - } - - return fetchCcwMcpConfig(); + return fetchCcwMcpConfig(path); } /** @@ -4811,7 +4781,7 @@ export async function fetchCcwMcpConfigForCodex(): Promise { return { isInstalled: false, enabledTools: [], installedScopes: [] }; } - const env = ccwServer.env || {}; + const env = isStdioMcpServer(ccwServer) ? (ccwServer.env || {}) : {}; // Note: CCW_ENABLED_TOOLS can be empty string (all tools disabled), 'all' (default set), or comma-separated list const enabledToolsStr = env.CCW_ENABLED_TOOLS; let enabledTools: string[]; diff --git a/ccw/frontend/src/pages/McpManagerPage.tsx b/ccw/frontend/src/pages/McpManagerPage.tsx index 655d6212..c0a28c4d 100644 --- a/ccw/frontend/src/pages/McpManagerPage.tsx +++ b/ccw/frontend/src/pages/McpManagerPage.tsx @@ -57,7 +57,6 @@ import { type McpServer, type McpServerConflict, type CcwMcpConfig, - type HttpMcpServer, isHttpMcpServer, isStdioMcpServer, } from '@/lib/api'; @@ -656,14 +655,31 @@ export function McpManagerPage() { // Template handlers const handleInstallTemplate = (template: any) => { - setEditingServer({ - name: template.name, - command: template.serverConfig.command, - args: template.serverConfig.args || [], - env: template.serverConfig.env, - scope: 'project', - enabled: true, - }); + const serverConfig = template?.serverConfig ?? {}; + const isHttp = + serverConfig.type === 'http' || + serverConfig.transport === 'http' || + typeof serverConfig.url === 'string'; + + if (isHttp) { + setEditingServer({ + name: template.name, + transport: 'http', + url: serverConfig.url ?? '', + scope: 'project', + enabled: true, + }); + } else { + setEditingServer({ + name: template.name, + transport: 'stdio', + command: serverConfig.command, + args: serverConfig.args || [], + env: serverConfig.env, + scope: 'project', + enabled: true, + }); + } setDialogOpen(true); }; @@ -1076,9 +1092,21 @@ export function McpManagerPage() { }} onSave={handleSaveAsTemplate} defaultName={serverToSaveAsTemplate?.name} - defaultCommand={serverToSaveAsTemplate?.command} - defaultArgs={serverToSaveAsTemplate?.args} - defaultEnv={serverToSaveAsTemplate?.env as Record} + defaultCommand={ + serverToSaveAsTemplate && isStdioMcpServer(serverToSaveAsTemplate) + ? serverToSaveAsTemplate.command + : undefined + } + defaultArgs={ + serverToSaveAsTemplate && isStdioMcpServer(serverToSaveAsTemplate) + ? serverToSaveAsTemplate.args + : undefined + } + defaultEnv={ + serverToSaveAsTemplate && isStdioMcpServer(serverToSaveAsTemplate) + ? (serverToSaveAsTemplate.env as Record) + : undefined + } /> ); diff --git a/ccw/src/commands/view.ts b/ccw/src/commands/view.ts index 7a855db3..49a0711e 100644 --- a/ccw/src/commands/view.ts +++ b/ccw/src/commands/view.ts @@ -22,42 +22,81 @@ interface SwitchWorkspaceResult { * Provides better error messages when response is not JSON (e.g., proxy errors) */ async function safeParseJson(response: Response, endpoint: string): Promise { - const contentType = response.headers.get('content-type'); + const contentType = response.headers.get('content-type') || 'unknown'; + const isJson = contentType.includes('application/json'); - // Check if response is JSON - if (!contentType?.includes('application/json')) { - // Get response text for error message (truncated to avoid huge output) + const truncate = (text: string, maxLen: number = 200): string => { + const trimmed = text.trim(); + if (trimmed.length <= maxLen) return trimmed; + return `${trimmed.slice(0, maxLen)}…`; + }; + + const isApiKeyProxyMessage = (text: string): boolean => { + return /APIKEY|api\s*key|apiKey/i.test(text); + }; + + // If response claims to not be JSON, surface a helpful error with a preview. + if (!isJson) { const text = await response.text(); - const preview = text.substring(0, 200); + const preview = truncate(text); - // Detect common proxy errors - if (text.includes('APIKEY') || text.includes('api key') || text.includes('apiKey')) { + if (isApiKeyProxyMessage(text)) { throw new Error( - `Request to ${endpoint} was intercepted by a proxy requiring API key. ` + + `Request to ${endpoint} was intercepted by a proxy requiring an API key. ` + `Check HTTP_PROXY/HTTPS_PROXY environment variables. ` + `Response: ${preview}` ); } throw new Error( - `Unexpected response from ${endpoint} (expected JSON, got: ${contentType || 'unknown'}). ` + + `Unexpected response from ${endpoint} (expected JSON, got: ${contentType}). ` + `This may indicate a proxy or network issue. Response: ${preview}` ); } - // Check for HTTP errors + // Read text once so we can provide good errors even when JSON parsing fails. + const text = await response.text(); + const preview = truncate(text); + + // Check for HTTP errors first; try to parse error JSON if possible. if (!response.ok) { - let errorMessage = response.statusText; - try { - const body = await response.json() as { error?: string; message?: string }; - errorMessage = body.error || body.message || response.statusText; - } catch { - // Ignore JSON parse errors for error response + if (isApiKeyProxyMessage(text)) { + throw new Error( + `Request to ${endpoint} was intercepted by a proxy requiring an API key. ` + + `Check HTTP_PROXY/HTTPS_PROXY environment variables. ` + + `Response: ${preview}` + ); } - throw new Error(`HTTP ${response.status}: ${errorMessage}`); + + let errorMessage = response.statusText; + if (text.trim()) { + try { + const body = JSON.parse(text) as { error?: string; message?: string }; + errorMessage = body.error || body.message || response.statusText; + } catch { + errorMessage = `${response.statusText} (invalid JSON body)`; + } + } + + throw new Error(`HTTP ${response.status}: ${errorMessage}${preview ? `. Response: ${preview}` : ''}`); } - return response.json() as Promise; + try { + return JSON.parse(text) as T; + } catch { + if (isApiKeyProxyMessage(text)) { + throw new Error( + `Request to ${endpoint} was intercepted by a proxy requiring an API key. ` + + `Check HTTP_PROXY/HTTPS_PROXY environment variables. ` + + `Response: ${preview}` + ); + } + + throw new Error( + `Unexpected response from ${endpoint} (invalid JSON despite content-type: ${contentType}). ` + + `This may indicate a proxy or network issue. Response: ${preview}` + ); + } } /** diff --git a/ccw/src/core/routes/mcp-routes.ts b/ccw/src/core/routes/mcp-routes.ts index 0f9d9a09..447af46d 100644 --- a/ccw/src/core/routes/mcp-routes.ts +++ b/ccw/src/core/routes/mcp-routes.ts @@ -1210,40 +1210,68 @@ export async function handleMcpRoutes(ctx: RouteContext): Promise { return true; } - // API: Install CCW MCP server to project + // API: Install CCW MCP server (global or project scope) if (pathname === '/api/mcp-install-ccw' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { if (!isRecord(body)) { return { error: 'Invalid request body', status: 400 }; } - const projectPath = body.projectPath; - if (typeof projectPath !== 'string' || !projectPath.trim()) { - return { error: 'projectPath is required', status: 400 }; + const rawScope = body.scope; + const scope = rawScope === 'global' || rawScope === 'project' ? rawScope : undefined; + + // Backward compatibility: allow legacy fields at top-level as well as body.env + const envInput = (isRecord(body.env) ? body.env : body) as Record; + + const projectPath = typeof body.projectPath === 'string' ? body.projectPath : undefined; + + const enableSandbox = envInput.enableSandbox === true; + + const enabledToolsRaw = envInput.enabledTools; + let enabledToolsEnv: string; + if (enabledToolsRaw === undefined || enabledToolsRaw === null) { + enabledToolsEnv = 'write_file,edit_file,read_file,core_memory,ask_question,smart_search'; + } else if (Array.isArray(enabledToolsRaw)) { + enabledToolsEnv = enabledToolsRaw.filter((t): t is string => typeof t === 'string').join(','); + } else if (typeof enabledToolsRaw === 'string') { + enabledToolsEnv = enabledToolsRaw; + } else { + enabledToolsEnv = 'write_file,edit_file,read_file,core_memory,ask_question,smart_search'; } - // Check if sandbox should be enabled - const enableSandbox = body.enableSandbox === true; + const projectRoot = typeof envInput.projectRoot === 'string' ? envInput.projectRoot : undefined; - // Parse enabled tools from request body - const enabledTools = Array.isArray(body.enabledTools) && body.enabledTools.length > 0 - ? (body.enabledTools as string[]).join(',') - : 'write_file,edit_file,read_file,core_memory,ask_question,smart_search'; + const allowedDirsRaw = envInput.allowedDirs; + let allowedDirsEnv: string | undefined; + if (Array.isArray(allowedDirsRaw)) { + allowedDirsEnv = allowedDirsRaw.filter((d): d is string => typeof d === 'string').join(','); + } else if (typeof allowedDirsRaw === 'string') { + allowedDirsEnv = allowedDirsRaw; + } - // Generate CCW MCP server config - // Use cmd /c on Windows to inherit Claude Code's working directory + // Generate CCW MCP server config using *server-side* platform detection. + // On WSL/Linux, this ensures we produce `npx -y ccw-mcp` even when the browser runs on Windows. const isWin = process.platform === 'win32'; + const env: Record = { CCW_ENABLED_TOOLS: enabledToolsEnv }; + if (projectRoot) env.CCW_PROJECT_ROOT = projectRoot; + if (allowedDirsEnv) env.CCW_ALLOWED_DIRS = allowedDirsEnv; + if (enableSandbox) env.CCW_ENABLE_SANDBOX = '1'; + const ccwMcpConfig: Record = { - command: isWin ? "cmd" : "npx", - args: isWin ? ["/c", "npx", "-y", "ccw-mcp"] : ["-y", "ccw-mcp"], - env: { - CCW_ENABLED_TOOLS: enabledTools, - ...(enableSandbox && { CCW_ENABLE_SANDBOX: "1" }) - } + command: isWin ? 'cmd' : 'npx', + args: isWin ? ['/c', 'npx', '-y', 'ccw-mcp'] : ['-y', 'ccw-mcp'], + env }; - // Use existing addMcpServerToProject to install CCW MCP - return addMcpServerToProject(projectPath, 'ccw-tools', ccwMcpConfig); + const resolvedScope: 'global' | 'project' = scope ?? (projectPath ? 'project' : 'global'); + if (resolvedScope === 'project') { + if (!projectPath || !projectPath.trim()) { + return { error: 'projectPath is required for project scope', status: 400 }; + } + return addMcpServerToProject(projectPath, 'ccw-tools', ccwMcpConfig); + } + + return addGlobalMcpServer('ccw-tools', ccwMcpConfig); }); return true; }