Skip to content

Backend tool-schema ingestion drops top-level oneOf/anyOf/enum #5976

Description

@JAORMX

Part of #5743. Found during the #5742 regression-gate audit (matrix §12).

Problem

When vMCP ingests a backend's tools/list, a tool whose inputSchema uses a top-level JSON-Schema keyword outside the shim struct's fixed fields — oneOf, anyOf, allOf, $ref, enum, patternProperties, … — has that keyword silently dropped, and a type-less schema gains a spurious "type": "". Nested keywords under properties survive (they ride an opaque map[string]any).

Root cause (upstream, toolhive-core)

The shim's mcp.Tool.InputSchema is a fixed-field struct (type, properties, required, $defs, additionalProperties), and the alternative RawInputSchema field is tagged json:"-" with no custom UnmarshalJSON (mcpcompat/mcp/tools.go). So when the SDK client decodes a tools/list response into mcp.Tool, any top-level keyword outside those five fields is discarded before vMCP's conversion.ConvertToolInputSchema (pkg/vmcp/client/client.go) ever runs. RawInputSchema is never populated on the ingest path.

There is no clean in-repo fix: re-decoding raw bytes in ToolHive would duplicate the SDK's job and leak SDK internals across the client boundary (vMCP anti-pattern #5). This must be fixed upstream.

Verified with a live probe against toolhive-core v0.0.32:

  • {"oneOf":[...]} → projected {"properties":{},"required":[],"type":""} (oneOf gone).
  • {"properties":{"x":{...}},"required":["x"]} (no type) → gains "type":"".
  • nested anyOf under properties survives.

Fix

  1. Upstream (toolhive-core): add Tool.UnmarshalJSON (symmetric with the existing MarshalJSON) that populates RawInputSchema from the raw inputSchema bytes, honoring the existing errToolSchemaConflict invariant. Cut a release.
  2. ToolHive: bump toolhive-core, then unskip the in-repo regression test (TestRegression_ToolSchemaFidelity_PreservesCompositors in pkg/vmcp/client), which asserts a oneOf/type-less backend schema is projected intact. The skip→green flip is the acceptance signal.

The in-repo skipped test lands first (with the #5742 gate work) so the regression is documented and the flip is a one-line change once the bump is available.

Generated with Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

needs-triageIssue needs initial triage by a maintainer

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions