Skip to content

MCP catalog: object-only params emit dangling $ref/oneOf with no $defs and no inline type:"object" — schema-stripping clients (Claude Code) stringify the value and the server rejects every comment.target #3588

@jacobjove

Description

@jacobjove

What problem does this solve?

apps/mcp's generated tool catalog expresses object-only params — comment.target, edit.target, delete_block_range start/end — as $refs into #/$defs/… (often wrapped in nested oneOf unions), but emits no $defs definitions block and no inline type:"object" on the param. Verified against packages/sdk/tools/catalog.json @ current main:

  • #/$defs/… references: 94
  • "$defs": definition containers: 0

So every $ref is dangling (e.g. #/$defs/BlockNodeAddress, #/$defs/SelectionTarget, #/$defs/TextAddress are referenced but never defined), and the param schema carries no resolvable type.

For an MCP client that normalizes/strips unknown or unresolvable JSON-Schema keywords before serializing tool-call arguments — Claude Code does exactly this — a param with no top-level type (object/array) is treated as untyped and JSON-stringified. The object value {kind:"text", blockId, range} reaches the server as the string "{\"kind\":\"text\",…}". Server-side validateCommentTargetisRecord(target) returns false for a string, so every documented target shape fails identically with:

target must be a TextAddress, TextTarget, SelectionTarget, or tracked-change target

How it was hit / proof it's transport, not shape

  • Three structurally-distinct valid targets — {kind:"text",blockId,range} (TextAddress), the segments form (TextTarget), {kind:"selection",start,end} (SelectionTarget) — all rejected with the identical error. A genuine shape error would distinguish them.
  • superdoc_edit with a valid object target ({kind:"block",nodeType,nodeId}) fails the same way — same type-less-param stringify.
  • Counter-proof — params with a top-level type transport intact: search.select (type:"object") works; mutations.steps (type:"array") preserves its nested object selectors; ref works because it's already a string. Only object-only params declared via $ref/oneOf-without-type break.
  • The validator source accepts the documented shapes — the server is fine; the break is schema-emission / transport.

Root cause (generator)

The catalog is // Auto-generated from packages/sdk/tools/catalog.json by packages/sdk/codegen/src/generate-intent-tools.mjs. The contract→JSON-Schema lowering keeps $ref/oneOf references to named Address/Target/Locator/Range schemas but never emits the corresponding $defs block, and adds no inline type:"object" fallback. The result is a schema that is correct-by-reference but unusable by any client that can't resolve the (absent) definitions.

Proposed solution (durable fix, generator-side)

Either of these makes the object-ness self-describing without relying on $defs resolution:

  1. Emit the $defs block — serialize the referenced contract schemas (BlockNodeAddress, TextAddress, TextTarget, SelectionTarget, DeletableBlockNodeAddress, DeleteBehavior, …) into a $defs map on each tool's inputSchema (or once at catalog root) so the 94 dangling refs resolve. Robust clients then see the object type through the ref.
  2. Inline type:"object" on object-only params (simpler, lossy but sufficient) — when a param is an object-only $ref/all-object oneOf union, stamp type:"object" (optionally inline the resolved properties) so even clients that strip $ref/oneOf keep the type. Same idea for the array params already carrying type:"array".

(1) is the fuller fix; (2) is a one-line lowering tweak that unblocks the stringify regression immediately. They compose.

Current workaround (client/fork-side, not a durable fix)

I shimmed this in a fork's apps/mcp/src/tools/intent.ts (isObjectOnlySchema() recurses oneOf/anyOf/allOf and resolves $refs heuristically by the /(Address|Target|Locator|Range)$/ naming convention, emitting z.looseObject({})type:"object"). It restores end-to-end comment/edit-by-object-target on Claude Code (live-verified: comment create + list round-trip writes to word/comments.xml). But it's a downstream band-aid keyed on a naming convention — the catalog should emit resolvable/typed schemas so no client needs the heuristic.

Additional context

  • Affects any MCP client that doesn't carry a $ref resolver and strips unrecognized keywords; clients that pass schemas through verbatim are unaffected, which is why this hid for a while.
  • The 10-tool catalog's object-only params are the blast radius; string/enum/number params and type:"array" params are unaffected.

Environment: apps/mcp run from source via bun run --conditions source; catalog inspected at packages/sdk/tools/catalog.json @ current main.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions