Skip to content
Open
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
@@ -0,0 +1 @@
Handle MCPListToolsSpanData so MCP list_tools spans get proper operation name and attributes instead of showing as "unknown"
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
GenerationSpanData,
GuardrailSpanData,
HandoffSpanData,
MCPListToolsSpanData,
ResponseSpanData,
SpeechSpanData,
TranscriptionSpanData,
Expand All @@ -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(
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe these are existing GenAI semantic conventions. This repo implements convention defined in https://github.com/open-telemetry/semantic-conventions-genai. There are some existing operations here that are not following conventions either and they are being removed in the #90.

Please consider modeling this operation using existing conventions or proposing a new one in the https://github.com/open-telemetry/semantic-conventions-genai repo

GEN_AI_MCP_TOOL_NAMES = "gen_ai.mcp.tool.names"
Comment on lines +250 to +251

# ---- Normalization utilities (embedded from utils.py) ----

Expand Down Expand Up @@ -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
Comment on lines +431 to +432

return base_name


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +1375 to +1380

# Generate spec-compliant span name
span_name = get_span_name(operation_name, model, agent_name, tool_name)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand All @@ -31,6 +32,7 @@
"AgentSpanData",
"GenerationSpanData",
"FunctionSpanData",
"MCPListToolsSpanData",
"ResponseSpanData",
]

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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
)