[BUG] Resuming a session with a pending background-task notification drops all configured MCP servers
Environment
- Claude Code CLI: reproduced on 2.1.201, 2.1.212, 2.1.214 (latest at time of filing)
- @anthropic-ai/claude-agent-sdk: 0.3.201 (headless,
query()) - OS: macOS (darwin arm64)
- Auth: subscription OAuth
Summary
When query() resumes a session whose previous process was interrupted while a background task (background
Bash or subagent) was still in flight, the resumed CLI delivers the pending task_notification beforesystem/init — and boots with all configured MCP servers missing. The init frame reports"mcp_servers": [] and no mcp__* tools exist, even though options.mcpServers was passed together withstrictMcpConfig: true, identically to the original (working) launch.
The bug is deterministic (100% hit rate in our repro) and self-masking: the MCP-less boot consumes the pending
notification, so the next resume of the same session comes up healthy. forkSession: true resumes skip
notification delivery and also do not reproduce.
Impact
We run an autonomous agent fleet where an in-process (createSdkMcpServer) MCP server is the agents' only
delivery channel for results. An account-failover wave interrupted 14 sessions mid-turn; the 5 whose
conversations had subagents in flight all resumed MCP-less, finished long scans (~$6 each), and could not
deliver — the tool they were instructed to call did not exist in their tool list. Sessions without pending
tasks resumed with MCP intact (9/9), with byte-identical spawn options.
Minimal repro
Step 1 — mint the broken state (mint.mjs): open a session with an sdk MCP server, let it start a background
task, interrupt while the task is still running.
import { query, createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
const cwd = process.argv[2]; // any scratch directory
const server = createSdkMcpServer({
name: 'probe-server',
tools: [tool('probe_tool', 'probe tool', { note: z.string().optional() },
async () => ({ content: [{ type: 'text', text: 'ok' }] }))],
});
const q = query({
prompt: (async function* () {
yield { type: 'user', message: { role: 'user', content:
'Use the Bash tool with run_in_background set to true to run exactly: sleep 90 && echo done. ' +
'After it launches, reply with exactly: launched. Do nothing else.' },
parent_tool_use_id: null, session_id: 'mint' };
await new Promise(() => {});
})(),
options: {
cwd, model: 'claude-haiku-4-5-20251001',
strictMcpConfig: true, mcpServers: { 'probe-server': server },
maxTurns: 4,
},
});
let sessionId = null;
for await (const msg of q) {
if (msg.type === 'system' && msg.subtype === 'init') sessionId = msg.session_id;
if (msg.type === 'result') {
console.log('interrupting mid-task; resume target:', sessionId);
try { await q.interrupt(); } catch {}
setTimeout(() => process.exit(0), 1500);
}
}
Step 2 — resume and inspect the init frame (probe.mjs, same cwd + the printed session id):
import { query, createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
const [cwd, resume] = process.argv.slice(2);
const server = createSdkMcpServer({
name: 'probe-server',
tools: [tool('probe_tool', 'probe tool', { note: z.string().optional() },
async () => ({ content: [{ type: 'text', text: 'ok' }] }))],
});
const controller = new AbortController();
const q = query({
prompt: (async function* () {
yield { type: 'user', message: { role: 'user', content: 'Reply with exactly: ok' },
parent_tool_use_id: null, session_id: 'probe' };
await new Promise(() => {});
})(),
options: {
cwd, resume, // NOTE: no forkSession — a fork does not reproduce
strictMcpConfig: true, mcpServers: { 'probe-server': server },
maxTurns: 1, abortController: controller,
},
});
for await (const msg of q) {
if (msg.type === 'system' && msg.subtype === 'task_notification') console.log('pre-init: task_notification');
if (msg.type === 'result') console.log('pre-init: result turns=', msg.num_turns);
if (msg.type === 'system' && msg.subtype === 'init') {
console.log('version:', msg.claude_code_version);
console.log('mcp_servers:', JSON.stringify(msg.mcp_servers));
console.log('mcp tools:', JSON.stringify(msg.tools.filter(t => t.startsWith('mcp__'))));
controller.abort();
break;
}
}
Run:
mkdir -p /tmp/mcp-repro && node mint.mjs /tmp/mcp-repro
node probe.mjs /tmp/mcp-repro <session-id-from-mint>
Expected
The resumed session has probe-server connected — same options as the original launch:
mcp_servers: [{"name":"probe-server","status":"connected"}]
mcp tools: ["mcp__probe-server__probe_tool"]
(This is exactly what a resume WITHOUT a pending task notification produces.)
Actual
pre-init: task_notification
version: 2.1.214
mcp_servers: []
mcp tools: []
Observed on 2.1.201, 2.1.212 and 2.1.214. In our production traces the failure also shows a degenerateresult envelope with num_turns: 0 ~1 ms after the first init, followed by a second init frame — both
inits MCP-less; the whole resumed process never gets the servers.
Notes for triage
- The trigger is the undelivered task notification, not the resume itself: after one buggy boot consumes
the notification (the transcript records its queue enqueue/dequeue), the next resume of the same session is
healthy. Any test that resumes twice will see the bug "disappear".
forkSession: trueresumes skip pending-notification delivery entirely and never reproduce.- The pending-task state that matters lives outside the transcript (the per-session
tasks/*.outputfiles
under the OS temp project dir) — copying a transcript to a new cwd does not reproduce; interrupting a real
in-flight task does, every time.
- 2.1.210's "Fixed plugin-provided MCP servers being torn down when MCP servers re-synced" looked adjacent but
does not cover this path (these are sdk-type servers, absent from the very first init).