[BUG] MCP tool arguments are stringified when the parameter's schema is empty (`{}`)

Status Open
Reported on v2.1.212
Maintainer reply ✓ Yes — bcherny
Activity 4 comments · opened Jul 30, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

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?

Summary

When an MCP tool declares a parameter with an empty JSON Schema ({} — i.e. "any
type permitted"), Claude Code sends that argument's value to the server as a JSON
string, regardless of what it should be. Numbers arrive as "8"; objects
arrive as "{\"database_id\": \"abc123\"}".

Every other schema shape tested preserves the value's JSON type correctly —
including $ref and unions. The trigger is specifically an absent type
declaration
, not schema complexity.

This makes it impossible to express a genuinely polymorphic parameter, which is a
legitimate and reasonably common pattern outside Zod-based servers (a settings
writer that accepts int/float/bool/string depending on the target field, for
example).

Environment

  • Claude Code 2.1.212
  • macOS (Darwin 25.5.0)
  • node v25.9.0, @modelcontextprotocol/sdk 1.30.0 (server side)

Reproduction

Minimal single-file stdio MCP server, one dependency, no Zod or third-party
server involved: https://gist.github.com/maybites/f86a427676402ad79d9186736b0f4cff

It exposes two tools whose parameters differ only in how their schema declares
a type; every parameter of a tool is asked for the same value. Register it in
.mcp.json, restart, then ask the agent to call each tool once — passing the
integer 8 to every parameter of type_probe, and the object
{"database_id":"abc123"} to every parameter of type_probe_objects.

Results

type_probe — every parameter asked for the JSON integer 8

| param | schema | received | JSON type | verdict |
| --- | --- | --- | --- | --- |
| a_typed_integer | {"type":"integer"} | 8 | number | PASS |
| b_no_type | {} | "8" | string | FAIL |
| c_inline_union | {"anyOf":[{"type":"integer"},{"type":"string"}]} | 8 | number | PASS |
| d_ref_to_integer | {"$ref":"#/$defs/intVal"} | 8 | number | PASS |
| e_ref_to_union | {"$ref":"#/$defs/unionVal"} | 8 | number | PASS |

type_probe_objects — every parameter asked for {"database_id":"abc123"}

| param | schema | received | JSON type | verdict |
| --- | --- | --- | --- | --- |
| f_obj_inline | inline {"type":"object",...} | {"database_id":"abc123"} | object | PASS |
| g_obj_no_type | {} | "{\"database_id\": \"abc123\"}" | string | FAIL |
| h_obj_ref_union | {"$ref":"#/$defs/parentLike"}oneOf of objects | {"database_id":"abc123"} | object | PASS |

Both matrices agree: the only failing variant is the one with an empty schema,
and it fails for both scalars and objects.

The corruption is client-side

Because the probe is the stdio MCP server, it taps its own process.stdin and
reports the raw JSON-RPC bytes before the MCP SDK parses them. The value is
already a string in those bytes:

{"method":"tools/call","params":{"name":"type_probe","arguments":{"a_typed_integer":8,"b_no_type":"8","c_inline_union":8,"d_ref_to_integer":8,"e_ref_to_union":8},"_meta":{"claudecode/toolUseId":"toolu_01XKync6mnnxxe8aERw2T8Eh","progressToken":2}},"jsonrpc":"2.0","id":2}
{"method":"tools/call","params":{"name":"type_probe_objects","arguments":{"f_obj_inline":{"database_id":"abc123"},"g_obj_no_type":"{\"database_id\": \"abc123\"}","h_obj_ref_union":{"database_id":"abc123"}},"_meta":{"claudecode/toolUseId":"toolu_01SG19wChirhfckg9tyLJ7hh","progressToken":3}},"jsonrpc":"2.0","id":3}

So this is not server-side deserialization, and not the MCP SDK — the value is a
string on the wire.

Corroborating detail: the string looks authored, not re-serialized

In the object frame, the correctly-passed parameters serialize compactly
({"database_id":"abc123"}), while the failing one carries a space after the
colon ({\"database_id\": \"abc123\"}). A client calling
JSON.stringify(parsedObject) would emit compact output, so the space indicates
the string was authored as text rather than derived from a parsed value.

This is consistent with the transcript-level finding in #77529 / #72248, where
the stringified value was already present in the emitted tool_use block before
any runtime processed it. Taken together, the defect looks like it sits in how
an untyped parameter is presented to (or generated by) the model, not in a
coercion step in the client's marshalling — which matters, because it means a
fix in the MCP transport layer would not address it.

Prior art — same rule, different surface

The underlying rule is not a new discovery; it has already been root-caused
for the built-in Workflow tool, and I want to credit that rather than restate
it as if it were novel:

  • #63253 (auto-closed for inactivity, not fixed) — @Slooz scanned 65 local

Workflow calls and found the correlation exactly: *typed param → real
structure; untyped (any) args → JSON string*. Workflow.args was the only
corpus-wide parameter holding a stringified container (11/11), while typed
params like AskUserQuestion.questions came through as structure 115/115.

  • #77529 / #72248 (both open) — confirmed at the transcript level that

input.args is already a JSON string in the emitted tool_use block,
before any runtime touches it. That establishes the model emits the string; it
is not a coercion step in the runtime.

Why this still warrants a separate issue:

  1. Different surface, and the accepted fix does not transfer. The remedy

proposed for Workflow.args — "declare a concrete type in the tool's input
schema" — is available to Anthropic because Anthropic owns that schema. MCP
tool schemas are authored by third-party server developers. There is no
schema for Anthropic to fix, so the remedy has to be client-side handling, or
explicit documented guidance that MCP parameters must never be untyped.

  1. Controlled rather than correlational. #63253 explicitly caveats *"I'm

inferring the schema typing from behavior — the tool input_schema isn't
persisted locally to byte-verify."* Here the schemas are authored and emitted
by the probe, so what varies is known exactly, and only one thing varies.

  1. The negative space is mapped. $ref, anyOf, oneOf, and $ref → union

all pass, for scalars and objects. Only {} fails. That bounds the defect.

Other issues checked, and why they differ

  • #18260 ($ref parameters stringified) — I built the $ref variants

specifically to test this, including $refoneOf of objects (the Notion
parent shape). They pass on 2.1.212.

  • #60963 (closed) — the closest MCP-side report, and worth revisiting: it

used a typed/untyped discriminator on a value parameter and concluded
arguments are coerced "regardless of schema type". But its typed tool
declared value: {type: "string"} and was sent 42 — so stringifying to
"42" was schema-compliant, not a defect. It never tested a
number-declared parameter receiving a number. Once that is accounted for, its
data is consistent with the finding here rather than contradicting it.

  • #24599 / #23549 / #22394 / #73137 — report typed (number, array)

parameters being stringified. Typed parameters round-trip correctly on
2.1.212.

What Should Happen?

Expected behavior

A parameter whose schema is {} should have its value transmitted with the JSON
type it was given — object, array, string, number, boolean, or null — since {}
in JSON Schema means "any type permitted", not "string".

Error Messages/Logs

## Results

### `type_probe` — every parameter asked for the JSON integer `8`

| param | schema | received | JSON type | verdict |
| --- | --- | --- | --- | --- |
| `a_typed_integer` | `{"type":"integer"}` | `8` | number | PASS |
| `b_no_type` | `{}` | `"8"` | string | **FAIL** |
| `c_inline_union` | `{"anyOf":[{"type":"integer"},{"type":"string"}]}` | `8` | number | PASS |
| `d_ref_to_integer` | `{"$ref":"#/$defs/intVal"}` | `8` | number | PASS |
| `e_ref_to_union` | `{"$ref":"#/$defs/unionVal"}` | `8` | number | PASS |

### `type_probe_objects` — every parameter asked for `{"database_id":"abc123"}`

| param | schema | received | JSON type | verdict |
| --- | --- | --- | --- | --- |
| `f_obj_inline` | inline `{"type":"object",...}` | `{"database_id":"abc123"}` | object | PASS |
| `g_obj_no_type` | `{}` | `"{\"database_id\": \"abc123\"}"` | string | **FAIL** |
| `h_obj_ref_union` | `{"$ref":"#/$defs/parentLike"}` → `oneOf` of objects | `{"database_id":"abc123"}` | object | PASS |

Both matrices agree: the only failing variant is the one with an empty schema,
and it fails for both scalars and objects.

Steps to Reproduce

Reproduction

Minimal single-file stdio MCP server, one dependency, no Zod or third-party
server involved: https://gist.github.com/maybites/f86a427676402ad79d9186736b0f4cff

It exposes two tools whose parameters differ only in how their schema declares
a type; every parameter of a tool is asked for the same value. Register it in
.mcp.json, restart, then ask the agent to call each tool once — passing the
integer 8 to every parameter of type_probe, and the object
{"database_id":"abc123"} to every parameter of type_probe_objects.

Claude Model

Opus

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.212

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Terminal.app (macOS)

Additional Information

_No response_

View original on GitHub ↗

3 Comments

gogakoreli · 1 month ago

have you tried declaring the object type as const anyObjectSchema = z.record(z.any());?

maybites · 1 month ago

Thanks — that works when the parameter is genuinely always an object, and it's consistent with what the probe found: any type information at all makes the value round-trip correctly. z.record(z.any()) emits {"type":"object"}, so it lands in the passing column.

It doesn't fit this case though. The parameter is polymorphic across scalars and objects — it accepts 8, 2.5, true, "text", or a dict depending on which field is being written. Constraining it to object would break every scalar write, which is the original failure. (The server is Python rather than Zod, but that's incidental — the same schema hand-written would have the same limitation.)

The workaround we've adopted is the general form of your suggestion: anyOf over the accepted types, which the probe confirms round-trips correctly.

To be clear about the ask: the issue isn't that there's no workaround — it's that {} is valid JSON Schema meaning "any type permitted", and it's silently coerced to string rather than passed through or rejected. Nothing errors at any layer.

That silence is the part worth weighing. In our case the value lands in a settings field whose declared type isn't enforced at write time unless an explicit validator is present. Fields with a validator rejected the string and the write was visibly dropped. Fields without one silently stored "42" where an int was declared — so the parameters that appeared to work were the ones being corrupted. If an untyped schema is going to be treated as string-typed, an explicit error would at least surface it; silent coercion means graphs can be written with wrong-typed values and nobody finds out until arithmetic fails much later.

bcherny collaborator · 15 days ago

Reproduced on v2.1.233 (macOS), Opus.

Steps:

  1. Wrote a minimal stdio MCP server exposing type_probe and type_probe_objects with the same parameter schemas as in the issue ({"type":"integer"}, {}, anyOf, $ref to integer, $ref to union; and inline object, {}, $ref to oneOf of objects). The server logs the raw JSON-RPC it receives on stdin.
  2. Registered it via .mcp.json and ran: claude -p --mcp-config .mcp.json --allowedTools "mcp__probe__type_probe,mcp__probe__type_probe_objects" --model opus 'Call type_probe once passing the integer 8 to every parameter. Then call type_probe_objects once passing the object {"database_id":"abc123"} to every parameter.'

Observed (raw tools/call frames as received by the server):

"arguments":{"a_typed_integer":8,"b_no_type":"8","c_inline_union":8,"d_ref_to_integer":8,"e_ref_to_union":8}
"arguments":{"f_obj_inline":{"database_id":"abc123"},"g_obj_no_type":"{\"database_id\":\"abc123\"}","h_obj_ref_union":{"database_id":"abc123"}}

Only the two parameters whose schema is {} arrive as strings. Every parameter with any type information (type, anyOf, $ref) keeps its JSON type.

Expected: a {} ("any type") parameter should be sent with the JSON type it was given, i.e. 8 and {"database_id":"abc123"}.

Assessment: This looks like a genuine bug. Claude Code forwards the MCP tool schema and the tool-call arguments as-is; the string value appears to originate in the model's tool call itself when a parameter has no type information at all, so it is likely model-side rather than something the CLI rewrites. That still needs a fix on our side, though: the most robust option is for Claude Code to send an explicit "any of object/array/string/number/boolean/null" schema in place of a bare {} so the model keeps the JSON type (your anyOf results show that round-trips correctly). Until then, giving such parameters an explicit type or anyOf in your server's schema is a workaround.

🤖 Generated with Claude Code

Showing cached comments. Read the full discussion on GitHub ↗