Skip to content

fix(host): self-contained MCP tool schemas; skip a failing tool instead of the whole mount - #671

Merged
elliotBraem merged 3 commits into
mainfrom
fix/mcp-self-contained-tool-schemas
Oct 31, 2026
Merged

elliotBraem merged 3 commits into
mainfrom
fix/mcp-self-contained-tool-schemas

Conversation

@saadiqbal-dev

Copy link
Copy Markdown
Contributor

Closes #670.

Problem

mountMcpRoute builds each tool's inputSchema from the operation's parameters and JSON body properties, but it drops spec.components. A $ref in an input (any recursive zod schema, e.g. z.json()) therefore fails Ajv compilation inside fromJsonSchema. There's no per-tool error handling, so that one failure aborts the whole mount. The host logs [MCP] Failed to mount /api/mcp, and /api/mcp returns 404 while /.well-known/mcp.json still advertises it. activity.nearbuilders.org hit this with a payload: z.json() body: 51 tools, 1 failed, MCP gone. That app has worked around it in NEARBuilders/activity.nearbuilders.org#109.

Fix

  • Self-contained tool schemas: a new buildToolInputSchema(operation, componentSchemas) (extracted from the loop) collects the #/components/schemas/* refs that the inputs reach, follows them transitively, copies those entries into $defs, and rewrites the refs to #/$defs/*. Inputs without refs come out the same as before.
  • Skip only the failing tool: if a tool's schema still fails to compile, the host logs [MCP] Skipping tool "<id>": … and moves on, instead of dropping the whole route.

Tests

  • New host/tests/unit/mcp-tool-schema.test.ts generates a real spec from an oRPC contract with a z.json() body. It asserts the tool schema has no #/components/ refs, compiles with fromJsonSchema, validates nested JSON, and still enforces required. Without the fix, the recursive case fails. A second case pins that ref-free inputs are unchanged.
  • Locally: host tsc --noEmit and biome check host are clean, and host unit tests pass (101). I couldn't run the oxlint type-aware pass locally because its config failed to load here (Unknown plugin: 'effecttsgo'), so it's left to CI.

Not included: suggestion 3 from #670 (only advertise /api/mcp in /.well-known/mcp.json after a successful mount).

…ad of the whole mount (#670)

mountMcpRoute copied parameter and request-body schemas into each tool's
inputSchema without spec.components, so any $ref (e.g. from a recursive
z.json() input) failed Ajv compilation in fromJsonSchema. With no per-tool
error handling, that one failure aborted the mount and /api/mcp returned 404.

Copy referenced component schemas into $defs with refs rewritten, and log
and skip any tool whose schema still fails to compile.
@elliotBraem

Copy link
Copy Markdown
Contributor

Review: approve

The approach is correct and the test is real (real oRPC contract → real OpenAPIGenerator → real fromJsonSchema → validates the recursive input passes and required is still enforced — not a tautology).

Correctness checks (all pass):

  • Transitive $ref collection works — iterating a Set while adding visits new items in JS, and cycles (A → B → A) are safe since the set dedupes.
  • The $defs rewrite covers refs nested inside the copied components; path/query param schemas with refs are handled too, since collectComponentRefs scans all properties.
  • registeredTools.add(...) moving after the try/catch is right — a skipped tool isn't marked registered, so a later duplicate operationId can still claim it.

Nits (non-blocking):

  1. Changeset — .changeset/config.json ignores host (private; never versions — host fixes ship via the deploy train on merge, not npm). The "everything-dev": patch entry is the release lever per Changesets: scope root to publishables; remove from children #572, i.e. it requests a framework rc release for a host-only change. Confirm that's intended, or drop the changeset.
  2. Dangling refs — a $ref whose component is missing from the spec still gets rewritten to #/$defs/<name>, so Ajv fails with a cryptic "can't resolve reference" before the skip log fires. Fine (it's caught), but naming the missing component in the skip message would aid debugging.
  3. Follow-up not included — Host /api/mcp silently fails to mount when any operation input has a $ref (e.g. z.json()) #670 suggestion 3 (only advertise /api/mcp in /.well-known/mcp.json after a successful mount) is deferred; worth a ticket so discovery stops lying on a bad mount.
  4. Streaming/SSE procedures remain invisible to MCP (pre-existing hasSseResponse skip) — fine for now, folded into Investigate @orpc/mcp McpRegistrar (or Hono MCP adapter) in every-plugin to replace the hand-rolled MCP bridge #673 (investigating @orpc/mcp McpRegistrar as the replacement for the hand-rolled bridge).

@elliotBraem

Copy link
Copy Markdown
Contributor

Pushed two follow-up commits addressing the review nits:

  • c835087 — fail fast when a $ref names a component not present in the spec: buildToolInputSchema now throws references component schema(s) not present in the OpenAPI spec: <names> before the fromJsonSchema compile attempt, so the skip log names the actual missing component instead of Ajv's cryptic "can't resolve reference". Pinned by a new test case.
  • 873e854 — dropped the changeset: the fix is host-only (host is changesets-ignored, never versions) and ships via the deploy train on merge; the everything-dev entry would have cut an rc release for a change not in that package.

Verified: host tsc --noEmit clean, all 242 host unit tests pass (including the new one), biome check and oxlint --type-aware clean on the touched files.

Left as-is (deliberately, to keep this PR small): #670 suggestion 3 (/.well-known/mcp.json advertise-after-mount) — tracked for follow-up in #673, where the systemic replacement for the hand-rolled bridge is being scoped.

@elliotBraem
elliotBraem merged commit cb6563a into main Oct 31, 2026
20 checks passed
@elliotBraem
elliotBraem deleted the fix/mcp-self-contained-tool-schemas branch October 31, 2026 03:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Host /api/mcp silently fails to mount when any operation input has a $ref (e.g. z.json())

2 participants