Skip to content

Anthropic MCP tools fail when input schema uses root-level anyOf/oneOf/allOf #47543

Open

Description

@jelloeater-agent

Bug description

Anthropic rejects a tool input_schema that uses anyOf/oneOf/allOf at the root level — these combinators are only accepted nested inside properties. MCP servers commonly emit "exactly one of these fields" patterns (e.g. anyOf: [{required:["route"]}, {required:["file"]}]) as root-level combinators, which then fail Anthropic validation and take the whole tool down with a 400.

OpenCode has an existing OpenAI sanitizer (#32489) but nothing equivalent for the Anthropic family.

Steps to reproduce

  1. Expose an MCP tool whose input_schema contains a root-level anyOf/oneOf/allOf (e.g. the schema in Anthropic API rejects MCP tools with anyOf in input_schema #35516 used anyOf: [{required:["route"]},{required:["file"]}]).
  2. Use it with an Anthropic model (or Copilot proxying Claude, or Vertex Anthropic).

Expected behavior

Tool continues to work; the schema is folded into the root object so Anthropic accepts it.

Actual behavior

The request fails with an Anthropic validation error and the tool is unusable.

Fix

fix(opencode): sanitize MCP tool schemas for Anthropic root combinators (#47542) adds a targeted sanitizer that folds root-level combinators into the root object schema while preserving nested content verbatim.

Activity

  1. github-actions commented on Sep 5, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

  2. iceteaSA commented on Oct 24, 2026

    @iceteaSA

    This problem also occurs on OpenCode 2. I examined 2.1.3 and dev at 17b79f4e0c.

    The problem is not only for MCP tools. A plugin tool that a plugin adds with ctx.tool.transform and a Zod discriminatedUnion input causes the same failure. When the tool list contains one such tool, all requests to an Anthropic model stop with this error before the model runs:

    tools.26.custom.input_schema: input_schema does not support oneOf, allOf, or anyOf at the top level
    

    Cause on dev:

    • packages/core/src/tool/runtime.ts:157-172 changes a Zod input to JSON Schema with toJSONSchema(schema, { target: "draft-2020-12", io }). A discriminatedUnion gives a root anyOf and no root type.
    • objectRoot in packages/ai/src/protocols/utils/tool-schema.ts:57-72 adds type: "object" but keeps the root anyOf. It removes anyOf only for the empty-struct marker (lines 66-70).
    • modelCompatibility (lines 78-90) applies a sanitizer only for Gemini and Moonshot.
    • packages/ai/src/protocols/anthropic-messages.ts:550 sends input_schema: tool.inputSchema with no change.

    Minimal check with the same objectRoot code as dev:

    const input = z.discriminatedUnion("action", [
      z.object({ action: z.literal("list") }),
      z.object({ action: z.literal("get"), id: z.string() }),
    ])
    const sent = objectRoot(toJSONSchema(input, { target: "draft-2020-12", io: "input" }))
    // root keys: type, $schema, anyOf (2 branches)

    A plugin author can prevent the error with an object root. An MCP server cannot change its schema for one provider. Two possible repairs are an Anthropic sanitizer for the root, or an error at tool registration that names the tool.

  3. opencodejr commented on Oct 28, 2026

    @opencodejr

    Thanks for the report. I reproduced this on v2.2.0 (the latest release; the issue doesn't name a version), and it still happens on the latest dev (b6c43a5).

    Steps:

    • A stdio MCP server exposes one tool whose inputSchema has a root-level anyOf: [{required:["route"]},{required:["file"]}]. The server is configured with codemode: false, so the tool goes on the native tool list.
    • anthropic is pointed at a local mock of the Messages API through settings.baseURL. The mock applies Anthropic's top-level combinator rule.
    • Running opencode run -m anthropic/claude-sonnet-4-5 "reply only: ok" sends the tool's schema to Anthropic unchanged, root anyOf included, and the run fails with:
      tools.5.custom.input_schema: input_schema does not support oneOf, allOf, or anyOf at the top level
      

    The script below reproduces it end to end. I had no real Anthropic account, so the 400 comes from the mock. What I saw directly is that OpenCode forwards the root combinator as is on the Anthropic path; the error is the one reported in #46628, #37916 and #35516.

    In the meantime, leaving codemode at its default (Code Mode) avoids the error. The MCP tools are then reached through execute, and their schemas aren't sent to Anthropic directly.

    Related (all v1 and closed):

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #47543: an MCP tool whose inputSchema has a root-level anyOf is sent to Anthropic unchanged.
    # Needs bun, node and `opencode` (v2) on PATH. No real Anthropic key: a local mock Messages endpoint
    # logs the tools it receives and applies Anthropic's documented rule (no anyOf/oneOf/allOf at the
    # root of input_schema), returning Anthropic's error text.
    set -euo pipefail
    W=$(mktemp -d); mkdir -p "$W/proj" "$W/home"
    cat > "$W/mcp.mjs" <<'JS'
    let buf = ""; const send = (m) => process.stdout.write(JSON.stringify(m) + "\n");
    process.stdin.on("data", (d) => { buf += d; let i;
      while ((i = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
        if (!line) continue; const msg = JSON.parse(line); if (msg.id === undefined) continue;
        if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: msg.params?.protocolVersion ?? "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "repro", version: "1.0.0" } } });
        else if (msg.method === "tools/list") send({ jsonrpc: "2.0", id: msg.id, result: { tools: [{ name: "api_impact", description: "Impact of a route or file",
          inputSchema: { type: "object", properties: { route: { type: "string" }, file: { type: "string" } }, anyOf: [{ required: ["route"] }, { required: ["file"] }] } }] } });
        else send({ jsonrpc: "2.0", id: msg.id, result: {} }); } });
    JS
    cat > "$W/mock.ts" <<'TS'
    Bun.serve({ port: 8787, async fetch(req) {
      const body: any = await req.json().catch(() => ({})); const tools = body.tools ?? [];
      for (const [i, t] of tools.entries()) { const s = t.input_schema ?? {};
        if (["anyOf", "oneOf", "allOf"].some((k) => k in s)) {
          console.log(`received tool ${i} ${t.name} input_schema:`, JSON.stringify(s));
          return Response.json({ type: "error", error: { type: "invalid_request_error", message: `tools.${i}.custom.input_schema: input_schema does not support oneOf, allOf, or anyOf at the top level` } }, { status: 400 }); } }
      const ev = (e: string, d: any) => `event: ${e}\ndata: ${JSON.stringify(d)}\n\n`;
      return new Response(ev("message_start", { type: "message_start", message: { id: "m", type: "message", role: "assistant", model: body.model, content: [], stop_reason: null, usage: { input_tokens: 1, output_tokens: 0 } } })
        + ev("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })
        + ev("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "ok" } })
        + ev("content_block_stop", { type: "content_block_stop", index: 0 })
        + ev("message_delta", { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } })
        + ev("message_stop", { type: "message_stop" }), { headers: { "content-type": "text/event-stream" } }); } });
    TS
    cat > "$W/proj/opencode.json" <<JSON
    { "providers": { "anthropic": { "settings": { "baseURL": "http://127.0.0.1:8787/v1", "apiKey": "sk-ant-fake" } } },
      "mcp": { "servers": { "repro": { "type": "local", "command": ["node", "$W/mcp.mjs"], "codemode": false } } } }
    JSON
    bun "$W/mock.ts" & MOCK=$!; trap 'kill $MOCK; HOME=$W/home opencode service stop >/dev/null 2>&1 || true' EXIT
    cd "$W/proj"; export HOME="$W/home" XDG_CONFIG_HOME="$W/home/.config" XDG_DATA_HOME="$W/home/.local/share"
    opencode --version
    opencode mcp list >/dev/null 2>&1 || true   # start the background service so the MCP server is connected
    sleep 2
    opencode run -m anthropic/claude-sonnet-4-5 "reply only: ok" || true

    Run it: bash repro.sh

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions