Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
GenerationSpanData,
GuardrailSpanData,
HandoffSpanData,
MCPListToolsSpanData,
ResponseSpanData,
SpeechSpanData,
TranscriptionSpanData,
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"GenerationSpanData",
"FunctionSpanData",
"ResponseSpanData",
"MCPListToolsSpanData",
]


Expand Down Expand Up @@ -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] = []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
AgentSpanData,
FunctionSpanData,
GenerationSpanData,
MCPListToolsSpanData,
ResponseSpanData,
)

Expand Down Expand Up @@ -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
Loading