Repository navigation
MCP tools/call fails with "Unexpected response type" when content annotations carry a non-integer priority (e.g. 0.6) #38979
Description
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.
Activity
fix: Codex client compatibility profile and fail-closed destructive c…
jdcodes1 commentedon Aug 17, 2026
Reproduced against
main@ 1f41cc5 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:codex/codex-rs/exec-server-protocol/Cargo.toml
Line 23 in 1f41cc5
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.muyiyr commentedon Aug 17, 2026
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.fix(mcp): escape headings in prompts, rate limit completion, and cove…
fix(mcp): escape headings in prompts, rate limit completion, and cove…
fix(mcp): conform to the server-surface specifications (#333)
jmrplens commentedon Sep 26, 2026
Some new facts from the current tree, since the fix proposed earlier in this thread would not change the shipped binary.
It still reproduces on
main(5f3180c) and onrust-v0.157.0. Both pinrmcp = "=3.2.0". A local stdio server's line is decoded withserde_json::from_slicestraight intoRxJsonRpcMessage<RoleClient>(codex-rs/rmcp-client/src/bounded_stdio_transport.rs:87), and anything butServerResult::CallToolResultbecomesUnexpectedResponse(codex-rs/rmcp-client/src/rmcp_client.rs:893-896).arbitrary_precisionhas two sources in thecodex-clibuild.cargo tree --locked -p codex-cli -e features,normal -i serde_json --target x86_64-unknown-linux-gnulists exactly two packages enabling it:starlark 0.14.2 declares
serde_json = { version = "1.0", features = ["arbitrary_precision"] }unconditionally, andcodex-clialready depended oncodex-execpolicywith starlark 0.14.2 before #33031 added the feature toexec-server-protocol. Removing it fromexec-server-protocolalone leaves the binary as it is.It reaches more than
priority. With the feature on, rmcp 3.2.0 (and 3.4.1, the latest) decodes the same bytes like this:tools/callresult,priority: 0.6or0.5CustomResulttools/callresult,priority: 1.0CustomResulttools/callresult,priority: 1or0CallToolResultnotifications/progress,progress: 0.5CustomNotificationEvery float in rmcp's model is exposed:
Annotations.priority,ProgressNotificationParam.progressandtotal,CreateMessageRequestParams.temperature, theModelPreferencespriorities, andNumberSchema.minimum,maximumanddefaultin elicitation. The failing payload in #33404 carries"priority": 0.8, so it is probably this bug too, as are the annotation reports in #29002.Decoding through
serde_json::Valuefirst, as #38399 does for rollout lines, only fixes shortest-form spellings. Throughfrom_value,0.6,1.0and1e-7decode, but0.60and6e-1still fall through toCustomResult. It also cannot reach a Streamable HTTP server that answers with SSE, since rmcp decodes those events itself (crates/rmcp/src/transport/streamable_http_client.rs:286and:409atrmcp-v3.2.0).What fixes every spelling and every transport is reading rmcp's float fields through
serde_json::Number, which understands both representations. With the feature on, every case above then decodes asCallToolResultorProgressNotification,0.6decodes to exactly0.6f32, a string priority is still rejected, and with the feature off nothing changes. I have reported it as modelcontextprotocol/rust-sdk#1299 and sent the fix, for all ten float fields, as modelcontextprotocol/rust-sdk#1300. Once it is released, bumping rmcp would fix this in Codex with no other change. A test incodex-rmcp-clientthat sendsannotations.priority: 0.6through the stdio transport would keep it fixed, because only a test built inside this workspace sees the feature (codex-rmcp-clientitself depends oncodex-exec-server).Minimal reproduction outside Codex (rmcp 3.2.0 with
default-features = false, features = ["client"], andserde_jsonwitharbitrary_precision):fix(model): decode float fields through serde_json::Number (#1300)
jmrplens commentedon Sep 27, 2026
Update: the rmcp fix is merged (modelcontextprotocol/rust-sdk#1300,
e02efbfc7). No rmcp release carries it yet; once one does, bumping Codex'srmcp = "=3.2.0"pin to that release should resolve this. I'll follow up here with the version.State where the server stands on rate limiting tool invocations and o…
jmrplens commentedon Sep 28, 2026
rmcp 3.5.0 (released 2026-09-28) carries the fix from modelcontextprotocol/rust-sdk#1300: https://github.com/modelcontextprotocol/rust-sdk/releases/tag/rmcp-v3.5.0
I checked the bump on
main(69f7140). With the pin moved from=3.2.0to=3.5.0,codex-rmcp-clientover the local stdio launcher in legacy mode returns aCallToolResultfor every priority I sent (0.6,0.5,1.0,1,0,0.60,6e-1), using the fake server from the issue description with the value written verbatim. On=3.2.0the same test fails withUnexpected response typefor all of them except1and0.Correcting my earlier comment, the bump is a little more than the pin and the lockfiles, because of two changes in rmcp 3.4.0:
rmcp::model::ClientInfoandServerInfoare deprecated in favor ofClientConfigandServerConfig. That is 24 warnings incodex-rmcp-client(7 of them in the library itself), whichclippy -D warningsrejects.rmcp-client/src/user_verification_cancellation_tests.rsstill expect that response (ordinary_elicitations_release_pending_responses_on_cancellationandcancelling_one_verification_leaves_the_mcp_connection_and_other_requests_alive): they time out on 3.5.0 and pass on 3.2.0. Every other test in the crate gives the same result on both versions.I only built
codex-rmcp-client.ServerInfois also used intui/src/dynamic_tools_mcp.rsand in the app-server tests.cc @etraut-openai
Drop priority from two-block content annotations
jmrplens commentedon Oct 5, 2026
A short note since #49473 moved the pin:
mainnow takes rmcp from git at thermcp-v3.3.0release commit (3e636cab), which is 31 commits behind the fix (e02efbfc7). Somainand therust-v0.162.0alphas so far still build without it, and the latest release,rust-v0.160.0, is still on=3.2.0. rmcp 3.5.0 is the first release that carries modelcontextprotocol/rust-sdk#1300; what the bump involves beyond the pin is in my previous comment.jmrplens commentedon Oct 16, 2026
An update for anyone following this:
rust-v0.163.0, released on 2026-10-15, is the first stable Codex built on an rmcp that carries the fix. Itscodex-rs/Cargo.tomlpinsrmcp = "=3.5.1"from git at4048ac40, which includes modelcontextprotocol/rust-sdk#1300, so a fractionalannotations.priorityshould decode again from 0.163.0 on. The 0.163 alphas fromrust-v0.163.0-alpha.8on use the same pin. Releases up torust-v0.162.1still pin rmcp 3.3.0 and are affected.From my side this issue can be closed once a ChatGPT.app build ships Codex 0.163.0 or later.
docs(upstream-bugs): record the go-sdk and Codex updates of 2026-10-16
docs(upstream-bugs): record the state of 2026-10-16 (#1397)