fix(mcp): inline seo + bylines schemas to avoid $ref indirection in content tools#1165
fix(mcp): inline seo + bylines schemas to avoid $ref indirection in content tools#1165abhishekshankar wants to merge 1 commit into
Conversation
…$ref indirection
Both schemas carry `.meta({ id: ... })`, so zod 4's `toJSONSchema()`
lifts them into `definitions` and emits the use sites as
`allOf:[{ $ref: "#/definitions/ContentSeoInput" }]`. Strict MCP
clients (Claude Desktop, recent Anthropic SDK builds) marshal tool
arguments by walking `properties[name].type` — when the property has
no top-level `type`, the client silently drops the arg.
Symptom: agents calling content_create / content_update with
`{ data: ..., seo: ... }` get the data write through (data is a
z.record with no `.meta`, inlines as `type: "object"`) and the
seo / bylines write silently ignored. No error, no warning, just
missing fields after a multi-arg update.
This commit wraps both schemas in `z.object(<schema>.shape)` inside
the MCP server only — preserving every field-level validation
(including the `httpUrl` refinement on canonical) while losing the
parent's `.meta` tag. The MCP tool's input JSON Schema then emits
the property as `{ type: "object", properties: { ... } }`, which
strict clients accept and marshal correctly.
REST and OpenAPI routes continue importing the originals from
#api/schemas.js — those paths benefit from the named $ref for
generated documentation. Only the MCP path strips the tag.
Verified locally against this codebase's fork (carries the same
inline trick via patch-package; live MCP traffic for seo + bylines
on content_update now lands in the DB instead of being silently
dropped).
|
PR template validation failedPlease fix the following issues by editing your PR description:
See CONTRIBUTING.md for the full contribution policy. |
|
I have read the CLA Document and I hereby sign the CLA Abhishek Shankar seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. |
Summary
contentSeoInputandcontentBylineInputSchemaare decorated with.meta({ id: ... }). When the MCP server passes them throughz.toJSONSchema()to produce the tool input schemas forcontent_createandcontent_update, zod 4 lifts them intodefinitionsand emits the property as:{ "seo": { "allOf": [{ "$ref": "#/definitions/ContentSeoInput" }] } }Technically valid JSON Schema, but strict MCP clients (Claude Desktop, recent Anthropic SDK builds) marshal tool-call arguments by walking
properties[name].type. A property without a top-leveltypesilently drops the argument — no error, no warning.Symptom
Agents calling
content_updatewith bothdataandseoget thedatawrite through (no.meta, inlines fine withtype: "object") and theseowrite silently ignored.{ "name": "content_update", "arguments": { "id": "01K...", "data": { "title": "X" }, "seo": { "title": "SEO X", "description": "SEO desc" } } }data.titlelands.seo.titleandseo.descriptiondon't. The_emdash_seorow stays at whatever it was before — completely opaque to the caller.Fix
Inline the schema shapes via
z.object(<schema>.shape)inside the MCP tool definitions. This preserves every field-level validation (including thehttpUrlrefinement oncanonical) but loses the parent's.metatag, sotoJSONSchema()emits the property as{ type: "object", properties: { ... } }— strict clients accept it.REST and OpenAPI routes continue importing the originals from
#api/schemas.js— those paths benefit from the named$reffor generated documentation. Only the MCP path strips the tag.Why not just remove
.metafrom the schema?contentSeoInputis also used inpackages/core/src/api/schemas/content.tslines 43 and 61 (insidecreateContentInput/updateContentInput), and referenced from REST handlers. The.meta({ id })is deliberate for OpenAPI doc generation. Stripping it at the source would regress those consumers. Keeping the fix MCP-local preserves the existing pattern.Long-term cleanup (separate PR if you'd like)
If you'd prefer one schema for everything, add a helper:
Then MCP call sites become:
…which keeps the intent obvious.
Test plan
Suggested addition (not included in this PR — happy to add if you'd like):
Reference
We currently carry this fix as a
patch-packagepatch in a downstream fork: https://github.com/abhishekshankar/abhishek-shankar.com-emdash/blob/master/patches/emdash%2B0.9.0.patch — happy to delete it once this lands.