diff --git a/cli/src/claude/claudeLocal.ts b/cli/src/claude/claudeLocal.ts index 0524b0b9..02e83f77 100644 --- a/cli/src/claude/claudeLocal.ts +++ b/cli/src/claude/claudeLocal.ts @@ -3,6 +3,7 @@ import { logger } from "@/ui/logger"; import { restoreTerminalState } from "@/ui/terminalState"; import { claudeCheckSession } from "./utils/claudeCheckSession"; import { getProjectPath } from "./utils/path"; +import { appendMcpConfigArg } from "./utils/mcpConfig"; import { systemPrompt } from "./utils/systemPrompt"; import { withBunRuntimeEnv } from "@/utils/bunRuntime"; import { spawnWithAbort } from "@/utils/spawnWithAbort"; @@ -41,6 +42,7 @@ export async function claudeLocal(opts: { } // Spawn the process + let cleanupMcpConfig: (() => void) | null = null; try { // Start the interactive process process.stdin.pause(); @@ -54,9 +56,9 @@ export async function claudeLocal(opts: { args.push('--append-system-prompt', systemPrompt); - if (opts.mcpServers && Object.keys(opts.mcpServers).length > 0) { - args.push('--mcp-config', JSON.stringify({ mcpServers: opts.mcpServers })); - } + cleanupMcpConfig = appendMcpConfigArg(args, opts.mcpServers, { + baseDir: projectDir + }); if (opts.allowedTools && opts.allowedTools.length > 0) { args.push('--allowedTools', opts.allowedTools.join(',')); @@ -96,6 +98,7 @@ export async function claudeLocal(opts: { }).then(r).catch(reject); }); } finally { + cleanupMcpConfig?.(); process.stdin.resume(); restoreTerminalState(); } diff --git a/cli/src/claude/sdk/query.ts b/cli/src/claude/sdk/query.ts index 585f1199..2b33f291 100644 --- a/cli/src/claude/sdk/query.ts +++ b/cli/src/claude/sdk/query.ts @@ -27,6 +27,7 @@ import { withBunRuntimeEnv } from '@/utils/bunRuntime' import { killProcessByChildProcess } from '@/utils/process' import type { Writable } from 'node:stream' import { logger } from '@/ui/logger' +import { appendMcpConfigArg } from '../utils/mcpConfig' /** * Query class manages Claude Code process interaction @@ -285,6 +286,7 @@ export function query(config: { // Build command arguments const args = ['--output-format', 'stream-json', '--verbose'] + let cleanupMcpConfig: (() => void) | null = null if (customSystemPrompt) args.push('--system-prompt', customSystemPrompt) if (appendSystemPrompt) args.push('--append-system-prompt', appendSystemPrompt) @@ -301,9 +303,6 @@ export function query(config: { if (settingsPath) args.push('--settings', settingsPath) if (allowedTools.length > 0) args.push('--allowedTools', allowedTools.join(',')) if (disallowedTools.length > 0) args.push('--disallowedTools', disallowedTools.join(',')) - if (mcpServers && Object.keys(mcpServers).length > 0) { - args.push('--mcp-config', JSON.stringify({ mcpServers })) - } if (strictMcpConfig) args.push('--strict-mcp-config') if (permissionMode) args.push('--permission-mode', permissionMode) @@ -334,6 +333,8 @@ export function query(config: { const spawnCommand = pathToClaudeCodeExecutable const spawnArgs = args + cleanupMcpConfig = appendMcpConfigArg(spawnArgs, mcpServers) + // Spawn Claude Code process // Use clean env for global claude to avoid local node_modules/.bin taking precedence const baseEnv = isCommandOnly ? getCleanEnv() : process.env @@ -394,6 +395,7 @@ export function query(config: { // Handle process errors child.on('error', (error) => { + cleanupMcpConfig?.() if (config.options?.abort?.aborted) { query.setError(new AbortError('Claude Code process aborted by user')) } else { @@ -408,6 +410,7 @@ export function query(config: { if (process.env.CLAUDE_SDK_MCP_SERVERS) { delete process.env.CLAUDE_SDK_MCP_SERVERS } + cleanupMcpConfig?.() }) return query diff --git a/cli/src/claude/utils/mcpConfig.ts b/cli/src/claude/utils/mcpConfig.ts new file mode 100644 index 00000000..2ed58295 --- /dev/null +++ b/cli/src/claude/utils/mcpConfig.ts @@ -0,0 +1,58 @@ +import { mkdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export type McpConfigArg = { + value: string; + cleanup?: () => void; +}; + +export type McpConfigOptions = { + useFile?: boolean; + baseDir?: string; +}; + +export function resolveMcpConfigArg( + mcpServers: Record, + options?: McpConfigOptions +): McpConfigArg { + const configJson = JSON.stringify({ mcpServers }); + const useFile = options?.useFile ?? process.platform === 'win32'; + if (!useFile) { + return { value: configJson }; + } + + const dir = options?.baseDir ?? tmpdir(); + mkdirSync(dir, { recursive: true }); + + const filePath = join( + dir, + `mcp-config-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json` + ); + writeFileSync(filePath, configJson, "utf8"); + + return { + value: filePath, + cleanup: () => { + try { + unlinkSync(filePath); + } catch { + // Ignore cleanup errors; config file is optional and short-lived. + } + } + }; +} + +export function appendMcpConfigArg( + args: string[], + mcpServers?: Record, + options?: McpConfigOptions +): (() => void) | null { + if (!mcpServers || Object.keys(mcpServers).length === 0) { + return null; + } + + const { value, cleanup } = resolveMcpConfigArg(mcpServers, options); + args.push('--mcp-config', value); + return cleanup ?? null; +}