mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-06 16:31:12 +08:00
ccw: fix view JSON errors and WSL MCP install
This commit is contained in:
@@ -324,7 +324,9 @@ export function CrossCliSyncPanel({ onSuccess, className }: CrossCliSyncPanelPro
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||||
{server.displayText}
|
{isHttpMcpServer(server)
|
||||||
|
? server.url
|
||||||
|
: (isStdioMcpServer(server) ? server.command : '')}
|
||||||
</p>
|
</p>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
crossCliCopy,
|
crossCliCopy,
|
||||||
fetchAllProjects,
|
fetchAllProjects,
|
||||||
fetchOtherProjectsServers,
|
fetchOtherProjectsServers,
|
||||||
|
isStdioMcpServer,
|
||||||
type McpServer,
|
type McpServer,
|
||||||
} from './api';
|
} from './api';
|
||||||
|
|
||||||
@@ -64,11 +65,14 @@ describe('MCP API (frontend ↔ backend contract)', () => {
|
|||||||
expect(global1?.scope).toBe('global');
|
expect(global1?.scope).toBe('global');
|
||||||
|
|
||||||
const projOnly = result.project[0];
|
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?.enabled).toBe(true);
|
||||||
expect(projOnly?.scope).toBe('project');
|
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 () => {
|
it('toggleMcpServer uses /api/mcp-toggle with { projectPath, serverName, enable }', async () => {
|
||||||
@@ -150,6 +154,7 @@ describe('MCP API (frontend ↔ backend contract)', () => {
|
|||||||
|
|
||||||
const inputServer: McpServer = {
|
const inputServer: McpServer = {
|
||||||
name: 's1',
|
name: 's1',
|
||||||
|
transport: 'stdio',
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['a'],
|
args: ['a'],
|
||||||
env: { K: 'V' },
|
env: { K: 'V' },
|
||||||
@@ -290,4 +295,3 @@ describe('MCP API (frontend ↔ backend contract)', () => {
|
|||||||
expect(res.servers['D:/a']?.[0]?.enabled).toBe(false);
|
expect(res.servers['D:/a']?.[0]?.enabled).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3507,7 +3507,8 @@ function requireProjectPath(projectPath: string | undefined, ctx: string): strin
|
|||||||
function toServerConfig(server: Partial<McpServer>): UnknownRecord {
|
function toServerConfig(server: Partial<McpServer>): UnknownRecord {
|
||||||
// Check if this is an HTTP server
|
// Check if this is an HTTP server
|
||||||
if (server.transport === 'http') {
|
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
|
// Claude format: type field
|
||||||
config.type = 'http';
|
config.type = 'http';
|
||||||
@@ -3538,25 +3539,62 @@ function toServerConfig(server: Partial<McpServer>): UnknownRecord {
|
|||||||
// STDIO server (default)
|
// STDIO server (default)
|
||||||
const config: UnknownRecord = {};
|
const config: UnknownRecord = {};
|
||||||
|
|
||||||
if (typeof server.command === 'string') {
|
if ('command' in server && typeof server.command === 'string') config.command = server.command;
|
||||||
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;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
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
|
* Update MCP server configuration
|
||||||
* Supports both STDIO and HTTP server types
|
* Supports both STDIO and HTTP server types
|
||||||
@@ -3572,12 +3610,14 @@ export async function updateMcpServer(
|
|||||||
|
|
||||||
// Validate based on transport type
|
// Validate based on transport type
|
||||||
if (config.transport === 'http') {
|
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');
|
throw new Error('updateMcpServer: url is required for HTTP servers');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// STDIO server (default)
|
// 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');
|
throw new Error('updateMcpServer: command is required for STDIO servers');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3619,26 +3659,13 @@ export async function updateMcpServer(
|
|||||||
|
|
||||||
if (options.projectPath) {
|
if (options.projectPath) {
|
||||||
const servers = await fetchMcpServers(options.projectPath);
|
const servers = await fetchMcpServers(options.projectPath);
|
||||||
return [...servers.project, ...servers.global].find((s) => s.name === serverName) ?? {
|
return (
|
||||||
name: serverName,
|
[...servers.project, ...servers.global].find((s) => s.name === serverName) ??
|
||||||
transport: config.transport ?? 'stdio',
|
_buildFallbackServer(serverName, config)
|
||||||
...(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 {
|
return _buildFallbackServer(serverName, config);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3756,12 +3783,15 @@ export async function toggleMcpServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const servers = await fetchMcpServers(projectPath);
|
const servers = await fetchMcpServers(projectPath);
|
||||||
return [...servers.project, ...servers.global].find((s) => s.name === serverName) ?? {
|
return (
|
||||||
name: serverName,
|
[...servers.project, ...servers.global].find((s) => s.name === serverName) ?? {
|
||||||
command: '',
|
name: serverName,
|
||||||
enabled,
|
transport: 'stdio',
|
||||||
scope: 'project',
|
command: '',
|
||||||
};
|
enabled,
|
||||||
|
scope: 'project',
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Codex MCP API ==========
|
// ========== Codex MCP API ==========
|
||||||
@@ -3769,9 +3799,7 @@ export async function toggleMcpServer(
|
|||||||
* Codex MCP Server - Read-only server with config path
|
* Codex MCP Server - Read-only server with config path
|
||||||
* Extends McpServer with optional configPath field
|
* Extends McpServer with optional configPath field
|
||||||
*/
|
*/
|
||||||
export interface CodexMcpServer extends McpServer {
|
export type CodexMcpServer = McpServer & { configPath?: string };
|
||||||
configPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CodexMcpServersResponse {
|
export interface CodexMcpServersResponse {
|
||||||
servers: CodexMcpServer[];
|
servers: CodexMcpServer[];
|
||||||
@@ -3958,13 +3986,16 @@ export async function fetchOtherProjectsServers(
|
|||||||
servers[path] = Object.entries(projectServersRecord)
|
servers[path] = Object.entries(projectServersRecord)
|
||||||
// Exclude globally-defined servers; this section is meant for project-local discovery
|
// Exclude globally-defined servers; this section is meant for project-local discovery
|
||||||
.filter(([name]) => !(name in userServers) && !(name in enterpriseServers))
|
.filter(([name]) => !(name in userServers) && !(name in enterpriseServers))
|
||||||
.map(([name, raw]) => {
|
.flatMap(([name, raw]) => {
|
||||||
const normalized = normalizeServerConfig(raw);
|
const normalized = normalizeServerConfig(raw);
|
||||||
return {
|
if (normalized.transport !== 'stdio') return [];
|
||||||
|
return [{
|
||||||
name,
|
name,
|
||||||
...normalized,
|
command: normalized.command,
|
||||||
|
args: normalized.args,
|
||||||
|
env: normalized.env,
|
||||||
enabled: !disabledSet.has(name),
|
enabled: !disabledSet.has(name),
|
||||||
};
|
}];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4552,58 +4583,6 @@ export interface CcwMcpConfig {
|
|||||||
installedScopes: ('global' | 'project')[];
|
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
|
* Fetch CCW Tools MCP configuration by checking if ccw-tools server exists
|
||||||
*/
|
*/
|
||||||
@@ -4698,13 +4677,14 @@ export async function updateCcwConfig(config: {
|
|||||||
allowedDirs?: string;
|
allowedDirs?: string;
|
||||||
enableSandbox?: boolean;
|
enableSandbox?: boolean;
|
||||||
}): Promise<CcwMcpConfig> {
|
}): Promise<CcwMcpConfig> {
|
||||||
const serverConfig = buildCcwMcpServerConfig(config);
|
const result = await fetchApi<{ success?: boolean; error?: string }>('/api/mcp-install-ccw', {
|
||||||
|
method: 'POST',
|
||||||
// Install/update to global config
|
body: JSON.stringify({
|
||||||
const result = await addGlobalMcpServer('ccw-tools', serverConfig);
|
scope: 'global',
|
||||||
if (!result.success) {
|
env: config,
|
||||||
throw new Error(result.error || 'Failed to update CCW config');
|
}),
|
||||||
}
|
});
|
||||||
|
if (result?.error) throw new Error(result.error || 'Failed to update CCW config');
|
||||||
|
|
||||||
return fetchCcwMcpConfig();
|
return fetchCcwMcpConfig();
|
||||||
}
|
}
|
||||||
@@ -4716,31 +4696,21 @@ export async function installCcwMcp(
|
|||||||
scope: 'global' | 'project' = 'global',
|
scope: 'global' | 'project' = 'global',
|
||||||
projectPath?: string
|
projectPath?: string
|
||||||
): Promise<CcwMcpConfig> {
|
): Promise<CcwMcpConfig> {
|
||||||
const serverConfig = buildCcwMcpServerConfig({
|
const path = scope === 'project' ? requireProjectPath(projectPath, 'installCcwMcp') : undefined;
|
||||||
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'],
|
|
||||||
|
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) {
|
return fetchCcwMcpConfig(path);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4811,7 +4781,7 @@ export async function fetchCcwMcpConfigForCodex(): Promise<CcwMcpConfig> {
|
|||||||
return { isInstalled: false, enabledTools: [], installedScopes: [] };
|
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
|
// Note: CCW_ENABLED_TOOLS can be empty string (all tools disabled), 'all' (default set), or comma-separated list
|
||||||
const enabledToolsStr = env.CCW_ENABLED_TOOLS;
|
const enabledToolsStr = env.CCW_ENABLED_TOOLS;
|
||||||
let enabledTools: string[];
|
let enabledTools: string[];
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ import {
|
|||||||
type McpServer,
|
type McpServer,
|
||||||
type McpServerConflict,
|
type McpServerConflict,
|
||||||
type CcwMcpConfig,
|
type CcwMcpConfig,
|
||||||
type HttpMcpServer,
|
|
||||||
isHttpMcpServer,
|
isHttpMcpServer,
|
||||||
isStdioMcpServer,
|
isStdioMcpServer,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
@@ -656,14 +655,31 @@ export function McpManagerPage() {
|
|||||||
|
|
||||||
// Template handlers
|
// Template handlers
|
||||||
const handleInstallTemplate = (template: any) => {
|
const handleInstallTemplate = (template: any) => {
|
||||||
setEditingServer({
|
const serverConfig = template?.serverConfig ?? {};
|
||||||
name: template.name,
|
const isHttp =
|
||||||
command: template.serverConfig.command,
|
serverConfig.type === 'http' ||
|
||||||
args: template.serverConfig.args || [],
|
serverConfig.transport === 'http' ||
|
||||||
env: template.serverConfig.env,
|
typeof serverConfig.url === 'string';
|
||||||
scope: 'project',
|
|
||||||
enabled: true,
|
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);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1076,9 +1092,21 @@ export function McpManagerPage() {
|
|||||||
}}
|
}}
|
||||||
onSave={handleSaveAsTemplate}
|
onSave={handleSaveAsTemplate}
|
||||||
defaultName={serverToSaveAsTemplate?.name}
|
defaultName={serverToSaveAsTemplate?.name}
|
||||||
defaultCommand={serverToSaveAsTemplate?.command}
|
defaultCommand={
|
||||||
defaultArgs={serverToSaveAsTemplate?.args}
|
serverToSaveAsTemplate && isStdioMcpServer(serverToSaveAsTemplate)
|
||||||
defaultEnv={serverToSaveAsTemplate?.env as Record<string, string>}
|
? serverToSaveAsTemplate.command
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
defaultArgs={
|
||||||
|
serverToSaveAsTemplate && isStdioMcpServer(serverToSaveAsTemplate)
|
||||||
|
? serverToSaveAsTemplate.args
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
defaultEnv={
|
||||||
|
serverToSaveAsTemplate && isStdioMcpServer(serverToSaveAsTemplate)
|
||||||
|
? (serverToSaveAsTemplate.env as Record<string, string>)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,42 +22,81 @@ interface SwitchWorkspaceResult {
|
|||||||
* Provides better error messages when response is not JSON (e.g., proxy errors)
|
* Provides better error messages when response is not JSON (e.g., proxy errors)
|
||||||
*/
|
*/
|
||||||
async function safeParseJson<T>(response: Response, endpoint: string): Promise<T> {
|
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
|
const truncate = (text: string, maxLen: number = 200): string => {
|
||||||
if (!contentType?.includes('application/json')) {
|
const trimmed = text.trim();
|
||||||
// Get response text for error message (truncated to avoid huge output)
|
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 text = await response.text();
|
||||||
const preview = text.substring(0, 200);
|
const preview = truncate(text);
|
||||||
|
|
||||||
// Detect common proxy errors
|
if (isApiKeyProxyMessage(text)) {
|
||||||
if (text.includes('APIKEY') || text.includes('api key') || text.includes('apiKey')) {
|
|
||||||
throw new Error(
|
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. ` +
|
`Check HTTP_PROXY/HTTPS_PROXY environment variables. ` +
|
||||||
`Response: ${preview}`
|
`Response: ${preview}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
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}`
|
`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) {
|
if (!response.ok) {
|
||||||
let errorMessage = response.statusText;
|
if (isApiKeyProxyMessage(text)) {
|
||||||
try {
|
throw new Error(
|
||||||
const body = await response.json() as { error?: string; message?: string };
|
`Request to ${endpoint} was intercepted by a proxy requiring an API key. ` +
|
||||||
errorMessage = body.error || body.message || response.statusText;
|
`Check HTTP_PROXY/HTTPS_PROXY environment variables. ` +
|
||||||
} catch {
|
`Response: ${preview}`
|
||||||
// Ignore JSON parse errors for error response
|
);
|
||||||
}
|
}
|
||||||
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<T>;
|
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}`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1210,40 +1210,68 @@ export async function handleMcpRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
return true;
|
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') {
|
if (pathname === '/api/mcp-install-ccw' && req.method === 'POST') {
|
||||||
handlePostRequest(req, res, async (body) => {
|
handlePostRequest(req, res, async (body) => {
|
||||||
if (!isRecord(body)) {
|
if (!isRecord(body)) {
|
||||||
return { error: 'Invalid request body', status: 400 };
|
return { error: 'Invalid request body', status: 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectPath = body.projectPath;
|
const rawScope = body.scope;
|
||||||
if (typeof projectPath !== 'string' || !projectPath.trim()) {
|
const scope = rawScope === 'global' || rawScope === 'project' ? rawScope : undefined;
|
||||||
return { error: 'projectPath is required', status: 400 };
|
|
||||||
|
// 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 projectRoot = typeof envInput.projectRoot === 'string' ? envInput.projectRoot : undefined;
|
||||||
const enableSandbox = body.enableSandbox === true;
|
|
||||||
|
|
||||||
// Parse enabled tools from request body
|
const allowedDirsRaw = envInput.allowedDirs;
|
||||||
const enabledTools = Array.isArray(body.enabledTools) && body.enabledTools.length > 0
|
let allowedDirsEnv: string | undefined;
|
||||||
? (body.enabledTools as string[]).join(',')
|
if (Array.isArray(allowedDirsRaw)) {
|
||||||
: 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
|
allowedDirsEnv = allowedDirsRaw.filter((d): d is string => typeof d === 'string').join(',');
|
||||||
|
} else if (typeof allowedDirsRaw === 'string') {
|
||||||
|
allowedDirsEnv = allowedDirsRaw;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate CCW MCP server config
|
// Generate CCW MCP server config using *server-side* platform detection.
|
||||||
// Use cmd /c on Windows to inherit Claude Code's working directory
|
// On WSL/Linux, this ensures we produce `npx -y ccw-mcp` even when the browser runs on Windows.
|
||||||
const isWin = process.platform === 'win32';
|
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> = {
|
const ccwMcpConfig: Record<string, any> = {
|
||||||
command: isWin ? "cmd" : "npx",
|
command: isWin ? 'cmd' : 'npx',
|
||||||
args: isWin ? ["/c", "npx", "-y", "ccw-mcp"] : ["-y", "ccw-mcp"],
|
args: isWin ? ['/c', 'npx', '-y', 'ccw-mcp'] : ['-y', 'ccw-mcp'],
|
||||||
env: {
|
env
|
||||||
CCW_ENABLED_TOOLS: enabledTools,
|
|
||||||
...(enableSandbox && { CCW_ENABLE_SANDBOX: "1" })
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use existing addMcpServerToProject to install CCW MCP
|
const resolvedScope: 'global' | 'project' = scope ?? (projectPath ? 'project' : 'global');
|
||||||
return addMcpServerToProject(projectPath, 'ccw-tools', ccwMcpConfig);
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user