Skip to content

[v2] JSON-RPC error responses leave client OpenTelemetry spans UNSET #3174

Description

@HarperZ9

Initial Checks

Description

At current main (11934c90aeff5e1e68aee223edd00c5d1fce1d5c), JSON-RPC error responses handled by JSONRPCDispatcher leave the corresponding client OpenTelemetry span looking like a normal exit:

  • span status: UNSET
  • error.type: absent
  • rpc.response.status_code: absent
  • exception events: none

The request still fails correctly with MCPError; this report is limited to client-side telemetry correctness.

The ordering appears to explain the result: send_raw_request() receives outcome inside the client span, exits the span, and only afterward converts ErrorData into MCPError at lines 432–433. The span context manager therefore observes no exception.

This differs from the current server OTel middleware, which records the error attributes and status, and from the design assumption documented in #2381 that a propagated MCPError would be recorded by the client span.

I ran the same in-memory success/error pair five times. Every run produced:

METHOD=resources/list CONTROL=UNSET RED_CODE=-32602 RED_MESSAGE='forced failure' RED_STATUS=UNSET ERROR_TYPE=None RPC_STATUS=None EVENTS=0

Expected: the failed client span should be distinguishable from the successful control and carry the JSON-RPC error code/status consistently with the SDK's server-side instrumentation.

Scope and claim boundary: this concerns JSON-RPC/stream-backed dispatcher calls, not direct in-process dispatch generally. The reproducer is entirely in-memory. I have not tested or claimed anything about a deployed transport, collector, or production frequency.

Closest adjacent work found: #2132 targets the removed BaseSession architecture and predates the current dispatcher; #2854 adds GenAI attributes but does not handle JSON-RPC errors. I did not find an issue or current PR covering this ordering on the present dispatcher.

Disclosure: I used AI assistance to help reproduce and analyze this issue. I reviewed the source, reran the evidence independently, and take responsibility for the report.

Example Code

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace import SpanKind

exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)

import anyio
from mcp.client.client import Client
from mcp.server.lowlevel.server import Server
from mcp.shared.exceptions import MCPError
from mcp_types import INVALID_PARAMS, ListResourcesResult

state = {"fail": False}

async def list_resources(ctx, params):
    if state["fail"]:
        raise MCPError(INVALID_PARAMS, "forced failure")
    return ListResourcesResult(resources=[])

async def main():
    server = Server(name="otel-audit", version="0.0.0", on_list_resources=list_resources)
    async with Client(server, mode="legacy", cache=None) as client:
        state["fail"] = False
        exporter.clear()
        await client.session.list_resources()
        [control] = [s for s in exporter.get_finished_spans() if s.kind == SpanKind.CLIENT]

        state["fail"] = True
        exporter.clear()
        try:
            await client.session.list_resources()
        except MCPError as exc:
            assert exc.error.code == INVALID_PARAMS
        else:
            raise AssertionError("expected MCPError")

        [failed] = [s for s in exporter.get_finished_spans() if s.kind == SpanKind.CLIENT]
        print(
            "control=", control.status.status_code.name,
            "failed=", failed.status.status_code.name,
            "error.type=", failed.attributes.get("error.type"),
            "rpc.response.status_code=", failed.attributes.get("rpc.response.status_code"),
            "events=", len(failed.events),
        )
        assert control.name == failed.name == "MCP send resources/list"
        assert failed.status.status_code.name == "UNSET"
        assert failed.attributes.get("error.type") is None
        assert failed.attributes.get("rpc.response.status_code") is None
        assert len(failed.events) == 0

anyio.run(main)

Python & MCP Python SDK

Python: 3.12.10
mcp: 2.0.0b2.dev33+11934c90
opentelemetry-sdk: 1.39.1
OS: Windows 11

Clean checkout command:
uv run --python 3.12 --frozen --no-default-groups --with opentelemetry-sdk==1.39.1 python repro.py

Activity

  1. Ranga-Prasath-22 commented on Jul 26, 2026

    @Ranga-Prasath-22

    Hi, I'd like to work on this fix.

    Root cause: In send_raw_request(), the otel_span context manager exits before ErrorData is converted to MCPError, so the span never observes an error — it stays UNSET with no error attributes.

    Proposed fix: Inside the span, after receiving outcome, check for ErrorData and set error attributes (error.type,
    pc.response.status_code, StatusCode.ERROR) on the span before exiting. This mirrors the server-side pattern in _otel.py:35-60.

    The change is ~5 lines in send_raw_request() plus a test using InMemorySpanExporter (adapting the reproducer from the issue).

    Happy to take this on if no one else is assigned. Thanks!

  2. chavalasantosh commented on Jul 29, 2026

    @chavalasantosh

    Hi maintainers, I’d like to work on this if the proposed direction is welcome.

    I don’t currently see an implementation linked to this issue. My plan is to first add an in-memory regression test covering successful and JSON-RPC error responses, then ensure the ErrorData to MCPError transition occurs within the active client span—or explicitly record the error status and semantic-convention attributes if that better matches the project’s telemetry design.

    I’ll keep the change scoped to the v2 JSONRPCDispatcher, run the targeted tests, Ruff, and Pyright, and personally review and understand the complete implementation. I will also disclose AI assistance in the pull request.

    Please assign it or mark it ready if this direction is acceptable.

  3. Ecocitizenz commented on Jul 29, 2026

    @Ecocitizenz
  4. chavalasantosh commented on Jul 29, 2026

    @chavalasantosh

    Thanks @Ecocitizenz for the additional operational context. I’ll wait for maintainer guidance on the implementation direction before proceeding.

  5. HarperZ9 commented on Aug 3, 2026

    @HarperZ9
    Author

    Author here. Assignment is the maintainers' call, but two things I can confirm to unblock whoever picks it up.

    @Ranga-Prasath-22 your root-cause reading matches mine: outcome is received inside the span, the span exits, and only afterward is ErrorData converted to MCPError (lines 432-433 at the pinned commit), so the context manager never observes an error. Recording status and error.type / rpc.response.status_code inside the span, mirroring server/_otel.py, is what I expected the fix to look like when I filed this.

    The in-memory reproducer in the issue body is free to adapt directly as the regression test. The acceptance criterion from my side is exactly: the failed CLIENT span is distinguishable from the success control (status ERROR, error.type present, JSON-RPC code carried).

    One boundary worth preserving: this is client telemetry only. The request already fails correctly with MCPError, so the fix should not alter the exception path, just what the span records before exit.

  6. Ecocitizenz commented on Aug 4, 2026

    @Ecocitizenz
  7. Ecocitizenz commented on Aug 4, 2026

    @Ecocitizenz
  8. added
    bugSomething isn't working
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    needs confirmationNeeds confirmation that the PR is actually required or needed.
    v2Affects the v2 line (2.x on main)
    on Aug 14, 2026
  9. mikemikimike commented on Aug 27, 2026

    @mikemikimike

    Implemented in PR #3401.

    • Changed: client JSON-RPC spans now record error status, error type, and the JSON-RPC response code before the span closes, while preserving the existing MCPError exception path.
    • Tests: uv run --frozen pytest tests/server/test_otel.py -q (17 passed), Ruff, formatting, Pyright, and git diff --check passed.
    • Full suite: 5574 passed, 16 skipped, 1 xfailed; one unrelated local HTTP 502 failure occurred in tests/client/test_transport_stream_cleanup.py::test_sse_client_closes_all_streams_on_connection_error.

    The PR was automatically closed by the repository's linked-issue policy because the contributor is not assigned to this issue.

  10. mikemikimike commented on Aug 27, 2026

    @mikemikimike

    I independently reproduced this on the current main branch using the in-memory client/server example. JSON-RPC error responses currently leave the client span UNSET and omit �rror.type and
    pc.response.status_code, while the request still raises MCPError.

    I opened PR #3401 with a focused fix that records the error while the client span is still active, together with regression coverage. The PR was auto-closed because I am not assigned to this issue. If this approach is useful, please feel free to assign the issue to me or reopen the PR.

  11. maxisbey commented on Oct 2, 2026

    @maxisbey
    Contributor

    Fixed in #3629, thanks for the careful report and the acceptance criterion.

    A client span whose request is answered with a JSON-RPC error now ends with status ERROR and carries the code in error.type and rpc.response.status_code. The MCPError you catch is unchanged.

    One thing to expect: with the default mode="auto", connecting to a server that doesn't know server/discover now produces one ERROR span for that probe. You can filter it out on mcp.method.name.

    If something still looks wrong in your traces, a new issue is welcome.

    AI Disclaimer

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

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething isn't workingneeds confirmationNeeds confirmation that the PR is actually required or needed.v2Affects 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