From f644ec905ae57ec80d377f4c9a27cda85b63c1ea Mon Sep 17 00:00:00 2001 From: Belaidouni Badr Islam <139909210+badrbel@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:49:33 +0100 Subject: [PATCH 1/2] fix(otel): set ERROR status and error.type=tool_error on client span when tool result has isError=true (#3810) --- src/mcp/shared/jsonrpc_dispatcher.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 223373da0f..9c6a1521e6 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -23,6 +23,7 @@ INTERNAL_ERROR, INVALID_PARAMS, REQUEST_TIMEOUT, + CallToolResult, ErrorData, JSONRPCError, JSONRPCMessage, @@ -418,6 +419,13 @@ async def send_raw_request( code = str(outcome.code) span.set_attributes({"error.type": code, "rpc.response.status_code": code}) span.set_status(StatusCode.ERROR, outcome.message) + elif method == "tools/call": + match outcome: + case CallToolResult(is_error=True) | {"isError": True}: + span.set_attribute("error.type", "tool_error") + span.set_status(StatusCode.ERROR) + case _: + pass except TimeoutError: if not timeout_armed: # The clock starts only after the write, so this TimeoutError is the From c70ab3adf039f2a6263ba1d2c83d6f5cd5298379 Mon Sep 17 00:00:00 2001 From: Belaidouni Badr Islam <139909210+badrbel@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:49:34 +0100 Subject: [PATCH 2/2] test(otel): verify client and server span agreement on tool_error, protocol error, and success (#3810) --- tests/shared/test_otel.py | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/shared/test_otel.py b/tests/shared/test_otel.py index a26d2fd40c..c2895c0b56 100644 --- a/tests/shared/test_otel.py +++ b/tests/shared/test_otel.py @@ -91,3 +91,63 @@ async def list_prompts( assert failed.attributes is not None assert failed.attributes["error.type"] == str(types.INVALID_PARAMS) assert failed.attributes["rpc.response.status_code"] == str(types.INVALID_PARAMS) + + +async def test_client_and_server_spans_agree_on_tool_error(capfire: CaptureLogfire): + """When tools/call returns a CallToolResult with isError=true, both the client and server spans + must record status ERROR and error.type='tool_error', while a successful tools/call leaves both + spans UNSET (#3810). + """ + server = MCPServer("test") + + @server.tool() + def ok_tool() -> str: + return "ok" + + @server.tool() + def failing_tool() -> types.CallToolResult: + return types.CallToolResult( + content=[types.TextContent(type="text", text="tool failure")], + is_error=True, + ) + + @server.tool() + def protocol_error_tool() -> str: + raise MCPError(types.INTERNAL_ERROR, "upstream failure") + + async with Client(server, mode="legacy") as client: + ok_res = await client.call_tool("ok_tool", {}) + err_res = await client.call_tool("failing_tool", {}) + with pytest.raises(MCPError): + await client.call_tool("protocol_error_tool", {}) + + assert not ok_res.is_error + assert err_res.is_error + + spans = { + span.name: span + for span in capfire.exporter.exported_spans + if (span.attributes or {}).get("logfire.span_type") != "pending_span" + } + + # 1. Successful tool call leaves both client and server spans UNSET + assert spans["MCP send tools/call ok_tool"].status.status_code == StatusCode.UNSET + assert "error.type" not in (spans["MCP send tools/call ok_tool"].attributes or {}) + assert spans["tools/call ok_tool"].status.status_code == StatusCode.UNSET + + # 2. Tool error (isError=true) sets ERROR and error.type="tool_error" on both spans + client_tool_err = spans["MCP send tools/call failing_tool"] + server_tool_err = spans["tools/call failing_tool"] + assert client_tool_err.status.status_code == StatusCode.ERROR + assert server_tool_err.status.status_code == StatusCode.ERROR + assert (client_tool_err.attributes or {}).get("error.type") == "tool_error" + assert (server_tool_err.attributes or {}).get("error.type") == "tool_error" + + # 3. Protocol error sets ERROR and matching error.type code on both spans + client_proto_err = spans["MCP send tools/call protocol_error_tool"] + server_proto_err = spans["tools/call protocol_error_tool"] + assert client_proto_err.status.status_code == StatusCode.ERROR + assert server_proto_err.status.status_code == StatusCode.ERROR + assert (client_proto_err.attributes or {}).get("error.type") == str(types.INTERNAL_ERROR) + assert (server_proto_err.attributes or {}).get("error.type") == str(types.INTERNAL_ERROR) +