claude-code - 💡(How to fix) Fix PostToolUse callback hooks: return value (updatedMCPToolOutput) silently discarded, never persisted to transcript [1 comments, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
anthropics/claude-code#47859Fetched 2026-04-15 06:40:18
View on GitHub
Comments
1
Participants
1
Timeline
5
Reactions
0
Author
Participants
Timeline (top)
labeled ×3closed ×1commented ×1

Root Cause

In Xh (the hook executor), there are two paths:

let Z = D.filter((h) => !WsK(h));  // Z = non-callback hooks
if (Z.length > 0) {
    // Full path — processes callback return values via FoY → c$7
    // c$7 extracts hookSpecificOutput.updatedMCPToolOutput
    // This path DOES yield updatedMCPToolOutput ✅
} else {
    // Callback-only fast path
    for (let [g, { hook: c }] of D.entries())
        if (c.type === "callback")
            await c.callback(q, _, Y, g, p);  // return value discarded!
    return;  // returns without yielding anything ❌
}

The callback-only fast path calls the hook but discards its return value, then returns without yielding. The upstream generator (ykKT37) never sees updatedMCPToolOutput, so q6 stays as the original n.data.

Fix Action

Workaround

We TOON-encode external MCP tool results at transcript persist time (after reading the JSONL from disk, before writing to PostgreSQL) as a workaround.

Code Example

let Z = D.filter((h) => !WsK(h));  // Z = non-callback hooks
if (Z.length > 0) {
    // Full path — processes callback return values via FoY → c$7
    // c$7 extracts hookSpecificOutput.updatedMCPToolOutput
    // This path DOES yield updatedMCPToolOutput ✅
} else {
    // Callback-only fast path
    for (let [g, { hook: c }] of D.entries())
        if (c.type === "callback")
            await c.callback(q, _, Y, g, p);  // return value discarded!
    return;  // returns without yielding anything ❌
}
RAW_BUFFERClick to expand / collapse

Bug

When all PostToolUse hooks are JavaScript callbacks (registered via HookCallback), the SDK's Xh function takes a "callback-only fast path" that ignores the return value entirely. This means updatedMCPToolOutput from the hook response is never yielded, so:

  1. The live API call does not see the replacement (no updatedMCPToolOutput propagated)
  2. The session transcript JSONL keeps the original tool response content

The feature works correctly when at least one non-callback hook (shell command, prompt, agent, or function hook) is registered for the same event — then the full processing path is taken where FoY captures callback return values via c$7.

Reproduction

  1. Register a PostToolUse callback hook that returns { hookSpecificOutput: { hookEventName: 'PostToolUse', updatedMCPToolOutput: [...] } }
  2. Trigger an MCP tool call
  3. Observe that the transcript JSONL file contains the original tool response, not the replacement from updatedMCPToolOutput

Root Cause

In Xh (the hook executor), there are two paths:

let Z = D.filter((h) => !WsK(h));  // Z = non-callback hooks
if (Z.length > 0) {
    // Full path — processes callback return values via FoY → c$7
    // c$7 extracts hookSpecificOutput.updatedMCPToolOutput
    // This path DOES yield updatedMCPToolOutput ✅
} else {
    // Callback-only fast path
    for (let [g, { hook: c }] of D.entries())
        if (c.type === "callback")
            await c.callback(q, _, Y, g, p);  // return value discarded!
    return;  // returns without yielding anything ❌
}

The callback-only fast path calls the hook but discards its return value, then returns without yielding. The upstream generator (ykKT37) never sees updatedMCPToolOutput, so q6 stays as the original n.data.

Expected Behavior

The callback-only fast path should process callback return values the same way FoY does in the mixed-hooks path — extract hookSpecificOutput fields and yield them.

Workaround

We TOON-encode external MCP tool results at transcript persist time (after reading the JSONL from disk, before writing to PostgreSQL) as a workaround.

Environment

  • @anthropic-ai/claude-agent-sdk (latest as of 2026-04-14)
  • Using the SDK programmatically (not CLI) with in-process callback hooks

extent analysis

TL;DR

Modify the Xh function to process callback return values in the callback-only fast path, similar to the full path, to yield updatedMCPToolOutput.

Guidance

  • Identify the Xh function and locate the callback-only fast path to understand where the return value is being discarded.
  • Modify the callback-only fast path to extract hookSpecificOutput fields from the callback return value, similar to how FoY does in the mixed-hooks path.
  • Verify that the modified Xh function yields updatedMCPToolOutput when all registered hooks are JavaScript callbacks.
  • Test the modification with the provided reproduction steps to ensure the transcript JSONL file contains the replaced tool response content.

Example

// Modified callback-only fast path
else {
    const callbackReturns = [];
    for (let [g, { hook: c }] of D.entries())
        if (c.type === "callback")
            callbackReturns.push(await c.callback(q, _, Y, g, p));
    const updatedMCPToolOutputs = callbackReturns.map((returnVal) => returnVal.hookSpecificOutput.updatedMCPToolOutput);
    // Yield the updatedMCPToolOutputs
    yield* updatedMCPToolOutputs;
}

Notes

The provided workaround of TOON-encoding external MCP tool results at transcript persist time may still be necessary until the Xh function is modified.

Recommendation

Apply the workaround until the Xh function can be modified to correctly process callback return values, as this will ensure that updatedMCPToolOutput is yielded and the transcript JSONL file contains the replaced tool response content.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING