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