Skip to content

OTel client span stays Unset when the tool result has isError: true #3810

Open

Description

@mkwitko

With mcp==2.3.0, the client span created in shared/jsonrpc_dispatcher.py sets ERROR status and error.type for a JSON-RPC error response (the #3174 fix), but not for a successful response whose CallToolResult.isError is true. The server span for the same call does set error.type=tool_error (server/_otel.py), so the two ends of one call disagree.

The OTel GenAI semantic conventions for MCP (docs/gen-ai/mcp.md, note on error.type) say instrumentation SHOULD set error.type to tool_error when the tool result reports an error.

Reproduction (Python client and server, Streamable HTTP, default OTel setup):

git clone https://github.com/mkwitko/mcp-reliability && cd mcp-reliability
examples/python-sdk-streamable-http/run.sh      # needs docker, uv, go
go run ./cmd/mcp-reliability doctor testdata/python-sdk-default.jsonl

Output includes:

[WARN] MCP-ERR-003: client and server spans for "tools/call fail" disagree on failure: the client span lacks error.type

Captured trace and write-up: https://github.com/mkwitko/mcp-reliability/blob/v0.1.0/docs/research/python-sdk-v2-validation.md

Suggested change: after the response is parsed in the dispatcher, if the result is a CallToolResult with isError, set error.type="tool_error" and status ERROR on the client span, matching server/_otel.py. Happy to send a PR if you agree with the direction.

Activity

  1. added
    bugSomething isn't working
    v2Affects the v2 line (2.x on main)
    on Nov 3, 2026
  2. Aryan130103 commented on Nov 3, 2026

    @Aryan130103

    Hi! I'd be interested in contributing to this issue.

    I went through the reproduction steps and the suggested fix involving setting the client span's error status when a parsed CallToolResult has isError=true.

    I'd like to help investigate the dispatcher behavior and add regression coverage to ensure the client and server spans report consistent error statuses.

    Is anyone already working on the implementation? If not, would it be okay for me to take this up and submit a PR?

    Thanks!

  3. media-ctrl commented on Nov 5, 2026

    @media-ctrl

    Confirmed on main, and the asymmetry is visible in two specific places rather than being an omission in the dispatcher's design.

    Client side, src/mcp/shared/jsonrpc_dispatcher.py only reaches the error attributes inside the isinstance(outcome, ErrorData) branch:

    if isinstance(outcome, ErrorData) and outcome is not _CLOSED_OUTCOME:
        code = str(outcome.code)
        span.set_attributes({"error.type": code, "rpc.response.status_code": code})
        span.set_status(StatusCode.ERROR, outcome.message)

    So a response object whose payload reports a tool failure never enters that branch: the outcome decodes fine, the span ends UNSET, and the call looks successful at the client end of the trace.

    Server side there is an explicit block for it in src/mcp/server/_otel.py, gated on the method:

    if ctx.method == "tools/call":
        match result:
            case CallToolResult(is_error=True) | {"isError": True}:
                span.set_attribute("error.type", "tool_error")
                span.set_status(StatusCode.ERROR)

    Two things in that server block are worth copying rather than paraphrasing, because a client fix that reads naturally will miss them.

    The first is the dual match. is_error is dropped by the alias-only wire validation, so a raw dict result arrives as isError. A client change that only tests the model attribute leaves the raw-dict shape disagreeing exactly as it does today. Mirroring the alternation is the whole point.

    The second is that this runs after the outcome is parsed and before the value is returned, which is also where the client would have to sit. Setting the attribute on the span after return result is not available to you, and setting it in the caller of the dispatcher means the span has already been closed.

    For the test, the interesting assertion is agreement, not the attribute in isolation: same trace, one tool error and one protocol error, assert both spans carry error.type and both are ERROR, and assert a successful call leaves the client span UNSET. That last one is what catches an over-eager fix that marks every tools/call span.

    We run an MCP server in front of a few dozen upstream business APIs, and a client-side blind spot on tool errors is expensive in a way that is easy to underestimate: in client dashboards the failed calls present as slow successes, so the retry and timeout logic keys on the wrong signal and the actual error text never surfaces.

    Direction is right. If you open the PR, the dual match plus the three-case test is the part I would review first.

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

    bugSomething isn't workingv2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions