MCP tools/call fails with Unexpected response type when valid tool result decodes as CustomResult

Open 💬 6 comments Opened Jun 18, 2026 by ytsssun

What version of Codex CLI is running?

codex-cli 0.141.0

What subscription do you have?

ChatGPT Pro. This local repro is running Codex CLI with an Amazon Bedrock model provider.

Which model were you using?

gpt-5.5

What platform is your computer?

Linux 6.12.90-120.164.amzn2023.x86_64 x86_64 x86_64

What terminal emulator and version are you using (if applicable)?

zsh under zellij 0.44.2 over SSH. The noninteractive repro environment reports TERM=dumb.

Codex doctor report

Redacted summary from codex doctor --json:

{
  "overallStatus": "fail",
  "codexVersion": "0.141.0",
  "checks": {
    "config.load": {
      "status": "ok",
      "summary": "config loaded",
      "details": {
        "mcp servers": "3",
        "model": "openai.gpt-5.5",
        "model provider": "amazon-bedrock"
      }
    },
    "installation": {
      "status": "ok",
      "summary": "installation looks consistent"
    },
    "mcp.config": {
      "status": "ok",
      "summary": "MCP configuration is locally consistent",
      "details": {
        "configured servers": "3",
        "disabled servers": "0",
        "stdio servers": "3"
      }
    },
    "runtime.provenance": {
      "status": "ok",
      "summary": "running standalone on linux-x86_64",
      "details": {
        "platform": "linux-x86_64",
        "version": "0.141.0"
      }
    },
    "terminal.env": {
      "status": "fail",
      "summary": "TERM=dumb - colors and cursor control are disabled"
    },
    "updates.status": {
      "status": "warning",
      "summary": "update configuration is locally consistent"
    }
  }
}

What issue are you seeing?

Codex can initialize a stdio MCP server and discover tools, but every tools/call fails with:

Unexpected response type

This was observed with an internal stdio MCP server. The same MCP server and same tools work through another MCP client, which suggests the server response is valid MCP and this is a Codex client-side result handling issue. If this were a tool-level auth, permissions, or application error, the server would return a normal MCP tool result containing that error payload. Instead Codex fails below tool logic while decoding/dispatching the JSON-RPC result.

Tracing showed that rmcp successfully parsed the JSON-RPC response, but represented the tools/call result as ServerResult::CustomResult(...) rather than ServerResult::CallToolResult(...):

PeerMessage(Response(... result: CustomResult(... redacted valid tools/call payload ...)))
...
Unexpected response type

The redacted custom result payload has the expected MCP CallToolResult shape, including a content array of text content blocks. Codex currently accepts only ServerResult::CallToolResult in rmcp-client, so a valid tool-call payload can be rejected solely because rmcp decoded it through the extensibility/custom-result variant.

What steps can reproduce the bug?

  1. Configure Codex CLI 0.141.0 with a stdio MCP server that returns a normal tools/call JSON-RPC result shape but is decoded by rmcp as ServerResult::CustomResult.
  2. Call any tool exposed by that server.
  3. Observe that initialization and discovery succeed, but the tool call fails with Unexpected response type.

Observed repro details:

  • Transport: stdio MCP
  • Server details: redacted internal MCP server
  • initialize, tools/list, and other discovery paths succeed
  • tools/call fails identically across tools
  • Raw trace shows the response decoded as ServerResult::CustomResult(... redacted valid tools/call payload ...)

What is the expected behavior?

For a tools/call request, Codex should accept a ServerResult::CustomResult if and only if its payload cleanly deserializes as an MCP CallToolResult.

It should still reject unrelated custom result shapes for tools/call.

Additional information

I have a tested local fix that keeps this narrow:

  • Preserve the existing ServerResult::CallToolResult path.
  • Add a coercion path for ServerResult::CustomResult that attempts to deserialize the custom payload into CallToolResult.
  • Return UnexpectedResponse for non-tool custom shapes or any other response variant.
  • Add unit coverage for:
  • Custom result with a valid CallToolResult shape
  • Normal CallToolResult
  • Invalid custom result that should still be rejected

Patch branch is available here:

https://github.com/ytsssun/codex/tree/fix/mcp-call-tool-custom-result

Local commit:

4a010370335e0ce30100164c6dff616bd74639c2 Handle custom MCP tool call results

Verification run against the patch:

cargo test -p codex-rmcp-client coerce_call_tool_result -- --nocapture
# 3 passed

cargo test -p codex-rmcp-client --no-fail-fast
# all passed

I also validated the patched Codex CLI against the same raw internal MCP binary with no compatibility wrapper. The previously failing tool call returned a normal redacted JSON tool result successfully.

This is related to MCP parse/interop issues, but it appears distinct from #25451: this case is stdio tools/call, and the JSON-RPC frame is already decoded into an rmcp::model::ServerResult; Codex then rejects the CustomResult variant as an unexpected response type.

View original on GitHub ↗

6 Comments

coEraldoEnergy · 2 months ago

I'm also running into possibly related challenges:

The Angular MCP server works: a direct SDK call to get_best_practices returns the best-practices text successfully.
Codex’s MCP adapter rejects the response with Unexpected response type.
The likely incompatibility is MCP content[].annotations: the rejected Angular tools include annotations, while list_projects does not and works.

[ChatGPT Business]

midthun · 1 month ago

I have had this issue for some time now too, the get_best_practices do not work inside codex, but works if using for example @modelcontextprotocol/inspector. It is kinda annoying, since every time I use codex inside my angular repos, it tries to use this and reports a failure.

DTM-7-RX · 1 month ago

I can confirm a very similar repro on a newer Codex CLI version with a different internal stdio MCP server.

Environment:

  • Codex CLI: 0.144.1.266 (stable)
  • MCP transport: stdio
  • MCP server type: internal Slack MCP server
  • Server details and version: redacted

Observed through Codex CLI:

  • Multiple message-related tool calls fail with Unexpected response type
  • A lightweight health-check tool also fails with the same error

The health-check failure is useful because it suggests this is not caused by a large Slack message payload.

Cross-check outside Codex:

I called the same stdio MCP server directly over JSON-RPC with:

  1. initialize
  2. notifications/initialized
  3. tools/call

Direct tools/call succeeds for all tested tools, including the health check.

Sanitized direct response shape summary:

{
  "jsonrpc": "2.0",
  "hasResult": true,
  "hasError": false,
  "resultKeys": ["content", "structuredContent"],
  "contentIsArray": true,
  "firstContentType": "text",
  "firstContentHasText": true,
  "firstContentHasAnnotations": true,
  "structuredContentType": "object",
  "isError": false
}

For one of the message-related tools, the direct response had:

{
  "resultKeys": ["content"],
  "contentIsArray": true,
  "firstContentType": "text",
  "firstContentHasText": true,
  "firstContentHasAnnotations": true,
  "structuredContentType": null,
  "isError": false
}

This looks consistent with the MCP tool result shape: content contains a text content item, structuredContent may be present, and content annotations appear to be allowed by the MCP spec.

One additional observation: tool error paths from the same server can return through Codex as structured JSON in some cases, so the server is reachable and this seems specific to successful tools/call results.

This aligns with the hypothesis in this issue: Codex/rmcp appears to reject a valid-looking tools/call result because it is decoded through a response variant that Codex does not currently accept. It does not look like a model-provider or inference issue, since the failure happens before the model can consume the tool output.

nisanthchunduru · 1 month ago

Leaving this comment in case it helps anyone

As this issue is a month old and yet to be resolved, I asked Codex to author a proxy Slack MCP server that transforms the upstream MCP server's response to a format acceptable to codex https://gist.github.com/nisanthchunduru/e9a462230c682d4bfee7e9b5c61b1b1e

tanaybaswa · 6 days ago

Independent reproduction, with what I think is a decisive data point: the same response bytes succeed on one path and fail on the other.

Setup

A third-party MCP server (Streamable HTTP, protocol 2025-06-18, JSON response mode) exposing Outlook/OneDrive tools. Reached two ways:

  • Directly by MCP clients (clientInfo.name = claude-code, Anthropic/ClaudeAI) — works.
  • Through the ChatGPT connector, where calls arrive as openai-mcp / openai-mcp (Codex) and are dispatched as mcp__codex_apps__<connector>_<tool> — fails with Unexpected response type on every tool call.

The decisive comparison

Same mailbox, same tool, same arguments, same server revision, ~12 minutes apart. Byte counts are from the server's own outbound logging:

| Client | Path | Response bytes | Keys on the result | Outcome |
|---|---|---|---|---|
| claude-code | direct | 13179 | content, isError, _meta | success, full result rendered |
| openai-mcp (Codex) | via codex_apps | 13179 | content, isError, _meta | Unexpected response type |

Identical byte count, identical key set, identical content-block count and types. Both logged as a well-formed CallToolResult against the correct request id, with no JSON-RPC error.

Ruled out server-side

Each of these was tested against a live failing call, not reasoned about:

  • Result shape / identification. rmcp's ServerResult is an untagged union ending in CustomResult(Value), so an unrecognised result silently deserialises as the wrong variant. We shipped all four recognised CallToolResult fields (content, structuredContent, isError, _meta) — still failed.
  • Payload size. Failures at 20215, 13179 and 7501 bytes. 7.5 KB fails, while 13179 succeeds on the direct path.
  • Unpaired UTF-16 surrogates. A real hazard here, since JSON.stringify emits a lone surrogate as a \udXXX escape that serde_json rejects outright. We instrumented a counter: 0 on every failing call.
  • Our wire format. We took the actual bytes our server produces (68 KB, built through the real handler) and fed them to a real rmcp 3.1.3 client running the same call_tool path: ServerResult variant deserialized: CallToolResult, parsed fine.
  • Response framing. Both SSE and plain application/json fail identically.

Why this points at the aggregator

codex_apps is the only element present on the failing path and absent on the working one. Since it re-serialises connector results before the client parses them, a result that leaves our socket as a valid CallToolResult can reach rmcp as something else — and rmcp's untagged union turns that into the opaque Unexpected response type rather than a parse error naming the problem.

What would help

  1. Does codex_apps log what it received vs. what it forwarded? That single diff would settle this.
  2. A more specific error. Because the union terminates in a catch-all, Unexpected response type is indistinguishable across "field dropped", "shadowing key added", and "content block rewritten". Logging the variant actually deserialised, or the first ~200 bytes of the unparsed result, would make this self-service to debug.
  3. Happy to supply full request/response logs for a specific failing call id to anyone at OpenAI who wants them.

Worth noting for anyone else landing here: no server-side change fixes this. We shipped three, on three plausible mechanisms, and all three were disproven by retest. If your server is emitting a valid CallToolResult — verify that by feeding your real bytes to rmcp directly — the problem is not on your side.

tanaybaswa · 6 days ago

Fixed on our side. Root cause is annotations on tool-result content blocks.

Posting the mechanism and the fix since this issue is still open and the workaround above deserves an explanation of why it works.

TL;DR

If your MCP server sets annotations on content blocks, every tool call through the ChatGPT connector fails with Unexpected response type. Strip them and it works immediately.

// on every result.content[] block, before returning
delete block.annotations;

That's the whole fix. @nisanthchunduru found this empirically a month ago with a proxy — it's the correct fix, and here is the reason.

Why it breaks

annotations.audience deserializes into a closed role enum in rmcp. A value outside that enum doesn't degrade gracefully: the entire CallToolResult fails to deserialize and falls through to ServerResult's catch-all CustomResult(Value) variant, which codex reports as Unexpected response type.

Verified against rmcp 3.1.3:

| Result on the wire | rmcp variant | Outcome |
|---|---|---|
| annotations: {audience:["user","assistant"]} | CallToolResult | OK |
| annotations: {audience:["assistant"],priority:1.0} | CallToolResult | OK |
| annotations: {audience:["system"]} (unknown role) | CustomResult | Unexpected response type |
| no annotations | CallToolResult | OK |

The catch: valid annotations parse fine as you emit them. We confirmed that our exact annotations deserialize correctly in rmcp — the corruption happens in transit. ChatGPT doesn't connect to a connector directly; it routes tool calls through an OpenAI-hosted aggregator MCP server (codex_apps), which re-serializes the result before their client parses it. Something in that hop reshapes annotations into a form rmcp rejects.

A server that sets no annotations has nothing to mangle, which is why this affects so few people — and why it looks like a mystery when it hits you.

How to tell if this is you

  • Does your server set annotations on content blocks? Grep for it. If yes, this is almost certainly your problem.
  • Does every tool call fail, not just one? Consistent with annotations, since they're usually set uniformly.
  • Does the same server work fine from Claude, MCP Inspector, or any direct MCP client? Then your payload is fine and the aggregator is the variable.

Evidence

Same tool, same server, same data. The only difference is the strip:

| Response bytes | annotations stripped | Outcome |
|---|---|---|
| 7501 | no | Unexpected response type |
| 13179 | no | Unexpected response type |
| 20215 | no | Unexpected response type |
| 7399 | yes (2 blocks) | success |
| 12607 | yes (2 blocks) | success |

Note 7,501 bytes failing against 7,399 succeeding — near-identical sizes, opposite outcomes.

We also confirmed, before finding this, that a byte-identical response succeeds when delivered directly and fails through codex_apps: 13,179 bytes both times, same server, same revision, minutes apart. The payload leaving the server is a valid CallToolResult — we fed our real production bytes (68 KB) to a real rmcp 3.1.3 client running the same call_tool path and it parsed cleanly.

Don't waste time on these

We shipped three fixes before finding it. All wrong, all disproven by instrumenting a live failing call:

  • "The result isn't recognizable as a CallToolResult." We shipped all four recognized fields (content, structuredContent, isError, _meta). Still failed. Worth knowing: redundant fields only help if content is absent — if content is present but unparseable, the deserializer fails as a whole regardless.
  • Unpaired UTF-16 surrogates. A genuine hazard (JSON.stringify emits \udXXX, which serde_json rejects) but not this bug — we instrumented a counter and it read 0 on every failing call.
  • Payload size. A 7,501-byte response fails.

If you're debugging this: feed your actual response bytes to a real rmcp client before changing anything. If they parse, the problem isn't your payload and no server-side reshaping will help — except removing annotations.

Ask for maintainers

  1. Can codex_apps log what it received vs. what it forwarded? That diff would confirm the reshaping directly.
  2. Unexpected response type is indistinguishable across "field dropped", "shadowing key added", and "annotation rewritten", because the union terminates in a catch-all. Logging the variant actually deserialized — or the first ~200 bytes of the unparsed result — would make this self-service instead of a multi-day hunt.
  3. If the aggregator is normalizing annotations, that's a spec-conformance bug worth fixing at the source: servers are allowed to set them, and there's no way for a server author to discover this failure mode from the error text.