MCP tools/call fails with "Unexpected response type" when content annotations carry a non-integer priority (e.g. 0.6)

Open 💬 2 comments Opened Aug 17, 2026 by jmrplens

What version of Codex is running?

codex-cli 0.148.0-alpha.9 (the binary bundled with ChatGPT.app on macOS, /Applications/ChatGPT.app/Contents/Resources/codex, x86_64). The binary embeds rmcp-3.0.0 (visible in its strings).

What platform is your computer?

macOS (Darwin 25.5.0), x86_64.

What issue are you seeing?

Every MCP tools/call whose result content carries a non-integer annotations.priority (for example 0.6) fails with:

tool call error: tool call failed for `<server>/<tool>`
Caused by: Unexpected response type

priority is a spec-valid field: the MCP schema defines Annotations.priority as a number between 0 and 1 describing the importance of the content block, so fractional values like 0.6 are exactly what the spec intends. Servers built on the official Go SDK (github.com/modelcontextprotocol/go-sdk) emit such annotations routinely; in our case (gitlab-mcp-server) every successful tool result was rejected by Codex while the identical server works in Claude Code, MCP Inspector, and other clients.

Bisection results

I replayed a captured 162 KB production CallToolResult through a minimal stdio MCP server and varied one field per run against the real Codex binary (codex exec). Only the annotation priority value matters:

| Variant | Result |
| ------------------------------------------------------------- | --------- |
| Full result, annotations: {"audience":["assistant"],"priority":0.6} | FAILED (Unexpected response type) |
| Same, annotations: {"priority": 0.6} | FAILED |
| Same, annotations: {"priority": 1} (integer) | SUCCEEDED |
| Same, annotations: {"audience": ["assistant"]} (no priority) | SUCCEEDED |
| Same, annotations removed entirely | SUCCEEDED |
| Same, structuredContent removed (priority 0.6 kept) | FAILED |
| Text truncated to 1 KB (priority 0.6 kept) | FAILED |

So neither result size, structuredContent, nor audience is involved — only the fractional priority.

What steps can reproduce the bug?

Minimal stdio server (fake_server.py):

#!/usr/bin/env python3
import json, os, sys

PRIORITY = float(os.environ.get("PRIORITY", "0.6"))

def send(o):
    sys.stdout.write(json.dumps(o) + "\n"); sys.stdout.flush()

for line in sys.stdin:
    req = json.loads(line)
    rid, method = req.get("id"), req.get("method")
    if rid is None:
        continue
    if method == "initialize":
        send({"jsonrpc": "2.0", "id": rid, "result": {
            "protocolVersion": req["params"]["protocolVersion"],
            "capabilities": {"tools": {}},
            "serverInfo": {"name": "repro", "version": "0.0.1"}}})
    elif method == "tools/list":
        send({"jsonrpc": "2.0", "id": rid, "result": {"tools": [{
            "name": "echo", "description": "echo",
            "inputSchema": {"type": "object", "properties": {}},
            "annotations": {"readOnlyHint": True}}]}})
    elif method == "tools/call":
        send({"jsonrpc": "2.0", "id": rid, "result": {"content": [{
            "type": "text", "text": "ok",
            "annotations": {"audience": ["assistant"], "priority": PRIORITY}}]}})
    else:
        send({"jsonrpc": "2.0", "id": rid,
              "error": {"code": -32601, "message": "Method not found"}})

~/.codex/config.toml:

[mcp_servers.repro]
command = "python3"
args = ["/path/to/fake_server.py"]

[mcp_servers.repro.env]
PRIORITY = "0.6"

Run:

codex exec "Call the echo tool and report whether the call succeeded or failed."
  • PRIORITY = "0.6" → the tool call fails with Unexpected response type.
  • PRIORITY = "1" (or removing the field) → the tool call succeeds.

What is the expected behavior?

Fractional priority values (0–1 per the MCP spec) should parse, and the tool result should be delivered as a CallToolResult.

Additional information

The failing payload is valid for the published rmcp crate. I compiled a probe against crates.io rmcp = "=3.0.0" with the same feature set Codex uses (auth,base64,client,macros,schemars,server,transport-*) and fed it the exact captured JSON:

  • serde_json::from_str::<ServerResult>(payload)ServerResult::CallToolResult ✔ (crates.io rmcp types priority as Option<f32>, so 0.6 parses)
  • The full JSON-RPC envelope also parses as JsonRpcMessage::Response with a CallToolResult inside ✔

Yet the shipped binary degrades the same payload to ServerResult::CustomResult, which the legacy call path in codex-rs/rmcp-client/src/rmcp_client.rs then rejects:

match result {
    ServerResult::CallToolResult(result) => Ok(result),
    _ => Err(rmcp::service::ServiceError::UnexpectedResponse),
}

This suggests the rmcp/serde configuration in the released build differs from crates.io rmcp 3.0.0 (patched crate or feature interaction) in a way that rejects non-integer priority values, silently punting the whole result to CustomResult through the untagged ServerResult union.

This is very likely the concrete root cause behind at least some reports of #29002 (valid tool result decodes as CustomResult) — any server that annotates content with fractional priorities hits it deterministically. Related symptom: #33404.

Server-side workaround we ship today: detect clientInfo.name == "codex-mcp-client" and round annotation priorities to 0/1 for those sessions.

View original on GitHub ↗

2 Comments

jdcodes1 · 10 days ago

Reproduced against main @ 1f41cc5d92 and root-caused. Your bisection was exactly right, and the mechanism is more surprising than a wrong type declaration: rmcp's types are correct, but a serde_json cargo feature enabled elsewhere in the codex workspace silently breaks float parsing inside rmcp's untagged result union.

Reproduction. I drove codex-rmcp-client directly (stdio launcher, McpProtocolMode::Legacy — the default for stdio servers) against a fake server modeled on yours: priority=1 → tool call succeeds; priority=0.6Unexpected response type. Instrumenting the client's result match showed the response parsed into a different ServerResult variant whose JSON round-trip is byte-identical to the expected CallToolResult — i.e. rmcp's untagged union fell through to its CustomResult catch-all.

Why CallToolResult fails to parse — but only inside this workspace. In isolation, rmcp 3.0.0 parses your payload fine (Annotations.priority is Option<f32>; I verified from_str and from_value at every layer in a scratch crate). The difference is here:

https://github.com/openai/codex/blob/1f41cc5d92722748e45cae9cecc6d883a4e7cbb1/codex-rs/exec-server-protocol/Cargo.toml#L23

exec-server-protocol enables serde_json's arbitrary_precision feature. Cargo feature unification applies that feature to serde_json for the entire workspace build — including rmcp. With arbitrary_precision on, serde_json's internally-buffered deserialization (used by tagged/untagged enums like MCP content blocks) represents numbers as the private token map {"$serde_json::private::Number": "0.6"}, and typed float fields then fail with invalid type: map, expected f32. Integers still parse through the integer path — which is precisely your integer-vs-fractional split. Demonstration outside codex entirely, rmcp 3.0.0 + serde_json with arbitrary_precision:

from_str priority=0.6: CallToolResult = Err("invalid type: map, expected f32 at line 1 column 99")
from_str priority=0.6: ServerResult = CustomResult      <-- untagged fell through
from_str priority=1:   CallToolResult = Ok
from_str priority=1:   ServerResult = CallToolResult

Chain: fractional priorityCallToolResult variant errors → untagged ServerResult resolves to CustomResult → codex's match in rmcp_client.rs (ServerResult::CallToolResult(r) => Ok(r), _ => Err(UnexpectedResponse)) returns the opaque error you saw.

Notably, exec-server-protocol knows about this footgun — its rpc.rs special-cases $serde_json::private::Number for its own types (rpc.rs#L28-L30). What the special-casing can't fix is every other crate in the workspace whose buffered-enum float parsing silently broke — this MCP annotations case is one instance; any workspace type with a float field behind an untagged/internally-tagged/flattened serde structure is exposed to the same landmine.

Fix options:

  1. Best: drop arbitrary_precision from exec-server-protocol and handle whatever motivated it (u64/i64/f64 all survive without the feature; it's only needed for >64-bit integers or exact-decimal preservation) with an explicit representation. This heals the whole workspace at once.
  2. If the feature is genuinely required for exec-server JSON-RPC passthrough fidelity, isolate it: move that protocol crate out of the unified build (separate workspace / separate serde_json via vendored alias), since features cannot be scoped per-crate within one build graph.
  3. Independent hardening: rmcp could deserialize Annotations tolerant of the private-number map, but that only patches this one field — (1) is the real fix.

Regression test shape: an rmcp-client integration test with a stdio fake server returning annotations.priority: 0.6 (exactly your bisection case) — it fails on main today and pins the workspace against arbitrary_precision reintroduction, which no unit test of any individual crate can catch.

muyiyr · 10 days ago

I investigated this against the current main checkout and the pinned crates.io rmcp = 3.0.0. The minimal fractional-priority payload already decodes as ServerResult::CallToolResult, preserving annotations.priority == Some(0.6). This means a broad CustomResult reclassification fallback in Codex would weaken malformed/extension response handling without reproducing the reported failure.

I prepared a focused regression test that locks in the spec-valid behavior and leaves production decoding unchanged:

The targeted test passes, and the full codex-rmcp-client run reached 225/228 passing; the three unrelated streamable_http_remote failures were caused by the local environment lacking target/debug/codex. Scoped Clippy and Rust formatting checks pass.

The remaining discrepancy appears to be specific to the shipped x86_64 0.148.0-alpha.9 artifact (for example, dependency/build provenance differing from the current crates.io source). Before changing runtime behavior, I recommend comparing that artifact's resolved rmcp source/features with the source build.

If the Codex team would like this compatibility regression coverage submitted as a PR, could a maintainer explicitly invite me on this issue? I will then open the prepared branch as a Draft PR, using Refs #38979 rather than claiming the artifact-specific failure is fixed.