mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 14:04:18 +02:00
Compare commits
3 commits
main
...
strawgate/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1b2c36e51 |
||
|
|
872ea73628 |
||
|
|
21db5c6cc0 |
7 changed files with 530 additions and 100 deletions
|
|
@ -49,12 +49,18 @@ class ClientPromptsMixin:
|
||||||
RuntimeError: If called while the client is not connected.
|
RuntimeError: If called while the client is not connected.
|
||||||
McpError: If the request results in a TimeoutError | JSONRPCError
|
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(
|
result = await self._await_with_session_monitoring(
|
||||||
self.session.list_prompts(cursor=cursor)
|
self.session.list_prompts(cursor=cursor)
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def list_prompts(
|
async def list_prompts(
|
||||||
self: Client,
|
self: Client,
|
||||||
|
|
|
||||||
|
|
@ -48,12 +48,18 @@ class ClientResourcesMixin:
|
||||||
RuntimeError: If called while the client is not connected.
|
RuntimeError: If called while the client is not connected.
|
||||||
McpError: If the request results in a TimeoutError | JSONRPCError
|
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(
|
result = await self._await_with_session_monitoring(
|
||||||
self.session.list_resources(cursor=cursor)
|
self.session.list_resources(cursor=cursor)
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def list_resources(
|
async def list_resources(
|
||||||
self: Client,
|
self: Client,
|
||||||
|
|
@ -118,12 +124,18 @@ class ClientResourcesMixin:
|
||||||
RuntimeError: If called while the client is not connected.
|
RuntimeError: If called while the client is not connected.
|
||||||
McpError: If the request results in a TimeoutError | JSONRPCError
|
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(
|
result = await self._await_with_session_monitoring(
|
||||||
self.session.list_resource_templates(cursor=cursor)
|
self.session.list_resource_templates(cursor=cursor)
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def list_resource_templates(
|
async def list_resource_templates(
|
||||||
self: Client,
|
self: Client,
|
||||||
|
|
|
||||||
|
|
@ -53,12 +53,18 @@ class ClientToolsMixin:
|
||||||
RuntimeError: If called while the client is not connected.
|
RuntimeError: If called while the client is not connected.
|
||||||
McpError: If the request results in a TimeoutError | JSONRPCError
|
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(
|
result = await self._await_with_session_monitoring(
|
||||||
self.session.list_tools(cursor=cursor)
|
self.session.list_tools(cursor=cursor)
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def list_tools(
|
async def list_tools(
|
||||||
self: Client,
|
self: Client,
|
||||||
|
|
|
||||||
|
|
@ -620,31 +620,33 @@ class FastMCP(
|
||||||
call_next=lambda context: self.list_tools(run_middleware=False),
|
call_next=lambda context: self.list_tools(run_middleware=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get all tools, apply session transforms, then filter enabled
|
# Core logic: list tools
|
||||||
# and model-visible (app-only tools are hidden from the model).
|
with server_span("tools/list", "tools/list", self.name, "tool", ""):
|
||||||
tools = list(await super().list_tools())
|
# Get all tools, apply session transforms, then filter enabled
|
||||||
tools = await apply_session_transforms(tools)
|
# and model-visible (app-only tools are hidden from the model).
|
||||||
tools = [t for t in tools if is_enabled(t) and _is_model_visible(t)]
|
tools = list(await super().list_tools())
|
||||||
|
tools = await apply_session_transforms(tools)
|
||||||
|
tools = [t for t in tools if is_enabled(t) and _is_model_visible(t)]
|
||||||
|
|
||||||
# Rewrite per-tool Prefab renderer URIs based on the tool's
|
# Rewrite per-tool Prefab renderer URIs based on the tool's
|
||||||
# mount-point address. The walk pairs each tool with the
|
# mount-point address. The walk pairs each tool with the
|
||||||
# provider that yielded it, computes the hashed URI, and
|
# provider that yielded it, computes the hashed URI, and
|
||||||
# produces a model_copy with the URI in place. Original
|
# produces a model_copy with the URI in place. Original
|
||||||
# Tool objects are not mutated.
|
# Tool objects are not mutated.
|
||||||
tools = self._rewrite_prefab_uris(tools)
|
tools = self._rewrite_prefab_uris(tools)
|
||||||
|
|
||||||
skip_auth, token = _get_auth_context()
|
skip_auth, token = _get_auth_context()
|
||||||
authorized: list[Tool] = []
|
authorized: list[Tool] = []
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
if not skip_auth and tool.auth is not None:
|
if not skip_auth and tool.auth is not None:
|
||||||
ctx = AuthContext(token=token, component=tool)
|
ctx = AuthContext(token=token, component=tool)
|
||||||
try:
|
try:
|
||||||
if not await run_auth_checks(tool.auth, ctx):
|
if not await run_auth_checks(tool.auth, ctx):
|
||||||
|
continue
|
||||||
|
except AuthorizationError:
|
||||||
continue
|
continue
|
||||||
except AuthorizationError:
|
authorized.append(tool)
|
||||||
continue
|
return authorized
|
||||||
authorized.append(tool)
|
|
||||||
return authorized
|
|
||||||
|
|
||||||
async def _get_tool(
|
async def _get_tool(
|
||||||
self, name: str, version: VersionSpec | None = None
|
self, name: str, version: VersionSpec | None = None
|
||||||
|
|
@ -754,32 +756,36 @@ class FastMCP(
|
||||||
call_next=lambda context: self.list_resources(run_middleware=False),
|
call_next=lambda context: self.list_resources(run_middleware=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get all resources, apply session transforms, then filter enabled
|
# Core logic: list resources
|
||||||
resources = list(await super().list_resources())
|
with server_span(
|
||||||
resources = await apply_session_transforms(resources)
|
"resources/list", "resources/list", self.name, "resource", ""
|
||||||
resources = [r for r in resources if is_enabled(r)]
|
):
|
||||||
|
# Get all resources, apply session transforms, then filter enabled
|
||||||
|
resources = list(await super().list_resources())
|
||||||
|
resources = await apply_session_transforms(resources)
|
||||||
|
resources = [r for r in resources if is_enabled(r)]
|
||||||
|
|
||||||
# Append synthetic Prefab renderer resources — one per
|
# Append synthetic Prefab renderer resources — one per
|
||||||
# prefab tool, hashed by mount address. These don't live on
|
# prefab tool, hashed by mount address. These don't live on
|
||||||
# any provider's storage; they're computed on demand.
|
# any provider's storage; they're computed on demand.
|
||||||
from fastmcp.server.providers.prefab_synthesis import (
|
from fastmcp.server.providers.prefab_synthesis import (
|
||||||
synthesize_prefab_resources,
|
synthesize_prefab_resources,
|
||||||
)
|
)
|
||||||
|
|
||||||
resources.extend(await synthesize_prefab_resources(self))
|
resources.extend(await synthesize_prefab_resources(self))
|
||||||
|
|
||||||
skip_auth, token = _get_auth_context()
|
skip_auth, token = _get_auth_context()
|
||||||
authorized: list[Resource] = []
|
authorized: list[Resource] = []
|
||||||
for resource in resources:
|
for resource in resources:
|
||||||
if not skip_auth and resource.auth is not None:
|
if not skip_auth and resource.auth is not None:
|
||||||
ctx = AuthContext(token=token, component=resource)
|
ctx = AuthContext(token=token, component=resource)
|
||||||
try:
|
try:
|
||||||
if not await run_auth_checks(resource.auth, ctx):
|
if not await run_auth_checks(resource.auth, ctx):
|
||||||
|
continue
|
||||||
|
except AuthorizationError:
|
||||||
continue
|
continue
|
||||||
except AuthorizationError:
|
authorized.append(resource)
|
||||||
continue
|
return authorized
|
||||||
authorized.append(resource)
|
|
||||||
return authorized
|
|
||||||
|
|
||||||
async def _get_resource(
|
async def _get_resource(
|
||||||
self, uri: str, version: VersionSpec | None = None
|
self, uri: str, version: VersionSpec | None = None
|
||||||
|
|
@ -887,23 +893,31 @@ class FastMCP(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get all templates, apply session transforms, then filter enabled
|
# Core logic: list resource templates
|
||||||
templates = list(await super().list_resource_templates())
|
with server_span(
|
||||||
templates = await apply_session_transforms(templates)
|
"resources/templates/list",
|
||||||
templates = [t for t in templates if is_enabled(t)]
|
"resources/templates/list",
|
||||||
|
self.name,
|
||||||
|
"resource_template",
|
||||||
|
"",
|
||||||
|
):
|
||||||
|
# Get all templates, apply session transforms, then filter enabled
|
||||||
|
templates = list(await super().list_resource_templates())
|
||||||
|
templates = await apply_session_transforms(templates)
|
||||||
|
templates = [t for t in templates if is_enabled(t)]
|
||||||
|
|
||||||
skip_auth, token = _get_auth_context()
|
skip_auth, token = _get_auth_context()
|
||||||
authorized: list[ResourceTemplate] = []
|
authorized: list[ResourceTemplate] = []
|
||||||
for template in templates:
|
for template in templates:
|
||||||
if not skip_auth and template.auth is not None:
|
if not skip_auth and template.auth is not None:
|
||||||
ctx = AuthContext(token=token, component=template)
|
ctx = AuthContext(token=token, component=template)
|
||||||
try:
|
try:
|
||||||
if not await run_auth_checks(template.auth, ctx):
|
if not await run_auth_checks(template.auth, ctx):
|
||||||
|
continue
|
||||||
|
except AuthorizationError:
|
||||||
continue
|
continue
|
||||||
except AuthorizationError:
|
authorized.append(template)
|
||||||
continue
|
return authorized
|
||||||
authorized.append(template)
|
|
||||||
return authorized
|
|
||||||
|
|
||||||
async def _get_resource_template(
|
async def _get_resource_template(
|
||||||
self, uri: str, version: VersionSpec | None = None
|
self, uri: str, version: VersionSpec | None = None
|
||||||
|
|
@ -1011,23 +1025,25 @@ class FastMCP(
|
||||||
call_next=lambda context: self.list_prompts(run_middleware=False),
|
call_next=lambda context: self.list_prompts(run_middleware=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get all prompts, apply session transforms, then filter enabled
|
# Core logic: list prompts
|
||||||
prompts = list(await super().list_prompts())
|
with server_span("prompts/list", "prompts/list", self.name, "prompt", ""):
|
||||||
prompts = await apply_session_transforms(prompts)
|
# Get all prompts, apply session transforms, then filter enabled
|
||||||
prompts = [p for p in prompts if is_enabled(p)]
|
prompts = list(await super().list_prompts())
|
||||||
|
prompts = await apply_session_transforms(prompts)
|
||||||
|
prompts = [p for p in prompts if is_enabled(p)]
|
||||||
|
|
||||||
skip_auth, token = _get_auth_context()
|
skip_auth, token = _get_auth_context()
|
||||||
authorized: list[Prompt] = []
|
authorized: list[Prompt] = []
|
||||||
for prompt in prompts:
|
for prompt in prompts:
|
||||||
if not skip_auth and prompt.auth is not None:
|
if not skip_auth and prompt.auth is not None:
|
||||||
ctx = AuthContext(token=token, component=prompt)
|
ctx = AuthContext(token=token, component=prompt)
|
||||||
try:
|
try:
|
||||||
if not await run_auth_checks(prompt.auth, ctx):
|
if not await run_auth_checks(prompt.auth, ctx):
|
||||||
|
continue
|
||||||
|
except AuthorizationError:
|
||||||
continue
|
continue
|
||||||
except AuthorizationError:
|
authorized.append(prompt)
|
||||||
continue
|
return authorized
|
||||||
authorized.append(prompt)
|
|
||||||
return authorized
|
|
||||||
|
|
||||||
async def _get_prompt(
|
async def _get_prompt(
|
||||||
self, name: str, version: VersionSpec | None = None
|
self, name: str, version: VersionSpec | None = None
|
||||||
|
|
|
||||||
180
tests/client/telemetry/test_client_list_tracing.py
Normal file
180
tests/client/telemetry/test_client_list_tracing.py
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"""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