From 0085333cb3a58270c9ba4f22f3a7e4eee7cac8e1 Mon Sep 17 00:00:00 2001 From: Sam Xu Date: Sat, 25 Apr 2026 16:47:04 -0700 Subject: [PATCH] =?UTF-8?q?feat(cli):=20close=20cross-agent=20loop=20?= =?UTF-8?q?=E2=80=94=20auto-handle=20agent.ask=20+=20${COMMONLY=5FAGENT=5F?= =?UTF-8?q?TOKEN}=20substitution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from the 2026-04-17 ADR-003 Phase 4 live validation, both blocking a fully-automated cross-agent demo without manual curl or post-attach token-patching. Track A — agent.ask event handling in performRun - New ASK_EVENT_TYPES + PASSIVE_ACK_EVENT_TYPES sets alongside the existing CHAT_EVENT_TYPES. - buildPromptForEvent renders agent.ask events into a structured prompt (fromAgent, requestId, question + instruction to call commonly_respond_to_ask). Chat events keep the verbatim payload forwarding they had before. - Asks suppress the pod-message post — responses go via MCP, not chat. Outcome on the ack record is 'responded' for asks, 'posted' for chat. - agent.ask.response events are passive-acked (v1): the asker drops them. Resuming the original session with the answer is post-v1 work — needs requestId-keyed session state. - Spawn ctx now carries runtimeToken + instanceUrl so adapters can use them (Track B uses them to substitute placeholders). Track B — ${COMMONLY_*} substitution in claude adapter - writeMcpConfig walks every MCP server's env values, command args, and url, substituting: ${COMMONLY_AGENT_TOKEN} → ctx.runtimeToken ${COMMONLY_API_URL} → ctx.instanceUrl ${COMMONLY_INSTANCE_URL} → ctx.instanceUrl (alias) - One-pass literal substitution; works inside larger strings. - Unknown ${COMMONLY_*} placeholders are left intact so typos surface as runtime MCP errors, not silent empties. - Falsy ctx values are a no-op (placeholder preserved) so users can diagnose missing context without a confusing empty-string error. Tests: 135/137 cli tests pass (4 new run-loop, 5 new claude env). Closes the manual-curl gap from the cross-agent demo report. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../adapters.claude.environment.test.mjs | 120 ++++++++++++ cli/__tests__/run-loop.test.mjs | 175 ++++++++++++++++++ cli/src/commands/agent.js | 82 +++++++- cli/src/lib/adapters/claude.js | 52 +++++- 4 files changed, 412 insertions(+), 17 deletions(-) diff --git a/cli/__tests__/adapters.claude.environment.test.mjs b/cli/__tests__/adapters.claude.environment.test.mjs index 5e28d8a3..9df1c69b 100644 --- a/cli/__tests__/adapters.claude.environment.test.mjs +++ b/cli/__tests__/adapters.claude.environment.test.mjs @@ -137,4 +137,124 @@ describe('claude adapter — ctx.environment', () => { expect(fs.existsSync(path.join(cwd, '.commonly'))).toBe(false); expect(fs.existsSync(path.join(cwd, '.claude'))).toBe(false); }); + + // ── ADR-003 Phase 4 follow-up: env placeholder substitution ───────────────── + + test('${COMMONLY_AGENT_TOKEN} in MCP env values is substituted with ctx.runtimeToken', async () => { + const { impl } = makeSpawnImpl(); + const environment = { + mcp: [ + { + name: 'commonly', + transport: 'stdio', + command: ['commonly-mcp'], + env: { + COMMONLY_API_URL: '${COMMONLY_API_URL}', + COMMONLY_AGENT_TOKEN: '${COMMONLY_AGENT_TOKEN}', + CUSTOM: 'literal-value-${COMMONLY_AGENT_TOKEN}-suffix', + }, + }, + ], + }; + await claude.spawn('hi', { + sessionId: null, + cwd, + environment, + runtimeToken: 'cm_agent_real_token_12345', + instanceUrl: 'https://api-dev.commonly.me', + _spawnImpl: impl, + }); + const cfg = JSON.parse(fs.readFileSync(path.join(cwd, '.commonly', 'mcp-config.json'), 'utf8')); + expect(cfg.mcpServers.commonly.env.COMMONLY_AGENT_TOKEN).toBe('cm_agent_real_token_12345'); + expect(cfg.mcpServers.commonly.env.COMMONLY_API_URL).toBe('https://api-dev.commonly.me'); + // Substitution is literal — interpolation works inside larger strings. + expect(cfg.mcpServers.commonly.env.CUSTOM).toBe( + 'literal-value-cm_agent_real_token_12345-suffix', + ); + }); + + test('${COMMONLY_INSTANCE_URL} alias substitutes to the same value as ${COMMONLY_API_URL}', async () => { + const { impl } = makeSpawnImpl(); + await claude.spawn('hi', { + sessionId: null, + cwd, + environment: { mcp: [{ name: 'x', transport: 'stdio', command: ['m'], env: { U: '${COMMONLY_INSTANCE_URL}' } }] }, + runtimeToken: 'cm_agent_t', + instanceUrl: 'http://localhost:5000', + _spawnImpl: impl, + }); + const cfg = JSON.parse(fs.readFileSync(path.join(cwd, '.commonly', 'mcp-config.json'), 'utf8')); + expect(cfg.mcpServers.x.env.U).toBe('http://localhost:5000'); + }); + + test('placeholders in command args + url are also substituted', async () => { + const { impl } = makeSpawnImpl(); + await claude.spawn('hi', { + sessionId: null, + cwd, + environment: { + mcp: [ + { + name: 'sse-server', + transport: 'sse', + url: '${COMMONLY_API_URL}/mcp/sse', + }, + { + name: 'arg-server', + transport: 'stdio', + command: ['some-bin', '--token', '${COMMONLY_AGENT_TOKEN}'], + }, + ], + }, + runtimeToken: 'cm_agent_x', + instanceUrl: 'https://api-dev.commonly.me', + _spawnImpl: impl, + }); + const cfg = JSON.parse(fs.readFileSync(path.join(cwd, '.commonly', 'mcp-config.json'), 'utf8')); + expect(cfg.mcpServers['sse-server'].url).toBe('https://api-dev.commonly.me/mcp/sse'); + expect(cfg.mcpServers['arg-server'].args).toEqual(['--token', 'cm_agent_x']); + }); + + test('unknown ${COMMONLY_*} placeholders are left intact (so misspellings surface as MCP errors, not silent empties)', async () => { + const { impl } = makeSpawnImpl(); + await claude.spawn('hi', { + sessionId: null, + cwd, + environment: { + mcp: [{ + name: 'x', + transport: 'stdio', + command: ['m'], + env: { TYPO: '${COMMONLY_AGNT_TOKEN}' /* typo, not a real key */ }, + }], + }, + runtimeToken: 'cm_agent_t', + instanceUrl: 'http://localhost:5000', + _spawnImpl: impl, + }); + const cfg = JSON.parse(fs.readFileSync(path.join(cwd, '.commonly', 'mcp-config.json'), 'utf8')); + expect(cfg.mcpServers.x.env.TYPO).toBe('${COMMONLY_AGNT_TOKEN}'); + }); + + test('substitution is a no-op when ctx.runtimeToken / instanceUrl are absent (pre-existing literal env values pass through)', async () => { + const { impl } = makeSpawnImpl(); + await claude.spawn('hi', { + sessionId: null, + cwd, + environment: { + mcp: [{ + name: 'x', + transport: 'stdio', + command: ['m'], + env: { LITERAL: 'plain-string', PLACEHOLDER: '${COMMONLY_AGENT_TOKEN}' }, + }], + }, + _spawnImpl: impl, + // Note: no runtimeToken, no instanceUrl. + }); + const cfg = JSON.parse(fs.readFileSync(path.join(cwd, '.commonly', 'mcp-config.json'), 'utf8')); + expect(cfg.mcpServers.x.env.LITERAL).toBe('plain-string'); + // Empty token → placeholder left intact (not substituted with empty string). + expect(cfg.mcpServers.x.env.PLACEHOLDER).toBe('${COMMONLY_AGENT_TOKEN}'); + }); }); diff --git a/cli/__tests__/run-loop.test.mjs b/cli/__tests__/run-loop.test.mjs index 4094eeca..910c2699 100644 --- a/cli/__tests__/run-loop.test.mjs +++ b/cli/__tests__/run-loop.test.mjs @@ -557,4 +557,179 @@ describe('performRun', () => { expect(ackCalls).toHaveLength(1); expect(ackCalls[0][0]).toContain('/events/e1/ack'); }); + + // ── ADR-003 Phase 4 — cross-agent ask handling ───────────────────────────── + + test('agent.ask event → spawn with structured prompt (requestId, fromAgent, question)', async () => { + const askEvent = makeEvent({ + _id: 'evt-ask', + type: 'agent.ask', + payload: { + requestId: '63677411-f573-48f2-b55b-d6c8346f8a97', + fromAgent: 'demo-claude2', + fromInstanceId: 'default', + question: 'What is one feature users will overlook?', + podId: 'pod-abc', + }, + }); + const mockGet = jest.fn().mockResolvedValue({ events: [askEvent] }); + const mockPost = jest.fn().mockResolvedValue({}); + createClient.mockReturnValue({ get: mockGet, post: mockPost }); + + const spawn = jest.fn(async () => ({ text: 'Called commonly_respond_to_ask successfully.' })); + const adapter = { name: 'stub', detect: stubAdapter.detect, spawn }; + + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', + token: 'cm_agent_test', + adapter, + agentName: 'demo-target', + setTimeoutImpl: noopTimeout, + }); + await drainMicrotasks(); + stop(); + + expect(spawn).toHaveBeenCalledTimes(1); + const renderedPrompt = spawn.mock.calls[0][0]; + expect(renderedPrompt).toContain('63677411-f573-48f2-b55b-d6c8346f8a97'); + expect(renderedPrompt).toContain('demo-claude2'); + expect(renderedPrompt).toContain('What is one feature users will overlook?'); + expect(renderedPrompt).toContain('commonly_respond_to_ask'); + // Outcome is 'responded', not 'posted'. + expect(mockPost).toHaveBeenCalledWith( + '/api/agents/runtime/events/evt-ask/ack', + { result: { outcome: 'responded' } }, + ); + }); + + test('agent.ask never posts a chat message — response goes via MCP tool, not pod chat', async () => { + const askEvent = makeEvent({ + _id: 'evt-ask-2', + type: 'agent.ask', + payload: { + requestId: 'req-xyz', + fromAgent: 'asker', + fromInstanceId: 'default', + question: 'ping?', + }, + }); + const mockGet = jest.fn().mockResolvedValue({ events: [askEvent] }); + const mockPost = jest.fn().mockResolvedValue({}); + createClient.mockReturnValue({ get: mockGet, post: mockPost }); + + // Adapter returns text — proves we suppress the post even when there IS + // text to post (chat events still post; ask events never do). + const spawn = jest.fn(async () => ({ text: 'pong (responded via MCP)' })); + const adapter = { name: 'stub', detect: stubAdapter.detect, spawn }; + + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', + token: 'cm_agent_test', + adapter, + agentName: 'demo-target', + setTimeoutImpl: noopTimeout, + }); + await drainMicrotasks(); + stop(); + + const postedMessages = mockPost.mock.calls.filter( + ([route]) => route.includes('/messages'), + ); + expect(postedMessages).toHaveLength(0); + }); + + test('agent.ask with missing requestId or question → no spawn, acked as no_action', async () => { + const malformed = makeEvent({ + _id: 'evt-bad-ask', + type: 'agent.ask', + payload: { fromAgent: 'asker', question: 'q without id' /* no requestId */ }, + }); + const mockGet = jest.fn().mockResolvedValue({ events: [malformed] }); + const mockPost = jest.fn().mockResolvedValue({}); + createClient.mockReturnValue({ get: mockGet, post: mockPost }); + + const spawn = jest.fn(); + const adapter = { name: 'stub', detect: stubAdapter.detect, spawn }; + + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', + token: 'cm_agent_test', + adapter, + agentName: 'demo-target', + setTimeoutImpl: noopTimeout, + }); + await drainMicrotasks(); + stop(); + + expect(spawn).not.toHaveBeenCalled(); + expect(mockPost).toHaveBeenCalledWith( + '/api/agents/runtime/events/evt-bad-ask/ack', + { result: { outcome: 'no_action' } }, + ); + }); + + test('agent.ask.response is passive-acked — no spawn, no post (v1 behaviour)', async () => { + const responseEvent = makeEvent({ + _id: 'evt-resp', + type: 'agent.ask.response', + payload: { + requestId: 'req-xyz', + fromAgent: 'demo-target', + question: 'original question', + response: 'the answer', + }, + }); + const mockGet = jest.fn().mockResolvedValue({ events: [responseEvent] }); + const mockPost = jest.fn().mockResolvedValue({}); + createClient.mockReturnValue({ get: mockGet, post: mockPost }); + + const spawn = jest.fn(); + const adapter = { name: 'stub', detect: stubAdapter.detect, spawn }; + + const { stop } = performRun({ + instanceUrl: 'http://localhost:5000', + token: 'cm_agent_test', + adapter, + agentName: 'asker', + setTimeoutImpl: noopTimeout, + }); + await drainMicrotasks(); + stop(); + + expect(spawn).not.toHaveBeenCalled(); + const postedMessages = mockPost.mock.calls.filter( + ([route]) => route.includes('/messages'), + ); + expect(postedMessages).toHaveLength(0); + expect(mockPost).toHaveBeenCalledWith( + '/api/agents/runtime/events/evt-resp/ack', + { result: { outcome: 'no_action' } }, + ); + }); + + test('runtimeToken + instanceUrl flow into adapter.spawn ctx (Track B prep)', async () => { + const events = [makeEvent()]; + const mockGet = jest.fn().mockResolvedValue({ events }); + const mockPost = jest.fn().mockResolvedValue({}); + createClient.mockReturnValue({ get: mockGet, post: mockPost }); + + const spawn = jest.fn(async () => ({ text: 'ok' })); + const adapter = { name: 'stub', detect: stubAdapter.detect, spawn }; + + const { stop } = performRun({ + instanceUrl: 'https://api-dev.commonly.me', + token: 'cm_agent_specific_token', + adapter, + agentName: 'my-stub', + setTimeoutImpl: noopTimeout, + }); + await drainMicrotasks(); + stop(); + + // Adapters need both to substitute ${COMMONLY_AGENT_TOKEN} and + // ${COMMONLY_API_URL} placeholders in MCP env entries (Track B). + const ctx = spawn.mock.calls[0][1]; + expect(ctx.runtimeToken).toBe('cm_agent_specific_token'); + expect(ctx.instanceUrl).toBe('https://api-dev.commonly.me'); + }); }); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index dd0cfbba..ab8c4d4a 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -98,11 +98,25 @@ export const listLocalAgents = () => { .filter(Boolean); }; -// Event types that carry a human/agent-authored prompt the wrapper should -// forward to the CLI. Other event types (heartbeat, delivery, etc.) are acked -// as no_action even if they happen to carry `content` in their payload. +// Event types that carry a human/agent-authored chat prompt — claude should +// reply with a public message in the pod. Other event types (heartbeat, +// delivery, etc.) are acked as no_action even if they happen to carry +// `content` in their payload. const CHAT_EVENT_TYPES = new Set(['chat.mention', 'message.posted', 'dm.message']); +// ADR-003 Phase 4 — peer-to-peer cross-agent ask. The target receives this +// in their poll queue when another agent calls `commonly_ask_agent`. The +// response goes back via the `commonly_respond_to_ask` MCP tool, NOT via a +// chat message. The wrapper renders a structured prompt that includes the +// requestId so claude can route its reply correctly. +const ASK_EVENT_TYPES = new Set(['agent.ask']); + +// ADR-003 Phase 4 — the answer to a prior commonly_ask_agent call lands here. +// v1 behaviour: ack and drop. The asker is expected to look up the answer via +// memory or a follow-up context call. Auto-resuming the original session with +// the response is post-v1 (needs requestId-keyed session state). +const PASSIVE_ACK_EVENT_TYPES = new Set(['agent.ask.response']); + // ── attach: register a local-CLI-wrapped agent (ADR-005) ──────────────────── /** @@ -231,10 +245,39 @@ export const performAttach = async ({ // ── run: local-CLI wrapper loop (ADR-005) ──────────────────────────────────── -const extractPrompt = (event) => { - if (!CHAT_EVENT_TYPES.has(event.type)) return null; - const p = event.payload || {}; - return p.content || p.prompt || p.text || null; +/** + * Convert a CAP event into the prompt string that gets forwarded to the + * adapter. Returns null when the event type isn't actionable (poll loop + * acks-and-drops in that case). + * + * Chat events forward the raw user message verbatim. Cross-agent asks + * (ADR-003 Phase 4) are wrapped in a small instruction header so claude + * knows to call `commonly_respond_to_ask` instead of replying in the pod. + */ +const buildPromptForEvent = (event) => { + if (CHAT_EVENT_TYPES.has(event.type)) { + const p = event.payload || {}; + return p.content || p.prompt || p.text || null; + } + if (ASK_EVENT_TYPES.has(event.type)) { + const p = event.payload || {}; + if (!p.requestId || !p.question) return null; + const fromLabel = p.fromInstanceId && p.fromInstanceId !== 'default' + ? `${p.fromAgent}:${p.fromInstanceId}` + : p.fromAgent; + return [ + 'You received an inbound question from another Commonly agent (ADR-003 Phase 4 cross-agent ask).', + '', + `From: ${fromLabel}`, + `requestId: ${p.requestId}`, + `Question: ${p.question}`, + '', + 'Reply by calling the `commonly_respond_to_ask` MCP tool with `requestId` set to the value above ' + + 'and your answer as `content`. Do NOT post a chat message — this is a peer-to-peer ask, not a ' + + 'public conversation. After the tool call succeeds, output a one-line confirmation and stop.', + ].join('\n'); + } + return null; }; /** @@ -284,8 +327,18 @@ export const performRun = ({ if (!existsSync(agentCwd)) mkdirSync(agentCwd, { recursive: true }); const processEvent = async (event) => { + if (PASSIVE_ACK_EVENT_TYPES.has(event.type)) { + // ADR-003 Phase 4 §asker side: the answer to a prior commonly_ask_agent + // call lands here. v1 ack-and-drop — claude will pick up the answer + // from memory or a context call on the next chat.mention turn. Auto- + // resuming the original session with the response is post-v1 work + // (needs requestId-keyed session state). + log(`[${event.type}] passive ack — answer to ${event.payload?.requestId || '?'} dropped (v1)`); + return { outcome: 'no_action' }; + } + const eventPodId = event.podId || podId; - const prompt = extractPrompt(event); + const prompt = buildPromptForEvent(event); if (!prompt || !eventPodId) { // No prompt, or nowhere to post the response — skip spawn entirely so // we never consume a CLI turn for a message with no destination. @@ -293,6 +346,11 @@ export const performRun = ({ return { outcome: 'no_action' }; } + // Cross-agent asks reply via MCP (commonly_respond_to_ask), not by + // posting a chat message. The adapter still produces stdout for + // diagnostics, but suppress the pod-post step so we don't double-reply. + const isAsk = ASK_EVENT_TYPES.has(event.type); + const sessionId = getSession(agentName, eventPodId); // ADR-005 §Memory bridge: read long_term before spawn, inject via ctx, // and (if the adapter returns a summary) patch-sync back after. @@ -304,17 +362,21 @@ export const performRun = ({ env: process.env, memoryLongTerm, environment, + runtimeToken: token, + instanceUrl, metadata: { event }, }); if (result.newSessionId) { setSession(agentName, eventPodId, result.newSessionId); } - if (result.text) { + if (result.text && !isAsk) { await client.post(`/api/agents/runtime/pods/${eventPodId}/messages`, { content: result.text, }); log(`[${event.type}] posted ${Buffer.byteLength(result.text)} bytes`); + } else if (result.text) { + log(`[${event.type}] ask handled — ${Buffer.byteLength(result.text)} bytes of stdout (not posted)`); } if (result.memorySummary) { try { @@ -328,7 +390,7 @@ export const performRun = ({ onError?.(new Error(`memory sync failed: ${err.message}`, { cause: err })); } } - return { outcome: 'posted' }; + return { outcome: isAsk ? 'responded' : 'posted' }; }; const tick = async () => { diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index f7e3a233..e56e36a7 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -79,19 +79,54 @@ const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) = // ── MCP config write — claude consumes this via --mcp-config ───────── -const buildMcpConfig = (mcpServers) => { +// Substitute Commonly-supplied placeholders in MCP env values so users don't +// have to hand-paste secrets into their env file every time they re-attach. +// Surfaced during the 2026-04-17 cross-agent demo: every spec referencing +// commonly-mcp had to be rewritten with the agent's runtime token after attach, +// because the token is minted at attach time and only known to the wrapper. +// +// Recognised placeholders (substituted everywhere in env value strings): +// ${COMMONLY_AGENT_TOKEN} — the per-(agent, pod) cm_agent_* runtime token +// ${COMMONLY_API_URL} — the instance URL the agent is attached to +// ${COMMONLY_INSTANCE_URL} — alias for COMMONLY_API_URL (clearer in context) +// +// Substitution is one-pass + literal — no nested expansion, no shell quoting. +// Unknown placeholders are left intact so the user sees a clear runtime error +// from the MCP server rather than a silent empty string. +const SUBSTITUTION_KEYS = ['COMMONLY_AGENT_TOKEN', 'COMMONLY_API_URL', 'COMMONLY_INSTANCE_URL']; +const PLACEHOLDER_RE = /\$\{(COMMONLY_[A-Z_]+)\}/g; + +const substitutePlaceholders = (value, ctx) => { + if (typeof value !== 'string') return value; + if (!value.includes('${COMMONLY_')) return value; + const subs = { + COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '', + COMMONLY_API_URL: ctx.instanceUrl || '', + COMMONLY_INSTANCE_URL: ctx.instanceUrl || '', + }; + return value.replace(PLACEHOLDER_RE, (whole, key) => ( + SUBSTITUTION_KEYS.includes(key) && subs[key] ? subs[key] : whole + )); +}; + +const buildMcpConfig = (mcpServers, ctx = {}) => { // Shape: `{ mcpServers: { : { ... } } }` — the standard MCP client // config, which claude's `--mcp-config` reads directly. const mcpServersMap = {}; for (const server of mcpServers) { const entry = { type: server.transport || 'stdio' }; - if (server.url) entry.url = server.url; + if (server.url) entry.url = substitutePlaceholders(server.url, ctx); if (server.command) { const [command, ...args] = server.command; entry.command = command; - if (args.length) entry.args = args; + if (args.length) entry.args = args.map((a) => substitutePlaceholders(a, ctx)); + } + if (server.env) { + entry.env = {}; + for (const [k, v] of Object.entries(server.env)) { + entry.env[k] = substitutePlaceholders(v, ctx); + } } - if (server.env) entry.env = server.env; mcpServersMap[server.name] = entry; } return { mcpServers: mcpServersMap }; @@ -100,11 +135,11 @@ const buildMcpConfig = (mcpServers) => { // Regenerated on every spawn from the env spec; do not hand-edit — the file // is overwritten before each `claude` invocation, so any local changes are // silently clobbered. ADR-008 §invariant #5 (edits propagate on next spawn). -const writeMcpConfig = async (cwd, mcpServers) => { +const writeMcpConfig = async (cwd, mcpServers, ctx = {}) => { const dir = join(cwd, '.commonly'); await mkdir(dir, { recursive: true }); const file = join(dir, 'mcp-config.json'); - await writeFile(file, JSON.stringify(buildMcpConfig(mcpServers), null, 2), 'utf8'); + await writeFile(file, JSON.stringify(buildMcpConfig(mcpServers, ctx), null, 2), 'utf8'); return file; }; @@ -133,7 +168,10 @@ const prepareArgv = async (innerArgv, ctx) => { if (!env) return { cmd: 'claude', args: innerArgv }; if (Array.isArray(env.mcp) && env.mcp.length > 0 && ctx.cwd) { - const configPath = await writeMcpConfig(ctx.cwd, env.mcp); + const configPath = await writeMcpConfig(ctx.cwd, env.mcp, { + runtimeToken: ctx.runtimeToken, + instanceUrl: ctx.instanceUrl, + }); // Insert --mcp-config immediately after the subcommand-style `-p` block // so claude parses it before prompt collection begins. innerArgv = [...innerArgv, '--mcp-config', configPath];