mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
OTEL: Fix attribute compliance with MCP semantic conventions
- Replace rpc.system/rpc.service/rpc.method with gen_ai.tool.name and gen_ai.prompt.name on server and client spans - Set error.type to exception class name and include str(e) in status description on error spans - Always set mcp.session.id when session_id is not None (was truthy check) - Move _parse_call_tool_result inside client_span so ToolError is captured - Pass tool_name/prompt_name through server_span and client_span calls - Add method attribute to delegate_span for mounted providers Closes #3886 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0a921f5372
commit
2f9b74beb4
9 changed files with 223 additions and 61 deletions
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
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"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue