Skip to content

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

Open

Description

@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.

Activity

  1. added
    bugSomething isn't working
    CLIIssues related to the Codex CLI
    mcpIssues related to the use of model context protocol (MCP) servers
    tool-callsIssues related to tool calling
    on Aug 17, 2026
  2. jdcodes1 commented on Aug 17, 2026

    @jdcodes1

    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-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.6 → Unexpected 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:

    serde_json = { workspace = true, features = ["arbitrary_precision", "raw_value"] }

    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 priority → CallToolResult 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.

  3. muyiyr commented on Aug 17, 2026

    @muyiyr

    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.

  4. jmrplens commented on Sep 26, 2026

    @jmrplens
    Author

    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 on rust-v0.157.0. Both pin rmcp = "=3.2.0". A local stdio server's line is decoded with serde_json::from_slice straight into RxJsonRpcMessage<RoleClient> (codex-rs/rmcp-client/src/bounded_stdio_transport.rs:87), and anything but ServerResult::CallToolResult becomes UnexpectedResponse (codex-rs/rmcp-client/src/rmcp_client.rs:893-896).

    arbitrary_precision has two sources in the codex-cli build. cargo tree --locked -p codex-cli -e features,normal -i serde_json --target x86_64-unknown-linux-gnu lists exactly two packages enabling it:

    ├── serde_json feature "arbitrary_precision"
    │   ├── codex-exec-server-protocol v0.0.0 (codex-rs/exec-server-protocol)
    │   └── starlark v0.14.2
    │       └── codex-execpolicy v0.0.0 (codex-rs/execpolicy)
    │           └── codex-execpolicy feature "default"
    │               ├── codex-cli v0.0.0 (codex-rs/cli)
    

    starlark 0.14.2 declares serde_json = { version = "1.0", features = ["arbitrary_precision"] } unconditionally, and codex-cli already depended on codex-execpolicy with starlark 0.14.2 before #33031 added the feature to exec-server-protocol. Removing it from exec-server-protocol alone 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:

    Input Decoded as
    tools/call result, priority: 0.6 or 0.5 CustomResult
    tools/call result, priority: 1.0 CustomResult
    tools/call result, priority: 1 or 0 CallToolResult
    notifications/progress, progress: 0.5 CustomNotification

    Every float in rmcp's model is exposed: Annotations.priority, ProgressNotificationParam.progress and total, CreateMessageRequestParams.temperature, the ModelPreferences priorities, and NumberSchema.minimum, maximum and default in 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::Value first, as #38399 does for rollout lines, only fixes shortest-form spellings. Through from_value, 0.6, 1.0 and 1e-7 decode, but 0.60 and 6e-1 still fall through to CustomResult. 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:286 and :409 at rmcp-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 as CallToolResult or ProgressNotification, 0.6 decodes to exactly 0.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 in codex-rmcp-client that sends annotations.priority: 0.6 through the stdio transport would keep it fixed, because only a test built inside this workspace sees the feature (codex-rmcp-client itself depends on codex-exec-server).

    Minimal reproduction outside Codex (rmcp 3.2.0 with default-features = false, features = ["client"], and serde_json with arbitrary_precision):

    let line = r#"{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"ok","annotations":{"priority":0.6}}]}}"#;
    let msg: rmcp::service::RxJsonRpcMessage<rmcp::service::RoleClient> =
        serde_json::from_str(line).unwrap();
    // With the feature: a Response whose result is ServerResult::CustomResult.
    // Without it, or with "priority":1: ServerResult::CallToolResult.
  5. jmrplens commented on Sep 27, 2026

    @jmrplens
    Author

    Update: the rmcp fix is merged (modelcontextprotocol/rust-sdk#1300, e02efbfc7). No rmcp release carries it yet; once one does, bumping Codex's rmcp = "=3.2.0" pin to that release should resolve this. I'll follow up here with the version.

  6. jmrplens commented on Sep 28, 2026

    @jmrplens
    Author

    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.0 to =3.5.0, codex-rmcp-client over the local stdio launcher in legacy mode returns a CallToolResult for 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.0 the same test fails with Unexpected response type for all of them except 1 and 0.

    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::ClientInfo and ServerInfo are deprecated in favor of ClientConfig and ServerConfig. That is 24 warnings in codex-rmcp-client (7 of them in the library itself), which clippy -D warnings rejects.
    • When the peer cancels a request, rmcp now cancels its handler and drops the response (fix(rmcp): route peer cancellation by lifecycle modelcontextprotocol/rust-sdk#1262), as the MCP cancellation rule asks. Two tests in rmcp-client/src/user_verification_cancellation_tests.rs still expect that response (ordinary_elicitations_release_pending_responses_on_cancellation and cancelling_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. ServerInfo is also used in tui/src/dynamic_tools_mcp.rs and in the app-server tests.

    cc @etraut-openai

  7. jmrplens commented on Oct 5, 2026

    @jmrplens
    Author

    A short note since #49473 moved the pin: main now takes rmcp from git at the rmcp-v3.3.0 release commit (3e636cab), which is 31 commits behind the fix (e02efbfc7). So main and the rust-v0.162.0 alphas 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.

  8. jmrplens commented on Oct 16, 2026

    @jmrplens
    Author

    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. Its codex-rs/Cargo.toml pins rmcp = "=3.5.1" from git at 4048ac40, which includes modelcontextprotocol/rust-sdk#1300, so a fractional annotations.priority should decode again from 0.163.0 on. The 0.163 alphas from rust-v0.163.0-alpha.8 on use the same pin. Releases up to rust-v0.162.1 still 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    CLIIssues related to the Codex CLIbugSomething isn't workingmcpIssues related to the use of model context protocol (MCP) serverstool-callsIssues related to tool calling

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions