Summary
When a PreToolUse hook returns { permissionDecision: 'defer' } for a tool served by an in-process createSdkMcpServer, the SDK parks the tool_use and emits stop_reason: 'tool_deferred' as expected. But calling query({ resume: sessionId, mcpServers: { same: inProcServer } }) later fails to re-invoke the hook — the CLI emits stop_reason: 'tool_deferred_unavailable' and re-surfaces the same deferred_tool_use unchanged, so the parked call can never be honored.
External stdio MCP servers registered via the same mcpServers option (identical shape, different transport) work correctly for the same defer→resume cycle. This makes the in-process API unusable for durable-approval / delayed-tool-approval flows.
Environment
@anthropic-ai/claude-agent-sdk: 0.3.159 (also verified on 0.3.196)
- Bundled
claude CLI: same version (whatever the SDK ships)
- Node: 20.x, macOS
- Same subprocess protocol as Python — see cross-repo references below
Reproduction
Minimal, ~120 LOC. Two phases:
Phase 1 — park a tool_use:
import { query, createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
import { z } from 'zod';
const demo = createSdkMcpServer({
name: 'demo',
version: '0.0.1',
tools: [tool('ping', 'echo back', { msg: z.string() },
async ({ msg }) => ({ content: [{ type: 'text', text: msg }] }))],
});
const q1 = query({
prompt: 'Call the ping tool with msg="hello".',
options: {
mcpServers: { demo },
hooks: {
PreToolUse: [{ hooks: [async () => ({
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'defer' },
})] }],
},
},
});
let sessionId;
for await (const msg of q1) {
if (msg.type === 'system' && msg.subtype === 'init') sessionId = msg.session_id;
if (msg.type === 'result' && msg.stop_reason === 'tool_deferred') break;
}
Phase 2 — try to resume with the same in-process server + a hook that would allow:
const q2 = query({
prompt: '',
options: {
resume: sessionId,
mcpServers: { demo: createSdkMcpServer({ /* same shape */ }) },
hooks: {
PreToolUse: [{ hooks: [async () => ({
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' },
})] }],
},
},
});
for await (const msg of q2) console.log(msg.type, msg.subtype ?? msg.stop_reason ?? '');
Observed output:
result tool_deferred_unavailable ← FIRST message, before init
system init ← MCP servers reported as connected here
result completed
Expected: the PreToolUse hook should fire for the parked tool_use_id; hook returns allow; SDK dispatches the tool via the re-registered in-process server; natural tool_result gets written.
Additional variations tested (all fail identically)
- Streaming resume with
setMcpServers({}) → setMcpServers({ demo }) → polling mcpServerStatus() until `connected` — polling reaches `connected` after ~200ms but `deferred_tool_use` was already surfaced.
startup({ options: { resume, mcpServers, hooks } }) + WarmQuery.query('') — same failure sequence; the deferred-replay pass fires before system:init even lands, so `await initializationResult()` gives no protection.
- With/without `strictMcpConfig: true`, `settingSources: []`, `permissionMode: 'default' | 'bypassPermissions'` — no effect.
Test scripts: repro.mjs / repro2.mjs / repro3.mjs / repro4.mjs — happy to share; they mirror the flow above.
Root cause (per cross-repo evidence)
This is almost certainly the TS-side manifestation of the same race documented in:
- anthropics/claude-code#49753 (auto-closed as duplicate, unfixed) — reporter traces the race to the CLI's `dHA` generator calling `H6()` unawaited at boot. `H6` reads the MCP server map `O`; if it fires before the CLI drains the `initialize` control_request that populates `O`, in-process tools are missing from the CLI's first outbound. Their symptom is cache-prefix miss; ours is deferred-tool-replay unavailable — same race, different symptom. Reporter even proposed a 3-line fix (Promise gate on `__initPromise`).
- anthropics/claude-agent-sdk-python#993 + PR #1008 — Python-side fix in flight, restructures startup ordering so `initialize` lands in stdin buffer before the CLI's main read loop.
No equivalent TS ticket exists yet; the CLI is the same binary so the same race manifests. Filing this to track the TS side explicitly.
Not the same as #356
Just to preempt confusion: #356 is about `defer_loading` (tool-search lazy-load), a different feature that shares the word "defer". Our bug is `permissionDecision: 'defer'` from a `PreToolUse` hook + `--resume`.
Two possible fix shapes
-
Fix the CLI ordering per #49753's proposal: gate `H6` on a Promise resolved once `initialize` populates `O`. Universal fix — closes the race for all startup-time codepaths (deferred replay, cache prefix, anything else that reads `O` early).
-
API refactor: move `resume` off `query()`/`startup()` options and onto `WarmQuery.query()`. Then `startup({ options: { mcpServers, hooks } })` pre-warms a clean CLI with MCP registered, and `.query({ resume: sessionId, prompt: '' })` triggers the transcript replay with MCP already present. Non-breaking if additive on `WarmQuery.query`.
Impact
Durable-approval / delayed-tool-approval flows (user approves days later) are architecturally impossible with in-process tools right now. Current workaround is to wrap every approval-gated tool behind an external stdio MCP shim so the tool identity survives via CLI argv rather than via the `initialize` control_request — considerable extra machinery for something the API contract implies should Just Work.
Happy to run further experiments if useful.
Summary
When a
PreToolUsehook returns{ permissionDecision: 'defer' }for a tool served by an in-processcreateSdkMcpServer, the SDK parks thetool_useand emitsstop_reason: 'tool_deferred'as expected. But callingquery({ resume: sessionId, mcpServers: { same: inProcServer } })later fails to re-invoke the hook — the CLI emitsstop_reason: 'tool_deferred_unavailable'and re-surfaces the samedeferred_tool_useunchanged, so the parked call can never be honored.External stdio MCP servers registered via the same
mcpServersoption (identical shape, different transport) work correctly for the same defer→resume cycle. This makes the in-process API unusable for durable-approval / delayed-tool-approval flows.Environment
@anthropic-ai/claude-agent-sdk: 0.3.159 (also verified on 0.3.196)claudeCLI: same version (whatever the SDK ships)Reproduction
Minimal, ~120 LOC. Two phases:
Phase 1 — park a tool_use:
Phase 2 — try to resume with the same in-process server + a hook that would allow:
Observed output:
Expected: the
PreToolUsehook should fire for the parkedtool_use_id; hook returnsallow; SDK dispatches the tool via the re-registered in-process server; naturaltool_resultgets written.Additional variations tested (all fail identically)
setMcpServers({})→setMcpServers({ demo })→ pollingmcpServerStatus()until `connected` — polling reaches `connected` after ~200ms but `deferred_tool_use` was already surfaced.startup({ options: { resume, mcpServers, hooks } })+WarmQuery.query('')— same failure sequence; the deferred-replay pass fires beforesystem:initeven lands, so `await initializationResult()` gives no protection.Test scripts:
repro.mjs/repro2.mjs/repro3.mjs/repro4.mjs— happy to share; they mirror the flow above.Root cause (per cross-repo evidence)
This is almost certainly the TS-side manifestation of the same race documented in:
No equivalent TS ticket exists yet; the CLI is the same binary so the same race manifests. Filing this to track the TS side explicitly.
Not the same as #356
Just to preempt confusion: #356 is about `defer_loading` (tool-search lazy-load), a different feature that shares the word "defer". Our bug is `permissionDecision: 'defer'` from a `PreToolUse` hook + `--resume`.
Two possible fix shapes
Fix the CLI ordering per #49753's proposal: gate `H6` on a Promise resolved once `initialize` populates `O`. Universal fix — closes the race for all startup-time codepaths (deferred replay, cache prefix, anything else that reads `O` early).
API refactor: move `resume` off `query()`/`startup()` options and onto `WarmQuery.query()`. Then `startup({ options: { mcpServers, hooks } })` pre-warms a clean CLI with MCP registered, and `.query({ resume: sessionId, prompt: '' })` triggers the transcript replay with MCP already present. Non-breaking if additive on `WarmQuery.query`.
Impact
Durable-approval / delayed-tool-approval flows (user approves days later) are architecturally impossible with in-process tools right now. Current workaround is to wrap every approval-gated tool behind an external stdio MCP shim so the tool identity survives via CLI argv rather than via the `initialize` control_request — considerable extra machinery for something the API contract implies should Just Work.
Happy to run further experiments if useful.