mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
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:
parent
e4bb6666ab
commit
7184a4ca21
11 changed files with 216 additions and 96 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue