MCP transport silently swallows non-2xx HTTP errors as JSON-RPC deserialization failures
What version of Codex CLI is running?
0.125.0
What subscription do you have?
Enterprise
Which model were you using?
gpt-5.5
What platform is your computer?
Darwin 25.4.0 arm64 arm
What terminal emulator and version are you using (if applicable)?
_No response_
What issue are you seeing?
When an MCP server returns a non-2xx HTTP response with Content-Type: application/json (e.g., a 403 Forbidden for insufficient OAuth scope), Codex reports a confusing deserialization error instead of the actual HTTP error:
Transport[rmcp::transport::worker::WorkerTransport<
rmcp::transport::streamable_http_client::StreamableHttpClientWorker<
codex_rmcp_client::http_client_adapter::StreamableHttpClientAdapter
>
>] error: Deserialize error: data did not match any variant of untagged enum JsonRpcMessage
The actual server response was:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{"error":"insufficient_scope","error_description":"Insufficient scope"}
What steps can reproduce the bug?
- Configure an MCP server that returns
403with a JSON body for unauthorized tool calls (e.g., an OAuth-protected server where the token lacks the required scope). - Invoke any tool via Codex that triggers the 403.
- Observe that the error message mentions a JSON-RPC deserialization failure rather than the HTTP 403.
What is the expected behavior?
Codex should detect the non-2xx status code before attempting to deserialize the body as a JsonRpcMessage, and surface a clear error like:
MCP tool call failed: HTTP 403 Forbidden - {"error":"insufficient_scope","error_description":"Insufficient scope"}
Additional information
The root cause is in codex-rs/rmcp-client/src/http_client_adapter.rs in the post_message method. After handling 401, 404, 202, and 204 explicitly, the code branches directly on Content-Type without a general non-2xx guard:
https://github.com/openai/codex/blob/8426edf71e4a5b754467749ce16090515e2c13c9/codex-rs/rmcp-client/src/http_client_adapter.rs#L158-L163
(A 403 with Content-Type: application/json falls into this branch.)
The fix would be to add a non-2xx guard before the content-type branch - the same pattern already used in get_stream in the same file:
if !status.is_success() {
let body = collect_body(&mut body_stream).await?;
return Err(StreamableHttpError::UnexpectedServerResponse(
format!("HTTP {}: {}", status, String::from_utf8_lossy(&body)),
));
}