From a2584e1f227fee41c36ca4abb68e28f7ab20828b Mon Sep 17 00:00:00 2001 From: Shadow2121 Date: Tue, 21 Apr 2026 12:04:17 -0300 Subject: [PATCH] issue #4038 - fix(mcp): handle dict/object result shapes in stream writer error parsing --- .../instrumentation/mcp/instrumentation.py | 34 +++++++++++++----- .../tests/test_fastmcp.py | 35 +++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/packages/opentelemetry-instrumentation-mcp/opentelemetry/instrumentation/mcp/instrumentation.py b/packages/opentelemetry-instrumentation-mcp/opentelemetry/instrumentation/mcp/instrumentation.py index 584f94a4b6..b02ee8d9a1 100644 --- a/packages/opentelemetry-instrumentation-mcp/opentelemetry/instrumentation/mcp/instrumentation.py +++ b/packages/opentelemetry-instrumentation-mcp/opentelemetry/instrumentation/mcp/instrumentation.py @@ -547,6 +547,29 @@ async def __aenter__(self) -> Any: async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any: return await self.__wrapped__.__aexit__(exc_type, exc_value, traceback) + @staticmethod + def _get_value(data: Any, key: str, default: Any = None) -> Any: + if isinstance(data, dict): + return data.get(key, default) + return getattr(data, key, default) + + @classmethod + def _extract_result_error(cls, result: Any) -> Tuple[bool, Union[str, None]]: + is_error = cls._get_value(result, "isError", False) is True + if not is_error: + return False, None + + content = cls._get_value(result, "content", []) + if not content: + return True, None + + first_item = content[0] + message = cls._get_value(first_item, "text") + if message is None: + return True, None + + return True, str(message) + @dont_throw async def send(self, item: Any) -> Any: from mcp.types import JSONRPCMessage, JSONRPCRequest @@ -567,14 +590,9 @@ async def send(self, item: Any) -> Any: span.set_attribute( SpanAttributes.MCP_RESPONSE_VALUE, f"{serialize(request.result)}" ) - if "isError" in request.result: - if request.result["isError"] is True: - span.set_status( - Status( - StatusCode.ERROR, - f"{request.result['content'][0]['text']}", - ) - ) + is_error, error_message = self._extract_result_error(request.result) + if is_error: + span.set_status(Status(StatusCode.ERROR, error_message)) if hasattr(request, "id"): span.set_attribute(SpanAttributes.MCP_REQUEST_ID, f"{request.id}") diff --git a/packages/opentelemetry-instrumentation-mcp/tests/test_fastmcp.py b/packages/opentelemetry-instrumentation-mcp/tests/test_fastmcp.py index e0e0e2db20..a52b2ca4b9 100644 --- a/packages/opentelemetry-instrumentation-mcp/tests/test_fastmcp.py +++ b/packages/opentelemetry-instrumentation-mcp/tests/test_fastmcp.py @@ -1,3 +1,8 @@ +from types import SimpleNamespace + +from opentelemetry.instrumentation.mcp.instrumentation import InstrumentedStreamWriter + + async def test_fastmcp_instrumentor(span_exporter, tracer_provider) -> None: from fastmcp import FastMCP, Client @@ -146,3 +151,33 @@ def get_greeting() -> str: assert workflow_name == 'test-server.mcp', ( f"Expected workflow name 'test-server.mcp' on tool span, got '{workflow_name}'" ) + + +def test_extract_result_error_with_dict_shape(): + result = {"isError": True, "content": [{"text": "dict error"}]} + + is_error, message = InstrumentedStreamWriter._extract_result_error(result) + + assert is_error is True + assert message == "dict error" + + +def test_extract_result_error_with_object_shape(): + result = SimpleNamespace( + isError=True, + content=[SimpleNamespace(text="object error")], + ) + + is_error, message = InstrumentedStreamWriter._extract_result_error(result) + + assert is_error is True + assert message == "object error" + + +def test_extract_result_error_with_missing_content(): + result = {"isError": True, "content": []} + + is_error, message = InstrumentedStreamWriter._extract_result_error(result) + + assert is_error is True + assert message is None