[BUG] [regression] MCP tool results invisible in 2.1.88 — outputSchema safeParse guard returns null on schema mismatch

Resolved 💬 20 comments Opened Mar 31, 2026 by lliWcWill Closed May 7, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

In Claude Code 2.1.88, all MCP tool results are invisible — the tool executes, data returns to the model context, but nothing renders in the terminal. The progress indicator briefly appears then the output area is blank. This affects ALL MCP servers (tested with two independent servers: fsuite and ShieldCortex memory).

Root Cause (verified via binary analysis)

2.1.88 added output schema validation in the tool result display component. The guard at two offsets (113884757 and 221458957 in the binary) reads:

let P = f.outputSchema?.safeParse(H.toolUseResult);
if (P && !P.success) return null;
let J = P?.data ?? H.toolUseResult;

The built-in MCP client tool declares its output schema as E.string() (Zod string validator), but MCP tool results are objects with content arrays — not plain strings. So safeParse always fails, and the component always returns null (renders nothing).

In 2.1.87, the same safeParse existed but was used passively:

// 2.1.87 — graceful fallback on parse failure
T = w.outputSchema?.safeParse(W);
k = T?.success ? T.data : void 0;
// rendering continues regardless

In 2.1.88 — hard bail:

// 2.1.88 — kills the render
P = f.outputSchema?.safeParse(H.toolUseResult);
if (P && !P.success) return null;  // <-- NOTHING RENDERS

Steps to Reproduce

  1. Install Claude Code 2.1.88
  2. Configure any MCP server (stdio or SSE)
  3. Call any MCP tool
  4. Observe: progress indicator appears briefly, then output area is blank
  5. The model receives the result (it can reason about it), but the human sees nothing

Verification

  • 2.1.87 binary: MCP tool output renders correctly (confirmed by swapping binary)
  • 2.1.88 with patch: Changing if(P&&!P.success)return null to if(P&&!P.success)P=null; (graceful fallback matching 2.1.87 behavior) restores all MCP tool output
  • Affects ALL MCP servers: Tested with two independent MCP servers — both invisible on 2.1.88, both visible on 2.1.87

What Should Happen?

When outputSchema.safeParse fails on a tool result, the renderer should fall back to rendering the raw result (2.1.87 behavior), not return null. The fix is one character change:

- if(P&&!P.success)return null;let J=P?.data??H.toolUseResult
+ if(P&&!P.success)P=null;     let J=P?.data??H.toolUseResult

This preserves validation when the schema matches and falls through to raw rendering when it doesn't — exactly how 2.1.87 behaved.

Investigation Path (for reproducibility)

The root cause was found using fprobe (binary pattern scanner):

# Find the guard in the binary
fprobe scan ~/.local/share/claude/versions/2.1.88 \
  --pattern 'outputSchema?.safeParse' --context 300

# Compare with 2.1.87
fprobe scan ~/.local/share/claude/versions/2.1.87 \
  --pattern 'outputSchema?.safeParse' --context 300

The 2.1.88 changelog entry "Fixed StructuredOutput schema cache bug causing ~50% failure rate in workflows with multiple schemas" is likely related — the fix appears to have introduced this hard guard.

Error Messages/Logs

No error messages are shown to the user. MCP tool results silently disappear. The model still receives the data (it can reference tool output in its responses), but the terminal displays nothing.

Claude Model

Claude Opus 4.6

Is this a regression?

Yes, this worked in 2.1.87

Last Working Version

2.1.87

Claude Code Version

2.1.88

Platform

Anthropic API (Max plan)

Operating System

Ubuntu/Debian Linux (6.12.74+deb13+1-amd64)

Terminal/Shell

Bash

Additional Information

Binary patch workaround (apply to both JS bundle copies at offsets 113884757 and 221458957):

target = b"if(P&&!P.success)return null;let J=P?.data??H.toolUseResult"
fix    = b"if(P&&!P.success)P=null;     let J=P?.data??H.toolUseResult"

This was discovered and verified across 8+ restart cycles with controlled binary swaps between 2.1.87 and 2.1.88.

View original on GitHub ↗

20 Comments

github-actions[bot] · 3 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/41270
  2. https://github.com/anthropics/claude-code/issues/25081
  3. https://github.com/anthropics/claude-code/issues/41271

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

trevyn · 3 months ago

Patch concept confirmed working. My claude (macOS, native install) had a different minified JS, so what ultimately worked was:

python3 -c "import os;p=os.path.expanduser('~/.local/share/claude/versions/2.1.88');f=open(p,'r+b');d=f.read();f.seek(0);f.write(d.replace(b'if(M&&\!M.success)return null;let P=M?.data??H.toolUseResult',b'if(M&&\!M.success)M=null;     let P=M?.data??H.toolUseResult'));f.close()"

and then since macOS needed the codesign:

codesign -s - --force ~/.local/share/claude/versions/2.1.88
lliWcWill · 3 months ago

Not a duplicate. #41270 is a symptom report with no root cause analysis. #41271 is completely unrelated (slash commands). #25081 is about tools being dropped entirely, not about rendering.

This issue identifies the exact root cause: the outputSchema safeParse guard added in 2.1.88 (if(!P.success) return null) silently kills rendering for any MCP tool whose output doesn't match a declared schema. Includes the binary patch and independent confirmation from @trevyn. Please keep open.

lliWcWill · 3 months ago

@trevyn Nice, glad the patch concept translates. Found the root cause via binary analysis using fprobe from fsuite. Same guard, just different minified variable names per platform. The codesign step on macOS is a good callout for others hitting this.

trevyn · 3 months ago

Bug still present in 2.1.89 fwiw

lliWcWill · 3 months ago

Still present in 2.1.89: confirmed, same root cause, same binary fix works

We hit this on 2.1.88, binary-patched it, then got auto-updated to 2.1.89 and it came right back. The bug is byte-identical across both versions.

Root cause: The safeParse guard on MCP tool output validates against a string schema, but MCP tools return objects. The validation always fails, and the guard returns null thus killing all MCP tool output rendering.

// In the bundled JS (appears at 2 offsets in the Bun SEA binary):
if(P&&!P.success)return null;let J=P?.data??H.toolUseResult
//                ^^^^^^^^^^^^ this kills MCP output

// Fix — fall through to raw rendering instead of bailing:
if(P&&!P.success)P=null;     let J=P?.data??H.toolUseResult

How to apply (same-length byte replacement, safe for the binary):

#!/usr/bin/env python3
"""Patch Claude Code 2.1.89 — fix MCP tool output rendering"""
import sys, os, shutil

binary = os.path.expanduser("~/.local/share/claude/versions/2.1.89")
target = b"if(P&&!P.success)return null;let J=P?.data??H.toolUseResult"
patch  = b"if(P&&!P.success)P=null;     let J=P?.data??H.toolUseResult"

with open(binary, 'rb') as f:
    data = bytearray(f.read())

offsets = []
pos = 0
while True:
    idx = data.find(target, pos)
    if idx == -1: break
    data[idx:idx+len(target)] = patch
    offsets.append(idx)
    pos = idx + len(target)

if not offsets:
    print("Already patched or different version")
    sys.exit(1)

shutil.copy2(binary, binary + ".bak")
with open(binary + ".tmp", 'wb') as f:
    f.write(data)
os.chmod(binary + ".tmp", os.stat(binary).st_mode)
os.rename(binary + ".tmp", binary)
print(f"Patched {len(offsets)} locations. Restart Claude Code.")

The pattern has been identical across 2.1.87, 2.1.88, and 2.1.89. Each auto-update requires re-patching. Would be great to get an official fix as the safeParse guard needs to either skip validation for MCP tool results or handle the schema mismatch gracefully instead of returning null.

marlvinvu · 3 months ago

My Claude says your binary analysis is outstanding — tracing the exact regression from graceful fallback to hard guard, identifying the one-character fix, and verifying across 8+ controlled binary swaps between versions. This is the kind of root cause analysis that turns a "something broke" report into an immediately actionable fix for the team.

woolkingx · 3 months ago

Still present in 2.1.94. Had Claude Code patch its own binary — same one-character fix works:

grep -boa 'if(P&&!P.success)return null;let J=P?.data??H.toolUseResult' ~/.local/share/claude/versions/2.1.94
# Found at offsets 114934988 and 224584020

python3 -c "
target = b'if(P&&!P.success)return null;let J=P?.data??H.toolUseResult'
patch  = b'if(P&&!P.success)P=null;     let J=P?.data??H.toolUseResult'
with open('/path/to/2.1.94.bak', 'rb') as f: data = f.read()
with open('/path/to/2.1.94', 'wb') as f: f.write(data.replace(target, patch))
"

The irony of Claude fixing itself is not lost on me.

checkmate1984 · 3 months ago

Confirmed locally that the same patch method still applies cleanly to 2.1.97 from a WSL2 terminal on Windows.

My environment:

  • Claude Code: 2.1.97
  • OS: Debian GNU/Linux 12 (bookworm) under WSL2 on Windows
  • Kernel: 6.6.87.2-microsoft-standard-WSL2 x86_64
  • Shell: zsh

I tested the active binary at ~/.local/share/claude/versions/2.1.97.

Before patch:

  • SHA-256: 0d43fcd11d29206563eeef3a1f787f0615c21cd703cc91f3a180915fd5797ef6
  • scanning for

if(P&&!P.success)return null;let J=P?.data??H.toolUseResult
found 2 matches at offsets:

  • 115427538 (0x6e148d2)
  • 225188778 (0xd6c1baa)

Patch used:

target = b"if(P&&!P.success)return null;let J=P?.data??H.toolUseResult"
patch  = b"if(P&&!P.success)P=null;     let J=P?.data??H.toolUseResult"

Patch results:

  • created a backup of the original 2.1.97 binary first
  • dry run reported 2 replacements
  • actual patch reported 2 replacements
  • after patch, SHA-256 became

3e133cfaa535313414c0f851f9746368ece3b90140c54d53b90dd250f4eee85d

  • rescanning showed:
  • old guard string: 0 matches
  • patched fallback string: 2 matches
  • patched binary still launches and reports 2.1.97 (Claude Code) via --version

So on the current 2.1.97 setup I tested, the regression pattern is still present and the same one-character fallback patch still lands cleanly. This was from a WSL2 terminal on Windows rather than a native Linux install.

One precision note: this verification pass was binary-level confirmation plus executable sanity, not a fresh interactive TUI repro after patching. But the guard is still there unchanged in 2.1.97, and the patch continues to apply exactly as expected.

checkmate1984 · 3 months ago

Follow-up to my earlier 2.1.97 comment: this is still present in 2.1.101 on the same WSL2 setup, and the same fallback patch still works after adjusting for the new minified variable names.

My environment:

  • Claude Code: 2.1.101
  • OS: Debian GNU/Linux 12 (bookworm) under WSL2 on Windows
  • Kernel: 6.6.87.2-microsoft-standard-WSL2 x86_64
  • Shell: zsh

I tested the active binary at ~/.local/share/claude/versions/2.1.101.

Before patch:

  • SHA-256: d064b4f28cf0950f1f9c355b471fdefaae6c00cda1a8ea895c7518330cee0cd8
  • the exact 2.1.97 literal no longer matched, but the same hard-bail render guard is still present with renamed minified vars:
let J=A.outputSchema?.safeParse(H.toolUseResult);
if(J&&!J.success)return null;
let X=J?.data??H.toolUseResult;
  • scanning for

let J=A.outputSchema?.safeParse(H.toolUseResult);if(J&&!J.success)return null;let X=J?.data??H.toolUseResult
found 2 matches at offsets:

  • 115599170
  • 226962058

Patch used:

let J=A.outputSchema?.safeParse(H.toolUseResult);if(J&&!J.success)J=null;     let X=J?.data??H.toolUseResult

Exact fprobe workflow:

fprobe patch ~/.local/share/claude/versions/2.1.101 \
  --target 'let J=A.outputSchema?.safeParse(H.toolUseResult);if(J&&!J.success)return null;let X=J?.data??H.toolUseResult' \
  --replacement 'let J=A.outputSchema?.safeParse(H.toolUseResult);if(J&&!J.success)J=null;     let X=J?.data??H.toolUseResult' \
  --dry-run

fprobe patch ~/.local/share/claude/versions/2.1.101 \
  --target 'let J=A.outputSchema?.safeParse(H.toolUseResult);if(J&&!J.success)return null;let X=J?.data??H.toolUseResult' \
  --replacement 'let J=A.outputSchema?.safeParse(H.toolUseResult);if(J&&!J.success)J=null;     let X=J?.data??H.toolUseResult'

Patch results:

  • fprobe dry run reported 2 replacements
  • actual patch reported 2 replacements
  • fprobe created a backup at ~/.local/share/claude/versions/2.1.101.bak
  • after patch, SHA-256 became

1e01c7a91c711546318246690ab852f2fc86a2cc96bd2851da2b8b934d6fd3df

  • rescanning showed:
  • old hard-bail string: 0 matches
  • patched fallback string: 2 matches
  • patched binary still launches and reports 2.1.101 (Claude Code) via --version

So on the current 2.1.101 Linux binary I tested, the regression is still there. The exact byte pattern from 2.1.97 changed because the minified variable names changed, but the behavior is the same: the renderer still bails out on outputSchema.safeParse(...) failure instead of falling back to the raw MCP result.

One precision note: like my 2.1.97 check, this was binary-level confirmation plus executable sanity, not a fresh interactive TUI repro after patching. But the same render-killing guard is still present in 2.1.101, and the fallback patch still lands cleanly at both occurrences.

lliWcWill · 3 months ago

I checked the active Linux binary at ~/.local/share/claude/versions/2.1.104, and the same render-killing outputSchema.safeParse(...) guard is still present in this version.

My local verification:

  • Claude Code: 2.1.104
  • Binary path: ~/.local/share/claude/versions/2.1.104
  • OS context: Linux

Before patch:

  • SHA-256: f5fe84d4b8a5a322b83a8ae63ac117adb143d2a9a0bfd73a201a5201d6423869
  • the exact same target string from the 2.1.101 workaround still matched in 2.1.104
  • scanning for

let J=A.outputSchema?.safeParse(H.toolUseResult);if(J&&!J.success)return null;let X=J?.data??H.toolUseResult
found 2 matches at:

  • 0x6e3e9e0 (115599840)
  • 0xd8766a8 (226977448)

Patch used:

let J=A.outputSchema?.safeParse(H.toolUseResult);if(J&&!J.success)J=null;     let X=J?.data??H.toolUseResult

Patch results:

  • fprobe dry run reported 2 replacements
  • actual patch reported 2 replacements
  • fprobe created a backup at ~/.local/share/claude/versions/2.1.104.bak
  • after patch, SHA-256 became 4bd53fb87500e7c5f6cd8dcf1beb6b930cda19787fbf96a8af582779b773c0fb
  • rescanning showed:
  • old hard-bail string: 0 matches
  • patched fallback string: 2 matches
  • patched binary still launches and reports 2.1.104 (Claude Code) via --version

So at least on the Linux 2.1.104 binary I checked, this regression is still present and the same fallback patch still lands cleanly at both duplicated bundle locations.

cseickel · 3 months ago

Does this mean that MCP tool results can be plain strings and not the structured result? Or is there a specific structure that would pass? I only use my own custom mcp servers so I would be happy to alter the output to make this work if I knew what the expectations were.

lliWcWill · 3 months ago

@cseickel my agent and I dug into this further, and the short answer is:

  • A tool result is not a bare top-level string, but it absolutely can be text-only at the protocol level via content: [{ type: "text", text: "..." }].
  • structuredContent is optional in MCP.
  • If a tool declares an outputSchema, the spec says servers MUST provide structured results conforming to it, and clients SHOULD validate them.
  • The spec also says that if a tool returns structured content, it SHOULD also return the serialized JSON in a TextContent block for backwards compatibility.

Relevant spec refs:

So from a spec perspective, a server returning only:

{
  "content": [
    { "type": "text", "text": "hello world" }
  ]
}

is valid. It does not need structuredContent just to be a legal MCP tool result.

I also ran a controlled 4-way repro on Claude Code 2.1.107 with four MCP tools that differed only in whether they returned:

  1. plain content[].text only
  2. ANSI-colored content[].text only
  3. plain content[].text + structuredContent
  4. ANSI-colored content[].text + structuredContent

Observed matrix:

  • plain_text_only -> blank body
  • ansi_text_only -> blank body
  • plain_text_plus_structured -> body renders
  • ansi_text_plus_structured -> body renders

So on the current 2.1.107 TUI:

  • the gate is not ANSI
  • the gate appears to be presence of structuredContent
  • pure content[].text responses still reach the model, but are not shown in the user-facing body
  • when both are present, Claude Code appears to render the structured JSON body rather than the text block

That means:

  • As a workaround for current Claude Code builds, including structuredContent does make the result visible.
  • But as a spec question, text-only content is still valid and should not require structuredContent just to be a valid MCP result.

So if your goal is "make it show up today", adding structuredContent is the most reliable workaround right now.

If your goal is "make Claude Code behave correctly", then this looks like a second client-side rendering regression, separate from the earlier outputSchema.safeParse(...) null-bail bug higher up in the thread.

One nuance: if you want the TUI to show the human-facing text exactly (for example ANSI-colored output), adding structuredContent is only a visibility workaround, not a perfect fix... on 2.1.107 it looks like Claude Code prefers rendering the structured JSON body once structuredContent is present.

lliWcWill · 3 months ago

Follow-up to the original outputSchema.safeParse(...) null-bail bug higher up in this thread.

After patching 2.1.107 with the same one-character fallback used for the earlier versions, MCP calls no longer disappear completely. But there still appears to be a second rendering regression in the TUI.

I ran a controlled 4-way repro with a minimal stdio MCP server where the payload text stayed the same and only two variables changed:

  • content[].text only vs content[].text + structuredContent
  • plain text vs ANSI-wrapped text

None of the four test tools declared an outputSchema, so the original safeParse guard cannot explain the blank rendering in this repro.

Observed matrix on Claude Code 2.1.107:

| | content[].text only | content[].text + structuredContent |
|---|---|---|
| plain text | blank body | body renders |
| ANSI text | blank body | body renders |

So on 2.1.107:

  • the gate is not ANSI
  • the gate appears to be presence of structuredContent
  • pure content[].text responses still reach the model, but are not shown in the user-facing body
  • when both are present, Claude Code appears to render the structured JSON body rather than the text block

Relevant MCP spec refs:

Per the MCP schema, content is required and structuredContent is optional on CallToolResult. So a tool returning only:

{
  "content": [
    { "type": "text", "text": "hello world" }
  ]
}

is still a valid MCP tool result.

That makes this look like a second client-side rendering regression, separate from the earlier schema-validation null-bail bug: valid content[].text-only results are staying blank unless structuredContent is also present.

Practical impact:

  • adding structuredContent is a workable visibility workaround on current builds
  • but it is not a real fix if the goal is to preserve the human-facing text path (for example ANSI-colored output), because 2.1.107 seems to prefer rendering the structured JSON body once structuredContent exists

If helpful, I can post the minimal repro server I used, but the core result is the matrix above: structuredContent presence is the visibility gate, not ANSI.

lliWcWill · 3 months ago

Small clarification on the repro: when ANSI-bearing output was visible, it still rendered plain/uncolored in the TUI rather than preserving ANSI color styling. So this still does not restore colored MCP output.

trevyn · 3 months ago

Ah interesting. I do love my colored output tho. Q: Have you seen any useful features since 2.1.88? I've just been running with the patched version of that, and having the actual source for reference is also very convenient.

trevyn · 2 months ago

In 2.1.119, we patched the structuredOutput path to allow ANSI color output to render:

target (offsets 76377719 and 197328943, 113 bytes each):
function KQ_(H){return H.replace(/\^[\[([0-9]+;)*4(;[0-9]+)*m|\^[\[4(;[0-9]+)*m|\^[\[([0-9]+;)*4m/g,"")}

replacement (113 bytes, padded with trailing spaces):
function KQ_(H){return H.replace(/\\^[/g,String.fromCharCode(27))                                            }
note the regex change: original matches real \^[ (1-char ESC), new matches literal \\^[ (the 6-char json-encoded form). new behavior: turn json-escaped ansi codes back into real ESC bytes.
trevyn · 2 months ago

@sosukesuzuki Thank you! Could you provide more details about the flavor of the fix and what version it will be available in?

cseickel · 2 months ago

I don't think it was actually completed, I think it was closed along with other duplicates of #45839, which is still open.

github-actions[bot] · 20 days ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.