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/tools.py b/src/fastmcp/client/mixins/tools.py index aec595019..1c21f3138 100644 --- a/src/fastmcp/client/mixins/tools.py +++ b/src/fastmcp/client/mixins/tools.py @@ -52,12 +52,18 @@ class ClientToolsMixin: RuntimeError: If called while the client is not connected. McpError: If the request results in a TimeoutError | JSONRPCError """ - logger.debug(f"[{self.name}] called list_tools") + with client_span( + "tools/list", + "tools/list", + "", + session_id=self.transport.get_session_id(), + ): + logger.debug(f"[{self.name}] called list_tools") - result = await self._await_with_session_monitoring( - self.session.list_tools(cursor=cursor) - ) - return result + result = await self._await_with_session_monitoring( + self.session.list_tools(cursor=cursor) + ) + return result async def list_tools( self: Client, @@ -144,6 +150,7 @@ class ClientToolsMixin: "tools/call", name, session_id=self.transport.get_session_id(), + tool_name=name, ): logger.debug(f"[{self.name}] called call_tool: {name}") @@ -277,16 +284,23 @@ class ClientToolsMixin: name, arguments, task_id, ttl, meta=request_meta or None ) - result = await self.call_tool_mcp( - name=name, - arguments=arguments or {}, - timeout=timeout, - progress_handler=progress_handler, - meta=request_meta or None, - ) - return await self._parse_call_tool_result( - name, result, raise_on_error=raise_on_error - ) + with client_span( + f"tools/call {name}", + "tools/call", + name, + session_id=self.transport.get_session_id(), + tool_name=name, + ): + result = await self.call_tool_mcp( + name=name, + arguments=arguments or {}, + timeout=timeout, + progress_handler=progress_handler, + meta=request_meta or None, + ) + return await self._parse_call_tool_result( + name, result, raise_on_error=raise_on_error + ) async def _call_tool_as_task( self: Client, diff --git a/src/fastmcp/client/telemetry.py b/src/fastmcp/client/telemetry.py index 10d6d825f..b2f4cf781 100644 --- a/src/fastmcp/client/telemetry.py +++ b/src/fastmcp/client/telemetry.py @@ -15,6 +15,8 @@ def client_span( component_key: str, session_id: str | None = None, resource_uri: str | None = None, + tool_name: str | None = None, + prompt_name: str | None = None, ) -> Generator[Span, None, None]: """Create a CLIENT span with standard MCP attributes. @@ -23,24 +25,26 @@ def client_span( tracer = get_tracer() with tracer.start_as_current_span(name, kind=SpanKind.CLIENT) as span: attrs: dict[str, str] = { - # RPC semantic conventions - "rpc.system": "mcp", - "rpc.method": method, # MCP semantic conventions "mcp.method.name": method, # FastMCP-specific attributes "fastmcp.component.key": component_key, } - if session_id: + if session_id is not None: attrs["mcp.session.id"] = session_id if resource_uri: attrs["mcp.resource.uri"] = resource_uri + if tool_name is not None: + attrs["gen_ai.tool.name"] = tool_name + if prompt_name is not None: + attrs["gen_ai.prompt.name"] = prompt_name span.set_attributes(attrs) try: yield span except Exception as e: + span.set_attribute("error.type", type(e).__name__) span.record_exception(e) - span.set_status(Status(StatusCode.ERROR)) + span.set_status(Status(StatusCode.ERROR, str(e))) raise diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py index dcdea14fc..ed510c091 100644 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ b/src/fastmcp/server/providers/fastmcp_provider.py @@ -139,7 +139,8 @@ class FastMCPProviderTool(Tool): version = VersionSpec(eq=self.version) if self.version else None with delegate_span( - self._original_name or "", "FastMCPProvider", self._original_name or "" + self._original_name or "", "FastMCPProvider", self._original_name or "", + method="tools/call", ): return await self._server.call_tool( self._original_name, @@ -232,7 +233,8 @@ class FastMCPProviderResource(Resource): version = VersionSpec(eq=self.version) if self.version else None with delegate_span( - self._original_uri or "", "FastMCPProvider", self._original_uri or "" + self._original_uri or "", "FastMCPProvider", self._original_uri or "", + method="resources/read", ): return await self._server.read_resource( self._original_uri, version=version, task_meta=task_meta @@ -311,7 +313,8 @@ class FastMCPProviderPrompt(Prompt): version = VersionSpec(eq=self.version) if self.version else None with delegate_span( - self._original_name or "", "FastMCPProvider", self._original_name or "" + self._original_name or "", "FastMCPProvider", self._original_name or "", + method="prompts/get", ): return await self._server.render_prompt( self._original_name, arguments, version=version, task_meta=task_meta @@ -430,7 +433,8 @@ class FastMCPProviderResourceTemplate(ResourceTemplate): version = VersionSpec(eq=self.version) if self.version else None with delegate_span( - original_uri, "FastMCPProvider", self._original_uri_template or "" + original_uri, "FastMCPProvider", self._original_uri_template or "", + method="resources/read", ): return await self._server.read_resource( original_uri, version=version, task_meta=task_meta diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 967ef1ceb..b0a453696 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -1204,7 +1204,8 @@ class FastMCP( # Core logic: find and execute tool with server_span( - f"tools/call {name}", "tools/call", self.name, "tool", name + f"tools/call {name}", "tools/call", self.name, "tool", name, + tool_name=name, ) as span: # Try normal display-name resolution first. tool: Tool | None = await self.get_tool(name, version=version) @@ -1506,7 +1507,8 @@ class FastMCP( # Core logic: find and render prompt (providers queried in parallel) # Use get_prompt to apply transforms and filter disabled with server_span( - f"prompts/get {name}", "prompts/get", self.name, "prompt", name + f"prompts/get {name}", "prompts/get", self.name, "prompt", name, + prompt_name=name, ) as span: prompt = await self.get_prompt(name, version=version) if prompt is None: diff --git a/src/fastmcp/server/telemetry.py b/src/fastmcp/server/telemetry.py index 6c263225d..218b308f0 100644 --- a/src/fastmcp/server/telemetry.py +++ b/src/fastmcp/server/telemetry.py @@ -60,6 +60,8 @@ def server_span( component_type: str, component_key: str, resource_uri: str | None = None, + tool_name: str | None = None, + prompt_name: str | None = None, ) -> Generator[Span, None, None]: """Create a SERVER span with standard MCP attributes and auth context. @@ -72,10 +74,6 @@ def server_span( kind=SpanKind.SERVER, ) as span: attrs: dict[str, str] = { - # RPC semantic conventions - "rpc.system": "mcp", - "rpc.service": server_name, - "rpc.method": method, # MCP semantic conventions "mcp.method.name": method, # FastMCP-specific attributes @@ -87,12 +85,17 @@ def server_span( } if resource_uri is not None: attrs["mcp.resource.uri"] = resource_uri + if tool_name is not None: + attrs["gen_ai.tool.name"] = tool_name + if prompt_name is not None: + attrs["gen_ai.prompt.name"] = prompt_name span.set_attributes(attrs) try: yield span except Exception as e: + span.set_attribute("error.type", type(e).__name__) span.record_exception(e) - span.set_status(Status(StatusCode.ERROR)) + span.set_status(Status(StatusCode.ERROR, str(e))) raise @@ -101,6 +104,7 @@ def delegate_span( name: str, provider_type: str, component_key: str, + method: str | None = None, ) -> Generator[Span, None, None]: """Create an INTERNAL span for provider delegation. @@ -109,12 +113,13 @@ def delegate_span( """ tracer = get_tracer() with tracer.start_as_current_span(f"delegate {name}") as span: - span.set_attributes( - { - "fastmcp.provider.type": provider_type, - "fastmcp.component.key": component_key, - } - ) + attrs: dict[str, str] = { + "fastmcp.provider.type": provider_type, + "fastmcp.component.key": component_key, + } + if method is not None: + attrs["mcp.method.name"] = method + span.set_attributes(attrs) try: yield span except Exception as e: diff --git a/tests/client/telemetry/test_client_tracing.py b/tests/client/telemetry/test_client_tracing.py index 309ec3af1..fcadab636 100644 --- a/tests/client/telemetry/test_client_tracing.py +++ b/tests/client/telemetry/test_client_tracing.py @@ -63,12 +63,52 @@ class TestClientToolTracing: assert client_span.attributes is not None # Standard MCP semantic conventions assert client_span.attributes["mcp.method.name"] == "tools/call" - # Standard RPC semantic conventions - assert client_span.attributes["rpc.system"] == "mcp" - assert client_span.attributes["rpc.method"] == "tools/call" + # gen_ai semantic conventions + assert client_span.attributes["gen_ai.tool.name"] == "add" + # RPC attributes must NOT be present + assert "rpc.system" not in client_span.attributes + assert "rpc.method" not in client_span.attributes # FastMCP-specific attributes assert client_span.attributes["fastmcp.component.key"] == "add" + async def test_call_tool_error_caught_by_client_span( + self, trace_exporter: InMemorySpanExporter + ): + """ToolError from _parse_call_tool_result should be caught by the client span.""" + server = FastMCP("test-server") + + @server.tool() + def failing_tool() -> str: + raise ValueError("boom") + + client = Client(server) + async with client: + with pytest.raises(ToolError): + await client.call_tool("failing_tool", {}) + + spans = trace_exporter.get_finished_spans() + + # Find the outer client span (from call_tool wrapping _parse_call_tool_result) + client_spans = [ + s + for s in spans + if s.name == "tools/call failing_tool" + and s.attributes is not None + and "fastmcp.server.name" not in s.attributes + ] + + # At least one client span should have ERROR status (the one catching ToolError) + error_client_spans = [ + s for s in client_spans if s.status.status_code == StatusCode.ERROR + ] + assert len(error_client_spans) >= 1, ( + "At least one client span should capture the ToolError" + ) + + error_span = error_client_spans[0] + assert error_span.attributes is not None + assert error_span.attributes["error.type"] == "ToolError" + class TestClientResourceTracing: """Tests for client resource read tracing.""" @@ -124,9 +164,9 @@ class TestClientResourceTracing: # Standard MCP semantic conventions assert client_span.attributes["mcp.method.name"] == "resources/read" assert "data://" in str(client_span.attributes["mcp.resource.uri"]) - # Standard RPC semantic conventions - assert client_span.attributes["rpc.system"] == "mcp" - assert client_span.attributes["rpc.method"] == "resources/read" + # RPC attributes must NOT be present + assert "rpc.system" not in client_span.attributes + assert "rpc.method" not in client_span.attributes # FastMCP-specific attributes # The URI may be normalized with trailing slash assert "data://" in str(client_span.attributes["fastmcp.component.key"]) @@ -183,9 +223,11 @@ class TestClientPromptTracing: assert client_span.attributes is not None # Standard MCP semantic conventions assert client_span.attributes["mcp.method.name"] == "prompts/get" - # Standard RPC semantic conventions - assert client_span.attributes["rpc.system"] == "mcp" - assert client_span.attributes["rpc.method"] == "prompts/get" + # gen_ai semantic conventions + assert client_span.attributes["gen_ai.prompt.name"] == "welcome" + # RPC attributes must NOT be present + assert "rpc.system" not in client_span.attributes + assert "rpc.method" not in client_span.attributes # FastMCP-specific attributes assert client_span.attributes["fastmcp.component.key"] == "welcome" @@ -242,7 +284,7 @@ class TestClientServerSpanHierarchy: assert server_span.kind == SpanKind.SERVER, "Server span should be SERVER kind" # Verify the spans have different characteristics - assert client_span.attributes["rpc.method"] == "tools/call" + assert client_span.attributes["mcp.method.name"] == "tools/call" assert server_span.attributes["fastmcp.server.name"] == "test-server" async def test_trace_context_propagation( diff --git a/tests/server/telemetry/test_delegate_method.py b/tests/server/telemetry/test_delegate_method.py new file mode 100644 index 000000000..87227750b --- /dev/null +++ b/tests/server/telemetry/test_delegate_method.py @@ -0,0 +1,80 @@ +"""Tests for mcp.method.name attribute on delegate spans.""" + +from __future__ import annotations + +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + +from fastmcp import FastMCP + + +class TestDelegateSpanMethod: + """Tests that delegate spans include mcp.method.name.""" + + async def test_mounted_tool_delegate_has_method( + self, trace_exporter: InMemorySpanExporter + ): + child = FastMCP("child-server") + + @child.tool() + def child_tool() -> str: + return "result" + + parent = FastMCP("parent-server") + parent.mount(child, namespace="child") + + await parent.call_tool("child_child_tool", {}) + + spans = trace_exporter.get_finished_spans() + delegate_span = next( + (s for s in spans if s.name == "delegate child_tool"), None + ) + assert delegate_span is not None + assert delegate_span.attributes is not None + assert delegate_span.attributes["mcp.method.name"] == "tools/call" + + async def test_mounted_resource_delegate_has_method( + self, trace_exporter: InMemorySpanExporter + ): + child = FastMCP("child-server") + + @child.resource("data://config") + def child_config() -> str: + return "config data" + + parent = FastMCP("parent-server") + parent.mount(child, namespace="child") + + await parent.read_resource("data://child/config") + + spans = trace_exporter.get_finished_spans() + delegate_spans = [ + s + for s in spans + if s.name.startswith("delegate") and "data://config" in s.name + ] + assert len(delegate_spans) >= 1 + span = delegate_spans[0] + assert span.attributes is not None + assert span.attributes["mcp.method.name"] == "resources/read" + + async def test_mounted_prompt_delegate_has_method( + self, trace_exporter: InMemorySpanExporter + ): + child = FastMCP("child-server") + + @child.prompt() + def child_prompt() -> str: + return "Hello from child!" + + parent = FastMCP("parent-server") + parent.mount(child, namespace="child") + + await parent.render_prompt("child_child_prompt", {}) + + spans = trace_exporter.get_finished_spans() + delegate_span = next( + (s for s in spans if s.name == "delegate child_prompt"), None + ) + assert delegate_span is not None + assert delegate_span.attributes is not None + assert delegate_span.attributes["mcp.method.name"] == "prompts/get" diff --git a/tests/server/telemetry/test_server_tracing.py b/tests/server/telemetry/test_server_tracing.py index 1d110effe..3cb2c33f8 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,9 @@ class TestToolTracing: span = spans[0] assert span.name == "tools/call failing_tool" assert span.status.status_code == StatusCode.ERROR + assert "Something went wrong" in span.status.description + assert span.attributes is not None + assert span.attributes["error.type"] == "ToolError" assert len(span.events) > 0 # Exception recorded async def test_call_nonexistent_tool_sets_error( @@ -76,6 +81,8 @@ class TestToolTracing: span = spans[0] assert span.name == "tools/call nonexistent" assert span.status.status_code == StatusCode.ERROR + assert span.attributes is not None + assert span.attributes["error.type"] == "NotFoundError" class TestResourceTracing: @@ -101,10 +108,10 @@ class TestResourceTracing: # Standard MCP semantic conventions assert span.attributes["mcp.method.name"] == "resources/read" assert span.attributes["mcp.resource.uri"] == "config://app" - # Standard RPC semantic conventions - assert span.attributes["rpc.system"] == "mcp" - assert span.attributes["rpc.service"] == "test-server" - assert span.attributes["rpc.method"] == "resources/read" + # RPC attributes must NOT be present + assert "rpc.system" not in span.attributes + assert "rpc.service" not in span.attributes + assert "rpc.method" not in span.attributes # FastMCP-specific attributes assert span.attributes["fastmcp.server.name"] == "test-server" assert span.attributes["fastmcp.component.type"] == "resource" @@ -132,8 +139,9 @@ class TestResourceTracing: # Standard MCP semantic conventions assert span.attributes["mcp.method.name"] == "resources/read" assert span.attributes["mcp.resource.uri"] == "users://123/profile" - # Standard RPC semantic conventions - assert span.attributes["rpc.method"] == "resources/read" + # RPC attributes must NOT be present + assert "rpc.system" not in span.attributes + assert "rpc.method" not in span.attributes # Template component type is set by get_span_attributes assert span.attributes["fastmcp.component.type"] == "resource_template" assert ( @@ -179,10 +187,12 @@ class TestPromptTracing: assert span.attributes is not None # Standard MCP semantic conventions assert span.attributes["mcp.method.name"] == "prompts/get" - # Standard RPC semantic conventions - assert span.attributes["rpc.system"] == "mcp" - assert span.attributes["rpc.service"] == "test-server" - assert span.attributes["rpc.method"] == "prompts/get" + # gen_ai semantic conventions + assert span.attributes["gen_ai.prompt.name"] == "greeting" + # RPC attributes must NOT be present + assert "rpc.system" not in span.attributes + assert "rpc.service" not in span.attributes + assert "rpc.method" not in span.attributes # FastMCP-specific attributes assert span.attributes["fastmcp.server.name"] == "test-server" assert span.attributes["fastmcp.component.type"] == "prompt"