[BUG] Resuming a session with a pending background-task notification drops all configured MCP servers

Status Open
Reported on v2.1.201
Maintainer reply None cached
Activity 0 comments · opened Jul 18, 2026

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 before
system/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 with
strictMcpConfig: 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 degenerate
result 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: true resumes skip pending-notification delivery entirely and never reproduce.
  • The pending-task state that matters lives outside the transcript (the per-session tasks/*.output files

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).

View original on GitHub ↗