Skip to content

fix(mcp): emit type:object for oneOf and object properties so MCP clients send objects, not strings#3192

Open
bjohas wants to merge 1 commit intosuperdoc-dev:mainfrom
OpenDevEd:fix/mcp-loose-object-for-oneof-and-object-properties
Open

fix(mcp): emit type:object for oneOf and object properties so MCP clients send objects, not strings#3192
bjohas wants to merge 1 commit intosuperdoc-dev:mainfrom
OpenDevEd:fix/mcp-loose-object-for-oneof-and-object-properties

Conversation

@bjohas
Copy link
Copy Markdown

@bjohas bjohas commented May 7, 2026

Summary

jsonSchemaPropertyToZod in apps/mcp/src/tools/intent.ts returns z.unknown() for properties whose JSON Schema declares oneOf/anyOf/allOf or type: "object". Through the MCP SDK's toJsonSchema conversion, the emitted property carries no type field at all — only description survives in the wire-level schema returned by tools/list.

Some MCP clients use the property-level type to decide how to encode the argument. With no type, 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:

superdoc_comment failed: target must be a TextAddress or TextTarget object.

Tools affected (every catalog property that uses oneOf or type: "object"):

  • superdoc_comment.target (oneOf TextAddress | TextTarget)
  • superdoc_comment.patch.target (oneOf)
  • superdoc_search.select (oneOf text | node)
  • superdoc_search.within (object)

superdoc_mutations.steps is unaffected because it is type: "array", which is why batch mutations work while single-call comment/search fail.

Reproduction

The shipped npm package @superdoc-dev/mcp@0.3.1 contains the bug. From a fresh Claude Code session with the MCP installed:

1. superdoc_open {file: "doc.docx"}
2. superdoc_comment {action: "create", text: "...", target: {kind: "text", blockId: "<id>", range: {start: 0, end: 5}}}
   → "target must be a TextAddress or TextTarget object"

A direct stdio handshake confirms the wire-level schema for superdoc_comment.target is { description: "..." } — no type, no oneOf, no properties. After this patch, the same handshake returns { type: "object", properties: {}, additionalProperties: true, description: "..." } and the call goes through.

Fix

Replace z.unknown() with z.looseObject({}) in the oneOf/anyOf/allOf branch and the type: "object" branch. z.looseObject emits { 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-mini toJSONSchema. z.looseObject does not have that limitation; it round-trips correctly through toJsonSchemaCompat({ pipeStrategy: "input" }).

Test plan

  • Locally hand-patched the bundled dist/index.js to apply the equivalent change
  • Verified via stdio MCP handshake that tools/list now reports type: "object" for superdoc_comment.target, superdoc_search.select, and superdoc_search.within
  • Confirmed z.looseObject is exported in zod ^4.3.6 (the apps/mcp pinned version)
  • End-to-end superdoc_comment.create succeeds against a real document from a Claude Code session (next-session validation)

Related

  • The same tools/list response flagged a separate documentation issue worth a follow-up: the superdoc_comment tool description tells LLMs to build target.range from result.highlightRange, but highlightRange is snippet-relative (offset by SNIPPET_PADDING = 30 chars). The block-relative handle is result.context.textRanges[0]. Happy to file separately if useful.

🤖 Generated with Claude Code

`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.
@bjohas bjohas requested a review from a team as a code owner May 7, 2026 20:03
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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({});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve scalar oneOf schemas

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 👍 / 👎.

Copy link
Copy Markdown
Contributor

@caio-pizzol caio-pizzol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

@caio-pizzol
Copy link
Copy Markdown
Contributor

@bjohas can you rebase + force-push? thanks!

@caio-pizzol caio-pizzol self-assigned this May 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants