MCP: one non-object tool inputSchema silently drops ALL tools from an HTTP server (no error either side)

Status Open
Reported on v2.1.234
Maintainer reply None cached
Activity 0 comments · opened Aug 19, 2026

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:

  • /mcp and session init report the server connected.
  • 0 tools ingested, 0 present in the ToolSearch index; ToolSearch returns "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: initialize 200, tools/list 200 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:

  1. 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.
  2. 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.

  1. Stand up an HTTP MCP server at http://localhost:<port>/mcp whose tools/list returns 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"] }
        ]
      }
    }
  ]
}
  1. Register it: claude mcp add --transport http repro http://localhost:<port>/mcp
  2. Start a session. /mcp shows repro — connected.
  3. Ask for the tools, or run ToolSearch for "alpha": nothing. alpha_read and beta_read — which are perfectly ordinary object schemas — are gone too.
  4. Remove only gamma_resolve from tools/list, restart the session: alpha_read and beta_read appear immediately.
  5. Alternatively, leave gamma_resolve in and add "type": "object" alongside its anyOf: 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 / /mcp output ("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/oneOf when 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-tool try/catch in createSdkServer would 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; discriminatedUnion emits top-level anyOf with no type.
  • 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.

View original on GitHub ↗