@@ -30,6 +30,7 @@ import { startStepsWatcher, stopStepsWatcher, readStepsForWorktree } from './ste
3030import { initPrChecks , startPrChecksWatcher , stopPrChecksWatcher , isPrUrl } from './pr-checks.js' ;
3131import { readCoverageSummary } from './coverage.js' ;
3232import { startRemoteServer , getMCPLogs } from '../remote/server.js' ;
33+ import { atomicWriteFileSync } from '../mcp/atomic.js' ;
3334import {
3435 getGitIgnoredDirs ,
3536 getMainBranch ,
@@ -1355,24 +1356,64 @@ export function registerAllHandlers(win: BrowserWindow): void {
13551356 remoteServerStartedForMcp = true ;
13561357 }
13571358
1358- // Write temp MCP config file — use the bundled single-file MCP server
1359+ // Resolve the source MCP server binary path.
13591360 const thisDir = path . dirname ( fileURLToPath ( import . meta. url ) ) ;
1360- let mcpServerPath = path . join ( thisDir , '..' , 'mcp-server.cjs' ) ;
1361-
1362- // In packaged builds, asar-unpacked files live in app.asar.unpacked/
1363- if ( mcpServerPath . includes ( '/app.asar/' ) ) {
1364- mcpServerPath = mcpServerPath . replace ( '/app.asar/' , '/app.asar.unpacked/' ) ;
1361+ let hostMcpServerPath = path . join ( thisDir , '..' , 'mcp-server.cjs' ) ;
1362+ if ( hostMcpServerPath . includes ( '/app.asar/' ) ) {
1363+ hostMcpServerPath = hostMcpServerPath . replace ( '/app.asar/' , '/app.asar.unpacked/' ) ;
13651364 }
1365+
1366+ // In Docker mode the server is copied into the worktree so the container can reach it.
1367+ // Compute the destination path now (pure, no side effects) so we can build mcpConfig
1368+ // and mergedMcpJson before committing any Docker filesystem writes.
1369+ const dockerMcpServerPath = args . dockerContainerName
1370+ ? getDockerMcpServerDestPath ( args . worktreePath , args . projectRoot )
1371+ : undefined ;
1372+ const mcpServerPath = dockerMcpServerPath ?? hostMcpServerPath ;
1373+
13661374 const serverUrl = getMCPRemoteServerUrl ( remoteServer . port , args . dockerContainerName ) ;
13671375
1368- // Docker+coordinator: copy the MCP server script into the mounted worktree/project path
1369- // so it is accessible inside the container.
1370- if ( args . dockerContainerName ) {
1371- const dockerMcpServerPath = getDockerMcpServerDestPath ( args . worktreePath , args . projectRoot ) ;
1376+ // Build mcpConfig and mergedMcpJson (pure computation — no filesystem or state side effects).
1377+ // Doing this before any Docker copy or coordinator mutation ensures that if .mcp.json
1378+ // merge logic ever grows fallible, Docker residue is never left behind.
1379+ const mcpConfig = {
1380+ mcpServers : {
1381+ 'parallel-code' : {
1382+ type : 'stdio' as const ,
1383+ command : 'node' ,
1384+ args : [
1385+ mcpServerPath ,
1386+ '--url' ,
1387+ serverUrl ,
1388+ '--coordinator-id' ,
1389+ args . coordinatorTaskId ,
1390+ ...( args . skipPermissions && args . propagateSkipPermissions
1391+ ? [ '--skip-permissions' ]
1392+ : [ ] ) ,
1393+ ] ,
1394+ env : { PARALLEL_CODE_MCP_TOKEN : remoteServer . token } ,
1395+ } ,
1396+ } ,
1397+ } ;
1398+
1399+ const configJson = JSON . stringify ( mcpConfig , null , 2 ) ;
1400+
1401+ // Merge mcpConfig into the pre-validated existingMcpContent (parsed above,
1402+ // before any coordinator state was mutated).
1403+ let mergedMcpJson : string | undefined ;
1404+ if ( mcpJsonDir && worktreeMcpPath ) {
1405+ const existingServers =
1406+ ( existingMcpContent . mcpServers as Record < string , unknown > | undefined ) ?? { } ;
1407+ existingMcpContent . mcpServers = { ...existingServers , ...mcpConfig . mcpServers } ;
1408+ mergedMcpJson = JSON . stringify ( existingMcpContent , null , 2 ) ;
1409+ }
1410+
1411+ // All pure computation done. Now commit side effects: coordinator state mutations,
1412+ // Docker filesystem writes, MCP config file writes.
1413+ if ( dockerMcpServerPath ) {
13721414 fs . mkdirSync ( path . dirname ( dockerMcpServerPath ) , { recursive : true } ) ;
1373- fs . copyFileSync ( mcpServerPath , dockerMcpServerPath ) ;
1374- mcpServerPath = dockerMcpServerPath ;
1375- coordinator . setDockerContainerName ( args . coordinatorTaskId , args . dockerContainerName ) ;
1415+ fs . copyFileSync ( hostMcpServerPath , dockerMcpServerPath ) ; // nosemgrep: semgrep.copyfilesync-side-effect -- all pure computation (mcpConfig, mergedMcpJson) is done above; this is correctly ordered
1416+ coordinator . setDockerContainerName ( args . coordinatorTaskId , args . dockerContainerName ?? '' ) ;
13761417 coordinator . setDockerImage ( args . coordinatorTaskId , args . dockerImage ?? null ) ;
13771418 console . warn ( '[MCP] Docker mode: copied MCP server to' , dockerMcpServerPath ) ;
13781419 // Keep .parallel-code/ out of git status in the sub-task worktree.
@@ -1420,38 +1461,6 @@ export function registerAllHandlers(win: BrowserWindow): void {
14201461 args . agentArgs ?? [ ] ,
14211462 ) ;
14221463
1423- const mcpConfig = {
1424- mcpServers : {
1425- 'parallel-code' : {
1426- type : 'stdio' as const ,
1427- command : 'node' ,
1428- args : [
1429- mcpServerPath ,
1430- '--url' ,
1431- serverUrl ,
1432- '--coordinator-id' ,
1433- args . coordinatorTaskId ,
1434- ...( args . skipPermissions && args . propagateSkipPermissions
1435- ? [ '--skip-permissions' ]
1436- : [ ] ) ,
1437- ] ,
1438- env : { PARALLEL_CODE_MCP_TOKEN : remoteServer . token } ,
1439- } ,
1440- } ,
1441- } ;
1442-
1443- const configJson = JSON . stringify ( mcpConfig , null , 2 ) ;
1444-
1445- // Merge mcpConfig into the pre-validated existingMcpContent (parsed above,
1446- // before any coordinator state was mutated).
1447- let mergedMcpJson : string | undefined ;
1448- if ( mcpJsonDir && worktreeMcpPath ) {
1449- const existingServers =
1450- ( existingMcpContent . mcpServers as Record < string , unknown > | undefined ) ?? { } ;
1451- existingMcpContent . mcpServers = { ...existingServers , ...mcpConfig . mcpServers } ;
1452- mergedMcpJson = JSON . stringify ( existingMcpContent , null , 2 ) ;
1453- }
1454-
14551464 // In docker mode the coordinator agent auto-discovers .mcp.json in the project root.
14561465 // No host-temp configPath needed.
14571466 let configPath : string | undefined ;
@@ -1460,15 +1469,14 @@ export function registerAllHandlers(win: BrowserWindow): void {
14601469 app . getPath ( 'temp' ) ,
14611470 `parallel-code-mcp-${ args . coordinatorTaskId } .json` ,
14621471 ) ;
1463- fs . writeFileSync ( configPath , configJson , { mode : 0o600 } ) ;
1472+ atomicWriteFileSync ( configPath , configJson , { mode : 0o600 } ) ;
14641473 }
14651474
14661475 // Write .mcp.json for auto-discovery. Read before writing — merge only the
14671476 // parallel-code key so we don't destroy user-defined entries. Track whether
14681477 // we created the file so deregisterCoordinator can clean up correctly.
14691478 if ( mcpJsonDir && worktreeMcpPath && mergedMcpJson !== undefined ) {
1470- fs . writeFileSync ( worktreeMcpPath , mergedMcpJson ) ;
1471- fs . chmodSync ( worktreeMcpPath , 0o600 ) ;
1479+ atomicWriteFileSync ( worktreeMcpPath , mergedMcpJson , { mode : 0o600 } ) ;
14721480 coordinator . setMcpJsonInfo ( args . coordinatorTaskId , worktreeMcpPath , ! mcpFileExistedBefore ) ;
14731481
14741482 // Append to .git/info/exclude (local-only gitignore, not committed)
0 commit comments