An MCP tool call can return HTTP 200 and still have failed, and a standard OTel span won't show it
A tool call over Streamable HTTP comes back 200. The span closes green, normal latency, no error recorded. The agent reads the result and moves on. The call still failed, and nothing in the trace says so.
The common shape: a tool hits a downstream timeout and returns a result with isError: true and a text block saying the fetch failed. That's a valid JSON-RPC result, so the transport is 200 and the span is clean. The model treats the error text as data and keeps going. You notice later, when the final answer is wrong.
MCP has two separate error channels, and they land in different places:
| Failure | How it surfaces | Span shows | What to assert |
|---|---|---|---|
| Tool ran, logic failed | isError: true in the result | 200, OK | result.isError is false |
| Unknown or disabled tool | JSON-RPC error, code -32602 | 200, OK | no error on the response |
| Bad arguments | JSON-RPC error, code -32602 | 200, OK | no error on the response |
| Output breaks its schema | client-side result-validation error | varies | output matches outputSchema |
Two things hide it. Execution errors sit in isError inside a successful result, protocol errors sit in a JSON-RPC error object, so one check never covers both. And over Streamable HTTP both ride an HTTP 200, where OTel's HTTP conventions leave span status unset on any 2xx. Complete span, wrong result.
What actually catches it: assert on isError and on a JSON-RPC error code, not the HTTP status. Validate tool output against its schema, not just the input. Capture tool input and output as eval cases, so a wrong-but-200 result becomes a failing test instead of a green line.
We build an MCP gateway, so we've watched this one closely. Key off the tool result, not the transport.
How are you separating an unknown tool from bad arguments when both come back as -32602? String-matching the message feels brittle, so curious what's worked for people.
Posted Aug 3, 2026, 2:01 PM UTC