From d1a5bd906843654cdd6e91ed59947e5e3a223a44 Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Tue, 3 Mar 2026 09:46:38 -0800 Subject: [PATCH] fix(openai-agents): add MCPListToolsSpanData support (#4197) The OpenAI Agents SDK emits MCPListToolsSpanData spans when fetching MCP server tool lists, but the instrumentation did not recognize this type, producing "unknown" spans with no data. Adds MCPListToolsSpanData handling with proper operation name (mcp.list_tools), span kind (CLIENT), and attribute extraction (mcp.server.name, mcp.tools.count, mcp.tools.list). Also adds a generic .type-based fallback so future unrecognized SDK span types get a meaningful name instead of "unknown". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openai_agents/span_processor.py | 28 ++++++++++++ .../tests/stubs/agents/tracing/__init__.py | 11 +++++ .../tests/test_z_span_processor_unit.py | 43 +++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/span_processor.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/span_processor.py index d1dce8ec5e..9385dc21dd 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/span_processor.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/span_processor.py @@ -34,6 +34,7 @@ GenerationSpanData, GuardrailSpanData, HandoffSpanData, + MCPListToolsSpanData, ResponseSpanData, SpeechSpanData, TranscriptionSpanData, @@ -53,6 +54,7 @@ TranscriptionSpanData = getattr( tracing_module, "TranscriptionSpanData", Any ) # type: ignore[assignment] + MCPListToolsSpanData = getattr(tracing_module, "MCPListToolsSpanData", Any) # type: ignore[assignment] from opentelemetry.context import attach, detach from opentelemetry.metrics import Histogram, get_meter @@ -1274,6 +1276,8 @@ def _get_span_kind(self, span_data: Any) -> SpanKind: return SpanKind.CLIENT if _is_instance_of(span_data, (GuardrailSpanData, HandoffSpanData)): return SpanKind.INTERNAL # Agent operations are internal + if _is_instance_of(span_data, MCPListToolsSpanData): + return SpanKind.CLIENT # MCP server interaction is a client call return SpanKind.INTERNAL def on_trace_start(self, trace: Trace) -> None: @@ -1543,6 +1547,12 @@ def _get_operation_name(self, span_data: Any) -> str: return GenAIOperationName.GUARDRAIL if _is_instance_of(span_data, HandoffSpanData): return GenAIOperationName.HANDOFF + if _is_instance_of(span_data, MCPListToolsSpanData): + return "mcp.list_tools" + # Generic fallback: use span_data.type if available + span_type = getattr(span_data, "type", None) + if isinstance(span_type, str) and span_type: + return span_type return "unknown" def _extract_genai_attributes( @@ -1603,6 +1613,10 @@ def _extract_genai_attributes( yield from self._get_attributes_from_guardrail_span_data(span_data) elif _is_instance_of(span_data, HandoffSpanData): yield from self._get_attributes_from_handoff_span_data(span_data) + elif _is_instance_of(span_data, MCPListToolsSpanData): + yield from self._get_attributes_from_mcp_list_tools_span_data( + span_data + ) def _get_attributes_from_generation_span_data( self, span_data: GenerationSpanData, payload: ContentPayload @@ -2156,6 +2170,20 @@ def _get_attributes_from_handoff_span_data( normalize_output_type(self._infer_output_type(span_data)), ) + def _get_attributes_from_mcp_list_tools_span_data( + self, span_data: Any + ) -> Iterator[tuple[str, AttributeValue]]: + """Extract attributes from MCP list tools span data.""" + yield GEN_AI_OPERATION_NAME, "mcp.list_tools" + server = getattr(span_data, "server", None) + if isinstance(server, str) and server: + yield "mcp.server.name", server + result = getattr(span_data, "result", None) + if isinstance(result, (list, tuple)) and result: + yield "mcp.tools.count", len(result) + if self.include_sensitive_data: + yield "mcp.tools.list", gen_ai_json_dumps(result) + def _cleanup_spans_for_trace(self, trace_id: str) -> None: """Clean up spans for a trace to prevent memory leaks.""" spans_to_remove = [ diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/__init__.py index 4ed06c8977..3103ac7a8c 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/__init__.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/__init__.py @@ -29,6 +29,7 @@ "GenerationSpanData", "FunctionSpanData", "ResponseSpanData", + "MCPListToolsSpanData", ] @@ -80,6 +81,16 @@ def type(self) -> str: return SPAN_TYPE_RESPONSE +@dataclass +class MCPListToolsSpanData: + server: str | None = None + result: list[str] | None = None + + @property + def type(self) -> str: + return "mcp_tools" + + class _ProcessorFanout(TracingProcessor): def __init__(self) -> None: self._processors: list[TracingProcessor] = [] diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_z_span_processor_unit.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_z_span_processor_unit.py index b2c8c7c8f3..5e1637a3e4 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_z_span_processor_unit.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_z_span_processor_unit.py @@ -15,6 +15,7 @@ AgentSpanData, FunctionSpanData, GenerationSpanData, + MCPListToolsSpanData, ResponseSpanData, ) @@ -550,3 +551,45 @@ def test_chat_span_renamed_with_model(processor_setup): span_names = {span.name for span in exporter.get_finished_spans()} assert "chat gpt-4o" in span_names + + +def test_mcp_list_tools_span(processor_setup): + """MCPListToolsSpanData should produce mcp.list_tools spans, not unknown.""" + processor, exporter = processor_setup + + trace = FakeTrace( + name="mcp-workflow", + trace_id="trace-mcp", + started_at="2025-01-01T00:00:00Z", + ended_at="2025-01-01T00:00:05Z", + ) + processor.on_trace_start(trace) + + mcp_data = MCPListToolsSpanData( + server="Time", result=["get_time", "convert_timezone"] + ) + mcp_span = FakeSpan( + trace_id="trace-mcp", + span_id="span-mcp-1", + span_data=mcp_data, + started_at="2025-01-01T00:00:01Z", + ended_at="2025-01-01T00:00:02Z", + ) + processor.on_span_start(mcp_span) + processor.on_span_end(mcp_span) + processor.on_trace_end(trace) + + spans = exporter.get_finished_spans() + # Find the MCP list tools span by operation name attribute + mcp_otel_span = [ + s + for s in spans + if s.attributes.get("gen_ai.operation.name") == "mcp.list_tools" + ] + assert mcp_otel_span, ( + f"No MCP span found; spans: {[(s.name, dict(s.attributes)) for s in spans]}" + ) + s = mcp_otel_span[0] + assert "unknown" not in s.name + assert s.attributes.get("mcp.server.name") == "Time" + assert s.kind == SpanKind.CLIENT