diff --git a/src/fastmcp/client/mixins/prompts.py b/src/fastmcp/client/mixins/prompts.py index bb32cc280..de503b448 100644 --- a/src/fastmcp/client/mixins/prompts.py +++ b/src/fastmcp/client/mixins/prompts.py @@ -49,12 +49,18 @@ class ClientPromptsMixin: 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_prompts") + with client_span( + "prompts/list", + "prompts/list", + "", + session_id=self.transport.get_session_id(), + ): + logger.debug(f"[{self.name}] called list_prompts") - result = await self._await_with_session_monitoring( - self.session.list_prompts(cursor=cursor) - ) - return result + result = await self._await_with_session_monitoring( + self.session.list_prompts(cursor=cursor) + ) + return result async def list_prompts( self: Client, diff --git a/src/fastmcp/client/mixins/resources.py b/src/fastmcp/client/mixins/resources.py index c97fbe90b..3cc845d50 100644 --- a/src/fastmcp/client/mixins/resources.py +++ b/src/fastmcp/client/mixins/resources.py @@ -48,12 +48,18 @@ class ClientResourcesMixin: 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_resources") + with client_span( + "resources/list", + "resources/list", + "", + session_id=self.transport.get_session_id(), + ): + logger.debug(f"[{self.name}] called list_resources") - result = await self._await_with_session_monitoring( - self.session.list_resources(cursor=cursor) - ) - return result + result = await self._await_with_session_monitoring( + self.session.list_resources(cursor=cursor) + ) + return result async def list_resources( self: Client, @@ -118,12 +124,18 @@ class ClientResourcesMixin: 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_resource_templates") + with client_span( + "resources/templates/list", + "resources/templates/list", + "", + session_id=self.transport.get_session_id(), + ): + logger.debug(f"[{self.name}] called list_resource_templates") - result = await self._await_with_session_monitoring( - self.session.list_resource_templates(cursor=cursor) - ) - return result + result = await self._await_with_session_monitoring( + self.session.list_resource_templates(cursor=cursor) + ) + return result async def list_resource_templates( self: Client, diff --git a/src/fastmcp/client/mixins/tools.py b/src/fastmcp/client/mixins/tools.py index 00a4a4265..07634b82e 100644 --- a/src/fastmcp/client/mixins/tools.py +++ b/src/fastmcp/client/mixins/tools.py @@ -53,12 +53,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, diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 415f12245..b50d4c2fb 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -606,7 +606,10 @@ class FastMCP( and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. """ - async with fastmcp.server.context.Context(fastmcp=self) as ctx: + with server_span( + "tools/list", "tools/list", self.name, "tool", "" + ): + async with fastmcp.server.context.Context(fastmcp=self) as ctx: if run_middleware: mw_context = MiddlewareContext( message=mcp.types.ListToolsRequest(method="tools/list"), @@ -740,7 +743,10 @@ class FastMCP( and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. """ - async with fastmcp.server.context.Context(fastmcp=self) as ctx: + with server_span( + "resources/list", "resources/list", self.name, "resource", "" + ): + async with fastmcp.server.context.Context(fastmcp=self) as ctx: if run_middleware: mw_context = MiddlewareContext( message={}, @@ -871,7 +877,14 @@ class FastMCP( auth filtering, and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. """ - async with fastmcp.server.context.Context(fastmcp=self) as ctx: + with server_span( + "resources/templates/list", + "resources/templates/list", + self.name, + "resource_template", + "", + ): + async with fastmcp.server.context.Context(fastmcp=self) as ctx: if run_middleware: mw_context = MiddlewareContext( message={}, @@ -997,7 +1010,10 @@ class FastMCP( and middleware execution. Returns all versions (no deduplication). Protocol handlers deduplicate for MCP wire format. """ - async with fastmcp.server.context.Context(fastmcp=self) as ctx: + with server_span( + "prompts/list", "prompts/list", self.name, "prompt", "" + ): + async with fastmcp.server.context.Context(fastmcp=self) as ctx: if run_middleware: mw_context = MiddlewareContext( message={}, diff --git a/tests/client/telemetry/test_client_list_tracing.py b/tests/client/telemetry/test_client_list_tracing.py new file mode 100644 index 000000000..33f066f5f --- /dev/null +++ b/tests/client/telemetry/test_client_list_tracing.py @@ -0,0 +1,181 @@ +"""Tests for client OpenTelemetry tracing on list operations.""" + +from __future__ import annotations + +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.trace import SpanKind + +from fastmcp import Client, FastMCP + + +class TestClientListToolsTracing: + """Tests for client tools/list tracing.""" + + async def test_list_tools_creates_client_span( + self, trace_exporter: InMemorySpanExporter + ): + server = FastMCP("test-server") + + @server.tool() + def greet(name: str) -> str: + return f"Hello, {name}!" + + client = Client(server) + async with client: + tools = await client.list_tools() + assert len(tools) == 1 + + spans = trace_exporter.get_finished_spans() + client_spans = [ + s + for s in spans + if s.name == "tools/list" + and s.attributes is not None + and "fastmcp.server.name" not in s.attributes + ] + assert len(client_spans) >= 1 + + span = client_spans[0] + assert span.kind == SpanKind.CLIENT + assert span.attributes is not None + assert span.attributes["mcp.method.name"] == "tools/list" + + async def test_list_tools_creates_both_client_and_server_spans( + self, trace_exporter: InMemorySpanExporter + ): + server = FastMCP("test-server") + + @server.tool() + def add(a: int, b: int) -> int: + return a + b + + client = Client(server) + async with client: + await client.list_tools() + + spans = trace_exporter.get_finished_spans() + tools_list_spans = [s for s in spans if s.name == "tools/list"] + assert len(tools_list_spans) >= 2 + + client_span = next( + ( + s + for s in tools_list_spans + if s.attributes is not None + and "fastmcp.server.name" not in s.attributes + ), + None, + ) + server_span = next( + ( + s + for s in tools_list_spans + if s.attributes is not None + and "fastmcp.server.name" in s.attributes + ), + None, + ) + + assert client_span is not None, "Client should create a span" + assert server_span is not None, "Server should create a span" + assert client_span.kind == SpanKind.CLIENT + assert server_span.kind == SpanKind.SERVER + + +class TestClientListResourcesTracing: + """Tests for client resources/list tracing.""" + + async def test_list_resources_creates_client_span( + self, trace_exporter: InMemorySpanExporter + ): + server = FastMCP("test-server") + + @server.resource("data://config") + def get_config() -> str: + return "config" + + client = Client(server) + async with client: + resources = await client.list_resources() + assert len(resources) >= 1 + + spans = trace_exporter.get_finished_spans() + client_spans = [ + s + for s in spans + if s.name == "resources/list" + and s.attributes is not None + and "fastmcp.server.name" not in s.attributes + ] + assert len(client_spans) >= 1 + + span = client_spans[0] + assert span.kind == SpanKind.CLIENT + assert span.attributes is not None + assert span.attributes["mcp.method.name"] == "resources/list" + + +class TestClientListResourceTemplatesTracing: + """Tests for client resources/templates/list tracing.""" + + async def test_list_resource_templates_creates_client_span( + self, trace_exporter: InMemorySpanExporter + ): + server = FastMCP("test-server") + + @server.resource("users://{user_id}/profile") + def get_profile(user_id: str) -> str: + return f"profile {user_id}" + + client = Client(server) + async with client: + templates = await client.list_resource_templates() + assert len(templates) >= 1 + + spans = trace_exporter.get_finished_spans() + client_spans = [ + s + for s in spans + if s.name == "resources/templates/list" + and s.attributes is not None + and "fastmcp.server.name" not in s.attributes + ] + assert len(client_spans) >= 1 + + span = client_spans[0] + assert span.kind == SpanKind.CLIENT + assert span.attributes is not None + assert span.attributes["mcp.method.name"] == "resources/templates/list" + + +class TestClientListPromptsTracing: + """Tests for client prompts/list tracing.""" + + async def test_list_prompts_creates_client_span( + self, trace_exporter: InMemorySpanExporter + ): + server = FastMCP("test-server") + + @server.prompt() + def greeting() -> str: + return "Hello!" + + client = Client(server) + async with client: + prompts = await client.list_prompts() + assert len(prompts) == 1 + + spans = trace_exporter.get_finished_spans() + client_spans = [ + s + for s in spans + if s.name == "prompts/list" + and s.attributes is not None + and "fastmcp.server.name" not in s.attributes + ] + assert len(client_spans) >= 1 + + span = client_spans[0] + assert span.kind == SpanKind.CLIENT + assert span.attributes is not None + assert span.attributes["mcp.method.name"] == "prompts/list" 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_list_tracing.py b/tests/server/telemetry/test_list_tracing.py new file mode 100644 index 000000000..92ff4f7fb --- /dev/null +++ b/tests/server/telemetry/test_list_tracing.py @@ -0,0 +1,130 @@ +"""Tests for server-level OpenTelemetry tracing on list operations.""" + +from __future__ import annotations + +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter +from opentelemetry.trace import SpanKind + +from fastmcp import FastMCP + + +class TestListToolsTracing: + async def test_list_tools_creates_span(self, trace_exporter: InMemorySpanExporter): + mcp = FastMCP("test-server") + + @mcp.tool() + def greet(name: str) -> str: + return f"Hello, {name}!" + + tools = await mcp.list_tools() + assert len(tools) == 1 + + spans = trace_exporter.get_finished_spans() + list_spans = [s for s in spans if s.name == "tools/list"] + assert len(list_spans) >= 1 + + span = list_spans[0] + assert span.kind == SpanKind.SERVER + assert span.attributes is not None + assert span.attributes["mcp.method.name"] == "tools/list" + assert span.attributes["fastmcp.server.name"] == "test-server" + assert span.attributes["fastmcp.component.type"] == "tool" + + async def test_list_tools_empty_creates_span( + self, trace_exporter: InMemorySpanExporter + ): + mcp = FastMCP("test-server") + + tools = await mcp.list_tools() + assert len(tools) == 0 + + spans = trace_exporter.get_finished_spans() + list_spans = [s for s in spans if s.name == "tools/list"] + assert len(list_spans) >= 1 + + +class TestListResourcesTracing: + async def test_list_resources_creates_span( + self, trace_exporter: InMemorySpanExporter + ): + mcp = FastMCP("test-server") + + @mcp.resource("config://app") + def get_config() -> str: + return "config" + + resources = await mcp.list_resources() + assert len(resources) >= 1 + + spans = trace_exporter.get_finished_spans() + list_spans = [s for s in spans if s.name == "resources/list"] + assert len(list_spans) >= 1 + + span = list_spans[0] + assert span.kind == SpanKind.SERVER + assert span.attributes is not None + assert span.attributes["mcp.method.name"] == "resources/list" + assert span.attributes["fastmcp.server.name"] == "test-server" + assert span.attributes["fastmcp.component.type"] == "resource" + + +class TestListResourceTemplatesTracing: + async def test_list_resource_templates_creates_span( + self, trace_exporter: InMemorySpanExporter + ): + mcp = FastMCP("test-server") + + @mcp.resource("users://{user_id}/profile") + def get_profile(user_id: str) -> str: + return f"profile {user_id}" + + templates = await mcp.list_resource_templates() + assert len(templates) >= 1 + + spans = trace_exporter.get_finished_spans() + list_spans = [s for s in spans if s.name == "resources/templates/list"] + assert len(list_spans) >= 1 + + span = list_spans[0] + assert span.kind == SpanKind.SERVER + assert span.attributes is not None + assert span.attributes["mcp.method.name"] == "resources/templates/list" + assert span.attributes["fastmcp.server.name"] == "test-server" + assert span.attributes["fastmcp.component.type"] == "resource_template" + + +class TestListPromptsTracing: + async def test_list_prompts_creates_span( + self, trace_exporter: InMemorySpanExporter + ): + mcp = FastMCP("test-server") + + @mcp.prompt() + def greeting(name: str) -> str: + return f"Hello, {name}!" + + prompts = await mcp.list_prompts() + assert len(prompts) == 1 + + spans = trace_exporter.get_finished_spans() + list_spans = [s for s in spans if s.name == "prompts/list"] + assert len(list_spans) >= 1 + + span = list_spans[0] + assert span.kind == SpanKind.SERVER + assert span.attributes is not None + assert span.attributes["mcp.method.name"] == "prompts/list" + assert span.attributes["fastmcp.server.name"] == "test-server" + assert span.attributes["fastmcp.component.type"] == "prompt" + + async def test_list_prompts_empty_creates_span( + self, trace_exporter: InMemorySpanExporter + ): + mcp = FastMCP("test-server") + + prompts = await mcp.list_prompts() + assert len(prompts) == 0 + + spans = trace_exporter.get_finished_spans() + list_spans = [s for s in spans if s.name == "prompts/list"] + assert len(list_spans) >= 1