Skip to content

Commit 05344e2

Browse files
committed
fix(coordinator): launch codex with inline MCP config
1 parent 588797e commit 05344e2

11 files changed

Lines changed: 222 additions & 20 deletions

File tree

electron/ipc/register.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { initPrChecks, startPrChecksWatcher, stopPrChecksWatcher, isPrUrl } from
3131
import { readCoverageSummary } from './coverage.js';
3232
import { startRemoteServer, getMCPLogs } from '../remote/server.js';
3333
import { atomicWriteFileSync } from '../mcp/atomic.js';
34+
import { buildMcpLaunchArgs } from '../mcp/agent-args.js';
3435
import {
3536
getGitIgnoredDirs,
3637
getMainBranch,
@@ -104,7 +105,7 @@ export interface CoordinatorMCPConfigOpts {
104105
propagateSkipPermissions?: boolean;
105106
}
106107

107-
/** Builds the coordinator `.mcp.json` / `--mcp-config` file content. */
108+
/** Builds the coordinator MCP server config used for launch args and `.mcp.json`. */
108109
export function buildCoordinatorMCPConfig(opts: CoordinatorMCPConfigOpts): {
109110
mcpServers: {
110111
'parallel-code': {
@@ -1596,24 +1597,30 @@ export function registerAllHandlers(win: BrowserWindow): void {
15961597
lastMcpConfigPath = configPath;
15971598
console.warn('[MCP] Config written to:', configPath);
15981599
}
1600+
const mcpLaunchArgs = buildMcpLaunchArgs(
1601+
args.agentCommand ?? 'claude',
1602+
configPath,
1603+
mcpConfig,
1604+
);
15991605
console.warn('[MCP] Server path:', mcpServerPath);
16001606
console.warn('[MCP] Remote URL:', redactServerUrl(serverUrl));
16011607

16021608
return {
16031609
configPath,
1610+
mcpLaunchArgs,
16041611
serverUrl,
16051612
port: remoteServer.port,
16061613
};
16071614
},
16081615
);
16091616

16101617
ipcMain.handle(IPC.StopMCPServer, async () => {
1611-
// The MCP server process is spawned by Claude Code (via --mcp-config),
1618+
// The MCP server process is spawned by the agent CLI via launch args,
16121619
// not by us. This handler is a no-op but kept for API completeness.
16131620
});
16141621

16151622
ipcMain.handle(IPC.GetMCPStatus, () => {
1616-
// The MCP server process is spawned by Claude Code (via --mcp-config),
1623+
// The MCP server process is spawned by the agent CLI via launch args,
16171624
// not by us. We report whether the remote HTTP server that the MCP
16181625
// server connects to is running — if it's up, MCP tools should work.
16191626
const remoteRunning = remoteServer !== null;

electron/mcp/agent-args.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { buildCodexMcpConfigOverride, buildMcpLaunchArgs, isCodexCommand } from './agent-args.js';
4+
5+
const config = {
6+
mcpServers: {
7+
'parallel-code': {
8+
command: 'node',
9+
args: ['/tmp/mcp-server.cjs', '--url', 'http://127.0.0.1:1234', '--coordinator-id', 'task-1'],
10+
env: {
11+
PARALLEL_CODE_MCP_TOKEN: 'token-1',
12+
},
13+
},
14+
},
15+
};
16+
17+
describe('MCP agent launch args', () => {
18+
it('detects codex commands by executable name', () => {
19+
expect(isCodexCommand('codex')).toBe(true);
20+
expect(isCodexCommand('/opt/homebrew/bin/codex')).toBe(true);
21+
expect(isCodexCommand('claude')).toBe(false);
22+
});
23+
24+
it('builds Codex inline config overrides instead of --mcp-config', () => {
25+
expect(buildMcpLaunchArgs('codex', '/tmp/config.json', config)).toEqual([
26+
'--config',
27+
buildCodexMcpConfigOverride(config),
28+
]);
29+
});
30+
31+
it('uses --mcp-config for Claude-compatible agents', () => {
32+
expect(buildMcpLaunchArgs('claude', '/tmp/config.json', config)).toEqual([
33+
'--mcp-config',
34+
'/tmp/config.json',
35+
]);
36+
});
37+
});

electron/mcp/agent-args.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
interface ParallelCodeMcpServerConfig {
2+
command: string;
3+
args: string[];
4+
env: Record<string, string>;
5+
}
6+
7+
export interface ParallelCodeMcpConfig {
8+
mcpServers: {
9+
'parallel-code': ParallelCodeMcpServerConfig;
10+
};
11+
}
12+
13+
export function isCodexCommand(command: string): boolean {
14+
return command.split('/').pop()?.includes('codex') === true;
15+
}
16+
17+
function tomlString(value: string): string {
18+
return JSON.stringify(value);
19+
}
20+
21+
function tomlStringArray(values: string[]): string {
22+
return `[${values.map(tomlString).join(', ')}]`;
23+
}
24+
25+
function tomlStringMap(values: Record<string, string>): string {
26+
return `{ ${Object.entries(values)
27+
.map(([key, value]) => `${key} = ${tomlString(value)}`)
28+
.join(', ')} }`;
29+
}
30+
31+
export function buildCodexMcpConfigOverride(config: ParallelCodeMcpConfig): string {
32+
const server = config.mcpServers['parallel-code'];
33+
return `mcp_servers.parallel-code={ command = ${tomlString(server.command)}, args = ${tomlStringArray(server.args)}, env = ${tomlStringMap(server.env)} }`;
34+
}
35+
36+
export function buildMcpLaunchArgs(
37+
command: string,
38+
configPath: string | undefined,
39+
config: ParallelCodeMcpConfig,
40+
): string[] {
41+
if (isCodexCommand(command)) {
42+
return ['--config', buildCodexMcpConfigOverride(config)];
43+
}
44+
return configPath ? ['--mcp-config', configPath] : [];
45+
}

electron/mcp/coordinator.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { join, dirname } from 'path';
1717
import os from 'os';
1818
import { getSubTaskMcpConfigPath } from './config.js';
19+
import { buildMcpLaunchArgs } from './agent-args.js';
1920
import { validateBranchName } from './validation.js';
2021
import { atomicWriteFileSync, atomicWriteFile } from './atomic.js';
2122
import { ReplayCache } from './replay-cache.js';
@@ -604,8 +605,9 @@ export class Coordinator {
604605
// Write a per-sub-task MCP config so the agent can call signal_done.
605606
// In Docker mode, write to the coordinator's .parallel-code/ dir (which IS the explicitly
606607
// mounted volume) rather than the sub-task worktree (which may not be in the container).
607-
// Always pass --mcp-config explicitly so Claude doesn't rely on auto-discovery.
608+
// Always pass explicit MCP launch args so agents don't rely on auto-discovery.
608609
const mcpServerInfoForTask = coordinatorState.mcpServerInfo;
610+
let subTaskMcpConfig: Parameters<typeof buildMcpLaunchArgs>[2] | undefined;
609611
if (mcpServerInfoForTask) {
610612
const { serverUrl, subtaskToken, serverPath } = mcpServerInfoForTask;
611613
const doneToken = randomBytes(24).toString('base64url');
@@ -623,6 +625,7 @@ export class Coordinator {
623625
},
624626
},
625627
};
628+
subTaskMcpConfig = mcpConfig;
626629
const configPath = getSubTaskMcpConfigPath(dockerContainerName, serverPath, task.id);
627630
await atomicWriteFile(configPath, JSON.stringify(mcpConfig, null, 2), { mode: 0o600 });
628631
subTaskMcpConfigPath = configPath;
@@ -635,7 +638,9 @@ export class Coordinator {
635638
...agentArgs,
636639
...(coordinatorState.propagateSkipPermissions ? getSkipPermissionsArgs(agentCommand) : []),
637640
];
638-
const mcpArgs = subTaskMcpConfigPath ? ['--mcp-config', subTaskMcpConfigPath] : [];
641+
const mcpArgs = subTaskMcpConfig
642+
? buildMcpLaunchArgs(agentCommand, subTaskMcpConfigPath, subTaskMcpConfig)
643+
: [];
639644
const agentFinalArgs = [...baseArgs, ...mcpArgs];
640645

641646
// In Docker coordinator mode, each sub-task gets its own `docker run` container

src/App.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {
5252
initMCPListeners,
5353
markTaskMcpPending,
5454
markTaskMcpReady,
55+
setTaskMcpLaunchArgs,
5556
markTaskMcpError,
5657
} from './store/store';
5758
import { isGitHubUrl } from './lib/github-url';
@@ -359,7 +360,7 @@ function App() {
359360
: undefined;
360361
markTaskMcpPending(taskId);
361362
mcpRestorePromises.push(
362-
invoke(IPC.StartMCPServer, {
363+
invoke<{ mcpLaunchArgs?: string[] }>(IPC.StartMCPServer, {
363364
coordinatorTaskId: task.id,
364365
projectId: task.projectId,
365366
projectRoot,
@@ -371,7 +372,10 @@ function App() {
371372
dockerContainerName,
372373
dockerImage: task.dockerMode ? task.dockerImage : undefined,
373374
})
374-
.then(() => markTaskMcpReady(taskId))
375+
.then((result) => {
376+
setTaskMcpLaunchArgs(taskId, result?.mcpLaunchArgs);
377+
markTaskMcpReady(taskId);
378+
})
375379
.catch((err) => {
376380
console.warn(`[MCP] Failed to restore MCP server for coordinator task ${taskId}:`, err);
377381
markTaskMcpError(taskId, String(err));

src/components/TaskAITerminal.tsx

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { createHighlightedMarkdown } from '../lib/marked-shiki';
2828
import type { Task } from '../store/types';
2929
import type { AgentDef } from '../ipc/types';
3030
import type { PromptInputHandle } from './PromptInput';
31+
import { buildTaskAgentArgs } from '../lib/agent-args';
3132

3233
function aiTerminalPanelId(agentId: string): string {
3334
return `ai-terminal:${agentId}`;
@@ -588,15 +589,7 @@ function AgentTerminalPane(props: {
588589
agentId={a().id}
589590
isFocused={isPanelFocused(props.task.id, aiTerminalPanelId(props.agentId))}
590591
command={a().def.command}
591-
args={[
592-
...(a().resumed && a().def.resume_args?.length
593-
? (a().def.resume_args ?? [])
594-
: a().def.args),
595-
...(props.task.skipPermissions && a().def.skip_permissions_args?.length
596-
? (a().def.skip_permissions_args ?? [])
597-
: []),
598-
...(props.task.mcpConfigPath ? ['--mcp-config', props.task.mcpConfigPath] : []),
599-
]}
592+
args={buildTaskAgentArgs(a().def, props.task, a().resumed)}
600593
cwd={props.task.worktreePath}
601594
stepsEnabled={props.task.stepsEnabled}
602595
dockerMode={

src/lib/agent-args.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { buildTaskAgentArgs } from './agent-args';
4+
5+
const codexAgent = {
6+
id: 'codex',
7+
name: 'Codex',
8+
description: 'Codex agent',
9+
command: 'codex',
10+
args: ['resume', '--last'],
11+
resume_args: ['resume', '--last'],
12+
skip_permissions_args: ['--dangerously-bypass-approvals-and-sandbox'],
13+
};
14+
15+
const claudeAgent = {
16+
id: 'claude',
17+
name: 'Claude',
18+
description: 'Claude agent',
19+
command: 'claude',
20+
args: [],
21+
resume_args: [],
22+
skip_permissions_args: ['--dangerously-skip-permissions'],
23+
};
24+
25+
describe('buildTaskAgentArgs', () => {
26+
it('uses explicit MCP launch args when provided', () => {
27+
expect(
28+
buildTaskAgentArgs(
29+
codexAgent,
30+
{
31+
skipPermissions: true,
32+
mcpConfigPath: '/tmp/mcp.json',
33+
mcpLaunchArgs: ['--config', 'mcp_servers.parallel-code={ command = "node" }'],
34+
},
35+
false,
36+
),
37+
).toEqual([
38+
'resume',
39+
'--last',
40+
'--dangerously-bypass-approvals-and-sandbox',
41+
'--config',
42+
'mcp_servers.parallel-code={ command = "node" }',
43+
]);
44+
});
45+
46+
it('does not fall back to --mcp-config for Codex', () => {
47+
expect(
48+
buildTaskAgentArgs(
49+
codexAgent,
50+
{
51+
skipPermissions: false,
52+
mcpConfigPath: '/tmp/mcp.json',
53+
},
54+
false,
55+
),
56+
).toEqual(['resume', '--last']);
57+
});
58+
59+
it('keeps --mcp-config fallback for Claude-compatible agents', () => {
60+
expect(
61+
buildTaskAgentArgs(
62+
claudeAgent,
63+
{
64+
skipPermissions: false,
65+
mcpConfigPath: '/tmp/mcp.json',
66+
},
67+
false,
68+
),
69+
).toEqual(['--mcp-config', '/tmp/mcp.json']);
70+
});
71+
});

src/lib/agent-args.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { AgentDef } from '../ipc/types';
2+
import type { Task } from '../store/types';
3+
4+
function isCodexCommand(command: string): boolean {
5+
return command.split('/').pop()?.includes('codex') === true;
6+
}
7+
8+
function legacyMcpConfigArgs(command: string, mcpConfigPath: string | undefined): string[] {
9+
if (!mcpConfigPath || isCodexCommand(command)) return [];
10+
return ['--mcp-config', mcpConfigPath];
11+
}
12+
13+
export function buildTaskAgentArgs(
14+
agentDef: AgentDef,
15+
task: Pick<Task, 'skipPermissions' | 'mcpConfigPath' | 'mcpLaunchArgs'>,
16+
resumed: boolean,
17+
): string[] {
18+
return [
19+
...(resumed && agentDef.resume_args?.length ? (agentDef.resume_args ?? []) : agentDef.args),
20+
...(task.skipPermissions && agentDef.skip_permissions_args?.length
21+
? (agentDef.skip_permissions_args ?? [])
22+
: []),
23+
...(task.mcpLaunchArgs ?? legacyMcpConfigArgs(agentDef.command, task.mcpConfigPath)),
24+
];
25+
}

src/store/store.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export {
6161
setTaskControl,
6262
markTaskMcpPending,
6363
markTaskMcpReady,
64+
setTaskMcpLaunchArgs,
6465
markTaskMcpError,
6566
retryTaskMcpStartup,
6667
} from './tasks';

src/store/tasks.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,17 @@ export async function createTask(opts: CreateTaskOptions): Promise<string> {
188188

189189
// Start MCP server BEFORE adding task to store — the store update triggers
190190
// a reactive render of TerminalView which spawns the PTY immediately.
191-
// If mcpConfigPath isn't set yet, the --mcp-config arg is missing.
191+
// If MCP launch args aren't set yet, the coordinator agent starts without MCP wiring.
192192
let mcpConfigPath: string | undefined;
193+
let mcpLaunchArgs: string[] | undefined;
193194
if (opts.coordinatorMode) {
194195
// When running in Docker, sub-agents will be spawned via `docker exec` into this container.
195196
const dockerContainerName = dockerMode ? `parallel-code-${agentId.slice(0, 12)}` : undefined;
196197
try {
197-
const mcpResult = await invoke<{ configPath: string | undefined }>(IPC.StartMCPServer, {
198+
const mcpResult = await invoke<{
199+
configPath: string | undefined;
200+
mcpLaunchArgs?: string[];
201+
}>(IPC.StartMCPServer, {
198202
coordinatorTaskId: taskId,
199203
projectId,
200204
projectRoot,
@@ -207,6 +211,7 @@ export async function createTask(opts: CreateTaskOptions): Promise<string> {
207211
dockerImage,
208212
});
209213
mcpConfigPath = mcpResult.configPath ?? undefined;
214+
mcpLaunchArgs = mcpResult.mcpLaunchArgs;
210215
console.warn('[MCP] Coordinator config path:', mcpConfigPath);
211216
await invoke(IPC.MCP_CoordinatorRegistered, {
212217
coordinatorTaskId: taskId,
@@ -270,6 +275,7 @@ export async function createTask(opts: CreateTaskOptions): Promise<string> {
270275
: undefined,
271276
controlledBy: opts.coordinatorMode ? 'coordinator' : undefined,
272277
mcpConfigPath,
278+
mcpLaunchArgs,
273279
// Coordinator tasks call StartMCPServer before entering the store, so MCP is ready immediately.
274280
mcpStartupStatus: opts.coordinatorMode ? ('ready' as const) : undefined,
275281
};
@@ -1236,6 +1242,10 @@ export function markTaskMcpReady(taskId: string): void {
12361242
if (store.tasks[taskId]) setStore('tasks', taskId, 'mcpStartupStatus', 'ready');
12371243
}
12381244

1245+
export function setTaskMcpLaunchArgs(taskId: string, args: string[] | undefined): void {
1246+
if (store.tasks[taskId]) setStore('tasks', taskId, 'mcpLaunchArgs', args);
1247+
}
1248+
12391249
export function markTaskMcpError(taskId: string, errorMsg: string): void {
12401250
if (!store.tasks[taskId]) return;
12411251
// eslint-disable-next-line no-control-regex -- strip escape chars to prevent injection
@@ -1260,7 +1270,7 @@ export function retryTaskMcpStartup(taskId: string): Promise<void> {
12601270
task.dockerMode && task.agentIds[0]
12611271
? `parallel-code-${task.agentIds[0].slice(0, 12)}`
12621272
: undefined;
1263-
return invoke(IPC.StartMCPServer, {
1273+
return invoke<{ mcpLaunchArgs?: string[] }>(IPC.StartMCPServer, {
12641274
coordinatorTaskId: task.id,
12651275
projectId: task.projectId,
12661276
projectRoot,
@@ -1272,7 +1282,10 @@ export function retryTaskMcpStartup(taskId: string): Promise<void> {
12721282
dockerContainerName,
12731283
dockerImage: task.dockerImage,
12741284
})
1275-
.then(() => markTaskMcpReady(taskId))
1285+
.then((result) => {
1286+
setTaskMcpLaunchArgs(taskId, result?.mcpLaunchArgs);
1287+
markTaskMcpReady(taskId);
1288+
})
12761289
.catch((err: unknown) => markTaskMcpError(taskId, String(err)));
12771290
}
12781291

0 commit comments

Comments
 (0)