diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx index bd8e94ee5..4e0a6f57b 100644 --- a/docs/development/v4-notes/change-register.mdx +++ b/docs/development/v4-notes/change-register.mdx @@ -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 ` 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. diff --git a/fastmcp_slim/fastmcp/exceptions.py b/fastmcp_slim/fastmcp/exceptions.py index e3c831ba9..fb6571f05 100644 --- a/fastmcp_slim/fastmcp/exceptions.py +++ b/fastmcp_slim/fastmcp/exceptions.py @@ -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) diff --git a/fastmcp_slim/fastmcp/server/low_level.py b/fastmcp_slim/fastmcp/server/low_level.py index 3e020db54..5267b1c66 100644 --- a/fastmcp_slim/fastmcp/server/low_level.py +++ b/fastmcp_slim/fastmcp/server/low_level.py @@ -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)) ) diff --git a/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py b/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py index 0286ea1fa..74e59ae8f 100644 --- a/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py +++ b/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py @@ -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 diff --git a/fastmcp_slim/fastmcp/server/telemetry.py b/fastmcp_slim/fastmcp/server/telemetry.py index d7e62540d..eeaa0aeb7 100644 --- a/fastmcp_slim/fastmcp/server/telemetry.py +++ b/fastmcp_slim/fastmcp/server/telemetry.py @@ -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", ] diff --git a/tests/server/telemetry/test_server_tracing.py b/tests/server/telemetry/test_server_tracing.py index 2b98ae6cc..c04f49fd6 100644 --- a/tests/server/telemetry/test_server_tracing.py +++ b/tests/server/telemetry/test_server_tracing.py @@ -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" + ) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 000000000..d85ea0322 --- /dev/null +++ b/tests/test_exceptions.py @@ -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