mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 21:44:18 +02:00
OTEL: Instrument list operations and add method to delegate spans
- Add server_span wrappers to list_tools, list_resources, list_resource_templates, and list_prompts in FastMCP server - Add client_span wrappers to list_tools_mcp, list_resources_mcp, list_resource_templates_mcp, and list_prompts_mcp in client mixins - Add optional `method` parameter to delegate_span in telemetry.py and set mcp.method.name on delegate spans - Update all delegate_span callers in fastmcp_provider.py to pass the MCP method name (tools/call, resources/read, prompts/get) - Add tests for new server list spans, client list spans, and delegate span method attributes Refs: #3887 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7dd573980a
commit
21db5c6cc0
7 changed files with 455 additions and 24 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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={},
|
||||
|
|
|
|||
181
tests/client/telemetry/test_client_list_tracing.py
Normal file
181
tests/client/telemetry/test_client_list_tracing.py
Normal file
|
|
@ -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"
|
||||
80
tests/server/telemetry/test_delegate_method.py
Normal file
80
tests/server/telemetry/test_delegate_method.py
Normal file
|
|
@ -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"
|
||||
130
tests/server/telemetry/test_list_tracing.py
Normal file
130
tests/server/telemetry/test_list_tracing.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue