mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-21 13:04:18 +02:00
Emit one SERVER span per request and adopt spec-correct error codes (#4445)
This commit is contained in:
parent
023a578279
commit
ac78e6f693
7 changed files with 561 additions and 42 deletions
|
|
@ -146,6 +146,18 @@ Resources and prompts have **no `task` field** on their params in b1, so task-au
|
|||
|
||||
*Verify:* `fastmcp_slim/fastmcp/_sdk_patches.py`, `server/tasks/*`.
|
||||
|
||||
### Single SERVER span per request — Absorbed (post-migration fix)
|
||||
|
||||
SDK v2 seeds an `OpenTelemetryMiddleware` into every lowlevel `Server`, so each inbound request already emits a SERVER span. FastMCP emits its own richer SERVER span per request (with `fastmcp.*` and auth/session attributes), so a server with an OTel exporter installed would export **two** SERVER spans per request under different attribute conventions. `LowLevelServer.__init__` now drops the SDK's seeded `OpenTelemetryMiddleware` (matched by type, not position, leaving any other seeded middleware intact) and keeps FastMCP's spans. Inbound W3C trace-context extraction is unaffected — FastMCP's telemetry reads `traceparent` from `_meta` itself, so distributed traces still link client to server. Client-side is not double-counted: the SDK's `ClientSession` emits a low-level `MCP send <method>` CLIENT span that nests *under* FastMCP's high-level client span, a legitimate parent/child hierarchy rather than a duplicate.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/server/low_level.py` (the `OpenTelemetryMiddleware` filter); `tests/server/telemetry/test_server_tracing.py::TestSingleServerSpan`.
|
||||
|
||||
### Spec-correct error codes via a central translator — Breaking (wire error code)
|
||||
|
||||
Resource-not-found responses from the core `resources/read` handler previously used `-32002`. SEP-2164 (and the SDK's own mcpserver, which maps `ResourceNotFoundError` → `INVALID_PARAMS`) makes this `-32602`. The per-adapter `MCPError(code=..., ...)` literals in `server/mixins/mcp_operations.py` are replaced by a single `fastmcp.exceptions.to_mcp_error()` translator that maps FastMCP's public exceptions to the `mcp_types` code constants (`NotFoundError`/`DisabledError`/`ValidationError` → `INVALID_PARAMS`, else `INTERNAL_ERROR`). Clients that string-matched on the old `-32002` for resource-not-found must switch to `-32602`; the human-readable message ("Resource not found: ...") is unchanged. The opt-in `ErrorHandlingMiddleware`, which has its own documented per-method-prefix code mapping, is intentionally left as-is.
|
||||
|
||||
*Verify:* `fastmcp_slim/fastmcp/exceptions.py` (`to_mcp_error`); `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py`; `tests/test_exceptions.py`.
|
||||
|
||||
## Client
|
||||
|
||||
The `fastmcp.Client` public API is preserved exactly. The client stays a wrapper around `mcp.ClientSession` in legacy/handshake mode; the first-class `mcp.client.Client` is deliberately not adopted in this PR.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,27 @@
|
|||
"""Custom exceptions for FastMCP."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from mcp_types import INTERNAL_ERROR, INVALID_PARAMS, ErrorData
|
||||
|
||||
try:
|
||||
from mcp import MCPError
|
||||
except ImportError:
|
||||
|
||||
class MCPError(Exception): # type: ignore[no-redef]
|
||||
"""Fallback used when MCP dependencies are not installed."""
|
||||
"""Fallback used when MCP dependencies are not installed.
|
||||
|
||||
Mirrors the real ``mcp.MCPError`` interface — the ``(code, message,
|
||||
data)`` constructor and the ``.error`` ``ErrorData`` payload — so static
|
||||
analysis of both construction and read sites (e.g. ``to_mcp_error`` and
|
||||
callers reading ``err.error.code``) is valid regardless of which branch
|
||||
is in effect.
|
||||
"""
|
||||
|
||||
def __init__(self, code: int, message: str, data: Any = None) -> None:
|
||||
super().__init__(message)
|
||||
self.error = ErrorData(code=code, message=message, data=data)
|
||||
|
||||
|
||||
# Catch-compatibility alias for the pre-v2 SDK name. `except McpError` must
|
||||
|
|
@ -68,3 +82,33 @@ class DisabledError(Exception):
|
|||
|
||||
class AuthorizationError(FastMCPError):
|
||||
"""Error when authorization check fails."""
|
||||
|
||||
|
||||
def to_mcp_error(exc: Exception, *, default_code: int = INTERNAL_ERROR) -> MCPError:
|
||||
"""Translate a FastMCP exception into a wire-format ``MCPError``.
|
||||
|
||||
Central mapping from FastMCP's public exception types to the JSON-RPC error
|
||||
codes defined by the MCP spec (imported from ``mcp_types``). Request-handler
|
||||
adapters call this instead of hand-rolling ``MCPError(code=..., ...)`` per
|
||||
call site, so the wire codes stay spec-correct and consistent across
|
||||
resources, prompts, and tools.
|
||||
|
||||
``NotFoundError`` and ``DisabledError`` map to ``INVALID_PARAMS`` (-32602):
|
||||
per SEP-2164 a request naming a component that does not exist (or is
|
||||
disabled) is an invalid-params error, which matches the SDK's own
|
||||
``ResourceNotFoundError -> INVALID_PARAMS`` mapping in ``mcp.server.mcpserver``.
|
||||
``ValidationError`` is also an invalid-params error. Everything else falls
|
||||
back to ``default_code`` (``INTERNAL_ERROR`` by default).
|
||||
|
||||
If ``exc`` is already an ``MCPError``, it is returned unchanged so an
|
||||
explicit code chosen upstream survives translation.
|
||||
"""
|
||||
if isinstance(exc, MCPError):
|
||||
return exc
|
||||
|
||||
message = str(exc)
|
||||
if isinstance(exc, (NotFoundError, DisabledError)):
|
||||
return MCPError(code=INVALID_PARAMS, message=message)
|
||||
if isinstance(exc, ValidationError):
|
||||
return MCPError(code=INVALID_PARAMS, message=message)
|
||||
return MCPError(code=default_code, message=message)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from contextlib import contextmanager
|
|||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import mcp_types
|
||||
from mcp.server._otel import OpenTelemetryMiddleware
|
||||
from mcp.server.context import (
|
||||
CallNext,
|
||||
HandlerResult,
|
||||
|
|
@ -26,6 +27,7 @@ from mcp.shared.exceptions import MCPError
|
|||
from pydantic import ValidationError
|
||||
|
||||
from fastmcp.apps.config import UI_EXTENSION_ID
|
||||
from fastmcp.server.telemetry import seam_span
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -85,7 +87,11 @@ class FastMCPServerMiddleware:
|
|||
from fastmcp.server.dependencies import bind_request_context
|
||||
|
||||
fastmcp = self._ref()
|
||||
with self._apply_shared_context(fastmcp), bind_request_context(ctx):
|
||||
with (
|
||||
self._apply_shared_context(fastmcp),
|
||||
bind_request_context(ctx),
|
||||
self._seam_span(fastmcp, ctx),
|
||||
):
|
||||
# Only initialize requests (request_id present) go through FastMCP
|
||||
# middleware here; every other request already binds the context in
|
||||
# its own adapter, so we just pass through.
|
||||
|
|
@ -94,6 +100,33 @@ class FastMCPServerMiddleware:
|
|||
return await self._run_initialize_mw(fastmcp, ctx, call_next)
|
||||
return await call_next(ctx)
|
||||
|
||||
@contextmanager
|
||||
def _seam_span(
|
||||
self, fastmcp: FastMCP | None, ctx: ServerRequestContext
|
||||
) -> Iterator[None]:
|
||||
"""Open the per-request SERVER span at the middleware seam.
|
||||
|
||||
The removed SDK ``OpenTelemetryMiddleware`` produced one SERVER span per
|
||||
inbound message. FastMCP re-creates that guarantee for *every* request
|
||||
method here — the last place shared by all request methods regardless of
|
||||
how their handler is registered — so a request rejected *before* the
|
||||
high-level path (FastMCP middleware, auth check, not-found mapping,
|
||||
params failure) is still traced with an error span.
|
||||
|
||||
For the high-level methods (tools/resources/prompts), the deep
|
||||
``server_span(...)`` call in ``fastmcp.server.server`` detects this seam
|
||||
span as the active span and *enriches* it in place with component
|
||||
attributes instead of opening a second span, so there is exactly one
|
||||
richly-attributed SERVER span per request. Notifications
|
||||
(``request_id is None``) are skipped — a SERVER span models an inbound
|
||||
request/response, not a fire-and-forget notification.
|
||||
"""
|
||||
if fastmcp is None or ctx.request_id is None:
|
||||
yield
|
||||
return
|
||||
with seam_span(ctx.method, fastmcp.name):
|
||||
yield
|
||||
|
||||
@contextmanager
|
||||
def _apply_shared_context(self, fastmcp: FastMCP | None) -> Iterator[None]:
|
||||
"""Re-establish app-scoped SharedContext ContextVars for this request.
|
||||
|
|
@ -205,8 +238,20 @@ class LowLevelServer(_Server[LifespanResultT]):
|
|||
tools_changed=True,
|
||||
)
|
||||
|
||||
# Route initialize through FastMCP middleware. Append so the SDK's
|
||||
# seeded OpenTelemetryMiddleware stays outermost and keeps emitting spans.
|
||||
# The SDK seeds `OpenTelemetryMiddleware` into `self.middleware` so every
|
||||
# lowlevel server emits a SERVER span per message. FastMCP emits its own,
|
||||
# richer SERVER span per request (see `fastmcp.server.telemetry`), so the
|
||||
# SDK's would produce a second, duplicate SERVER span with different
|
||||
# attribute conventions for every request. Drop it — match by type rather
|
||||
# than position so we don't depend on the SDK seeding it at index 0, and
|
||||
# leave any other seeded middleware intact. FastMCP's telemetry extracts
|
||||
# inbound W3C trace context from `_meta` itself, so distributed-trace
|
||||
# propagation is unaffected.
|
||||
self.middleware = [
|
||||
mw for mw in self.middleware if not isinstance(mw, OpenTelemetryMiddleware)
|
||||
]
|
||||
|
||||
# Route initialize through FastMCP middleware.
|
||||
self.middleware.append(
|
||||
cast("ServerMiddleware[LifespanResultT]", FastMCPServerMiddleware(fastmcp))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import mcp_types
|
|||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp_types import (
|
||||
INVALID_PARAMS,
|
||||
CallToolRequestParams,
|
||||
EmptyResult,
|
||||
GetPromptRequestParams,
|
||||
|
|
@ -17,7 +18,12 @@ from mcp_types import (
|
|||
SetLevelRequestParams,
|
||||
)
|
||||
|
||||
from fastmcp.exceptions import DisabledError, FastMCPError, NotFoundError
|
||||
from fastmcp.exceptions import (
|
||||
DisabledError,
|
||||
FastMCPError,
|
||||
NotFoundError,
|
||||
to_mcp_error,
|
||||
)
|
||||
from fastmcp.server.dependencies import bind_request_context, extract_version_spec
|
||||
from fastmcp.server.tasks.config import TaskMeta
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
|
@ -46,7 +52,7 @@ def _apply_pagination(
|
|||
try:
|
||||
return paginate_sequence(items, cursor, page_size)
|
||||
except ValueError as e:
|
||||
raise MCPError(code=-32602, message=str(e)) from e
|
||||
raise MCPError(code=INVALID_PARAMS, message=str(e)) from e
|
||||
|
||||
|
||||
def _normalize_call_tool_result(
|
||||
|
|
@ -275,8 +281,8 @@ class MCPOperationsMixin:
|
|||
try:
|
||||
result = await self.read_resource(str(uri), version=version)
|
||||
except (DisabledError, NotFoundError) as e:
|
||||
raise MCPError(
|
||||
code=-32002, message=f"Resource not found: {str(uri)!r}"
|
||||
raise to_mcp_error(
|
||||
NotFoundError(f"Resource not found: {str(uri)!r}")
|
||||
) from e
|
||||
|
||||
if isinstance(result, mcp_types.CreateTaskResult):
|
||||
|
|
@ -308,7 +314,7 @@ class MCPOperationsMixin:
|
|||
try:
|
||||
result = await self.render_prompt(name, arguments, version=version)
|
||||
except (DisabledError, NotFoundError) as e:
|
||||
raise MCPError(code=-32602, message=f"Unknown prompt: {name!r}") from e
|
||||
raise to_mcp_error(NotFoundError(f"Unknown prompt: {name!r}")) from e
|
||||
|
||||
if isinstance(result, mcp_types.CreateTaskResult):
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -2,13 +2,30 @@
|
|||
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
|
||||
from opentelemetry.context import Context
|
||||
from opentelemetry.trace import Span, SpanKind, Status, StatusCode
|
||||
from opentelemetry.trace import Span, SpanKind, Status, StatusCode, get_current_span
|
||||
|
||||
from fastmcp.exceptions import ToolError as _ToolError
|
||||
from fastmcp.telemetry import extract_trace_context, get_tracer
|
||||
|
||||
# Marker attribute set on the SERVER span opened at the FastMCP middleware seam
|
||||
# (see `fastmcp.server.low_level.FastMCPServerMiddleware._seam_span`). The seam
|
||||
# opens one span per inbound request so failures rejected *before* the
|
||||
# high-level path (auth, not-found, middleware vetoes) are still traced. The
|
||||
# attribute is set for observability; enrichment detection uses the ContextVar
|
||||
# below (the API `Span` type does not expose readable attributes).
|
||||
SEAM_SPAN_MARKER = "fastmcp.span.seam"
|
||||
|
||||
# Tracks the SERVER span opened at the current request's seam. When the
|
||||
# high-level path reaches `server_span` and this span is still the active span,
|
||||
# `server_span` enriches it with component attributes instead of opening a
|
||||
# second span — restoring attribute parity on the single per-request span.
|
||||
_active_seam_span: ContextVar[Span | None] = ContextVar(
|
||||
"fastmcp_active_seam_span", default=None
|
||||
)
|
||||
|
||||
|
||||
def get_auth_span_attributes() -> dict[str, str]:
|
||||
"""Get auth attributes for the current request, if authenticated."""
|
||||
|
|
@ -51,6 +68,83 @@ def _get_parent_trace_context() -> Context | None:
|
|||
return None
|
||||
|
||||
|
||||
def _build_server_span_attrs(
|
||||
method: str,
|
||||
server_name: str,
|
||||
component_type: str,
|
||||
component_key: str,
|
||||
resource_uri: str | None,
|
||||
tool_name: str | None,
|
||||
prompt_name: str | None,
|
||||
) -> dict[str, str]:
|
||||
attrs: dict[str, str] = {
|
||||
# MCP semantic conventions
|
||||
"mcp.method.name": method,
|
||||
# FastMCP-specific attributes
|
||||
"fastmcp.server.name": server_name,
|
||||
"fastmcp.component.type": component_type,
|
||||
"fastmcp.component.key": component_key,
|
||||
**get_auth_span_attributes(),
|
||||
**get_session_span_attributes(),
|
||||
}
|
||||
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
|
||||
return attrs
|
||||
|
||||
|
||||
def record_span_exception(span: Span, e: Exception) -> None:
|
||||
"""Record an exception and error status on a span."""
|
||||
if span.is_recording():
|
||||
error_type = "tool_error" if isinstance(e, _ToolError) else type(e).__qualname__
|
||||
span.set_attribute("error.type", error_type)
|
||||
span.record_exception(e)
|
||||
span.set_status(Status(StatusCode.ERROR, str(e)))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def seam_span(method: str, server_name: str) -> Generator[Span, None, None]:
|
||||
"""Open the per-request SERVER span at the FastMCP middleware seam.
|
||||
|
||||
The span is named after the method and carries the base MCP attributes
|
||||
(`mcp.method.name`, `fastmcp.server.name`, auth/session context) so
|
||||
seam-only methods (`logging/setLevel`, `tasks/*`, `ping`, `initialize`, ...)
|
||||
are fully attributed even though they never reach the high-level path. It is
|
||||
marked with `SEAM_SPAN_MARKER` so a later `server_span` call in the
|
||||
high-level path enriches this span with component attributes instead of
|
||||
opening a second one. Exceptions raised anywhere below the seam — including
|
||||
rejections *before* the high-level path (auth, not-found, middleware vetoes)
|
||||
that would otherwise produce no SERVER span at all — are recorded here.
|
||||
"""
|
||||
tracer = get_tracer()
|
||||
with tracer.start_as_current_span(
|
||||
method,
|
||||
context=_get_parent_trace_context(),
|
||||
kind=SpanKind.SERVER,
|
||||
) as span:
|
||||
if span.is_recording():
|
||||
span.set_attribute(SEAM_SPAN_MARKER, True)
|
||||
span.set_attributes(
|
||||
{
|
||||
"mcp.method.name": method,
|
||||
"fastmcp.server.name": server_name,
|
||||
**get_auth_span_attributes(),
|
||||
**get_session_span_attributes(),
|
||||
}
|
||||
)
|
||||
token = _active_seam_span.set(span)
|
||||
try:
|
||||
yield span
|
||||
except Exception as e:
|
||||
record_span_exception(span, e)
|
||||
raise
|
||||
finally:
|
||||
_active_seam_span.reset(token)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def server_span(
|
||||
name: str,
|
||||
|
|
@ -62,10 +156,46 @@ def server_span(
|
|||
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.
|
||||
"""Emit or enrich a SERVER span with standard MCP attributes and auth context.
|
||||
|
||||
When the current active span is the request's seam span (opened by
|
||||
`FastMCPServerMiddleware` and marked with `SEAM_SPAN_MARKER`), this sets the
|
||||
component attributes on that span and yields it *without* starting a second
|
||||
span — so failures rejected before this point and the successful high-level
|
||||
call share one richly-attributed SERVER span. Otherwise (non-seam contexts,
|
||||
e.g. in-process `mcp.call_tool()` calls that bypass the dispatcher) it opens a
|
||||
new SERVER span as before.
|
||||
|
||||
Automatically records any exception on the span and sets error status.
|
||||
"""
|
||||
attrs = _build_server_span_attrs(
|
||||
method,
|
||||
server_name,
|
||||
component_type,
|
||||
component_key,
|
||||
resource_uri,
|
||||
tool_name,
|
||||
prompt_name,
|
||||
)
|
||||
|
||||
seam = _active_seam_span.get()
|
||||
active = get_current_span()
|
||||
if (
|
||||
seam is not None
|
||||
and seam is active
|
||||
and seam.is_recording()
|
||||
and seam.get_span_context().is_valid
|
||||
):
|
||||
# Enrich the already-active seam span rather than opening a second one.
|
||||
seam.update_name(name)
|
||||
seam.set_attributes(attrs)
|
||||
try:
|
||||
yield seam
|
||||
except Exception as e:
|
||||
record_span_exception(seam, e)
|
||||
raise
|
||||
return
|
||||
|
||||
tracer = get_tracer()
|
||||
with tracer.start_as_current_span(
|
||||
name,
|
||||
|
|
@ -73,33 +203,11 @@ def server_span(
|
|||
kind=SpanKind.SERVER,
|
||||
) as span:
|
||||
if span.is_recording():
|
||||
attrs: dict[str, str] = {
|
||||
# MCP semantic conventions
|
||||
"mcp.method.name": method,
|
||||
# FastMCP-specific attributes
|
||||
"fastmcp.server.name": server_name,
|
||||
"fastmcp.component.type": component_type,
|
||||
"fastmcp.component.key": component_key,
|
||||
**get_auth_span_attributes(),
|
||||
**get_session_span_attributes(),
|
||||
}
|
||||
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:
|
||||
if span.is_recording():
|
||||
error_type = (
|
||||
"tool_error" if isinstance(e, _ToolError) else type(e).__qualname__
|
||||
)
|
||||
span.set_attribute("error.type", error_type)
|
||||
span.record_exception(e)
|
||||
span.set_status(Status(StatusCode.ERROR, str(e)))
|
||||
record_span_exception(span, e)
|
||||
raise
|
||||
|
||||
|
||||
|
|
@ -128,19 +236,16 @@ def delegate_span(
|
|||
try:
|
||||
yield span
|
||||
except Exception as e:
|
||||
if span.is_recording():
|
||||
error_type = (
|
||||
"tool_error" if isinstance(e, _ToolError) else type(e).__qualname__
|
||||
)
|
||||
span.set_attribute("error.type", error_type)
|
||||
span.record_exception(e)
|
||||
span.set_status(Status(StatusCode.ERROR, str(e)))
|
||||
record_span_exception(span, e)
|
||||
raise
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SEAM_SPAN_MARKER",
|
||||
"delegate_span",
|
||||
"get_auth_span_attributes",
|
||||
"get_session_span_attributes",
|
||||
"record_span_exception",
|
||||
"seam_span",
|
||||
"server_span",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import pytest
|
|||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
from opentelemetry.trace import SpanKind, StatusCode
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.exceptions import NotFoundError, ToolError
|
||||
from fastmcp.server.auth import AccessToken
|
||||
from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext
|
||||
|
||||
|
||||
class TestToolTracing:
|
||||
|
|
@ -350,3 +351,227 @@ class TestAuthAttributesOnSpans:
|
|||
assert span.attributes["enduser.id"] == "client-no-scopes"
|
||||
# Scope attribute should not be present when scopes list is empty
|
||||
assert "enduser.scope" not in span.attributes
|
||||
|
||||
|
||||
class TestSingleServerSpan:
|
||||
"""Full-dispatch tests guarding against duplicate SERVER spans.
|
||||
|
||||
The SDK seeds its own `OpenTelemetryMiddleware` into every lowlevel server,
|
||||
which would emit a second SERVER span per request alongside FastMCP's. These
|
||||
tests exercise the real request path (through a `Client`) so a regression
|
||||
that re-enables the SDK middleware would surface as an extra SERVER span.
|
||||
Note the in-process `mcp.call_tool` tests above bypass the dispatcher and so
|
||||
cannot catch this.
|
||||
"""
|
||||
|
||||
async def test_tool_call_emits_single_server_span(
|
||||
self, trace_exporter: InMemorySpanExporter
|
||||
):
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
@mcp.tool()
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
await client.call_tool("greet", {"name": "World"})
|
||||
|
||||
spans = trace_exporter.get_finished_spans()
|
||||
# Exactly one SERVER span for the tools/call request. Match by method name
|
||||
# (not just the "tools/call greet" name) so a seam-level span accidentally
|
||||
# opened for this high-level method — which would be named "tools/call" —
|
||||
# is also counted and would trip the assertion.
|
||||
tool_call_server_spans = [
|
||||
s
|
||||
for s in spans
|
||||
if s.kind == SpanKind.SERVER
|
||||
and s.attributes is not None
|
||||
and s.attributes.get("mcp.method.name") == "tools/call"
|
||||
]
|
||||
assert len(tool_call_server_spans) == 1
|
||||
span = tool_call_server_spans[0]
|
||||
assert span.name == "tools/call greet"
|
||||
# The single span carries the rich component attributes that the
|
||||
# high-level path enriches the seam span with (attribute parity: the
|
||||
# enrichment must not lose the component context by opening a second
|
||||
# span or by leaving the seam span bare).
|
||||
assert span.attributes is not None
|
||||
assert span.attributes["fastmcp.component.key"] == "tool:greet@"
|
||||
assert span.attributes["gen_ai.tool.name"] == "greet"
|
||||
assert span.attributes["fastmcp.component.type"] == "tool"
|
||||
|
||||
async def test_server_span_shares_client_trace(
|
||||
self, trace_exporter: InMemorySpanExporter
|
||||
):
|
||||
"""FastMCP extracts inbound W3C trace context from `_meta`, so the single
|
||||
SERVER span must still join the client's distributed trace."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
@mcp.tool()
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
await client.call_tool("greet", {"name": "World"})
|
||||
|
||||
spans = trace_exporter.get_finished_spans()
|
||||
server_span = next(
|
||||
s
|
||||
for s in spans
|
||||
if s.kind == SpanKind.SERVER and s.name == "tools/call greet"
|
||||
)
|
||||
client_span = next(
|
||||
s
|
||||
for s in spans
|
||||
if s.kind == SpanKind.CLIENT and s.name == "MCP send tools/call greet"
|
||||
)
|
||||
assert server_span.context is not None
|
||||
assert client_span.context is not None
|
||||
assert server_span.context.trace_id == client_span.context.trace_id
|
||||
assert server_span.parent is not None
|
||||
|
||||
|
||||
class TestSeamServerSpan:
|
||||
"""SERVER spans for methods outside the high-level tool/resource/prompt path.
|
||||
|
||||
FastMCP's rich SERVER spans are created deep in the high-level path, so
|
||||
methods like `logging/setLevel`, `tasks/*`, `ping`, and `initialize` — which
|
||||
never reach that code — would have no span at all once the SDK's
|
||||
`OpenTelemetryMiddleware` is removed. The FastMCP middleware seam
|
||||
(`FastMCPServerMiddleware`) emits a span for exactly those methods, so every
|
||||
request method carries a SERVER span again.
|
||||
"""
|
||||
|
||||
async def test_set_logging_level_emits_seam_span(
|
||||
self, trace_exporter: InMemorySpanExporter
|
||||
):
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
await client.set_logging_level("info")
|
||||
|
||||
spans = trace_exporter.get_finished_spans()
|
||||
seam_spans = [
|
||||
s
|
||||
for s in spans
|
||||
if s.kind == SpanKind.SERVER and s.name == "logging/setLevel"
|
||||
]
|
||||
assert len(seam_spans) == 1
|
||||
span = seam_spans[0]
|
||||
assert span.attributes is not None
|
||||
assert span.attributes["mcp.method.name"] == "logging/setLevel"
|
||||
assert span.attributes["fastmcp.server.name"] == "test-server"
|
||||
|
||||
async def test_seam_method_emits_single_server_span(
|
||||
self, trace_exporter: InMemorySpanExporter
|
||||
):
|
||||
"""A seam-spanned method must produce exactly one SERVER span, not two."""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
await client.set_logging_level("info")
|
||||
|
||||
spans = trace_exporter.get_finished_spans()
|
||||
set_level_server_spans = [
|
||||
s
|
||||
for s in spans
|
||||
if s.kind == SpanKind.SERVER
|
||||
and s.attributes is not None
|
||||
and s.attributes.get("mcp.method.name") == "logging/setLevel"
|
||||
]
|
||||
assert len(set_level_server_spans) == 1
|
||||
|
||||
|
||||
class TestFailurePathServerSpan:
|
||||
"""A tools/call rejected before the high-level handler must still be traced.
|
||||
|
||||
The rich SERVER span for tools/call is created deep in the high-level path,
|
||||
after FastMCP middleware runs. A request rejected *before* that point (a
|
||||
raising middleware, a not-found tool) never reaches it, so without the seam
|
||||
span such failures would produce no SERVER span at all — the failure-path
|
||||
observability regression this guards against. The seam span opened by
|
||||
`FastMCPServerMiddleware` wraps the whole request, so every tools/call
|
||||
carries a SERVER span with `mcp.method.name=tools/call`, and an exception
|
||||
that propagates past the handler marks that span's status as an error.
|
||||
"""
|
||||
|
||||
async def test_middleware_error_before_handler_emits_error_span(
|
||||
self, trace_exporter: InMemorySpanExporter
|
||||
):
|
||||
"""An unexpected error raised in FastMCP middleware, before the
|
||||
high-level path, propagates to the seam span, which records it as an
|
||||
error — where previously this method produced no SERVER span at all."""
|
||||
|
||||
class RaisingMiddleware(Middleware):
|
||||
async def on_call_tool(
|
||||
self,
|
||||
context: MiddlewareContext,
|
||||
call_next: CallNext,
|
||||
):
|
||||
raise RuntimeError("rejected before handler")
|
||||
|
||||
mcp = FastMCP("test-server", middleware=[RaisingMiddleware()])
|
||||
|
||||
@mcp.tool()
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(Exception):
|
||||
await client.call_tool("greet", {"name": "World"})
|
||||
|
||||
spans = trace_exporter.get_finished_spans()
|
||||
tool_call_server_spans = [
|
||||
s
|
||||
for s in spans
|
||||
if s.kind == SpanKind.SERVER
|
||||
and s.attributes is not None
|
||||
and s.attributes.get("mcp.method.name") == "tools/call"
|
||||
]
|
||||
assert len(tool_call_server_spans) == 1
|
||||
span = tool_call_server_spans[0]
|
||||
assert span.status.status_code == StatusCode.ERROR
|
||||
assert span.attributes is not None
|
||||
assert span.attributes["mcp.method.name"] == "tools/call"
|
||||
assert span.attributes["error.type"] == "RuntimeError"
|
||||
assert len(span.events) > 0 # exception recorded
|
||||
|
||||
async def test_tool_visible_rejection_before_handler_still_spans(
|
||||
self, trace_exporter: InMemorySpanExporter
|
||||
):
|
||||
"""A tool-visible error raised in middleware is returned as an error
|
||||
result (correct MCP semantics, so the span status is unset), but the
|
||||
seam span still guarantees exactly one SERVER span for the tools/call —
|
||||
the request is no longer invisible to tracing."""
|
||||
|
||||
class RejectingMiddleware(Middleware):
|
||||
async def on_call_tool(
|
||||
self,
|
||||
context: MiddlewareContext,
|
||||
call_next: CallNext,
|
||||
):
|
||||
raise ToolError("rejected before handler")
|
||||
|
||||
mcp = FastMCP("test-server", middleware=[RejectingMiddleware()])
|
||||
|
||||
@mcp.tool()
|
||||
def greet(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(ToolError):
|
||||
await client.call_tool("greet", {"name": "World"})
|
||||
|
||||
spans = trace_exporter.get_finished_spans()
|
||||
tool_call_server_spans = [
|
||||
s
|
||||
for s in spans
|
||||
if s.kind == SpanKind.SERVER
|
||||
and s.attributes is not None
|
||||
and s.attributes.get("mcp.method.name") == "tools/call"
|
||||
]
|
||||
assert len(tool_call_server_spans) == 1
|
||||
assert (
|
||||
tool_call_server_spans[0].attributes is not None
|
||||
and tool_call_server_spans[0].attributes["mcp.method.name"] == "tools/call"
|
||||
)
|
||||
|
|
|
|||
82
tests/test_exceptions.py
Normal file
82
tests/test_exceptions.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Tests for FastMCP's exception-to-wire-error translation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from mcp import MCPError
|
||||
from mcp_types import INTERNAL_ERROR, INVALID_PARAMS
|
||||
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.exceptions import (
|
||||
DisabledError,
|
||||
NotFoundError,
|
||||
PromptError,
|
||||
ResourceError,
|
||||
ToolError,
|
||||
ValidationError,
|
||||
to_mcp_error,
|
||||
)
|
||||
|
||||
|
||||
class TestToMcpError:
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[NotFoundError("missing"), DisabledError("off"), ValidationError("bad")],
|
||||
)
|
||||
def test_invalid_params_mapping(self, exc: Exception):
|
||||
"""Not-found, disabled, and validation errors map to INVALID_PARAMS.
|
||||
|
||||
SEP-2164 defines a request naming a nonexistent (or disabled) component
|
||||
as an invalid-params error, matching the SDK's own mcpserver mapping.
|
||||
"""
|
||||
result = to_mcp_error(exc)
|
||||
assert isinstance(result, MCPError)
|
||||
assert result.error.code == INVALID_PARAMS
|
||||
assert result.error.message == str(exc)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[ResourceError("boom"), PromptError("boom"), ToolError("boom")],
|
||||
)
|
||||
def test_default_code_for_unmapped_errors(self, exc: Exception):
|
||||
"""Operation errors without a dedicated mapping use the default code."""
|
||||
assert to_mcp_error(exc).error.code == INTERNAL_ERROR
|
||||
|
||||
def test_custom_default_code(self):
|
||||
assert (
|
||||
to_mcp_error(ResourceError("x"), default_code=-32000).error.code == -32000
|
||||
)
|
||||
|
||||
def test_existing_mcp_error_passes_through(self):
|
||||
"""An MCPError chosen upstream survives translation unchanged."""
|
||||
existing = MCPError(code=-32000, message="explicit")
|
||||
assert to_mcp_error(existing) is existing
|
||||
|
||||
|
||||
class TestWireErrorCodes:
|
||||
"""The core request-handler adapters must emit spec-correct wire codes."""
|
||||
|
||||
async def test_resource_not_found_uses_invalid_params(self):
|
||||
"""Resource-not-found is INVALID_PARAMS (-32602), not -32002.
|
||||
|
||||
SEP-2164 corrected this: the SDK's mcpserver maps ResourceNotFoundError
|
||||
to INVALID_PARAMS. FastMCP previously deviated with -32002.
|
||||
"""
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.read_resource_mcp("config://missing")
|
||||
|
||||
assert exc_info.value.error.code == INVALID_PARAMS
|
||||
assert "Resource not found" in exc_info.value.error.message
|
||||
|
||||
async def test_prompt_not_found_uses_invalid_params(self):
|
||||
mcp = FastMCP("test-server")
|
||||
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.get_prompt("missing", {})
|
||||
|
||||
assert exc_info.value.error.code == INVALID_PARAMS
|
||||
assert "Unknown prompt" in exc_info.value.error.message
|
||||
Loading…
Add table
Add a link
Reference in a new issue