fix(mcp): emit type:object for oneOf and object properties so MCP clients send objects, not strings#3192
Conversation
`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.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5acf0e9c26
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // 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({}); |
There was a problem hiding this comment.
When a catalog property uses oneOf for scalar variants, this branch now maps it to z.looseObject({}) and MCP validation rejects valid scalar arguments before DocumentApi dispatch. I checked apps/mcp/src/generated/catalog.ts: superdoc_comment.text and superdoc_create.text are string/string unions, superdoc_list.level is an integer union, and superdoc_table.color/cellSpacingPt allow string/number/null, so calls like superdoc_comment { action:"create", text:"...", target:{...} } will fail on text even though only object unions needed the object type hint. Please restrict the loose-object fallback to unions whose variants are objects, or preserve/recurse scalar union types.
Useful? React with 👍 / 👎.
caio-pizzol
left a comment
There was a problem hiding this comment.
@bjohas thanks for the PR - this catches a real bug.
one edge case the change misses: the content field on superdoc_edit accepts either a single fragment or a list of fragments, and the list form now gets rejected before it reaches the dispatch layer. nothing for you to do here, i'll take it in a follow-up.
|
@bjohas can you rebase + force-push? thanks! |
Summary
jsonSchemaPropertyToZodinapps/mcp/src/tools/intent.tsreturnsz.unknown()for properties whose JSON Schema declaresoneOf/anyOf/allOfortype: "object". Through the MCP SDK'stoJsonSchemaconversion, the emitted property carries notypefield at all — onlydescriptionsurvives in the wire-level schema returned bytools/list.Some MCP clients use the property-level
typeto decide how to encode the argument. With notype, the Claude Code harness sends the value as a string, so the JSON object the LLM constructs arrives at the server as a literal string and is rejected by DocumentApi:Tools affected (every catalog property that uses
oneOfortype: "object"):superdoc_comment.target(oneOf TextAddress | TextTarget)superdoc_comment.patch.target(oneOf)superdoc_search.select(oneOf text | node)superdoc_search.within(object)superdoc_mutations.stepsis unaffected because it istype: "array", which is why batch mutations work while single-call comment/search fail.Reproduction
The shipped npm package
@superdoc-dev/mcp@0.3.1contains the bug. From a fresh Claude Code session with the MCP installed:A direct stdio handshake confirms the wire-level schema for
superdoc_comment.targetis{ description: "..." }— notype, nooneOf, noproperties. After this patch, the same handshake returns{ type: "object", properties: {}, additionalProperties: true, description: "..." }and the call goes through.Fix
Replace
z.unknown()withz.looseObject({})in theoneOf/anyOf/allOfbranch and thetype: "object"branch.z.looseObjectemits{ type: "object", additionalProperties: true }after JSON-Schema conversion, preserving the structural-type signal clients need. DocumentApi continues to validate the actual payload at dispatch time, so input validation is not weakened — only the wire-level type tag is restored.The existing inline comment warned that
z.record()cannot be converted by the MCP SDK's z4-minitoJSONSchema.z.looseObjectdoes not have that limitation; it round-trips correctly throughtoJsonSchemaCompat({ pipeStrategy: "input" }).Test plan
dist/index.jsto apply the equivalent changetools/listnow reportstype: "object"forsuperdoc_comment.target,superdoc_search.select, andsuperdoc_search.withinz.looseObjectis exported in zod ^4.3.6 (the apps/mcp pinned version)superdoc_comment.createsucceeds against a real document from a Claude Code session (next-session validation)Related
tools/listresponse flagged a separate documentation issue worth a follow-up: thesuperdoc_commenttool description tells LLMs to buildtarget.rangefromresult.highlightRange, buthighlightRangeis snippet-relative (offset bySNIPPET_PADDING = 30chars). The block-relative handle isresult.context.textRanges[0]. Happy to file separately if useful.🤖 Generated with Claude Code