Skip to content

[BUG] MCP tool parameters with $ref schemas are incorrectly serialized as strings #18260

Open

Description

@scottyallen

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

  1. 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", ...})
  2. Call the tool passing objects to both parameters

  3. Expected: Both parameters received as objects

  4. Actual: The $ref parameter 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

These were marked closed but the bug persists in v2.1.7, possibly because the root cause ($ref handling) wasn't identified.

Activity

  1. Aern28 commented on Jan 25, 2026

    @Aern28

    This issue provides additional root cause analysis that may be useful for debugging.

    The key insight is that parameters using $ref in their schema are stringified, while parameters with inline type: object schemas in the same tool call work correctly. This points to $ref resolution as the specific issue.

    Related: #11359 (actively being investigated by @domdomegg) covers similar symptoms with nested structs and oneOf types.

  2. FuzzySecurity commented on Jan 26, 2026

    @FuzzySecurity

    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 Schema oneOf), Claude
    Code passes the JSON as a string literal rather than a parsed object.

    Reproduction

    1. Create an MCP server with a tool that expects an object parameter:
      inputSchema: {
        module: z.discriminatedUnion("kind", [
          z.object({ kind: z.literal("module_ref"), module_ref: z.string() }),
          z.object({ kind: z.literal("source"), source: z.string() }),
        ])
      }
    2. Call the tool from Claude Code
    3. Server receives: "module": "{"kind":"module_ref","module_ref":"[email protected]"}" (string)
      Expected: "module": {"kind":"module_ref","module_ref":"[email protected]"} (object)

    Error

    MCP error -32602: Invalid arguments: expected object, received string

  3. gogakoreli commented on Jan 31, 2026

    @gogakoreli

    Workaround for MCP server authors

    Until this is fixed, wrap $ref/oneOf parameters with z.preprocess() to handle stringified input:

    operation: z.preprocess(
      (val) => typeof val === "string" ? JSON.parse(val) : val,
      z.discriminatedUnion("type", [
        z.object({ type: z.literal("create"), file_text: z.string() }),
        z.object({ type: z.literal("append"), new_str: z.string() }),
      ])
    )

    This accepts both the correct object and the stringified version Claude Code sends.

  4. rstierli commented on Feb 18, 2026

    @rstierli

    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 parent parameter (which uses $ref in the Notion API schema) gets stringified:

    Expected: "parent": {"database_id": "2275ad9e-..."}
    Received: "parent": "{\"database_id\":\"2275ad9e-...\"}"
    

    Notion API rejects it: body.parent should be an object, instead was a string

    Current workaround: Using direct curl API calls to Notion, bypassing MCP entirely. This works but defeats the purpose of having MCP integration.

    The z.preprocess workaround shared by @gogakoreli is helpful for server authors, but this should be fixed at the Claude Code MCP bridge level — every MCP server with $ref schemas is affected.

    Commenting to prevent stale closure — this is a real blocker for MCP adoption in production workflows.

  5. gogakoreli commented on Feb 20, 2026

    @gogakoreli

    I dug into this and traced the root cause through the MCP SDK. The issue is that $ref is 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 calls z.toJSONSchema() and returns raw output:

    export function schemaToJson(schema: AnySchema, options?: { io?: 'input' | 'output' }): Record<string, unknown> {
        return z.toJSONSchema(schema, options) as Record<string, unknown>;
    }

    This feeds directly into the tools/list response with no $ref resolution. On the client side, listTools() and callTool() also do zero schema processing. I searched the entire SDK codebase for $ref, dereference, resolveRef — zero hits.

    Zod v4 widened the blast radius

    $ref in tool schemas has always been possible — from non-Zod servers (Notion MCP, Rust schemars, OpenAPI-derived) and even from the old zod-to-json-schema library (which used $refStrategy: "root" by default, producing $ref when the same schema object was reused via identity-based deduplication).

    However, PR #1460 significantly widened the blast radius: Zod v4's z.toJSONSchema() produces $ref for any schema registered in z.globalRegistry with an id — even on first and only use — and for all recursive types via z.lazy(). The old library only triggered $ref on the second encounter of the same JS object reference; the new one triggers on common patterns like registries and discriminated unions.

    const MyType = z.object({ x: z.number() });
    z.globalRegistry.add(MyType, { id: "MyType" });
    z.toJSONSchema(z.object({ a: MyType, b: MyType }));
    // → { properties: { a: { "$ref": "#/$defs/MyType" }, ... }, "$defs": { "MyType": { ... } } }

    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 $ref resolver in schemaToJson() would fix this for all Zod-based servers in one place:

    export function schemaToJson(schema: AnySchema, options?: { io?: 'input' | 'output' }): Record<string, unknown> {
        const jsonSchema = z.toJSONSchema(schema, options) as Record<string, unknown>;
        return dereferenceLocalRefs(jsonSchema); // inline $ref, drop $defs
    }

    I have a working implementation with tests — PR incoming against modelcontextprotocol/typescript-sdk.

  6. gogakoreli commented on Feb 20, 2026

    @gogakoreli

    Submitted a fix to the MCP TypeScript SDK:

    The fix adds a dereferenceLocalRefs() step to schemaToJson() that inlines all local $ref pointers 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 $ref would still need a client-side fix since their schemas don't pass through schemaToJson().

    Preview packages are available for testing:

    npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1563
    
  7. maybites commented on Jul 29, 2026

    @maybites

    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.

  8. gogakoreli commented on Jul 29, 2026

    @gogakoreli

    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.

  9. maybites commented on Jul 30, 2026

    @maybites

    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 of
    confounded: https://gist.github.com/maybites/f86a427676402ad79d9186736b0f4cff

    On Claude Code 2.1.212, asking for the object {"database_id":"abc123"}:

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

    And with a scalar 8, $ref → integer and $ref → oneOf both pass too; the
    only failure is again the empty schema.

    So $ref → oneOf of objects — the parent shape from the original report —
    round-trips correctly for me.
    Two caveats before anyone reads too much into
    that: my parentLike is a simplified stand-in for Notion's real parentRequest,
    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 $ref case already passes, it'd be
    worth 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 is
    polymorphic 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:corearea:mcpbugSomething isn't workinghas reproHas detailed reproduction stepsplatform:macosIssue specifically occurs on macOSstaleIssue is inactive

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions