OTEL: Fix attribute compliance with MCP semantic conventions (#3889)

* OTEL: Fix attribute compliance and improve telemetry helpers

Attribute compliance:
- Remove rpc.system/service/method (MCP is not traditional RPC)
- Add gen_ai.tool.name on tools/call spans
- Add gen_ai.prompt.name on prompts/get spans
- Fix session_id check (truthy -> is not None)

Telemetry helper improvements:
- Add is_recording() guards to skip work on non-recording spans
- Add error.type attribute with __qualname__ on error spans
- Use isinstance check for ToolError to set "tool_error" error type
- Include exception message in span status description
- Add tool_name/prompt_name params to server_span and client_span

Client call_tool enrichment:
- Reflect tool-level errors (result.isError) on client span status
  so callers see ERROR even though the MCP protocol call succeeded

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove resource URI from span names to avoid high-cardinality

Per MCP semantic conventions, resource URIs SHOULD NOT be included in
span names by default since they can be unbounded (especially with
templates like users://{id}/profile). The URI remains available via
the mcp.resource.uri attribute.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add missing gen_ai/mcp attributes to proxy and delegate spans

- Proxy tool spans: add gen_ai.tool.name
- Proxy prompt spans: add gen_ai.prompt.name
- All delegate spans: add mcp.method.name
- Docs: remove rpc.* references, update span names and attributes table

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Hoist ToolError imports to module level, add rpc.* migration note

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bill Easton 2026-04-14 15:28:47 -05:00 committed by GitHub
commit 7184a4ca21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 216 additions and 96 deletions

View file

@ -61,14 +61,14 @@ The server creates spans for each operation using [MCP semantic conventions](htt
| Span Name | Description |
|-----------|-------------|
| `tools/call {name}` | Tool execution (e.g., `tools/call get_weather`) |
| `resources/read {uri}` | Resource read (e.g., `resources/read config://database`) |
| `resources/read` | Resource read (URI in `mcp.resource.uri` attribute, not span name) |
| `prompts/get {name}` | Prompt render (e.g., `prompts/get greeting`) |
For mounted servers, an additional `delegate {name}` span shows the delegation to the child server.
### Client Spans
The FastMCP client creates spans for outgoing requests with the same naming pattern (`tools/call {name}`, `resources/read {uri}`, `prompts/get {name}`).
The FastMCP client creates spans for outgoing requests with the same naming pattern (`tools/call {name}`, `resources/read`, `prompts/get {name}`).
### Span Hierarchy
@ -186,21 +186,16 @@ def risky_operation() -> str:
raise ValueError("Something went wrong")
# The span will have:
# - status = ERROR
# - status = ERROR with exception message as description
# - error.type = "tool_error" (or exception class name for non-tool errors)
# - exception event with stack trace
```
## Attributes Reference
### RPC Semantic Conventions
Standard [RPC semantic conventions](https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/):
| Attribute | Value |
|-----------|-------|
| `rpc.system` | `"mcp"` |
| `rpc.service` | Server name |
| `rpc.method` | MCP protocol method |
<Warning>
**Migrating from v3.1 or earlier:** The `rpc.system`, `rpc.service`, and `rpc.method` span attributes were removed in favor of the [MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/) listed below. If you have dashboards or alerts keyed on those `rpc.*` attributes, update them to use `mcp.method.name` and the `fastmcp.*` attributes instead.
</Warning>
### MCP Semantic Conventions
@ -211,6 +206,9 @@ FastMCP implements the [OpenTelemetry MCP semantic conventions](https://opentele
| `mcp.method.name` | The MCP method being called (`tools/call`, `resources/read`, `prompts/get`) |
| `mcp.session.id` | Session identifier for the MCP connection |
| `mcp.resource.uri` | The resource URI (for resource operations) |
| `gen_ai.tool.name` | Tool name (on `tools/call` spans) |
| `gen_ai.prompt.name` | Prompt name (on `prompts/get` spans) |
| `error.type` | Error classification (`tool_error` for ToolError, otherwise exception class name) |
### Auth Attributes

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

@ -193,7 +193,7 @@ class ClientResourcesMixin:
"""
uri_str = str(uri)
with client_span(
f"resources/read {uri_str}",
"resources/read",
"resources/read",
uri_str,
session_id=self.transport.get_session_id(),

View file

@ -7,6 +7,7 @@ import weakref
from typing import TYPE_CHECKING, Any, Literal, overload
import mcp.types
from opentelemetry.trace import Status, StatusCode
from pydantic import RootModel
if TYPE_CHECKING:
@ -144,7 +145,8 @@ class ClientToolsMixin:
"tools/call",
name,
session_id=self.transport.get_session_id(),
):
tool_name=name,
) as span:
logger.debug(f"[{self.name}] called call_tool: {name}")
# Inject trace context into meta for propagation to server
@ -159,6 +161,18 @@ class ClientToolsMixin:
meta=propagated_meta if propagated_meta else None,
)
)
# Reflect tool-level errors on the span so callers see ERROR
# status even though the MCP protocol call itself succeeded.
if result.isError and span.is_recording():
span.set_attribute("error.type", "tool_error")
description = ""
if result.content and isinstance(
result.content[0], mcp.types.TextContent
):
description = result.content[0].text
span.set_status(Status(StatusCode.ERROR, description))
return result
async def _parse_call_tool_result(

View file

@ -5,6 +5,7 @@ from contextlib import contextmanager
from opentelemetry.trace import Span, SpanKind, Status, StatusCode
from fastmcp.exceptions import ToolError as _ToolError
from fastmcp.telemetry import get_tracer
@ -15,6 +16,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.
@ -22,25 +25,32 @@ 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:
attrs["mcp.session.id"] = session_id
if resource_uri:
attrs["mcp.resource.uri"] = resource_uri
span.set_attributes(attrs)
if span.is_recording():
attrs: dict[str, str] = {
# MCP semantic conventions
"mcp.method.name": method,
# FastMCP-specific attributes
"fastmcp.component.key": component_key,
}
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.record_exception(e)
span.set_status(Status(StatusCode.ERROR))
if span.is_recording():
error_type = (
"tool_error" if isinstance(e, _ToolError) else type(e).__qualname__
)
span.set_attribute("error.type", error_type)
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
raise

View file

@ -109,7 +109,10 @@ 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,
@ -202,7 +205,10 @@ 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
@ -281,7 +287,10 @@ 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
@ -400,7 +409,10 @@ 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

@ -119,7 +119,10 @@ class ProxyTool(Tool):
"""Executes the tool by making a call through the client."""
backend_name = self._backend_name or self.name
with client_span(
f"tools/call {backend_name}", "tools/call", backend_name
f"tools/call {backend_name}",
"tools/call",
backend_name,
tool_name=backend_name,
) as span:
span.set_attribute("fastmcp.provider.type", "ProxyProvider")
client = await self._get_client()
@ -241,7 +244,7 @@ class ProxyResource(Resource):
backend_uri = self._backend_uri or str(self.uri)
with client_span(
f"resources/read {backend_uri}",
"resources/read",
"resources/read",
backend_uri,
resource_uri=backend_uri,
@ -456,7 +459,10 @@ class ProxyPrompt(Prompt):
"""Render the prompt by making a call through the client."""
backend_name = self._backend_name or self.name
with client_span(
f"prompts/get {backend_name}", "prompts/get", backend_name
f"prompts/get {backend_name}",
"prompts/get",
backend_name,
prompt_name=backend_name,
) as span:
span.set_attribute("fastmcp.provider.type", "ProxyProvider")
client = await self._get_client()

View file

@ -1204,7 +1204,12 @@ 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)
@ -1341,7 +1346,7 @@ class FastMCP(
# Core logic: find and read resource (providers queried in parallel)
with server_span(
f"resources/read {uri}",
"resources/read",
"resources/read",
self.name,
"resource",
@ -1506,7 +1511,12 @@ 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

@ -7,6 +7,7 @@ from mcp.server.lowlevel.server import request_ctx
from opentelemetry.context import Context
from opentelemetry.trace import Span, SpanKind, Status, StatusCode
from fastmcp.exceptions import ToolError as _ToolError
from fastmcp.telemetry import extract_trace_context, get_tracer
@ -60,6 +61,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.
@ -71,28 +74,34 @@ def server_span(
context=_get_parent_trace_context(),
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
"fastmcp.server.name": server_name,
"fastmcp.component.type": component_type,
"fastmcp.component.key": component_key,
**get_auth_span_attributes(),
**get_session_span_attributes(),
}
if resource_uri is not None:
attrs["mcp.resource.uri"] = resource_uri
span.set_attributes(attrs)
if span.is_recording():
attrs: dict[str, str] = {
# MCP semantic conventions
"mcp.method.name": method,
# FastMCP-specific attributes
"fastmcp.server.name": server_name,
"fastmcp.component.type": component_type,
"fastmcp.component.key": component_key,
**get_auth_span_attributes(),
**get_session_span_attributes(),
}
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.record_exception(e)
span.set_status(Status(StatusCode.ERROR))
if span.is_recording():
error_type = (
"tool_error" if isinstance(e, _ToolError) else type(e).__qualname__
)
span.set_attribute("error.type", error_type)
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
raise
@ -101,6 +110,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,17 +119,24 @@ def delegate_span(
"""
tracer = get_tracer()
with tracer.start_as_current_span(f"delegate {name}") as span:
span.set_attributes(
{
if span.is_recording():
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:
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR))
if span.is_recording():
error_type = (
"tool_error" if isinstance(e, _ToolError) else type(e).__qualname__
)
span.set_attribute("error.type", error_type)
span.record_exception(e)
span.set_status(Status(StatusCode.ERROR, str(e)))
raise

View file

@ -63,12 +63,50 @@ 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
):
"""Tool error should be reflected on the client span via isError check."""
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 client span (from call_tool_mcp)
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
]
# Exactly one client span should exist (no duplicate from call_tool)
assert len(client_spans) == 1, (
"There should be exactly one client span for call_tool"
)
error_span = client_spans[0]
assert error_span.status.status_code == StatusCode.ERROR
assert error_span.attributes is not None
assert error_span.attributes["error.type"] == "tool_error"
class TestClientResourceTracing:
"""Tests for client resource read tracing."""
@ -90,8 +128,8 @@ class TestClientResourceTracing:
spans = trace_exporter.get_finished_spans()
span_names = [s.name for s in spans]
# Client should create "resources/read data://config" span
assert "resources/read data://config" in span_names
# Client should create "resources/read" span (URI in attributes, not name)
assert "resources/read" in span_names
async def test_read_resource_span_attributes(
self, trace_exporter: InMemorySpanExporter
@ -113,7 +151,7 @@ class TestClientResourceTracing:
(
s
for s in spans
if s.name.startswith("resources/read data://")
if s.name == "resources/read"
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
),
@ -124,9 +162,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 +221,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 +282,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(
@ -380,7 +420,7 @@ class TestClientErrorTracing:
(
s
for s in spans
if s.name.startswith("resources/read data://fail")
if s.name == "resources/read"
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
),
@ -391,7 +431,7 @@ class TestClientErrorTracing:
(
s
for s in spans
if s.name.startswith("resources/read data://fail")
if s.name == "resources/read"
and s.attributes is not None
and "fastmcp.server.name" in s.attributes
),

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,10 @@ class TestToolTracing:
span = spans[0]
assert span.name == "tools/call failing_tool"
assert span.status.status_code == StatusCode.ERROR
assert span.status.description is not None
assert "Something went wrong" in span.status.description
assert span.attributes is not None
assert span.attributes["error.type"] == "tool_error"
assert len(span.events) > 0 # Exception recorded
async def test_call_nonexistent_tool_sets_error(
@ -76,6 +82,9 @@ class TestToolTracing:
span = spans[0]
assert span.name == "tools/call nonexistent"
assert span.status.status_code == StatusCode.ERROR
assert span.attributes is not None
# NotFoundError is not a ToolError, so uses class name as fallback
assert span.attributes["error.type"] == "NotFoundError"
class TestResourceTracing:
@ -95,16 +104,16 @@ class TestResourceTracing:
assert len(spans) == 1
span = spans[0]
assert span.name == "resources/read config://app"
assert span.name == "resources/read"
assert span.kind == SpanKind.SERVER
assert span.attributes is not None
# 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"
@ -126,14 +135,15 @@ class TestResourceTracing:
assert len(spans) == 1
span = spans[0]
assert span.name == "resources/read users://123/profile"
assert span.name == "resources/read"
assert span.kind == SpanKind.SERVER
assert span.attributes is not None
# 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 (
@ -153,7 +163,7 @@ class TestResourceTracing:
assert len(spans) == 1
span = spans[0]
assert span.name == "resources/read nonexistent://resource"
assert span.name == "resources/read"
assert span.status.status_code == StatusCode.ERROR
@ -179,10 +189,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"