MCP tools/call fails with "Unexpected response type" when content annotations carry a non-integer priority (e.g. 0.6)
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 withUnexpected 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 typespriorityasOption<f32>, so0.6parses)- The full JSON-RPC envelope also parses as
JsonRpcMessage::Responsewith aCallToolResultinside ✔
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.
2 Comments
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-clientdirectly (stdio launcher,McpProtocolMode::Legacy— the default for stdio servers) against a fake server modeled on yours:priority=1→ tool call succeeds;priority=0.6→Unexpected response type. Instrumenting the client's result match showed the response parsed into a differentServerResultvariant whose JSON round-trip is byte-identical to the expectedCallToolResult— i.e. rmcp's untagged union fell through to itsCustomResultcatch-all.Why
CallToolResultfails to parse — but only inside this workspace. In isolation, rmcp 3.0.0 parses your payload fine (Annotations.priorityisOption<f32>; I verifiedfrom_strandfrom_valueat 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-protocolenables serde_json'sarbitrary_precisionfeature. Cargo feature unification applies that feature to serde_json for the entire workspace build — includingrmcp. Witharbitrary_precisionon, 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 withinvalid 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 witharbitrary_precision:Chain: fractional
priority→CallToolResultvariant errors → untaggedServerResultresolves toCustomResult→ codex'smatchinrmcp_client.rs(ServerResult::CallToolResult(r) => Ok(r), _ => Err(UnexpectedResponse)) returns the opaque error you saw.Notably,
exec-server-protocolknows about this footgun — itsrpc.rsspecial-cases$serde_json::private::Numberfor 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:
arbitrary_precisionfromexec-server-protocoland 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.Annotationstolerant 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 onmaintoday and pins the workspace againstarbitrary_precisionreintroduction, which no unit test of any individual crate can catch.I investigated this against the current
maincheckout and the pinned crates.iormcp = 3.0.0. The minimal fractional-priority payload already decodes asServerResult::CallToolResult, preservingannotations.priority == Some(0.6). This means a broadCustomResultreclassification 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-clientrun reached 225/228 passing; the three unrelatedstreamable_http_remotefailures were caused by the local environment lackingtarget/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.9artifact (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 #38979rather than claiming the artifact-specific failure is fixed.