Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ccw/frontend/src/components/mcp/CrossCliSyncPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
12 changes: 8 additions & 4 deletions ccw/frontend/src/lib/api.mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
crossCliCopy,
fetchAllProjects,
fetchOtherProjectsServers,
isStdioMcpServer,
type McpServer,
} from './api';

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -150,6 +154,7 @@ describe('MCP API (frontend ↔ backend contract)', () => {

const inputServer: McpServer = {
name: 's1',
transport: 'stdio',
command: 'node',
args: ['a'],
env: { K: 'V' },
Expand Down Expand Up @@ -290,4 +295,3 @@ describe('MCP API (frontend ↔ backend contract)', () => {
expect(res.servers['D:/a']?.[0]?.enabled).toBe(false);
});
});

228 changes: 99 additions & 129 deletions ccw/frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -3538,23 +3539,60 @@ function toServerConfig(server: Partial<McpServer>): UnknownRecord {
// STDIO server (default)
const config: UnknownRecord = {};

if (typeof server.command === 'string') {
config.command = server.command;
}
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;

if (server.args && server.args.length > 0) {
config.args = server.args;
}
return config;
}

if (server.env && Object.keys(server.env).length > 0) {
config.env = server.env;
}
function _buildFallbackServer(serverName: string, config: Partial<McpServer>): McpServer {
const transport = config.transport ?? 'stdio';
const enabled = config.enabled ?? true;
const scope = config.scope ?? 'project';

if (server.cwd) {
config.cwd = server.cwd;
if (transport === 'http') {
const url = 'url' in config && typeof config.url === 'string' ? config.url : '';
return {
name: serverName,
transport: 'http',
url,
enabled,
scope,
};
Comment on lines +3555 to +3563
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

HTTP fallback server omits optional auth/header fields.

When fallback is used, HTTP servers keep only url, dropping header/token-related fields from returned state.

💡 Proposed fix
   if (transport === 'http') {
     const url = 'url' in config && typeof config.url === 'string' ? config.url : '';
+    const headers =
+      'headers' in config && config.headers && typeof config.headers === 'object'
+        ? (config.headers as Record<string, string>)
+        : undefined;
+    const bearerTokenEnvVar =
+      'bearerTokenEnvVar' in config && typeof config.bearerTokenEnvVar === 'string'
+        ? config.bearerTokenEnvVar
+        : undefined;
+    const httpHeaders =
+      'httpHeaders' in config && config.httpHeaders && typeof config.httpHeaders === 'object'
+        ? (config.httpHeaders as Record<string, string>)
+        : undefined;
+    const envHttpHeaders =
+      'envHttpHeaders' in config && Array.isArray(config.envHttpHeaders)
+        ? config.envHttpHeaders
+        : undefined;
+
     return {
       name: serverName,
       transport: 'http',
       url,
+      headers,
+      bearerTokenEnvVar,
+      httpHeaders,
+      envHttpHeaders,
       enabled,
       scope,
     };
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (transport === 'http') {
const url = 'url' in config && typeof config.url === 'string' ? config.url : '';
return {
name: serverName,
transport: 'http',
url,
enabled,
scope,
};
if (transport === 'http') {
const url = 'url' in config && typeof config.url === 'string' ? config.url : '';
const headers =
'headers' in config && config.headers && typeof config.headers === 'object'
? (config.headers as Record<string, string>)
: undefined;
const bearerTokenEnvVar =
'bearerTokenEnvVar' in config && typeof config.bearerTokenEnvVar === 'string'
? config.bearerTokenEnvVar
: undefined;
const httpHeaders =
'httpHeaders' in config && config.httpHeaders && typeof config.httpHeaders === 'object'
? (config.httpHeaders as Record<string, string>)
: undefined;
const envHttpHeaders =
'envHttpHeaders' in config && Array.isArray(config.envHttpHeaders)
? config.envHttpHeaders
: undefined;
return {
name: serverName,
transport: 'http',
url,
headers,
bearerTokenEnvVar,
httpHeaders,
envHttpHeaders,
enabled,
scope,
};
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ccw/frontend/src/lib/api.ts` around lines 3555 - 3563, The HTTP fallback
branch that constructs the returned server state (the block where transport ===
'http' returns { name: serverName, transport: 'http', url, enabled, scope })
drops optional auth/header fields from config; update this branch to also copy
through any auth or header-related fields present on config (for example
headers, token, apiKey, authorization) into the returned object, validating
types (e.g. string for tokens/keys, object for headers) before including them so
the fallback preserves authentication info.

}

return config;
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,
};
}

/**
Expand All @@ -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');
}
}
Expand Down Expand Up @@ -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)
);
Comment on lines 3661 to +3665
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Return the updated server from the requested scope.

The post-update lookup searches project before global regardless of config.scope. If the same server name exists in both scopes, a global update can return the project server.

💡 Proposed fix
   if (options.projectPath) {
     const servers = await fetchMcpServers(options.projectPath);
-    return (
-      [...servers.project, ...servers.global].find((s) => s.name === serverName) ??
-      _buildFallbackServer(serverName, config)
-    );
+    const scopedServers = config.scope === 'global' ? servers.global : servers.project;
+    return (
+      scopedServers.find((s) => s.name === serverName) ??
+      _buildFallbackServer(serverName, config)
+    );
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
);
const servers = await fetchMcpServers(options.projectPath);
const scopedServers = config.scope === 'global' ? servers.global : servers.project;
return (
scopedServers.find((s) => s.name === serverName) ??
_buildFallbackServer(serverName, config)
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ccw/frontend/src/lib/api.ts` around lines 3661 - 3665, The lookup after
fetching servers incorrectly always prefers project servers; modify the
selection to honor config.scope: when config.scope === 'global' search
servers.global first and then servers.project, otherwise search servers.project
first then servers.global; use the existing fetchMcpServers(options.projectPath)
result and fallback to _buildFallbackServer(serverName, config) if not found,
keeping serverName and config as the identifying symbols to locate the change.

}

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

/**
Expand Down Expand Up @@ -3756,22 +3783,23 @@ 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 ==========
/**
* 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[];
Expand Down Expand Up @@ -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),
};
}];
});
}

Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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();
}
Expand All @@ -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', {
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');
}
}
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})`);

return fetchCcwMcpConfig();
return fetchCcwMcpConfig(path);
}

/**
Expand Down Expand Up @@ -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[];
Expand Down
Loading
Loading