From 5acf0e9c26b1275a4b3ab842ce4cbda9f5bd0e8f Mon Sep 17 00:00:00 2001 From: bjohas Date: Thu, 7 May 2026 21:02:38 +0100 Subject: [PATCH] fix(mcp): emit type:object for oneOf and object properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `jsonSchemaPropertyToZod` returned `z.unknown()` for properties whose JSON Schema declared `oneOf`/`anyOf`/`allOf` or `type: "object"`. After the SDK's JSON-Schema conversion, the emitted property carries no `type` field at all (only `description` survives). Some MCP clients — notably the Claude Code harness — rely on the property-level `type` to decide how to encode the argument before sending. With no `type`, they fall back to sending the value as a string, so the JSON object the LLM constructs arrives server-side as a literal string, and `target must be a TextAddress or TextTarget object` or equivalent validation fails. This affects every catalog property that uses `oneOf` or `type: "object"`, including `superdoc_comment.target`, `superdoc_comment.patch.target`, `superdoc_search.select`, and `superdoc_search.within`. Since `superdoc_mutations.steps` is `type: "array"` it is unaffected, which is why batch mutations work while single-call comment/search do not. Use `z.looseObject({})` instead of `z.unknown()` for these branches. `z.looseObject` emits `{ type: "object", additionalProperties: true }`, preserving the structural-type signal clients need. The actual payload is still validated by DocumentApi at dispatch time, so this does not weaken validation. `z.record()` would have the same JSON-Schema effect but cannot be converted by the MCP SDK's z4-mini `toJSONSchema`, which is why the existing comments warned against it; `z.looseObject` does not have that limitation. --- apps/mcp/src/tools/intent.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/apps/mcp/src/tools/intent.ts b/apps/mcp/src/tools/intent.ts index eaca2a6c89..c5929e0d41 100644 --- a/apps/mcp/src/tools/intent.ts +++ b/apps/mcp/src/tools/intent.ts @@ -50,10 +50,15 @@ function jsonSchemaPropertyToZod(prop: Record): z.ZodTypeAny { } } - // Complex schemas (oneOf, anyOf, allOf) — pass through as opaque; - // DocumentApi validates the actual payload at dispatch time. + // Complex schemas (oneOf, anyOf, allOf) — pass through as a loose object. + // z.unknown() emits a JSON Schema with no `type` field, which some MCP + // clients (notably the Claude Code harness) treat as a string and fail + // to JSON-encode object payloads. z.looseObject({}) emits + // {type: "object", additionalProperties: true}, so the value reaches + // the server as an object. DocumentApi validates the actual payload + // at dispatch time. if (prop.oneOf || prop.anyOf || prop.allOf) { - return desc ? z.unknown().describe(desc) : z.unknown(); + return desc ? z.looseObject({}).describe(desc) : z.looseObject({}); } switch (type) { @@ -69,9 +74,12 @@ function jsonSchemaPropertyToZod(prop: Record): z.ZodTypeAny { // z4-mini toJSONSchema cannot convert z.record() from zod v4 classic. return desc ? z.array(z.unknown()).describe(desc) : z.array(z.unknown()); case 'object': - // Use z.unknown() instead of z.record() to avoid MCP SDK Zod v4 classic/mini - // incompatibility. DocumentApi validates the actual shape at dispatch time. - return desc ? z.unknown().describe(desc) : z.unknown(); + // Use z.looseObject({}) so the emitted JSON Schema carries + // `type: "object"`. z.unknown() drops the type (clients treat it + // as a string); z.record() can't be converted by the MCP SDK's + // z4-mini toJSONSchema. DocumentApi validates the actual shape + // at dispatch time. + return desc ? z.looseObject({}).describe(desc) : z.looseObject({}); default: return desc ? z.unknown().describe(desc) : z.unknown(); }