MCP client stringifies object parameters for tools with complex schemas

Resolved 💬 3 comments Opened Jan 25, 2026 by Aern28 Closed Jan 29, 2026

Description

When calling MCP tools that have parameters defined with $ref/oneOf schemas, Claude Code's MCP client incorrectly serializes object parameters as JSON strings instead of passing them as proper objects in the JSON-RPC message.

Reproduction

  1. Configure the Notion MCP server (@notionhq/notion-mcp-server)
  2. Use API-post-search with a filter object parameter → works
  3. Use API-post-page with parent and properties object parameters → fails

Error received:

body failed validation: body.parent should be an object or `undefined`, 
instead was `"{\"database_id\": \"...\"}"`

Proof that the bug is in Claude Code (not the MCP server)

I wrote a test script that sends JSON-RPC messages directly to the Notion MCP server:

// Test 1: Object parameter (works)
arguments: { parent: { database_id: "..." } }  // ✅ Page created

// Test 2: Stringified parameter (fails with same error)
arguments: { parent: "{\"database_id\": \"...\"}" }  // ❌ Validation error

The MCP server handles object parameters correctly. Claude Code appears to stringify them before sending.

Schema difference

| Tool | Parameter Schema | Result |
|------|------------------|--------|
| API-post-search | filter: {type: "object"} | ✅ Works |
| API-post-page | parent: {$ref: "#/$defs/parentRequest"} (oneOf) | ❌ Stringified |

The issue appears specific to parameters using $ref with oneOf schemas.

Environment

  • Claude Code CLI (latest)
  • Windows 11
  • @notionhq/notion-mcp-server@2.0.0

Test script

<details>
<summary>Direct MCP server test (test-notion-mcp.js)</summary>

const { spawn } = require('child_process');

const server = spawn('npx', ['-y', '@notionhq/notion-mcp-server'], {
  env: {
    ...process.env,
    OPENAPI_MCP_HEADERS: JSON.stringify({
      "Authorization": "Bearer <token>",
      "Notion-Version": "2022-06-28"
    })
  },
  shell: true
});

let buffer = '';
server.stdout.on('data', (data) => {
  buffer += data.toString();
  const lines = buffer.split('\n');
  buffer = lines.pop();
  for (const line of lines) {
    if (line.trim()) {
      try {
        console.log('\n[RESPONSE]', JSON.stringify(JSON.parse(line), null, 2));
      } catch (e) {
        console.log('[RAW]', line);
      }
    }
  }
});

server.stderr.on('data', (data) => console.error('[STDERR]', data.toString()));

setTimeout(() => {
  server.stdin.write(JSON.stringify({
    jsonrpc: "2.0", id: 1, method: "initialize",
    params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "test", version: "1.0" } }
  }) + '\n');
}, 2000);

setTimeout(() => {
  server.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + '\n');
}, 3000);

// Object params - WORKS
setTimeout(() => {
  server.stdin.write(JSON.stringify({
    jsonrpc: "2.0", id: 2, method: "tools/call",
    params: {
      name: "API-post-page",
      arguments: {
        parent: { database_id: "<db-id>" },
        properties: { title: [{ text: { content: "Test" } }] }
      }
    }
  }) + '\n');
}, 4000);

// String params - FAILS (simulates Claude Code behavior)
setTimeout(() => {
  server.stdin.write(JSON.stringify({
    jsonrpc: "2.0", id: 3, method: "tools/call",
    params: {
      name: "API-post-page",
      arguments: {
        parent: JSON.stringify({ database_id: "<db-id>" }),
        properties: JSON.stringify({ title: [{ text: { content: "Test" } }] })
      }
    }
  }) + '\n');
}, 6000);

setTimeout(() => { server.kill(); process.exit(0); }, 10000);

</details>

Expected behavior

Object parameters should be passed as objects in the JSON-RPC arguments field, not stringified.

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗