Skip to content

fix(mcp): inline seo + bylines schemas to avoid $ref indirection in content tools#1165

Open
abhishekshankar wants to merge 1 commit into
emdash-cms:mainfrom
abhishekshankar:fix/mcp-seo-allof-ref
Open

fix(mcp): inline seo + bylines schemas to avoid $ref indirection in content tools#1165
abhishekshankar wants to merge 1 commit into
emdash-cms:mainfrom
abhishekshankar:fix/mcp-seo-allof-ref

Conversation

@abhishekshankar
Copy link
Copy Markdown

Summary

contentSeoInput and contentBylineInputSchema are decorated with .meta({ id: ... }). When the MCP server passes them through z.toJSONSchema() to produce the tool input schemas for content_create and content_update, zod 4 lifts them into definitions and 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-level type silently drops the argument — no error, no warning.

Symptom

Agents calling content_update with both data and seo get the data write through (no .meta, inlines fine with type: "object") and the seo write silently ignored.

{
  "name": "content_update",
  "arguments": {
    "id": "01K...",
    "data": { "title": "X" },
    "seo": { "title": "SEO X", "description": "SEO desc" }
  }
}

data.title lands. seo.title and seo.description don't. The _emdash_seo row 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 the httpUrl refinement on canonical) but loses the parent's .meta tag, so toJSONSchema() 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 $ref for generated documentation. Only the MCP path strips the tag.

Why not just remove .meta from the schema?

contentSeoInput is also used in packages/core/src/api/schemas/content.ts lines 43 and 61 (inside createContentInput / 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:

// In #api/schemas.js
export function forMcp<T extends z.ZodObject<z.ZodRawShape>>(s: T) {
  return z.object(s.shape);
}

Then MCP call sites become:

import { forMcp } from "#api/schemas.js";
seo: forMcp(contentSeoInput).optional().describe(...)

…which keeps the intent obvious.

Test plan

Suggested addition (not included in this PR — happy to add if you'd like):

import { z } from "zod";
import { contentSeoInput as upstream } from "../../src/api/schemas/content";

describe("contentSeoInput when inlined for MCP", () => {
  it("emits an inline object schema, not allOf:$ref", () => {
    const inlined = z.object(upstream.shape);
    const schema = z.toJSONSchema(inlined);
    expect(schema.type).toBe("object");
    expect(schema.$ref).toBeUndefined();
    expect(schema.allOf).toBeUndefined();
  });
});

Reference

We currently carry this fix as a patch-package patch 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.

…$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).
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 25, 2026

⚠️ No Changeset found

Latest commit: 71f47a1

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor

PR template validation failed

Please fix the following issues by editing your PR description:

  • This PR does not use the required PR template. Please edit the description to use the PR template. Copy it into your PR description and fill out all sections.

See CONTRIBUTING.md for the full contribution policy.

@github-actions
Copy link
Copy Markdown
Contributor


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


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.
You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

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.

1 participant