Merge pull request #2886 from jlowin/mcp-semconv-2083

Adopt OpenTelemetry MCP semantic conventions
This commit is contained in:
Chris Guidry 2026-01-15 14:14:18 -05:00 committed by GitHub
commit 7b9a01c789
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 151 additions and 84 deletions

View file

@ -55,36 +55,36 @@ FastMCP creates spans for all MCP operations, providing end-to-end visibility in
### Server Spans
The server creates spans for each operation:
The server creates spans for each operation using [MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/):
| Span Name | Description |
|-----------|-------------|
| `tool {name}` | Tool execution (e.g., `tool get_weather`) |
| `resource {uri}` | Resource read (e.g., `resource config://database`) |
| `prompt {name}` | Prompt render (e.g., `prompt greeting`) |
| `tools/call {name}` | Tool execution (e.g., `tools/call get_weather`) |
| `resources/read {uri}` | Resource read (e.g., `resources/read config://database`) |
| `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 (`tool {name}`, `resource {uri}`, `prompt {name}`).
The FastMCP client creates spans for outgoing requests with the same naming pattern (`tools/call {name}`, `resources/read {uri}`, `prompts/get {name}`).
### Span Hierarchy
Spans form a hierarchy showing the request flow. For mounted servers:
```
tool weather_forecast (CLIENT)
└── tool weather_forecast (SERVER, provider=FastMCPProvider)
tools/call weather_forecast (CLIENT)
└── tools/call weather_forecast (SERVER, provider=FastMCPProvider)
└── delegate get_weather (INTERNAL)
└── tool get_weather (SERVER, provider=LocalProvider)
└── tools/call get_weather (SERVER, provider=LocalProvider)
```
For proxy providers connecting to remote servers:
```
tool remote_search (CLIENT)
└── tool remote_search (SERVER, provider=ProxyProvider)
tools/call remote_search (CLIENT)
└── tools/call remote_search (SERVER, provider=ProxyProvider)
└── [remote server spans via trace context propagation]
```
@ -191,22 +191,38 @@ def risky_operation() -> str:
## Attributes Reference
### Standard Semantic Conventions
### RPC Semantic Conventions
FastMCP uses OpenTelemetry semantic conventions where applicable:
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 |
| `error.type` | Exception class name (on errors) |
### MCP Semantic Conventions
FastMCP implements the [OpenTelemetry MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/):
| Attribute | Description |
|-----------|-------------|
| `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) |
### Auth Attributes
Standard [identity attributes](https://opentelemetry.io/docs/specs/semconv/attributes-registry/enduser/):
| Attribute | Description |
|-----------|-------------|
| `enduser.id` | Client ID from access token (when authenticated) |
| `enduser.scope` | Space-separated OAuth scopes (when authenticated) |
### FastMCP Custom Attributes
All custom attributes use the `fastmcp.` prefix:
All custom attributes use the `fastmcp.` prefix for features unique to FastMCP:
| Attribute | Description |
|-----------|-------------|
@ -214,7 +230,6 @@ All custom attributes use the `fastmcp.` prefix:
| `fastmcp.component.type` | `tool`, `resource`, `prompt`, or `resource_template` |
| `fastmcp.component.key` | Full component identifier (e.g., `tool:greet`) |
| `fastmcp.provider.type` | Provider class (`LocalProvider`, `FastMCPProvider`, `ProxyProvider`) |
| `fastmcp.session.id` | Client session identifier |
Provider-specific attributes for delegation context:
@ -260,5 +275,5 @@ async def test_tool_creates_span(trace_exporter: InMemorySpanExporter) -> None:
await mcp.call_tool("hello", {})
spans = trace_exporter.get_finished_spans()
assert any(s.name == "tool hello" for s in spans)
assert any(s.name == "tools/call hello" for s in spans)
```

View file

@ -881,10 +881,11 @@ class Client(Generic[ClientTransportT]):
"""
uri_str = str(uri)
with client_span(
f"resource {uri_str}",
f"resources/read {uri_str}",
"resources/read",
uri_str,
session_id=self.transport.get_session_id(),
resource_uri=uri_str,
):
logger.debug(f"[{self.name}] called read_resource: {uri}")
@ -1101,7 +1102,7 @@ class Client(Generic[ClientTransportT]):
McpError: If the request results in a TimeoutError | JSONRPCError
"""
with client_span(
f"prompt {name}",
f"prompts/get {name}",
"prompts/get",
name,
session_id=self.transport.get_session_id(),
@ -1393,7 +1394,7 @@ class Client(Generic[ClientTransportT]):
McpError: If the tool call requests results in a TimeoutError | JSONRPCError
"""
with client_span(
f"tool {name}",
f"tools/call {name}",
"tools/call",
name,
session_id=self.transport.get_session_id(),

View file

@ -14,6 +14,7 @@ def client_span(
method: str,
component_key: str,
session_id: str | None = None,
resource_uri: str | None = None,
) -> Generator[Span, None, None]:
"""Create a CLIENT span with standard MCP attributes.
@ -22,12 +23,18 @@ 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["fastmcp.session.id"] = session_id
attrs["mcp.session.id"] = session_id
if resource_uri:
attrs["mcp.resource.uri"] = resource_uri
span.set_attributes(attrs)
try:
yield span

View file

@ -115,7 +115,7 @@ 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"proxy tool {backend_name}", "tools/call", backend_name
f"tools/call {backend_name}", "tools/call", backend_name
) as span:
span.set_attribute("fastmcp.provider.type", "ProxyProvider")
client = await self._get_client()
@ -222,7 +222,10 @@ class ProxyResource(Resource):
backend_uri = self._backend_uri or str(self.uri)
with client_span(
f"proxy resource {backend_uri}", "resources/read", backend_uri
f"resources/read {backend_uri}",
"resources/read",
backend_uri,
resource_uri=backend_uri,
) as span:
span.set_attribute("fastmcp.provider.type", "ProxyProvider")
client = await self._get_client()
@ -434,7 +437,7 @@ 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"proxy prompt {backend_name}", "prompts/get", backend_name
f"prompts/get {backend_name}", "prompts/get", backend_name
) as span:
span.set_attribute("fastmcp.provider.type", "ProxyProvider")
client = await self._get_client()

View file

@ -1473,7 +1473,7 @@ class FastMCP(Generic[LifespanResultT]):
# Core logic: find and execute tool (providers queried in parallel)
with server_span(
f"tool {name}", "tools/call", self.name, "tool", name
f"tools/call {name}", "tools/call", self.name, "tool", name
) as span:
tool = await self.get_tool(name)
span.set_attributes(tool.get_span_attributes())
@ -1567,7 +1567,12 @@ class FastMCP(Generic[LifespanResultT]):
# Core logic: find and read resource (providers queried in parallel)
with server_span(
f"resource {uri}", "resources/read", self.name, "resource", uri
f"resources/read {uri}",
"resources/read",
self.name,
"resource",
uri,
resource_uri=uri,
) as span:
# Try concrete resources first
try:
@ -1681,7 +1686,7 @@ class FastMCP(Generic[LifespanResultT]):
# Core logic: find and render prompt (providers queried in parallel)
with server_span(
f"prompt {name}", "prompts/get", self.name, "prompt", name
f"prompts/get {name}", "prompts/get", self.name, "prompt", name
) as span:
prompt = await self.get_prompt(name)
span.set_attributes(prompt.get_span_attributes())

View file

@ -35,7 +35,7 @@ def get_session_span_attributes() -> dict[str, str]:
try:
ctx = get_context()
if ctx.request_context is not None and ctx.session_id is not None:
attrs["fastmcp.session.id"] = ctx.session_id
attrs["mcp.session.id"] = ctx.session_id
except RuntimeError:
pass
return attrs
@ -59,6 +59,7 @@ def server_span(
server_name: str,
component_type: str,
component_key: str,
resource_uri: str | None = None,
) -> Generator[Span, None, None]:
"""Create a SERVER span with standard MCP attributes and auth context.
@ -70,18 +71,23 @@ def server_span(
context=_get_parent_trace_context(),
kind=SpanKind.SERVER,
) as span:
span.set_attributes(
{
"rpc.system": "mcp",
"rpc.service": server_name,
"rpc.method": method,
"fastmcp.server.name": server_name,
"fastmcp.component.type": component_type,
"fastmcp.component.key": component_key,
**get_auth_span_attributes(),
**get_session_span_attributes(),
}
)
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)
try:
yield span
except Exception as e:

View file

@ -30,8 +30,8 @@ class TestClientToolTracing:
spans = trace_exporter.get_finished_spans()
span_names = [s.name for s in spans]
# Client should create "tool greet" span
assert "tool greet" in span_names
# Client should create "tools/call greet" span
assert "tools/call greet" in span_names
async def test_call_tool_span_attributes(
self, trace_exporter: InMemorySpanExporter
@ -53,15 +53,19 @@ class TestClientToolTracing:
(
s
for s in spans
if s.name == "tool add"
if s.name == "tools/call add"
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
),
None,
)
assert client_span 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"
# FastMCP-specific attributes
assert client_span.attributes["fastmcp.component.key"] == "add"
@ -85,8 +89,8 @@ class TestClientResourceTracing:
spans = trace_exporter.get_finished_spans()
span_names = [s.name for s in spans]
# Client should create "resource data://config" span
assert "resource data://config" in span_names
# Client should create "resources/read data://config" span
assert "resources/read data://config" in span_names
async def test_read_resource_span_attributes(
self, trace_exporter: InMemorySpanExporter
@ -108,15 +112,20 @@ class TestClientResourceTracing:
(
s
for s in spans
if s.name.startswith("resource data://")
if s.name.startswith("resources/read data://")
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
),
None,
)
assert client_span is not None
# 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"
# FastMCP-specific attributes
# The URI may be normalized with trailing slash
assert "data://" in str(client_span.attributes["fastmcp.component.key"])
@ -139,8 +148,8 @@ class TestClientPromptTracing:
spans = trace_exporter.get_finished_spans()
span_names = [s.name for s in spans]
# Client should create "prompt greeting" span
assert "prompt greeting" in span_names
# Client should create "prompts/get greeting" span
assert "prompts/get greeting" in span_names
async def test_get_prompt_span_attributes(
self, trace_exporter: InMemorySpanExporter
@ -162,15 +171,19 @@ class TestClientPromptTracing:
(
s
for s in spans
if s.name == "prompt welcome"
if s.name == "prompts/get welcome"
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
),
None,
)
assert client_span 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"
# FastMCP-specific attributes
assert client_span.attributes["fastmcp.component.key"] == "welcome"
@ -198,7 +211,7 @@ class TestClientServerSpanHierarchy:
(
s
for s in spans
if s.name == "tool echo"
if s.name == "tools/call echo"
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
),
@ -208,7 +221,7 @@ class TestClientServerSpanHierarchy:
(
s
for s in spans
if s.name == "tool echo"
if s.name == "tools/call echo"
and s.attributes is not None
and "fastmcp.server.name" in s.attributes
),
@ -248,7 +261,7 @@ class TestClientServerSpanHierarchy:
(
s
for s in spans
if s.name == "tool add"
if s.name == "tools/call add"
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
),
@ -258,7 +271,7 @@ class TestClientServerSpanHierarchy:
(
s
for s in spans
if s.name == "tool add"
if s.name == "tools/call add"
and s.attributes is not None
and "fastmcp.server.name" in s.attributes
),
@ -315,7 +328,7 @@ class TestClientErrorTracing:
(
s
for s in spans
if s.name == "tool failing_tool"
if s.name == "tools/call failing_tool"
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
),
@ -326,7 +339,7 @@ class TestClientErrorTracing:
(
s
for s in spans
if s.name == "tool failing_tool"
if s.name == "tools/call failing_tool"
and s.attributes is not None
and "fastmcp.server.name" in s.attributes
),
@ -362,7 +375,7 @@ class TestClientErrorTracing:
(
s
for s in spans
if s.name.startswith("resource data://fail")
if s.name.startswith("resources/read data://fail")
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
),
@ -373,7 +386,7 @@ class TestClientErrorTracing:
(
s
for s in spans
if s.name.startswith("resource data://fail")
if s.name.startswith("resources/read data://fail")
and s.attributes is not None
and "fastmcp.server.name" in s.attributes
),
@ -409,7 +422,7 @@ class TestClientErrorTracing:
(
s
for s in spans
if s.name == "prompt failing_prompt"
if s.name == "prompts/get failing_prompt"
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
),
@ -420,7 +433,7 @@ class TestClientErrorTracing:
(
s
for s in spans
if s.name == "prompt failing_prompt"
if s.name == "prompts/get failing_prompt"
and s.attributes is not None
and "fastmcp.server.name" in s.attributes
),
@ -452,7 +465,7 @@ class TestClientErrorTracing:
(
s
for s in spans
if s.name == "tool nonexistent"
if s.name == "tools/call nonexistent"
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
),
@ -463,7 +476,7 @@ class TestClientErrorTracing:
(
s
for s in spans
if s.name == "tool nonexistent"
if s.name == "tools/call nonexistent"
and s.attributes is not None
and "fastmcp.server.name" in s.attributes
),
@ -518,7 +531,7 @@ class TestSessionIdOnSpans:
(
s
for s in spans
if s.name == "tool echo"
if s.name == "tools/call echo"
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
),
@ -526,8 +539,8 @@ class TestSessionIdOnSpans:
)
assert client_span is not None, "Client should create a span"
assert "fastmcp.session.id" in client_span.attributes
assert client_span.attributes["fastmcp.session.id"] is not None
assert "mcp.session.id" in client_span.attributes
assert client_span.attributes["mcp.session.id"] is not None
async def test_server_span_includes_session_id(
self,
@ -549,7 +562,7 @@ class TestSessionIdOnSpans:
(
s
for s in spans
if s.name == "tool echo"
if s.name == "tools/call echo"
and s.attributes is not None
and "fastmcp.server.name" in s.attributes
),
@ -557,8 +570,8 @@ class TestSessionIdOnSpans:
)
assert server_span is not None, "Server should create a span"
assert "fastmcp.session.id" in server_span.attributes
assert server_span.attributes["fastmcp.session.id"] is not None
assert "mcp.session.id" in server_span.attributes
assert server_span.attributes["mcp.session.id"] is not None
async def test_client_and_server_share_same_session_id(
self,
@ -580,7 +593,7 @@ class TestSessionIdOnSpans:
(
s
for s in spans
if s.name == "tool echo"
if s.name == "tools/call echo"
and s.attributes is not None
and "fastmcp.server.name" not in s.attributes
),
@ -590,7 +603,7 @@ class TestSessionIdOnSpans:
(
s
for s in spans
if s.name == "tool echo"
if s.name == "tools/call echo"
and s.attributes is not None
and "fastmcp.server.name" in s.attributes
),
@ -601,8 +614,8 @@ class TestSessionIdOnSpans:
assert server_span is not None
# Both should have session IDs and they should match
client_session = client_span.attributes.get("fastmcp.session.id")
server_session = server_span.attributes.get("fastmcp.session.id")
client_session = client_span.attributes.get("mcp.session.id")
server_session = server_span.attributes.get("mcp.session.id")
assert client_session is not None, "Client span should have session ID"
assert server_session is not None, "Server span should have session ID"

View file

@ -32,12 +32,12 @@ class TestFastMCPProviderTracing:
spans = trace_exporter.get_finished_spans()
span_names = [s.name for s in spans]
# Parent server creates "tool child_child_tool"
assert "tool child_child_tool" in span_names
# Parent server creates "tools/call child_child_tool"
assert "tools/call child_child_tool" in span_names
# FastMCPProvider creates "delegate child_tool"
assert "delegate child_tool" in span_names
# Child server creates "tool child_tool"
assert "tool child_tool" in span_names
# Child server creates "tools/call child_tool"
assert "tools/call child_tool" in span_names
# Verify delegate span has correct attributes
delegate_span = next(s for s in spans if s.name == "delegate child_tool")
@ -120,9 +120,9 @@ class TestProviderSpanHierarchy:
spans = trace_exporter.get_finished_spans()
# Find the spans
parent_span = next(s for s in spans if s.name == "tool ns_greet")
parent_span = next(s for s in spans if s.name == "tools/call ns_greet")
delegate_span = next(s for s in spans if s.name == "delegate greet")
child_span = next(s for s in spans if s.name == "tool greet")
child_span = next(s for s in spans if s.name == "tools/call greet")
# Verify parent-child relationships
assert delegate_span.parent.span_id == parent_span.context.span_id

View file

@ -28,12 +28,16 @@ class TestToolTracing:
assert len(spans) == 1
span = spans[0]
assert span.name == "tool greet"
assert span.name == "tools/call greet"
assert span.kind == SpanKind.SERVER
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"
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "tool"
assert span.attributes["fastmcp.component.key"] == "tool:greet"
@ -54,7 +58,7 @@ class TestToolTracing:
assert len(spans) == 1
span = spans[0]
assert span.name == "tool failing_tool"
assert span.name == "tools/call failing_tool"
assert span.status.status_code == StatusCode.ERROR
assert len(span.events) > 0 # Exception recorded
@ -70,7 +74,7 @@ class TestToolTracing:
assert len(spans) == 1
span = spans[0]
assert span.name == "tool nonexistent"
assert span.name == "tools/call nonexistent"
assert span.status.status_code == StatusCode.ERROR
@ -91,12 +95,17 @@ class TestResourceTracing:
assert len(spans) == 1
span = spans[0]
assert span.name == "resource config://app"
assert span.name == "resources/read config://app"
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"
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "resource"
assert span.attributes["fastmcp.component.key"] == "resource:config://app"
@ -117,9 +126,13 @@ class TestResourceTracing:
assert len(spans) == 1
span = spans[0]
assert span.name == "resource users://123/profile"
assert span.name == "resources/read users://123/profile"
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"
# Template component type is set by get_span_attributes
assert span.attributes["fastmcp.component.type"] == "resource_template"
@ -140,7 +153,7 @@ class TestResourceTracing:
assert len(spans) == 1
span = spans[0]
assert span.name == "resource nonexistent://resource"
assert span.name == "resources/read nonexistent://resource"
assert span.status.status_code == StatusCode.ERROR
@ -161,12 +174,16 @@ class TestPromptTracing:
assert len(spans) == 1
span = spans[0]
assert span.name == "prompt greeting"
assert span.name == "prompts/get greeting"
assert span.kind == SpanKind.SERVER
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"
# FastMCP-specific attributes
assert span.attributes["fastmcp.server.name"] == "test-server"
assert span.attributes["fastmcp.component.type"] == "prompt"
assert span.attributes["fastmcp.component.key"] == "prompt:greeting"
@ -183,7 +200,7 @@ class TestPromptTracing:
assert len(spans) == 1
span = spans[0]
assert span.name == "prompt nonexistent"
assert span.name == "prompts/get nonexistent"
assert span.status.status_code == StatusCode.ERROR