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;
}