Skip to content

PreToolUse permissionDecision: 'defer' on createSdkMcpServer tools cannot be resumed — CLI's deferred-replay pass runs before initialize populates the MCP registry #370

Description

@sakalys

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

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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions