[BUG] Cowork/Desktop: MCP tool inputSchema with property-level anyOf [X, null] (Optional[X]) is silently stripped before reaching the model
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?
When an MCP tool's inputSchema contains a parameter with anyOf [X, null] shape — the standard output for Python Optional[X] generated by FastMCP — Claude desktop strips the anyOf block, leaving only default and description. The LLM sees no type info and serializes arguments inconsistently. In particular, Optional[list[str]] parameters end up serialized as stringified JSON ('["a","b"]'), which servers then reject with "expected list, got str".
Affected: Claude desktop, both Code tab and Cowork tab (verified 2026-05-05).
Not affected: Claude Code CLI (preserves full schema).
What Should Happen?
Claude desktop should pass the full inputSchema to the LLM, including any anyOf blocks. The current stripping breaks any MCP tool that uses Optional[X] Python parameters — i.e. virtually every FastMCP-generated tool with optional list/dict/object inputs.
Error Messages/Logs
Steps to Reproduce
MCP server: FastMCP 3.2.4 + Pydantic 2.x, streamable-HTTP transport.
Server-side raw tools/list response (verified via curl):
"keywords": {
"anyOf": [
{"items": {"type": "string"}, "type": "array"},
{"type": "null"}
],
"default": null,
"description": "Seed keywords..."
}
In Claude desktop Code tab and Cowork tab, asking Claude to dump the raw schema for the same parameter returns:
{
"default": null,
"description": "Seed keywords..."
}
anyOf, type, items are gone. The same prompt in Claude Code CLI returns the full anyOf block intact.
Claude Model
Opus
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.128 (Claude Code) / Claude 1.5354.0 (9a9e3d)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Terminal.app (macOS)
Additional Information
Symptom in Cowork tab
Calling the tool with keywords=["judo gi", "bjj gi", "karate gi"] causes the LLM to serialize the parameter as one stringified JSON value ('["judo gi", "bjj gi", "karate gi"]'), and the server-side pydantic validator rejects with:
Invalid arguments for tool 'generate_keyword_ideas':
keywords: Input should be a valid list
The same call in Claude Code CLI works (LLM sees type: array, sends array). In Claude desktop Code tab, the LLM happens to infer "array" from the description's example, but that is non-deterministic — different model runs or different examples in description will trip this.
Counter-evidence — narrowing the bug location
I ran the same tools/list request through three layers and inspected the schema at each step:
- Server (FastMCP): raw tools/list response over HTTP contains the full anyOf (verified via curl).
- mcp-remote proxy: the stdio adapter that Claude desktop uses to bridge HTTP MCP servers preserves the full anyOf intact (verified by piping JSON-RPC to npx mcp-remote and inspecting its stdout — schema passes through unchanged).
- Claude Code CLI: connects directly to the HTTP server (no proxy). LLM sees the full anyOf; tool calls succeed.
- Claude desktop (Code tab and Cowork tab): uses mcp-remote as a stdio bridge. LLM sees only {default, description}. anyOf is gone.
Therefore the stripping happens inside the desktop client, after mcp-remote hands the schema over via stdio and before the schema reaches the model context. mcp-remote and the MCP server are not at fault.
Environment
Claude desktop on macOS, build Claude 1.5354.0 (9a9e3d), 2026-04-29T01:14:34.000Z
Reproduced: 2026-05-05
MCP transport: streamable-HTTP
Server: FastMCP 3.2.4 + Pydantic 2.x
Same server tested from Claude Code CLI: full schema preserved (control group)
Showing cached comments. Read the full discussion on GitHub ↗
8 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
Cross-references:
→ My report shows the fix landed in CLI but not in Desktop/Cowork
I filed https://github.com/anthropics/claude-code/issues/58747, which is similar except with a type union instead of anyOf. Possibly the same root cause?
Worth separating the two diagnostic layers, because the bug is on the consuming end and the stripping pattern is more specific than just
Optional[X]:curlagainst the rawtools/listresponse already does this —keywordsarrives withanyOf: [{"items": ..., "type": "array"}, {"type": "null"}]intact. That's the proof the server is doing its job and the SDK isn't truncating mid-stream.anyOfis gone.The pattern that's getting stripped is more specific than
Optional[X]in general — based on a similar repro I worked through, the trigger is **any property-levelanyOfarray whose members are type schemas rather than$refschemas**. SoOptional[list[str]](your case),Optional[dict[str, Any]], andOptional[CustomTypedDict]all hit it. Inline$ref-onlyanyOf(e.g.anyOf: [{"$ref": "#/$defs/Foo"}, {"$ref": "#/$defs/Bar"}]) survives — which is suggestive of where the bug lives in Desktop's preprocessing pipeline (probably a schema-flattener that drops the array when it can't reduce it to a single named type).For other people hitting this thread: if you're not sure whether your server is upstream or downstream of the bug, MCP Inspector (
@modelcontextprotocol/inspector) gives you a clickable view of the tools/list response so you can confirm the schema is intact server-side. For a one-shot non-interactive scan that records the on-wire schema for each tool,npx @incultnitostudiosllc/mcp-probe test "<launch command>"writes the resolved inputSchema to its scorecard output — useful for diffing against what Desktop ends up showing.CLI preserving the full schema while Desktop strips it is the smoking gun — same SDK, different preprocessing path. Whichever team owns the Cowork tool-bridge layer is where the fix lives.
Confirming this on Windows with a fresh repro — same root cause (property-level
anyOf: [X, null]stripped), extending the original macOS report.Environment
ant.dir.cursortouch.windows-mcp).Tool / parameter. The mouse tools
Scroll/Click/Move/Typedeclare:FastMCP emits the standard
Optional[list]shape:What reaches the model: the
anyOfis stripped — only{"default": null}survives (no type). The argument[1000, 600]is then sent as the string"[1000, 600]".Server rejection (Pydantic):
Two data points this adds to #56263:
list[int](coordinate pairs), not onlylist[str]— so it breaks even basic mouse-coordinate tools, i.e. any FastMCP tool with an optional list/tuple input.A server-side
X | str-and-coerce workaround is viable only for scalars (Windows-MCP already does this for booleans via_as_bool) but doesn't scale to lists/objects — preserving theanyOfblock client-side is the real fix.Related: #58771, #18260 (
$refvariant), #26094 (object params).Still reproduces — independent repro on a newer build with a different server stack (server-agnostic, client-side confirmed)
Hit this independently on 2026-06-22. Adds two new data points and confirms it's unfixed ~7 weeks on.
Environment
Claude Desktop 1.14271.0 (Code tab), macOS 15.5
MCP server: TypeScript — @modelcontextprotocol/sdk 1.29.0 + Zod 4.4.3, streamable-HTTP, bridged into Desktop via mcp-remote 0.1.38
Original report was FastMCP 3.2.4 + Pydantic (Python). Same bug on a completely different server stack ⇒ the trigger is purely the anyOf [X, null] JSON-Schema shape, not any server framework.
Same trigger, same strip. Param declared z.number().min(0).nullable().optional(). Server emits (verified by reproducing the SDK conversion and an in-process client.listTools()):
"amount": { "description": "Deal amount…", "anyOf": [ { "type": "number", "minimum": 0 }, { "type": "null" } ] }
What the model receives in Desktop:
"amount": { "description": "Deal amount…" }
anyOf gone → LLM sends a string → server rejects expected number, received string. Passing null to clear a field is also impossible (arrives as the string "null").
New data point 1 — the client also injects additionalProperties: false. It's not only dropping the union; it overlays additionalProperties:false on the object schema. The faithful server/connector output has additionalProperties absent. So the ingestion path makes two unrequested mutations to the declared schema.
New data point 2 — a same-client A/B that isolates transport, not client. Earlier narrowing varied client and transport together (Desktop+mcp-remote vs CLI+direct-HTTP). In a single Desktop session I had the same server reachable two ways:
via mcp-remote (stdio bridge) → schema stripped
via a native Desktop HTTP connector (direct HTTP, same /api/mcp endpoint) → schema intact (anyOf preserved, no additionalProperties injected)
Same client, differing only by ingestion path ⇒ the defect is in the stdio-bridged tool-schema ingestion within the desktop client; direct-HTTP ingestion in the same client is unaffected.
mcp-remote re-confirmed innocent. Read its mcpProxy (0.1.38 — it only filters tools/list by tool name, spreads the rest verbatim) and captured its raw stdout for tools/list: full anyOf present, additionalProperties absent. That stdout is exactly the byte stream the desktop client reads, so the strip happens strictly after.
Mechanism note. The stripper drops the type-bearing combinator (anyOf/type/items) but keeps sibling scalar keywords — description always, and default when present (which is why the Python repro showed {default, description} and the TS repro showed {description}). Single-type params (e.g. { "type": "string" }) are unaffected; only union/anyOf params are lost.
Workaround that works: route the server through direct HTTP instead of the mcp-remote stdio bridge — Desktop Settings → Connectors (custom connector to the HTTPS MCP URL), or the standalone Claude Code CLI with a type:"http" server. Both preserve the full schema.
Note re: "Not affected: Claude Code CLI (preserves full schema)" (verified 2026-05-05) — I just reproduced the identical symptom today (2026-07-22) from a Claude Code session, against our own MCP server (
@modelcontextprotocol/sdk1.27.1,zod4.3.6).Two parameters with the same underlying shape (
z.array(z.string()).max(50)), one plain-optional and one.nullable().optional(), on sibling tools in the same server:{"items":{"type":"string"},"type":"array"}anyOf: [array, null]): surfaced to the model as a bare{}— no type info at allConfirmed server-side the raw
tools/listschema is correct in both cases (checked via the SDK's ownzod-json-schema-compat.js), so the stripping is happening between that response and whatever schema Claude Code exposes to the model. Once a field is untyped like this, non-string values (arrays, numbers) get serialized as strings before the call reaches the server, which then rejects them — full detail and a second scalar (non-array) case in my comment on #73137.Given this reproduced inside a Claude Code session, either this has regressed since the May verification, or the CLI is affected under some conditions not covered by that earlier check — flagging in case it's useful for narrowing down where the stripping actually happens.
Still reproducing four months on, and with two differences from the original report that may help
narrow it.
Environment: Claude Desktop 1.40609.1, Claude Code CLI 2.1.258, macOS 26.6.2, Python MCP SDK
1.29.1. Same A/B as this issue: server registered in
claude_desktop_config.jsonis affected;the same server registered with
claude mcp addis not.1. The collapse is more complete than described here
This issue reports the
anyOfblock being removed "leaving onlydefaultanddescription". In ourcase the property becomes an empty object —
defaultanddescriptionare gone too.Server sends (verified with the
mcpPython client against the live endpoint):Model sees, via
claude_desktop_config.json:2. Descriptions are also dropped from plain-typed properties
#78237 notes that plain typed sibling fields "survive sanitization intact". That does not hold here —
they survive structurally but still lose their descriptions. On a 13-property tool:
| Property | Server sends | via
claude_desktop_config.json| viaclaude mcp add||---|---|---|---|
|
pre_agg_query|anyOf[string,null],default, description |{}| intact ||
run_date|anyOf[string(format:date),null], description |{}| intact ||
limit|type:integer,default:10, description |{"default":10,"type":"number"}| intact ||
base|type:string,enum[4],default, description |{"default":"ALERTS","enum":[…],"type":"string"}| intact |So on the affected path the transformation is: every
properties[*].descriptionremoved regardless oftype; property-level
anyOfcollapsed to{};integer→number;format: datedropped; and a$schema: "http://json-schema.org/draft-07/schema#"key added that the server never sent.Worth stressing that the tool-level
descriptionsurvives intact on both paths. That is what makesthis so easy to miss — the model still gets the tool summary and only the per-parameter contract is
missing, so it confabulates parameter syntax instead of failing loudly.
3. Minimal reproducer — one tool, one parameter
Register it in
claude_desktop_config.jsonwith an absolute interpreter path, fully quit and relaunchDesktop, then ask: *"What is the description of the
noteparameter ofecho_note? Quote itverbatim."* The sentinel is unreachable. Register the identical command with
claude mcp add schema-repro -- /abs/python /abs/server.pyand it comes back word for word.Why this bites hard in practice
On the server this was found with, two parameters accept a sandboxed lambda expression, and the entire
grammar of what is accepted lives in their descriptions. With those stripped the model invents syntax,
every call is rejected by the server's validator, and neither the user nor the server author can see
why — the server logs a rejected query and the schema looks fine from its side.
Two things it is not, both checked: not content filtering (
limit's description is plain prose andis dropped, while the tool-level description containing literal lambda code samples survives), and not
a trust rule for third-party servers (a third-party
npx -y @zereight/mcp-gitlabregistered throughClaude Code keeps its descriptions and its
anyOf).