From 7184a4ca211dd34ca3635a53c74a9cffbd0d26cb Mon Sep 17 00:00:00 2001 From: Bill Easton Date: Tue, 14 Apr 2026 15:28:47 -0500 Subject: [PATCH] OTEL: Fix attribute compliance with MCP semantic conventions (#3889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * Hoist ToolError imports to module level, add rpc.* migration note Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- docs/servers/telemetry.mdx | 22 +++--- src/fastmcp/client/mixins/prompts.py | 1 + src/fastmcp/client/mixins/resources.py | 2 +- src/fastmcp/client/mixins/tools.py | 16 ++++- src/fastmcp/client/telemetry.py | 42 ++++++----- .../server/providers/fastmcp_provider.py | 20 ++++-- src/fastmcp/server/providers/proxy.py | 12 +++- src/fastmcp/server/server.py | 16 ++++- src/fastmcp/server/telemetry.py | 65 ++++++++++------- tests/client/telemetry/test_client_tracing.py | 70 +++++++++++++++---- tests/server/telemetry/test_server_tracing.py | 46 +++++++----- 11 files changed, 216 insertions(+), 96 deletions(-) diff --git a/docs/servers/telemetry.mdx b/docs/servers/telemetry.mdx index 1055dc0c0..bf755ceba 100644 --- a/docs/servers/telemetry.mdx +++ b/docs/servers/telemetry.mdx @@ -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 | + +**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. + ### 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 diff --git a/src/fastmcp/client/mixins/prompts.py b/src/fastmcp/client/mixins/prompts.py index 4b87bf270..bb32cc280 100644 --- a/src/fastmcp/client/mixins/prompts.py +++ b/src/fastmcp/client/mixins/prompts.py @@ -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}") diff --git a/src/fastmcp/client/mixins/resources.py b/src/fastmcp/client/mixins/resources.py index c0dc27fff..c97fbe90b 100644 --- a/src/fastmcp/client/mixins/resources.py +++ b/src/fastmcp/client/mixins/resources.py @@ -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(), diff --git a/src/fastmcp/client/mixins/tools.py b/src/fastmcp/client/mixins/tools.py index aec595019..00a4a4265 100644 --- a/src/fastmcp/client/mixins/tools.py +++ b/src/fastmcp/client/mixins/tools.py @@ -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( diff --git a/src/fastmcp/client/telemetry.py b/src/fastmcp/client/telemetry.py index 10d6d825f..e66cd7b47 100644 --- a/src/fastmcp/client/telemetry.py +++ b/src/fastmcp/client/telemetry.py @@ -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 diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index c04741eae..8ae2c18d3 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -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 diff --git a/src/fastmcp/server/providers/proxy.py b/src/fastmcp/server/providers/proxy.py index fd6f44228..82c2fed0e 100644 --- a/src/fastmcp/server/providers/proxy.py +++ b/src/fastmcp/server/providers/proxy.py @@ -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() diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 1950d7a3b..415f12245 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -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: diff --git a/src/fastmcp/server/telemetry.py b/src/fastmcp/server/telemetry.py index 6c263225d..974d4dcf6 100644 --- a/src/fastmcp/server/telemetry.py +++ b/src/fastmcp/server/telemetry.py @@ -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 diff --git a/tests/client/telemetry/test_client_tracing.py b/tests/client/telemetry/test_client_tracing.py index 309ec3af1..54c4c08f3 100644 --- a/tests/client/telemetry/test_client_tracing.py +++ b/tests/client/telemetry/test_client_tracing.py @@ -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 ), diff --git a/tests/server/telemetry/test_server_tracing.py b/tests/server/telemetry/test_server_tracing.py index 1d110effe..2b98ae6cc 100644 --- a/tests/server/telemetry/test_server_tracing.py +++ b/tests/server/telemetry/test_server_tracing.py @@ -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"