Emit one SERVER span per request and adopt spec-correct error codes (#4445)

This commit is contained in:
Jeremiah Lowin 2026-07-07 07:49:06 -04:00 committed by GitHub
commit ac78e6f693
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 561 additions and 42 deletions

View file

@ -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.

View file

@ -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)

View file

@ -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))
)

View file

@ -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

View file

@ -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",
]

View file

@ -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
View 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