diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 87bdf31ceb..14ce6db092 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,11 @@ 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_attributes( + {"error.type": str(outcome.code), "rpc.response.status_code": str(outcome.code)} + ) + span.set_status(StatusCode.ERROR, outcome.message) 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..0d58953d54 100644 --- a/tests/server/test_otel.py +++ b/tests/server/test_otel.py @@ -70,6 +70,29 @@ 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(server: SrvT, spans: SpanCapture): + """A JSON-RPC error marks the client span while preserving the MCPError contract.""" + + async def failing(ctx: Ctx, params: PaginatedRequestParams | None) -> Any: + raise MCPError(code=INVALID_PARAMS, message="forced failure") + + server.add_request_handler("resources/list", PaginatedRequestParams, failing) + async with connected_runner(server) as (client, _): + spans.clear() + with pytest.raises(MCPError) as exc: + await client.send_raw_request("resources/list", None) + + assert exc.value.error.code == INVALID_PARAMS + [span] = [s for s in spans.finished() if s.kind == SpanKind.CLIENT] + assert span.status.status_code == StatusCode.ERROR + assert span.status.description == "forced failure" + assert span.attributes is not None + assert span.attributes["error.type"] == str(INVALID_PARAMS) + assert span.attributes["rpc.response.status_code"] == str(INVALID_PARAMS) + assert not [event for event in span.events if event.name == "exception"] + + @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]: