MCP: one non-object tool inputSchema silently drops ALL tools from an HTTP server (no error either side)
What's Wrong?
If one tool advertised by an HTTP MCP server has an inputSchema whose top level is not an object — e.g. {"anyOf": [ ...object arms... ]} with no "type": "object" — the CLI discards every tool from that server. Not the offending tool. The whole server.
And it does it completely silently, on both sides:
/mcpand session init report the server connected.- 0 tools ingested, 0 present in the ToolSearch index;
ToolSearchreturns "No matching deferred tools found" for any keyword the server owns. - No error, no warning, no log line from the CLI — nothing on stderr, nothing in the session transcript, nothing in
~/.claude/logs. - The server sees nothing wrong either:
initialize200,tools/list200 with the full catalog, normal response size. From the server's access log this looks like a completely successful handshake.
The failure is therefore indistinguishable from "the MCP server is down" — except the server says it is up, and so does the client. We lost several hours to this, chasing a payload-size theory, because there is no signal anywhere that points at a schema.
Top-level anyOf with object arms is valid JSON Schema, and it is the natural emission of any tagged-union validator (e.g. zod's discriminatedUnion). It is a shape servers will keep producing.
Measurements
Our gateway is an HTTP MCP server for an accounting app. Two of its tools take a tagged union as input, so their generated JSON Schema has anyOf at the top level with no type. We bisected by serving different scope subsets and measuring the exact byte length of the tools/list result against what the CLI actually ingested:
| tools/list result | tools | has a top-level-anyOf tool? | tools ingested by the CLI |
|---|---|---|---|
| 8,338 B | 3 | yes (1) | 0 |
| 28,102 B | 9 | no | 9 |
| 368,785 B | 123 | yes (2) | 0 |
| 368,817 B | 123 | no (after the fix below) | 123 |
Two things this rules out:
- It is not size. The smallest catalog we served (8.3 KB, 3 tools) ingested nothing; a catalog 3.4× larger ingested completely. And the full 369 KB / 123-tool catalog ingests fine once the two schemas are repaired.
- It is not the ToolSearch / deferred-tools path. The 3-tool catalog is far below any deferral threshold and still dropped to zero.
The last two rows are the same catalog 32 bytes apart: the only change was adding ,"type":"object" (16 bytes) to each of the two union tools' advertised schema. 0 tools → 123 tools.
Steps to Reproduce
Any HTTP MCP server will do; the only thing that matters is that one tool's inputSchema top level is anyOf/oneOf with no type.
- Stand up an HTTP MCP server at
http://localhost:<port>/mcpwhosetools/listreturns three tools — two ordinary object-schema tools and one union tool:
{
"tools": [
{
"name": "alpha_read",
"description": "ordinary tool",
"inputSchema": { "type": "object", "properties": { "id": { "type": "number" } }, "required": ["id"] }
},
{
"name": "beta_read",
"description": "ordinary tool",
"inputSchema": { "type": "object", "properties": { "q": { "type": "string" } }, "required": ["q"] }
},
{
// the only offender: top level is anyOf, with NO "type": "object"
"name": "gamma_resolve",
"description": "tagged-union tool",
"inputSchema": {
"anyOf": [
{ "type": "object", "properties": { "mode": { "const": "a" }, "x": { "type": "number" } }, "required": ["mode", "x"] },
{ "type": "object", "properties": { "mode": { "const": "b" }, "y": { "type": "string" } }, "required": ["mode", "y"] }
]
}
}
]
}
- Register it:
claude mcp add --transport http repro http://localhost:<port>/mcp - Start a session.
/mcpshows repro — connected. - Ask for the tools, or run
ToolSearchfor "alpha": nothing.alpha_readandbeta_read— which are perfectly ordinary object schemas — are gone too. - Remove only
gamma_resolvefromtools/list, restart the session:alpha_readandbeta_readappear immediately. - Alternatively, leave
gamma_resolvein and add"type": "object"alongside itsanyOf: all three appear.
Confirm at each step with curl that the server is serving the full list with a 200 — it always is.
Expected
- A schema the client cannot use should cost that one tool, never the server. Isolate per tool: skip the tool, keep the rest.
- Say something. Any of: a startup warning naming the tool and the reason, a line in the session log, an entry in
claude mcp list//mcpoutput ("12 of 13 tools loaded, 1 skipped: invalid inputSchema"). Right now the only way to discover this is to bisect your own tool catalog by hand. - Ideally, accept top-level
anyOf/oneOfwhen every arm is an object schema — that is valid JSON Schema and it means exactly "an object matching arm A or arm B".
Workaround (for anyone else hitting this)
At the emission layer only, hoist the type onto the advertised schema when every arm is an object. The validator/union itself is untouched; only the advertised JSON Schema changes:
function objectAtTopLevel(schema) {
if (!schema || typeof schema !== "object" || schema.type === "object") return schema;
const arms = schema.anyOf ?? schema.oneOf;
if (!Array.isArray(arms) || arms.length === 0) return schema;
if (!arms.every((a) => a && typeof a === "object" && a.type === "object")) return schema;
return { ...schema, type: "object" };
}
That took our catalog from 0 ingested to 123 ingested with no other change.
Related
- #82949 — same blast radius (one bad tool schema drops all tools of a server), different trigger: a boolean schema at a named property, in Claude Desktop, throwing in
jsonSchemaToZodShape. Notably, that one at least writes an error to the Desktop log. This one writes nothing anywhere. #82949's suggested per-tooltry/catchincreateSdkServerwould fix both classes at once. - #50194 — the earlier, closed report of the same blast radius.
- #56263 — property-level
anyOf: [X, null]silently stripped; different outcome (one property lost, server survives), same "silently" theme.
I filed separately rather than commenting on #82949 because the trigger (top-level non-object vs nested boolean), the client surface (CLI + HTTP transport vs Desktop + binary extension), and above all the observability (nothing logged at all vs a logged TypeError) are different — but if you'd rather fold them together, the blast-radius fix is shared and I'm happy to have this closed as a dupe.
Environment
- Claude Code CLI 2.1.234 (not re-tested on 2.1.235; nothing in the changelog suggests it changed)
- Windows 11
- MCP server: Node/TypeScript over HTTP transport, per-project registration, bearer auth. Schemas generated from a zod-style validator;
discriminatedUnionemits top-levelanyOfwith notype. - Regression: unknown. One of the two union tools had been in the catalog and ingesting fine for ~10 days before this surfaced, which smells like a client-side tightening — but I could not establish a clean version boundary (both binaries in play read 2.1.234 before and after), so I am not claiming one.