Skip to content

Commit 7acbbc2

Browse files
committed
fix: record JSON-RPC errors on client spans
1 parent a4f4ccd commit 7acbbc2

2 files changed

Lines changed: 30 additions & 2 deletions

File tree

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
ProgressToken,
3333
RequestId,
3434
)
35-
from opentelemetry.trace import SpanKind
35+
from opentelemetry.trace import SpanKind, StatusCode
3636
from pydantic import ValidationError
3737
from typing_extensions import TypeVar
3838

@@ -385,7 +385,7 @@ async def send_raw_request(
385385
span_name,
386386
kind=SpanKind.CLIENT,
387387
attributes={"mcp.method.name": method, "jsonrpc.request.id": str(request_id)},
388-
):
388+
) as span:
389389
# SEP-414: inject W3C trace context; `_meta` stays on the wire even with a no-op tracer.
390390
inject_trace_context(out_meta)
391391
msg = JSONRPCRequest(jsonrpc="2.0", id=request_id, method=method, params=out_params)
@@ -401,6 +401,11 @@ async def send_raw_request(
401401
with anyio.fail_after(opts.get("timeout")):
402402
timeout_armed = True
403403
outcome = await receive.receive()
404+
if isinstance(outcome, ErrorData):
405+
span.set_attributes(
406+
{"error.type": str(outcome.code), "rpc.response.status_code": str(outcome.code)}
407+
)
408+
span.set_status(StatusCode.ERROR, outcome.message)
404409
except TimeoutError:
405410
if not timeout_armed:
406411
# `fail_after` arms only after the write, so this TimeoutError is the

tests/server/test_otel.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,29 @@ async def test_emits_server_span_with_method_and_target(server: SrvT, spans: Spa
7070
assert span.status.status_code == StatusCode.UNSET
7171

7272

73+
@pytest.mark.anyio
74+
async def test_client_span_records_jsonrpc_error(server: SrvT, spans: SpanCapture):
75+
"""A JSON-RPC error marks the client span while preserving the MCPError contract."""
76+
77+
async def failing(ctx: Ctx, params: PaginatedRequestParams | None) -> Any:
78+
raise MCPError(code=INVALID_PARAMS, message="forced failure")
79+
80+
server.add_request_handler("resources/list", PaginatedRequestParams, failing)
81+
async with connected_runner(server) as (client, _):
82+
spans.clear()
83+
with pytest.raises(MCPError) as exc:
84+
await client.send_raw_request("resources/list", None)
85+
86+
assert exc.value.error.code == INVALID_PARAMS
87+
[span] = [s for s in spans.finished() if s.kind == SpanKind.CLIENT]
88+
assert span.status.status_code == StatusCode.ERROR
89+
assert span.status.description == "forced failure"
90+
assert span.attributes is not None
91+
assert span.attributes["error.type"] == str(INVALID_PARAMS)
92+
assert span.attributes["rpc.response.status_code"] == str(INVALID_PARAMS)
93+
assert not [event for event in span.events if event.name == "exception"]
94+
95+
7396
@pytest.mark.anyio
7497
async def test_tool_error_dict_result_sets_error_type(server: SrvT, spans: SpanCapture):
7598
async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]:

0 commit comments

Comments
 (0)