Repository navigation
Description
Environment
- Claude Code version: 2.1.7
- OS: macOS (Darwin 24.6.0)
- MCP Server: @notionhq/notion-mcp-server v2.0.0
Description
When calling MCP tools, parameters whose schema uses $ref are incorrectly serialized as JSON strings instead of objects. Parameters with direct type: object schemas in the same tool call are handled correctly.
Reproduction
-
Use an MCP server with a tool that has both:
- A parameter using
$ref(e.g.,"parent": {"$ref": "#/$defs/parentRequest"}) - A parameter with direct object schema (e.g.,
"properties": {"type": "object", ...})
- A parameter using
-
Call the tool passing objects to both parameters
-
Expected: Both parameters received as objects
-
Actual: The
$refparameter is received as a stringified JSON string
Evidence
I created a wrapper around the Notion MCP server to intercept traffic. When calling API-post-page:
What Claude Code sent:
{
"parent": "{\"data_source_id\": \"2e85ff8b-a448-8010-a867-000bc4ae78d2\"}",
"properties": {
"Phone Model": {
"title": [{"text": {"content": "DEBUG TEST"}}]
}
}
}Analysis:
| Parameter | Schema | Type Received |
|---|---|---|
parent |
{"$ref": "#/$defs/parentRequest"} |
string (wrong) |
properties |
{"type": "object", "properties": {...}} |
object (correct) |
Both parameters were passed identically in my tool call, but only parent (with $ref) was stringified.
Schema Details
From the MCP server's tool definition:
{
"properties": {
"parent": {"$ref": "#/$defs/parentRequest"},
"properties": {
"type": "object",
"properties": {
"title": {...}
},
"required": ["title"],
"additionalProperties": false
}
}
}The $defs section contains parentRequest defined as a oneOf union, but the issue appears to be triggered by the $ref itself, not the oneOf.
Impact
This breaks any MCP tool that uses $ref for parameter schemas, including:
- Notion MCP server (official @notionhq/notion-mcp-server)
- Any server using JSON Schema references for complex types
Related Issues
- [BUG] MCP FastMCP Pydantic model parameters serialized as JSON strings causing validation errors #3084 - FastMCP Pydantic model parameters (closed)
- [BUG] Claude Code incorrectly serializes JSON objects as strings when calling MCP tools #5504 - JSON objects serialized as strings (closed as dup of [BUG] MCP FastMCP Pydantic model parameters serialized as JSON strings causing validation errors #3084)
- [BUG] "parent" are double-stringified in Notion-MCP tool calls → server throws “Expected object, received string” #3023 - Notion-MCP parent double-stringified (closed)
These were marked closed but the bug persists in v2.1.7, possibly because the root cause ($ref handling) wasn't identified.
Activity
Aern28 commentedon Jan 25, 2026
This issue provides additional root cause analysis that may be useful for debugging.
The key insight is that parameters using
$refin their schema are stringified, while parameters with inlinetype: objectschemas in the same tool call work correctly. This points to$refresolution as the specific issue.Related: #11359 (actively being investigated by @domdomegg) covers similar symptoms with nested structs and oneOf types.
FuzzySecurity commentedon Jan 26, 2026
I have this same issue, more detail below.
Version: 2.1.19
OS: Windows
When calling MCP tools that have complex object parameters (e.g., Zod
discriminatedUnion/ JSON SchemaoneOf), ClaudeCode passes the JSON as a string literal rather than a parsed object.
Reproduction
Expected: "module": {"kind":"module_ref","module_ref":"[email protected]"} (object)
Error
MCP error -32602: Invalid arguments: expected object, received string
gogakoreli commentedon Jan 31, 2026
Workaround for MCP server authors
Until this is fixed, wrap
$ref/oneOfparameters withz.preprocess()to handle stringified input:This accepts both the correct object and the stringified version Claude Code sends.
fix: add z.preprocess() to config params for Claude MCP serialization…
rstierli commentedon Feb 18, 2026
Still hitting this on Claude Code 2.x with the Notion MCP server.
Our scenario: Automated deployment pipeline (n8n → Claude Code on prod-ai) creates Notion tasks for review. The
parentparameter (which uses$refin the Notion API schema) gets stringified:Notion API rejects it:
body.parent should be an object, instead was a stringCurrent workaround: Using direct
curlAPI calls to Notion, bypassing MCP entirely. This works but defeats the purpose of having MCP integration.The
z.preprocessworkaround shared by @gogakoreli is helpful for server authors, but this should be fixed at the Claude Code MCP bridge level — every MCP server with$refschemas is affected.Commenting to prevent stale closure — this is a real blocker for MCP adoption in production workflows.
gogakoreli commentedon Feb 20, 2026
I dug into this and traced the root cause through the MCP SDK. The issue is that
$refis never resolved anywhere in the stack — not in the server, not in the SDK, not in the client.Where it breaks
The SDK's
schemaToJson()just callsz.toJSONSchema()and returns raw output:This feeds directly into the
tools/listresponse with no$refresolution. On the client side,listTools()andcallTool()also do zero schema processing. I searched the entire SDK codebase for$ref,dereference,resolveRef— zero hits.Zod v4 widened the blast radius
$refin tool schemas has always been possible — from non-Zod servers (Notion MCP, Rustschemars, OpenAPI-derived) and even from the oldzod-to-json-schemalibrary (which used$refStrategy: "root"by default, producing$refwhen the same schema object was reused via identity-based deduplication).However, PR #1460 significantly widened the blast radius: Zod v4's
z.toJSONSchema()produces$reffor any schema registered inz.globalRegistrywith anid— even on first and only use — and for all recursive types viaz.lazy(). The old library only triggered$refon the second encounter of the same JS object reference; the new one triggers on common patterns like registries and discriminated unions.This isn't just Claude Code
I hit the exact same bug in Kiro CLI — completely different codebase, same issue. Every MCP client that feeds schemas to an LLM will have this problem because LLMs don't understand
$ref.Fix should be in the SDK
A local
$refresolver inschemaToJson()would fix this for all Zod-based servers in one place:I have a working implementation with tests — PR incoming against
modelcontextprotocol/typescript-sdk.fix: inline local $ref in tool inputSchema for LLM consumption
gogakoreli commentedon Feb 20, 2026
Submitted a fix to the MCP TypeScript SDK:
The fix adds a
dereferenceLocalRefs()step toschemaToJson()that inlines all local$refpointers before they reach clients. ~95 lines, zero new dependencies. Handles registered types, recursive schemas (bounded fallback to{ type: "object" }at cycle points), diamond references (cached), and preserves sibling keywords per JSON Schema 2020-12.This fixes the problem for all Zod-based MCP servers — their tool schemas will be flat and self-contained. Non-Zod servers (like Notion MCP) that send raw JSON Schema with
$refwould still need a client-side fix since their schemas don't pass throughschemaToJson().Preview packages are available for testing:
fix: accept co_ids as comma-separated string in regenerate_controls
fix: accept co_ids as comma-separated string in regenerate_controls
fix: use comma-separated strings for all list params
fix: use comma-separated strings for all list params
fix: use comma-separated strings for all list params
fix: use comma-separated strings for all list params
fix: use comma-separated strings for all list params (#59)
MRMcp: reify string-encoded array/object args from MCP clients
MRMcp: server-side hardening — tool validator + arg reification (#5999)
maybites commentedon Jul 29, 2026
Confirming this also hits parameters with no type in the schema at all — not just $ref/oneOf. Worth flagging since the proposed fix (typescript-sdk#1563) targets $ref dereferencing specifically, and this variant has no $ref to dereference.
Setup: a Python MCP server (not Zod) exposing a tool with a deliberately polymorphic value parameter — its JSON schema omits type entirely because the field can legitimately hold any JSON type depending on target. Calling it with a bare integer ("value": 8) results in the server receiving "value": "8" (string).
Isolated the origin precisely: instrumented our stdio MCP proxy to log raw stdin bytes before any SDK parsing. Claude Code's own JSON-RPC write already contains "value":"8" (quoted) at that point — confirming the stringification happens inside Claude Code's client, upstream of the proxy, the MCP SDK's request layer, and the server, all of which were separately verified clean.
Given there's no $ref involved here, this looks like the trigger condition may be broader than $ref-resolution — possibly "any parameter without a flat primitive type" rather than "any parameter with $ref." Might be worth double-checking the SDK fix's scope covers schema-less/type-less parameters too, not just $ref/oneOf ones.
gogakoreli commentedon Jul 29, 2026
if you are not using zod and do not define no types at all, then how would agent know what to pass at all? the problem here is that we define the types via zod, but the agent still breaks.
maybites commentedon Jul 30, 2026
Fair question — so I tested it rather than argued it, and the result went against
what I expected. Posting it because it's relevant to this thread's root-cause
theory, not to defend my case.
I built a probe that varies only the type declaration across parameters of a
single tool, so
$ref-ness and type-determinability are separated instead ofconfounded: https://gist.github.com/maybites/f86a427676402ad79d9186736b0f4cff
On Claude Code 2.1.212, asking for the object
{"database_id":"abc123"}:f_obj_inline{"type":"object",...}{"database_id":"abc123"}g_obj_no_type{}"{\"database_id\": \"abc123\"}"h_obj_ref_union{"$ref":"#/$defs/parentLike"}→oneOfof objects{"database_id":"abc123"}And with a scalar
8,$ref→ integer and$ref→oneOfboth pass too; theonly failure is again the empty schema.
So
$ref→oneOfof objects — theparentshape from the original report —round-trips correctly for me. Two caveats before anyone reads too much into
that: my
parentLikeis a simplified stand-in for Notion's realparentRequest,which may carry nesting or keywords mine doesn't; and the original report was on
2.1.7, ~200 versions back, so this may simply have been fixed in between.
It seemed worth flagging either way, since
typescript-sdk#1563
is scoped to inlining
$ref— if a minimal$refcase already passes, it'd beworth confirming the real Notion schema still fails, and why, before that lands
as the fix. If someone hitting this with Notion can run the probe against their
actual schema, that would settle it.
To answer the question directly: my own case has no
$ref— the parameter ispolymorphic by design and declares no type, which turns out to be its own
separate bug (empty schema
{}→ value stringified, for scalars and objects).I've filed that separately as #82652 rather than pile it on here, since
the evidence above says it isn't the same defect.
perf(mcp): tools/list 28,7 tys. znakow zamiast 65,9 tys. - opisy na w…