From 5352de36b246de67c8a5b205872b019f3acbdc65 Mon Sep 17 00:00:00 2001 From: Jonathan Wrede Date: Sat, 30 May 2026 12:42:27 +0000 Subject: [PATCH 1/2] fix(openai-agents): handle MCPListToolsSpanData in span processor MCPListToolsSpanData was not handled in the span processor, causing MCP list_tools spans to show as "unknown" operation. Add handling for this span type with proper operation name (mcp_list_tools), span kind (CLIENT), span naming, and attribute extraction (gen_ai.mcp.server.name, gen_ai.mcp.tool.names). Fixes open-telemetry/opentelemetry-python-contrib#4197 Assisted-by: Claude Opus 4.6 --- .../.changelog/0.fixed | 1 + .../genai/openai_agents/span_processor.py | 39 +++++++- .../tests/stubs/agents/tracing/__init__.py | 12 +++ .../tests/test_z_span_processor_unit.py | 93 +++++++++++++++++++ 4 files changed, 140 insertions(+), 5 deletions(-) create mode 100644 instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/0.fixed diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/0.fixed b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/0.fixed new file mode 100644 index 00000000..ddd752f9 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/0.fixed @@ -0,0 +1 @@ +Handle MCPListToolsSpanData so MCP list_tools spans get proper operation name and attributes instead of showing as "unknown" diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/span_processor.py b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/span_processor.py index f78552f1..4c7aa2f2 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/span_processor.py +++ b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/src/opentelemetry/instrumentation/genai/openai_agents/span_processor.py @@ -37,6 +37,7 @@ GenerationSpanData, GuardrailSpanData, HandoffSpanData, + MCPListToolsSpanData, ResponseSpanData, SpeechSpanData, TranscriptionSpanData, @@ -51,6 +52,7 @@ GenerationSpanData = getattr(tracing_module, "GenerationSpanData", Any) # type: ignore[assignment] GuardrailSpanData = getattr(tracing_module, "GuardrailSpanData", Any) # type: ignore[assignment] HandoffSpanData = getattr(tracing_module, "HandoffSpanData", Any) # type: ignore[assignment] + MCPListToolsSpanData = getattr(tracing_module, "MCPListToolsSpanData", Any) # type: ignore[assignment] ResponseSpanData = getattr(tracing_module, "ResponseSpanData", Any) # type: ignore[assignment] SpeechSpanData = getattr(tracing_module, "SpeechSpanData", Any) # type: ignore[assignment] TranscriptionSpanData = getattr( @@ -123,6 +125,7 @@ class GenAIOperationName: SPEECH = "speech_generation" GUARDRAIL = "guardrail_check" HANDOFF = "agent_handoff" + MCP_LIST_TOOLS = "mcp_list_tools" RESPONSE = "response" # internal aggregator in current processor CLASS_FALLBACK = { @@ -244,6 +247,8 @@ def _attr(name: str, fallback: str) -> str: GEN_AI_HANDOFF_TO_AGENT = "gen_ai.handoff.to_agent" GEN_AI_EMBEDDINGS_DIMENSION_COUNT = "gen_ai.embeddings.dimension.count" GEN_AI_TOKEN_TYPE = _attr("GEN_AI_TOKEN_TYPE", "gen_ai.token.type") +GEN_AI_MCP_SERVER_NAME = "gen_ai.mcp.server.name" +GEN_AI_MCP_TOOL_NAMES = "gen_ai.mcp.tool.names" # ---- Normalization utilities (embedded from utils.py) ---- @@ -423,6 +428,9 @@ def get_span_name( if operation_name == GenAIOperationName.HANDOFF: return f"{base_name} {agent_name}" if agent_name else base_name + if operation_name == GenAIOperationName.MCP_LIST_TOOLS: + return f"{base_name} {tool_name}" if tool_name else base_name + return base_name @@ -1286,6 +1294,8 @@ def _get_span_kind(self, span_data: Any) -> SpanKind: return SpanKind.CLIENT # API calls to model providers if _is_instance_of(span_data, AgentSpanData): return SpanKind.CLIENT + if _is_instance_of(span_data, MCPListToolsSpanData): + return SpanKind.CLIENT # MCP server call if _is_instance_of(span_data, (GuardrailSpanData, HandoffSpanData)): return SpanKind.INTERNAL # Agent operations are internal return SpanKind.INTERNAL @@ -1362,11 +1372,12 @@ def on_span_start(self, span: Span[Any]) -> None: if not agent_name: agent_name = self._agent_name_default - tool_name = ( - getattr(span.span_data, "name", None) - if _is_instance_of(span.span_data, FunctionSpanData) - else None - ) + if _is_instance_of(span.span_data, FunctionSpanData): + tool_name = getattr(span.span_data, "name", None) + elif _is_instance_of(span.span_data, MCPListToolsSpanData): + tool_name = getattr(span.span_data, "server", None) + else: + tool_name = None # Generate spec-compliant span name span_name = get_span_name(operation_name, model, agent_name, tool_name) @@ -1548,6 +1559,8 @@ 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 GenAIOperationName.MCP_LIST_TOOLS return "unknown" def _extract_genai_attributes( @@ -1608,6 +1621,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 @@ -2173,6 +2190,18 @@ 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: MCPListToolsSpanData + ) -> Iterator[tuple[str, AttributeValue]]: + """Extract attributes from MCP list tools span.""" + yield GEN_AI_OPERATION_NAME, GenAIOperationName.MCP_LIST_TOOLS + + if span_data.server: + yield GEN_AI_MCP_SERVER_NAME, span_data.server + + if span_data.result: + yield GEN_AI_MCP_TOOL_NAMES, span_data.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/opentelemetry-instrumentation-genai-openai-agents/tests/stubs/agents/tracing/__init__.py b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/stubs/agents/tracing/__init__.py index f3c7b942..d739e3a8 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/stubs/agents/tracing/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/stubs/agents/tracing/__init__.py @@ -17,6 +17,7 @@ SPAN_TYPE_AGENT = "agent" SPAN_TYPE_FUNCTION = "function" SPAN_TYPE_GENERATION = "generation" +SPAN_TYPE_MCP_TOOLS = "mcp.list_tools" SPAN_TYPE_RESPONSE = "response" __all__ = [ @@ -31,6 +32,7 @@ "AgentSpanData", "GenerationSpanData", "FunctionSpanData", + "MCPListToolsSpanData", "ResponseSpanData", ] @@ -71,6 +73,16 @@ def type(self) -> str: return SPAN_TYPE_GENERATION +@dataclass +class MCPListToolsSpanData: + server: str | None = None + result: list[str] | None = None + + @property + def type(self) -> str: + return SPAN_TYPE_MCP_TOOLS + + @dataclass class ResponseSpanData: response: Any = None diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_z_span_processor_unit.py b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_z_span_processor_unit.py index 3b64f785..9deef045 100644 --- a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_z_span_processor_unit.py +++ b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_z_span_processor_unit.py @@ -18,6 +18,7 @@ AgentSpanData, FunctionSpanData, GenerationSpanData, + MCPListToolsSpanData, ResponseSpanData, ) @@ -212,6 +213,24 @@ class UnknownSpanData: == "create_agent" ) + # MCPListToolsSpanData maps to mcp_list_tools + mcp_data = MCPListToolsSpanData(server="Time") + assert ( + processor._get_operation_name(mcp_data) + == sp.GenAIOperationName.MCP_LIST_TOOLS + ) + assert processor._get_span_kind(mcp_data) is SpanKind.CLIENT + assert ( + sp.get_span_name( + sp.GenAIOperationName.MCP_LIST_TOOLS, tool_name="Time" + ) + == "mcp_list_tools Time" + ) + assert ( + sp.get_span_name(sp.GenAIOperationName.MCP_LIST_TOOLS) + == "mcp_list_tools" + ) + def test_attribute_builders(processor_setup): processor, _ = processor_setup @@ -361,6 +380,44 @@ def __init__(self) -> None: assert function_attrs[sp.GEN_AI_TOOL_CALL_RESULT] == {"temperature": 70} assert function_attrs[sp.GEN_AI_OUTPUT_TYPE] == sp.GenAIOutputType.JSON + # MCP list tools span + mcp_span = MCPListToolsSpanData( + server="Time", + result=["get_current_time", "convert_timezone"], + ) + mcp_attrs = _collect( + processor._get_attributes_from_mcp_list_tools_span_data(mcp_span) + ) + assert mcp_attrs[sp.GEN_AI_OPERATION_NAME] == "mcp_list_tools" + assert mcp_attrs[sp.GEN_AI_MCP_SERVER_NAME] == "Time" + assert mcp_attrs[sp.GEN_AI_MCP_TOOL_NAMES] == [ + "get_current_time", + "convert_timezone", + ] + + # MCP list tools span without result + mcp_span_no_result = MCPListToolsSpanData(server="Empty") + mcp_attrs_no_result = _collect( + processor._get_attributes_from_mcp_list_tools_span_data( + mcp_span_no_result + ) + ) + assert mcp_attrs_no_result[sp.GEN_AI_OPERATION_NAME] == "mcp_list_tools" + assert mcp_attrs_no_result[sp.GEN_AI_MCP_SERVER_NAME] == "Empty" + assert sp.GEN_AI_MCP_TOOL_NAMES not in mcp_attrs_no_result + + # MCP list tools span without server + mcp_span_no_server = MCPListToolsSpanData( + result=["tool_a"], + ) + mcp_attrs_no_server = _collect( + processor._get_attributes_from_mcp_list_tools_span_data( + mcp_span_no_server + ) + ) + assert sp.GEN_AI_MCP_SERVER_NAME not in mcp_attrs_no_server + assert mcp_attrs_no_server[sp.GEN_AI_MCP_TOOL_NAMES] == ["tool_a"] + def test_extract_genai_attributes_unknown_type(processor_setup): processor, _ = processor_setup @@ -538,3 +595,39 @@ 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_lifecycle(processor_setup): + processor, exporter = processor_setup + + trace = FakeTrace(name="workflow", trace_id="trace-mcp") + processor.on_trace_start(trace) + + mcp_data = MCPListToolsSpanData( + server="Time", + result=["get_current_time", "convert_timezone"], + ) + mcp_span = FakeSpan( + trace_id=trace.trace_id, + span_id="mcp-span", + span_data=mcp_data, + started_at="2025-01-01T00:00:00Z", + ended_at="2025-01-01T00:00:01Z", + ) + processor.on_span_start(mcp_span) + processor.on_span_end(mcp_span) + processor.on_trace_end(trace) + + finished = exporter.get_finished_spans() + mcp_otel_span = next( + span for span in finished if span.name == "mcp_list_tools Time" + ) + assert mcp_otel_span.kind is SpanKind.CLIENT + assert ( + mcp_otel_span.attributes[sp.GEN_AI_OPERATION_NAME] == "mcp_list_tools" + ) + assert mcp_otel_span.attributes[sp.GEN_AI_MCP_SERVER_NAME] == "Time" + assert mcp_otel_span.attributes[sp.GEN_AI_MCP_TOOL_NAMES] == ( + "get_current_time", + "convert_timezone", + ) From 48ff4524033bba1a1e9f6cf7600ca9e794849589 Mon Sep 17 00:00:00 2001 From: Jonathan Wrede Date: Sat, 30 May 2026 12:44:57 +0000 Subject: [PATCH 2/2] chore: rename changelog fragment to match PR number Assisted-by: Claude Opus 4.6 --- .../.changelog/{0.fixed => 100.fixed} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/{0.fixed => 100.fixed} (100%) diff --git a/instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/0.fixed b/instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/100.fixed similarity index 100% rename from instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/0.fixed rename to instrumentation/opentelemetry-instrumentation-genai-openai-agents/.changelog/100.fixed