From bf332a84f24b4264c6d433f49ae0e8a754888cea Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Thu, 27 Aug 2026 12:09:06 +0800 Subject: [PATCH] fix(otel): record client JSON-RPC errors on spans --- src/mcp/shared/jsonrpc_dispatcher.py | 8 ++++++-- tests/server/test_otel.py | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 87bdf31ceb..ba5a829b06 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -32,7 +32,7 @@ ProgressToken, RequestId, ) -from opentelemetry.trace import SpanKind +from opentelemetry.trace import SpanKind, StatusCode from pydantic import ValidationError from typing_extensions import TypeVar @@ -385,7 +385,7 @@ async def send_raw_request( span_name, kind=SpanKind.CLIENT, attributes={"mcp.method.name": method, "jsonrpc.request.id": str(request_id)}, - ): + ) as span: # SEP-414: inject W3C trace context; `_meta` stays on the wire even with a no-op tracer. inject_trace_context(out_meta) msg = JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=out_params) @@ -401,6 +401,10 @@ async def send_raw_request( with anyio.fail_after(opts.get("timeout")): timeout_armed = True outcome = await receive.receive() + if isinstance(outcome, ErrorData): + span.set_status(StatusCode.ERROR) + span.set_attribute("error.type", "mcp_error") + span.set_attribute("rpc.response.status_code", outcome.code) except TimeoutError: if not timeout_armed: # `fail_after` arms only after the write, so this TimeoutError is the diff --git a/tests/server/test_otel.py b/tests/server/test_otel.py index c3a06e1a50..e68a05874b 100644 --- a/tests/server/test_otel.py +++ b/tests/server/test_otel.py @@ -14,8 +14,10 @@ from mcp_types import ( INTERNAL_ERROR, INVALID_PARAMS, + METHOD_NOT_FOUND, CallToolRequestParams, CallToolResult, + ErrorData, GetPromptRequestParams, GetPromptResult, ListToolsResult, @@ -70,6 +72,25 @@ async def test_emits_server_span_with_method_and_target(server: SrvT, spans: Spa assert span.status.status_code == StatusCode.UNSET +@pytest.mark.anyio +async def test_client_span_records_jsonrpc_error_response(server: SrvT, spans: SpanCapture): + async def missing_method(ctx: Ctx, params: PaginatedRequestParams | None) -> ErrorData: + return ErrorData(code=METHOD_NOT_FOUND, message="missing") + + server.add_request_handler("missing", PaginatedRequestParams, missing_method) + async with connected_runner(server) as (client, _): + spans.clear() + with pytest.raises(MCPError) as exc: + await client.send_raw_request("missing", {}) + + assert exc.value.error.code == METHOD_NOT_FOUND + [span] = [s for s in spans.finished() if s.kind == SpanKind.CLIENT] + assert span.status.status_code == StatusCode.ERROR + assert span.attributes is not None + assert span.attributes["error.type"] == "mcp_error" + assert span.attributes["rpc.response.status_code"] == METHOD_NOT_FOUND + + @pytest.mark.anyio async def test_tool_error_dict_result_sets_error_type(server: SrvT, spans: SpanCapture): async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]: