OTEL: Fix attribute compliance with MCP semantic conventions

- Replace rpc.system/rpc.service/rpc.method with gen_ai.tool.name and
  gen_ai.prompt.name on server and client spans
- Set error.type to exception class name and include str(e) in status
  description on error spans
- Always set mcp.session.id when session_id is not None (was truthy check)
- Move _parse_call_tool_result inside client_span so ToolError is captured
- Pass tool_name/prompt_name through server_span and client_span calls
- Add method attribute to delegate_span for mounted providers

Closes #3886

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
strawgate 2026-04-13 00:26:10 -05:00
commit 2f9b74beb4
9 changed files with 223 additions and 61 deletions

View file

@ -130,6 +130,7 @@ class ClientPromptsMixin:
"prompts/get",
name,
session_id=self.transport.get_session_id(),
prompt_name=name,
):
logger.debug(f"[{self.name}] called get_prompt: {name}")

View file

@ -52,12 +52,18 @@ class ClientToolsMixin:
RuntimeError: If called while the client is not connected.
McpError: If the request results in a TimeoutError | JSONRPCError
"""
logger.debug(f"[{self.name}] called list_tools")
with client_span(
"tools/list",
"tools/list",
"",
session_id=self.transport.get_session_id(),
):
logger.debug(f"[{self.name}] called list_tools")
result = await self._await_with_session_monitoring(
self.session.list_tools(cursor=cursor)
)
return result
result = await self._await_with_session_monitoring(
self.session.list_tools(cursor=cursor)
)
return result
async def list_tools(
self: Client,
@ -144,6 +150,7 @@ class ClientToolsMixin:
"tools/call",
name,
session_id=self.transport.get_session_id(),
tool_name=name,
):
logger.debug(f"[{self.name}] called call_tool: {name}")
@ -277,16 +284,23 @@ class ClientToolsMixin:
name, arguments, task_id, ttl, meta=request_meta or None
)
result = await self.call_tool_mcp(
name=name,
arguments=arguments or {},
timeout=timeout,
progress_handler=progress_handler,
meta=request_meta or None,
)
return await self._parse_call_tool_result(
name, result, raise_on_error=raise_on_error
)
with client_span(
f"tools/call {name}",
"tools/call",
name,
session_id=self.transport.get_session_id(),
tool_name=name,
):
result = await self.call_tool_mcp(
name=name,
arguments=arguments or {},
timeout=timeout,
progress_handler=progress_handler,
meta=request_meta or None,
)
return await self._parse_call_tool_result(
name, result, raise_on_error=raise_on_error
)
async def _call_tool_as_task(
self: Client,

View file

@ -15,6 +15,8 @@ def client_span(
component_key: str,
session_id: str | None = None,
resource_uri: str | None = None,
tool_name: str | None = None,
prompt_name: str | None = None,
) -> Generator[Span, None, None]:
"""Create a CLIENT span with standard MCP attributes.
@ -23,24 +25,26 @@ def client_span(
tracer = get_tracer()
with tracer.start_as_current_span(name, kind=SpanKind.CLIENT) as span:
attrs: dict[str, str] = {
# RPC semantic conventions
"rpc.system": "mcp",
"rpc.method": method,
# MCP semantic conventions
"mcp.method.name": method,
# FastMCP-specific attributes
"fastmcp.component.key": component_key,
}
if session_id:
if session_id is not None:
attrs["mcp.session.id"] = session_id
if resource_uri:
attrs["mcp.resource.uri"] = resource_uri
if tool_name is not None:
attrs["gen_ai.tool.name"] = tool_name
if prompt_name is not None:
attrs["gen_ai.prompt.name"] = prompt_name
span.set_attributes(attrs)
try:
yield span
except Exception as e:
span.set_attribute("error.type", type(e).__name__)
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR))
span.set_status(Status(StatusCode.ERROR, str(e)))
raise

View file

@ -139,7 +139,8 @@ class FastMCPProviderTool(Tool):
version = VersionSpec(eq=self.version) if self.version else None
with delegate_span(
self._original_name or "", "FastMCPProvider", self._original_name or ""
self._original_name or "", "FastMCPProvider", self._original_name or "",
method="tools/call",
):
return await self._server.call_tool(
self._original_name,
@ -232,7 +233,8 @@ class FastMCPProviderResource(Resource):
version = VersionSpec(eq=self.version) if self.version else None
with delegate_span(
self._original_uri or "", "FastMCPProvider", self._original_uri or ""
self._original_uri or "", "FastMCPProvider", self._original_uri or "",
method="resources/read",
):
return await self._server.read_resource(
self._original_uri, version=version, task_meta=task_meta
@ -311,7 +313,8 @@ class FastMCPProviderPrompt(Prompt):
version = VersionSpec(eq=self.version) if self.version else None
with delegate_span(
self._original_name or "", "FastMCPProvider", self._original_name or ""
self._original_name or "", "FastMCPProvider", self._original_name or "",
method="prompts/get",
):
return await self._server.render_prompt(
self._original_name, arguments, version=version, task_meta=task_meta
@ -430,7 +433,8 @@ class FastMCPProviderResourceTemplate(ResourceTemplate):
version = VersionSpec(eq=self.version) if self.version else None
with delegate_span(
original_uri, "FastMCPProvider", self._original_uri_template or ""
original_uri, "FastMCPProvider", self._original_uri_template or "",
method="resources/read",
):
return await self._server.read_resource(
original_uri, version=version, task_meta=task_meta

View file

@ -1204,7 +1204,8 @@ class FastMCP(
# Core logic: find and execute tool
with server_span(
f"tools/call {name}", "tools/call", self.name, "tool", name
f"tools/call {name}", "tools/call", self.name, "tool", name,
tool_name=name,
) as span:
# Try normal display-name resolution first.
tool: Tool | None = await self.get_tool(name, version=version)
@ -1506,7 +1507,8 @@ class FastMCP(
# Core logic: find and render prompt (providers queried in parallel)
# Use get_prompt to apply transforms and filter disabled
with server_span(
f"prompts/get {name}", "prompts/get", self.name, "prompt", name
f"prompts/get {name}", "prompts/get", self.name, "prompt", name,
prompt_name=name,
) as span:
prompt = await self.get_prompt(name, version=version)
if prompt is None:

View file

@ -60,6 +60,8 @@ def server_span(
component_type: str,
component_key: str,
resource_uri: str | None = None,
tool_name: str | None = None,
prompt_name: str | None = None,
) -> Generator[Span, None, None]:
"""Create a SERVER span with standard MCP attributes and auth context.
@ -72,10 +74,6 @@ def server_span(
kind=SpanKind.SERVER,
) as span:
attrs: dict[str, str] = {
# RPC semantic conventions
"rpc.system": "mcp",
"rpc.service": server_name,
"rpc.method": method,
# MCP semantic conventions
"mcp.method.name": method,
# FastMCP-specific attributes
@ -87,12 +85,17 @@ def server_span(
}
if resource_uri is not None:
attrs["mcp.resource.uri"] = resource_uri
if tool_name is not None:
attrs["gen_ai.tool.name"] = tool_name
if prompt_name is not None:
attrs["gen_ai.prompt.name"] = prompt_name
span.set_attributes(attrs)
try:
yield span
except Exception as e:
span.set_attribute("error.type", type(e).__name__)
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR))
span.set_status(Status(StatusCode.ERROR, str(e)))
raise
@ -101,6 +104,7 @@ def delegate_span(
name: str,
provider_type: str,
component_key: str,
method: str | None = None,
) -> Generator[Span, None, None]:
"""Create an INTERNAL span for provider delegation.
@ -109,12 +113,13 @@ def delegate_span(
"""
tracer = get_tracer()
with tracer.start_as_current_span(f"delegate {name}") as span:
span.set_attributes(
{
"fastmcp.provider.type": provider_type,
"fastmcp.component.key": component_key,
}
)
attrs: dict[str, str] = {
"fastmcp.provider.type": provider_type,
"fastmcp.component.key": component_key,
}
if method is not None:
attrs["mcp.method.name"] = method
span.set_attributes(attrs)
try:
yield span
except Exception as e:

View file

@ -63,12 +63,52 @@ class TestClientToolTracing:
assert client_span.attributes is not None
# Standard MCP semantic conventions
assert client_span.attributes["mcp.method.name"] == "tools/call"
# Standard RPC semantic conventions
assert client_span.attributes["rpc.system"] == "mcp"
assert client_span.attributes["rpc.method"] == "tools/call"
# gen_ai semantic conventions
assert client_span.attributes["gen_ai.tool.name"] == "add"
# RPC attributes must NOT be present
assert "rpc.system" not in client_span.attributes
assert "rpc.method" not in client_span.attributes
# FastMCP-specific attributes
assert client_span.attributes["fastmcp.component.key"] == "add"
async def test_call_tool_error_caught_by_client_span(
self, trace_exporter: InMemorySpanExporter
):
"""ToolError from _parse_call_tool_result should be caught by the client span."""
server = FastMCP("test-server")
@server.tool()
def failing_tool() -> str:
raise ValueError("boom")
client = Client(server)
async with client:
with pytest.raises(ToolError):
await client.call_tool("failing_tool", {})
spans = trace_exporter.get_finished_spans()
# Find the outer client span (from call_tool wrapping _parse_call_tool_result)
client_spans = [
s
for s in spans
if s.name == "tools/call failing_tool"
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
]
# At least one client span should have ERROR status (the one catching ToolError)
error_client_spans = [
s for s in client_spans if s.status.status_code == StatusCode.ERROR
]
assert len(error_client_spans) >= 1, (
"At least one client span should capture the ToolError"
)
error_span = error_client_spans[0]
assert error_span.attributes is not None
assert error_span.attributes["error.type"] == "ToolError"
class TestClientResourceTracing:
"""Tests for client resource read tracing."""
@ -124,9 +164,9 @@ class TestClientResourceTracing:
# Standard MCP semantic conventions
assert client_span.attributes["mcp.method.name"] == "resources/read"
assert "data://" in str(client_span.attributes["mcp.resource.uri"])
# Standard RPC semantic conventions
assert client_span.attributes["rpc.system"] == "mcp"
assert client_span.attributes["rpc.method"] == "resources/read"
# RPC attributes must NOT be present
assert "rpc.system" not in client_span.attributes
assert "rpc.method" not in client_span.attributes
# FastMCP-specific attributes
# The URI may be normalized with trailing slash
assert "data://" in str(client_span.attributes["fastmcp.component.key"])
@ -183,9 +223,11 @@ class TestClientPromptTracing:
assert client_span.attributes is not None
# Standard MCP semantic conventions
assert client_span.attributes["mcp.method.name"] == "prompts/get"
# Standard RPC semantic conventions
assert client_span.attributes["rpc.system"] == "mcp"
assert client_span.attributes["rpc.method"] == "prompts/get"
# gen_ai semantic conventions
assert client_span.attributes["gen_ai.prompt.name"] == "welcome"
# RPC attributes must NOT be present
assert "rpc.system" not in client_span.attributes
assert "rpc.method" not in client_span.attributes
# FastMCP-specific attributes
assert client_span.attributes["fastmcp.component.key"] == "welcome"
@ -242,7 +284,7 @@ class TestClientServerSpanHierarchy:
assert server_span.kind == SpanKind.SERVER, "Server span should be SERVER kind"
# Verify the spans have different characteristics
assert client_span.attributes["rpc.method"] == "tools/call"
assert client_span.attributes["mcp.method.name"] == "tools/call"
assert server_span.attributes["fastmcp.server.name"] == "test-server"
async def test_trace_context_propagation(

View file

@ -0,0 +1,80 @@
"""Tests for mcp.method.name attribute on delegate spans."""
from __future__ import annotations
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from fastmcp import FastMCP
class TestDelegateSpanMethod:
"""Tests that delegate spans include mcp.method.name."""
async def test_mounted_tool_delegate_has_method(
self, trace_exporter: InMemorySpanExporter
):
child = FastMCP("child-server")
@child.tool()
def child_tool() -> str:
return "result"
parent = FastMCP("parent-server")
parent.mount(child, namespace="child")
await parent.call_tool("child_child_tool", {})
spans = trace_exporter.get_finished_spans()
delegate_span = next(
(s for s in spans if s.name == "delegate child_tool"), None
)
assert delegate_span is not None
assert delegate_span.attributes is not None
assert delegate_span.attributes["mcp.method.name"] == "tools/call"
async def test_mounted_resource_delegate_has_method(
self, trace_exporter: InMemorySpanExporter
):
child = FastMCP("child-server")
@child.resource("data://config")
def child_config() -> str:
return "config data"
parent = FastMCP("parent-server")
parent.mount(child, namespace="child")
await parent.read_resource("data://child/config")
spans = trace_exporter.get_finished_spans()
delegate_spans = [
s
for s in spans
if s.name.startswith("delegate") and "data://config" in s.name
]
assert len(delegate_spans) >= 1
span = delegate_spans[0]
assert span.attributes is not None
assert span.attributes["mcp.method.name"] == "resources/read"
async def test_mounted_prompt_delegate_has_method(
self, trace_exporter: InMemorySpanExporter
):
child = FastMCP("child-server")
@child.prompt()
def child_prompt() -> str:
return "Hello from child!"
parent = FastMCP("parent-server")
parent.mount(child, namespace="child")
await parent.render_prompt("child_child_prompt", {})
spans = trace_exporter.get_finished_spans()
delegate_span = next(
(s for s in spans if s.name == "delegate child_prompt"), None
)
assert delegate_span is not None
assert delegate_span.attributes is not None
assert delegate_span.attributes["mcp.method.name"] == "prompts/get"

View file

@ -33,10 +33,12 @@ class TestToolTracing:
assert span.attributes is not None
# Standard MCP semantic conventions
assert span.attributes["mcp.method.name"] == "tools/call"
# Standard RPC semantic conventions
assert span.attributes["rpc.system"] == "mcp"
assert span.attributes["rpc.service"] == "test-server"
assert span.attributes["rpc.method"] == "tools/call"
# gen_ai semantic conventions
assert span.attributes["gen_ai.tool.name"] == "greet"
# RPC attributes must NOT be present
assert "rpc.system" not in span.attributes
assert "rpc.service" not in span.attributes
assert "rpc.method" not in span.attributes
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "tool"
@ -60,6 +62,9 @@ class TestToolTracing:
span = spans[0]
assert span.name == "tools/call failing_tool"
assert span.status.status_code == StatusCode.ERROR
assert "Something went wrong" in span.status.description
assert span.attributes is not None
assert span.attributes["error.type"] == "ToolError"
assert len(span.events) > 0 # Exception recorded
async def test_call_nonexistent_tool_sets_error(
@ -76,6 +81,8 @@ class TestToolTracing:
span = spans[0]
assert span.name == "tools/call nonexistent"
assert span.status.status_code == StatusCode.ERROR
assert span.attributes is not None
assert span.attributes["error.type"] == "NotFoundError"
class TestResourceTracing:
@ -101,10 +108,10 @@ class TestResourceTracing:
# Standard MCP semantic conventions
assert span.attributes["mcp.method.name"] == "resources/read"
assert span.attributes["mcp.resource.uri"] == "config://app"
# Standard RPC semantic conventions
assert span.attributes["rpc.system"] == "mcp"
assert span.attributes["rpc.service"] == "test-server"
assert span.attributes["rpc.method"] == "resources/read"
# RPC attributes must NOT be present
assert "rpc.system" not in span.attributes
assert "rpc.service" not in span.attributes
assert "rpc.method" not in span.attributes
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "resource"
@ -132,8 +139,9 @@ class TestResourceTracing:
# Standard MCP semantic conventions
assert span.attributes["mcp.method.name"] == "resources/read"
assert span.attributes["mcp.resource.uri"] == "users://123/profile"
# Standard RPC semantic conventions
assert span.attributes["rpc.method"] == "resources/read"
# RPC attributes must NOT be present
assert "rpc.system" not in span.attributes
assert "rpc.method" not in span.attributes
# Template component type is set by get_span_attributes
assert span.attributes["fastmcp.component.type"] == "resource_template"
assert (
@ -179,10 +187,12 @@ class TestPromptTracing:
assert span.attributes is not None
# Standard MCP semantic conventions
assert span.attributes["mcp.method.name"] == "prompts/get"
# Standard RPC semantic conventions
assert span.attributes["rpc.system"] == "mcp"
assert span.attributes["rpc.service"] == "test-server"
assert span.attributes["rpc.method"] == "prompts/get"
# gen_ai semantic conventions
assert span.attributes["gen_ai.prompt.name"] == "greeting"
# RPC attributes must NOT be present
assert "rpc.system" not in span.attributes
assert "rpc.service" not in span.attributes
assert "rpc.method" not in span.attributes
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "prompt"