ccw: fix view JSON errors and WSL MCP install

This commit is contained in:
catlog22
2026-03-05 14:29:06 +08:00
parent dc1dc87023
commit f6c7c14042
6 changed files with 258 additions and 187 deletions

View File

@@ -324,7 +324,9 @@ export function CrossCliSyncPanel({ onSuccess, className }: CrossCliSyncPanelPro
)}
</div>
<p className="text-xs text-muted-foreground font-mono truncate">
{server.displayText}
{isHttpMcpServer(server)
? server.url
: (isStdioMcpServer(server) ? server.command : '')}
</p>
</label>
</div>

View File

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

View File

@@ -3507,7 +3507,8 @@ function requireProjectPath(projectPath: string | undefined, ctx: string): strin
function toServerConfig(server: Partial<McpServer>): 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<McpServer>): 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>): 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<string, string>)
: 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) ?? {
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<string, string> } {
const env: Record<string, string> = {};
// 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<CcwMcpConfig> {
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<CcwMcpConfig> {
const serverConfig = buildCcwMcpServerConfig({
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'],
});
const path = scope === 'project' ? requireProjectPath(projectPath, 'installCcwMcp') : undefined;
if (scope === 'project' && projectPath) {
const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-copy-server', {
const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-install-ccw', {
method: 'POST',
body: JSON.stringify({
projectPath,
serverName: 'ccw-tools',
serverConfig,
configType: 'mcp',
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 to project');
}
} else {
const result = await addGlobalMcpServer('ccw-tools', serverConfig);
if (!result.success) {
throw new Error(result.error || 'Failed to install CCW MCP');
}
}
if (result?.error) throw new Error(result.error || `Failed to install CCW MCP (${scope})`);
return fetchCcwMcpConfig();
return fetchCcwMcpConfig(path);
}
/**
@@ -4811,7 +4781,7 @@ export async function fetchCcwMcpConfigForCodex(): Promise<CcwMcpConfig> {
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[];

View File

@@ -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) => {
const serverConfig = template?.serverConfig ?? {};
const isHttp =
serverConfig.type === 'http' ||
serverConfig.transport === 'http' ||
typeof serverConfig.url === 'string';
if (isHttp) {
setEditingServer({
name: template.name,
command: template.serverConfig.command,
args: template.serverConfig.args || [],
env: template.serverConfig.env,
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<string, string>}
defaultCommand={
serverToSaveAsTemplate && isStdioMcpServer(serverToSaveAsTemplate)
? serverToSaveAsTemplate.command
: undefined
}
defaultArgs={
serverToSaveAsTemplate && isStdioMcpServer(serverToSaveAsTemplate)
? serverToSaveAsTemplate.args
: undefined
}
defaultEnv={
serverToSaveAsTemplate && isStdioMcpServer(serverToSaveAsTemplate)
? (serverToSaveAsTemplate.env as Record<string, string>)
: undefined
}
/>
</div>
);

View File

@@ -22,42 +22,81 @@ interface SwitchWorkspaceResult {
* Provides better error messages when response is not JSON (e.g., proxy errors)
*/
async function safeParseJson<T>(response: Response, endpoint: string): Promise<T> {
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
}
throw new Error(`HTTP ${response.status}: ${errorMessage}`);
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}`
);
}
return response.json() as Promise<T>;
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}` : ''}`);
}
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}`
);
}
}
/**

View File

@@ -1210,40 +1210,68 @@ export async function handleMcpRoutes(ctx: RouteContext): Promise<boolean> {
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<string, unknown>;
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<string, string> = { 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<string, any> = {
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
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;
}