diff --git a/docs/development/v4-notes/change-register.mdx b/docs/development/v4-notes/change-register.mdx index 464403e9d..e698ee679 100644 --- a/docs/development/v4-notes/change-register.mdx +++ b/docs/development/v4-notes/change-register.mdx @@ -407,6 +407,24 @@ A proxy is a server on its front and a client on its back, and the two eras have *Verify:* `fastmcp_slim/fastmcp/server/providers/proxy.py` (`_mirror_front_era_mode`, the `_create_client_factory` non-Client branch, the era guard in `ProxyInitializeMiddleware.on_initialize`), `fastmcp_slim/fastmcp/client/transports/base.py` (`TransportOptions.backend_mode`), `fastmcp_slim/fastmcp/client/transports/config.py` (`MCPConfigTransport.connect_session` / `_create_proxy`), `fastmcp_slim/fastmcp/server/server.py` (`create_proxy` docstring), `tests/server/test_mrtr_guards.py` (`TestProxyEraMirroring`, `TestMultiServerConfigEraMirroring`). +### Resource and prompt errors survive the modern era — Absorbed (defect fix) + +`_on_call_tool` returns a `ResourceError`-equivalent as an error result, but `_on_read_resource` and `_on_get_prompt` caught only `DisabledError`/`NotFoundError`, so a `ResourceError`, `PromptError`, or an argument-conversion failure on a resource template escaped as a raw handler exception. On the handshake eras that reached the wire as `str(exc)`, which is survivable; on `2026-07-28` the runner masks anything that is not an `MCPError` or `ValidationError` as a generic `"Internal server error"`, so a legitimate client-input error became indistinguishable from a server bug. Both handlers now translate a `FastMCPError` through `to_mcp_error` the way tools already do. Masking is unchanged — `mask_error_details` is still applied inside `read_resource`/`render_prompt`, so these paths leak no more than tools do. + +*Verify:* `fastmcp_slim/fastmcp/server/mixins/mcp_operations.py` (`_on_read_resource`, `_on_get_prompt`), `tests/server/test_protocol_eras.py`. + +### Proxies forward upstream instructions on the modern era — Absorbed (defect fix) + +`ProxyInitializeMiddleware` forwards an upstream server's `instructions` by patching the `InitializeResult`, but `on_initialize` only fires for the handshake era. A modern client negotiates via `server/discover`, which the SDK builds from the low-level server's own `instructions`, so a proxy silently dropped its upstream's instructions for every modern client. `FastMCPProxy` now registers a `server/discover` handler (the same `add_request_handler` hook it already uses for `ping`, and a replacement the SDK explicitly sanctions) that delegates to the SDK's own implementation and fills in only the instructions that would otherwise be lost. The proxy's lazy-connect contract is unchanged: the backend is contacted when a client asks, never at construction. Because era mirroring pins a modern backend to an exact version — and a pinned version adopts a synthesized `DiscoverResult` rather than probing the wire — this read negotiates with `mode="auto"`; instructions are metadata with no back-channel, so they do not need the era consistency mirroring exists to protect. + +*Verify:* `fastmcp_slim/fastmcp/server/providers/proxy.py` (`FastMCPProxy._setup_proxy_discover_handler`), `tests/server/providers/proxy/test_proxy_server.py` (`TestProxyModernEraInstructions`). + +### Proxy list methods raise `MCPError` on backend failure — Breaking (in-process error type) + +`ProxyProvider`'s four `_list_*` methods caught only `MCPError`, so a failed backend connection escaped as the `RuntimeError` the client wraps it in (or a raw `httpx2.ConnectError`). On the handshake eras that reached the wire as `str(exc)` and named the real failure; on `2026-07-28` it was masked as `"Internal server error"`, leaving a modern client unable to tell a dead backend from a server bug. The list methods now normalize transport failures through `_proxy_upstream_error`, matching `ProxyInitializeMiddleware.on_initialize`. Code calling a proxy's `list_tools()` (and friends) in-process must now catch `MCPError` rather than `RuntimeError`; the over-the-wire error type is unchanged. + +*Verify:* `fastmcp_slim/fastmcp/server/providers/proxy.py` (`_PROXY_TRANSPORT_ERRORS` and the four `_list_*` methods), `tests/server/providers/proxy/test_proxy_server.py` (`TestProxyProviderTransportErrors`). + ### The xfail register — Known gap Roughly forty `xfail` markers across the test tree (concentrated in `tests/server/tasks/`, `tests/client/tasks/`, and `test_protocol_eras.py`) are the built-in beta tracker: each names the SDK gap it waits on. They are enumerated and mapped to sdk-feedback findings on the [Known Gaps](/development/v4-notes/known-gaps) page. diff --git a/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py b/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py index 355ec0734..174fd3589 100644 --- a/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py +++ b/fastmcp_slim/fastmcp/server/mixins/mcp_operations.py @@ -318,6 +318,16 @@ class MCPOperationsMixin: raise to_mcp_error( NotFoundError(f"Resource not found: {str(uri)!r}") ) from e + except FastMCPError as e: + # Resource-visible errors (ResourceError, ValidationError, ...) + # must reach the wire as an MCPError. Resources have no + # error-result shape the way tools do, so the equivalent of + # _on_call_tool's error result is a translated MCPError: at + # 2026-07-28 the runner only preserves MCPError/ValidationError + # messages and masks anything else as "Internal server error", + # which would hide a legitimate client-input error. Masking + # already happened inside read_resource. + raise to_mcp_error(e) from e if isinstance(result, mcp_types.CreateTaskResult): return result @@ -349,6 +359,13 @@ class MCPOperationsMixin: result = await self.render_prompt(name, arguments, version=version) except (DisabledError, NotFoundError) as e: raise to_mcp_error(NotFoundError(f"Unknown prompt: {name!r}")) from e + except FastMCPError as e: + # Prompt-visible errors (PromptError, ValidationError, ...) must + # reach the wire as an MCPError for the same reason as + # resources: at 2026-07-28 anything that is not an + # MCPError/ValidationError is masked as "Internal server error". + # Masking already happened inside render_prompt. + raise to_mcp_error(e) from e if isinstance(result, mcp_types.CreateTaskResult): return result diff --git a/fastmcp_slim/fastmcp/server/providers/proxy.py b/fastmcp_slim/fastmcp/server/providers/proxy.py index c795212ba..78c45d434 100644 --- a/fastmcp_slim/fastmcp/server/providers/proxy.py +++ b/fastmcp_slim/fastmcp/server/providers/proxy.py @@ -94,6 +94,22 @@ PROXY_TRANSPORT_OPTIONS = TransportOptions( ) +#: Transport-level failures that can escape a backend connection attempt. +#: `Client._connect` wraps most connect failures in a ``RuntimeError("Client +#: failed to connect: ...")``, but a transport can also surface an httpx or +#: anyio stream error directly. Every proxy entry point that opens a backend +#: connection normalizes these into an ``MCPError`` so callers see a protocol +#: error instead of a raw transport exception. +_PROXY_TRANSPORT_ERRORS: tuple[type[Exception], ...] = ( + RuntimeError, + TimeoutError, + httpx2.HTTPError, + anyio.ClosedResourceError, + anyio.EndOfStream, + anyio.BrokenResourceError, +) + + def _proxy_upstream_error(error: Exception) -> MCPError: return MCPError( code=mcp_types.INTERNAL_ERROR, @@ -163,14 +179,7 @@ class ProxyInitializeMiddleware(Middleware): upstream_instructions = init_result.instructions except MCPError: raise - except ( - RuntimeError, - TimeoutError, - httpx2.HTTPError, - anyio.ClosedResourceError, - anyio.EndOfStream, - anyio.BrokenResourceError, - ) as error: + except _PROXY_TRANSPORT_ERRORS as error: raise _proxy_upstream_error(error) from error result = await call_next(context) @@ -739,6 +748,8 @@ class ProxyProvider(Provider): tools = [] else: raise + except _PROXY_TRANSPORT_ERRORS as error: + raise _proxy_upstream_error(error) from error self._tools_cache = _CacheEntry(tools, time.monotonic()) return tools @@ -776,6 +787,8 @@ class ProxyProvider(Provider): resources = [] else: raise + except _PROXY_TRANSPORT_ERRORS as error: + raise _proxy_upstream_error(error) from error self._resources_cache = _CacheEntry(resources, time.monotonic()) return resources @@ -813,6 +826,8 @@ class ProxyProvider(Provider): templates = [] else: raise + except _PROXY_TRANSPORT_ERRORS as error: + raise _proxy_upstream_error(error) from error self._templates_cache = _CacheEntry(templates, time.monotonic()) return templates @@ -850,6 +865,8 @@ class ProxyProvider(Provider): prompts = [] else: raise + except _PROXY_TRANSPORT_ERRORS as error: + raise _proxy_upstream_error(error) from error self._prompts_cache = _CacheEntry(prompts, time.monotonic()) return prompts @@ -1089,6 +1106,7 @@ class FastMCPProxy(FastMCP): self.add_provider(provider) self.middleware.append(ProxyInitializeMiddleware(self)) self._setup_proxy_ping_handler() + self._setup_proxy_discover_handler() async def _get_client(self) -> Client: client = self.client_factory() @@ -1110,6 +1128,63 @@ class FastMCPProxy(FastMCP): "ping", mcp_types.RequestParams, ping_remote ) + def _setup_proxy_discover_handler(self) -> None: + """Forward the backend's instructions on the modern (`server/discover`) path. + + `ProxyInitializeMiddleware` forwards upstream instructions by patching + the `InitializeResult`, but `on_initialize` only fires for the legacy + handshake. A modern client negotiates via `server/discover`, whose + default SDK handler reads `self.instructions` off the low-level server + directly, so a proxy would silently drop its upstream's instructions for + every modern client. + + The SDK sanctions replacing this handler wholesale, so we delegate to + its own implementation for the rest of the result (supported versions, + capabilities, server info) and only fill in the instructions we would + otherwise lose. Resolving them here — at request time, from a live + backend session — keeps the proxy's lazy-connect contract intact: the + backend is contacted when a client actually asks, never at construction. + """ + build_default_result = self._mcp_server._handle_discover + + async def discover_remote( + ctx: ServerRequestContext[Any, Any], + params: mcp_types.RequestParams | None, + ) -> mcp_types.DiscoverResult: + result = await build_default_result(ctx, params) + # A proxy with its own instructions keeps them, matching the + # precedence `ProxyInitializeMiddleware` applies on the legacy path. + if result.instructions is not None: + return result + client = await self._get_client() + # `session.instructions` is era-neutral: it reads the backend's + # `DiscoverResult` or `InitializeResult` depending on what the + # backend negotiated, so a modern front can proxy a legacy backend. + if client.is_connected(): + result.instructions = client.session.instructions + return result + # Era mirroring pins a modern backend to an exact version, and a + # pinned version adopts a synthesized `DiscoverResult` instead of + # probing the wire — so the pinned client would report no + # instructions at all. Instructions are metadata with no + # back-channel, so this read does not need the era consistency + # mirroring exists to protect; negotiate with "auto" instead, which + # probes `server/discover` and falls back to the handshake for a + # legacy-only backend. + client.mode = "auto" + try: + async with client: + result.instructions = client.session.instructions + except MCPError: + raise + except _PROXY_TRANSPORT_ERRORS as error: + raise _proxy_upstream_error(error) from error + return result + + self._mcp_server.add_request_handler( + "server/discover", mcp_types.RequestParams, discover_remote + ) + # ----------------------------------------------------------------------------- # ProxyClient and Related diff --git a/tests/server/providers/proxy/test_proxy_server.py b/tests/server/providers/proxy/test_proxy_server.py index cedd5f595..bb2a91b5a 100644 --- a/tests/server/providers/proxy/test_proxy_server.py +++ b/tests/server/providers/proxy/test_proxy_server.py @@ -4,12 +4,14 @@ import time from typing import Any, cast from unittest.mock import AsyncMock, patch +import httpx2 import mcp_types import pytest from anyio import create_task_group from dirty_equals import Contains from mcp import MCPError from mcp_types import Icon, TextContent, TextResourceContents +from mcp_types.version import MODERN_PROTOCOL_VERSIONS from pydantic import AnyUrl from fastmcp import FastMCP @@ -311,13 +313,20 @@ async def test_proxy_initialize_forwards_remote_connection_error(): async def test_proxy_list_tools_surfaces_remote_connection_error(): + """A dead backend surfaces as an MCPError naming the connection failure. + + The provider normalizes transport failures into `MCPError` (rather than + letting the client's `RuntimeError` escape) so the error survives the + modern era's wire boundary, which masks any non-MCPError as a generic + "Internal server error". + """ port = find_available_port() proxy = create_proxy( StreamableHttpTransport(f"http://127.0.0.1:{port}/mcp"), provider_error_strategy="raise", ) - with pytest.raises(RuntimeError, match="Client failed to connect"): + with pytest.raises(MCPError, match="Client failed to connect"): await proxy.list_tools() @@ -1456,3 +1465,97 @@ class TestProxyForwardingAppliesToEveryBackendClient: assert result.is_error is False assert result.structured_content == {"status": "weird"} + + +class TestProxyModernEraInstructions: + """Upstream instructions must reach a client on the modern era too. + + `ProxyInitializeMiddleware.on_initialize` only fires for the legacy + handshake. A `mode="auto"` client negotiates via `server/discover`, which + the SDK builds from the low-level server's own `instructions`, so without a + discover-side hook the proxy drops its upstream's instructions entirely. + """ + + async def test_proxy_forwards_upstream_instructions_on_modern_era(self): + upstream = FastMCP(name="upstream", instructions="USE_THIS_MARKER_123") + proxy = create_proxy(upstream, name="proxy") + + async with Client(proxy, mode="auto") as client: + assert client.protocol_version in MODERN_PROTOCOL_VERSIONS + assert client.session.instructions == "USE_THIS_MARKER_123" + + async def test_proxy_own_instructions_take_precedence_on_modern_era(self): + upstream = FastMCP(name="upstream", instructions="upstream instructions") + proxy = create_proxy(upstream, name="proxy", instructions="proxy instructions") + + async with Client(proxy, mode="auto") as client: + assert client.session.instructions == "proxy instructions" + + async def test_proxy_instructions_none_when_upstream_has_none_on_modern_era(self): + upstream = FastMCP(name="upstream") + proxy = create_proxy(upstream, name="proxy") + + async with Client(proxy, mode="auto") as client: + assert client.session.instructions is None + + +class TestProxyProviderTransportErrors: + """A dead backend must surface as an MCPError, not a raw transport error. + + `ProxyTool.run` and `ProxyInitializeMiddleware.on_initialize` normalize + connection failures into `MCPError`; the provider's list methods caught + only `MCPError`, so an `httpx2.ConnectError` (or the `RuntimeError` the + client wraps a failed connect in) escaped unwrapped to the caller. + """ + + @pytest.fixture + def unreachable_provider(self) -> ProxyProvider: + port = find_available_port() + return ProxyProvider( + lambda: ProxyClient(f"http://127.0.0.1:{port}/mcp/"), + cache_ttl=0, + ) + + @pytest.mark.parametrize( + "method", + ["_list_tools", "_list_resources", "_list_resource_templates", "_list_prompts"], + ) + async def test_list_method_wraps_connection_failure( + self, unreachable_provider: ProxyProvider, method: str + ): + with pytest.raises(MCPError): + await getattr(unreachable_provider, method)() + + @pytest.mark.parametrize( + "method", + ["_list_tools", "_list_resources", "_list_resource_templates", "_list_prompts"], + ) + async def test_list_method_wraps_raw_transport_error(self, method: str): + """A raw transport error raised mid-call is normalized, not leaked.""" + + def exploding_factory() -> Client: + raise httpx2.ConnectError("backend refused the connection") + + provider = ProxyProvider(exploding_factory, cache_ttl=0) + with pytest.raises(MCPError, match="backend refused the connection"): + await getattr(provider, method)() + + @pytest.mark.parametrize("mode", ["legacy", "auto"]) + async def test_connection_error_reaches_client_on_both_eras(self, mode: str): + """The actual defect: the modern era masked the connection failure. + + An unwrapped `RuntimeError` reaching the modern wire boundary is + replaced with a generic "Internal server error", so a client on the + newer protocol could not tell a dead backend from a server bug. On the + legacy era the same exception reached the wire as `str(exc)`, which is + why nothing caught this while tests pinned the older version. + """ + port = find_available_port() + proxy = create_proxy( + StreamableHttpTransport(f"http://127.0.0.1:{port}/mcp"), + provider_error_strategy="raise", + ) + + with pytest.raises(MCPError, match="Client failed to connect"): + async with Client(proxy, mode=mode) as client: + await client.list_tools() diff --git a/tests/server/test_protocol_eras.py b/tests/server/test_protocol_eras.py index d248c69ed..1da476c4c 100644 --- a/tests/server/test_protocol_eras.py +++ b/tests/server/test_protocol_eras.py @@ -36,6 +36,7 @@ from pydantic import FileUrl from fastmcp import Client as FastMCPClient from fastmcp import Context, FastMCP, settings +from fastmcp.exceptions import PromptError, ResourceError from fastmcp.server.elicitation import AcceptedElicitation from fastmcp.server.middleware import Middleware @@ -732,3 +733,90 @@ async def test_middleware_runs_on_both_eras(): # One invocation observed from each era. assert counter.count == 2 + + +# --------------------------------------------------------------------------- +# Resource / prompt handler errors must survive both eras +# --------------------------------------------------------------------------- + + +@pytest.fixture +def erroring_server() -> FastMCP: + """A server whose resource and prompt handlers raise FastMCP errors.""" + mcp = FastMCP("erroring") + + @mcp.resource("data://boom") + def boom() -> str: + raise ResourceError("resource detail marker") + + @mcp.resource("data://items/{item_id}") + def item(item_id: int) -> str: + return f"item {item_id}" + + @mcp.prompt + def explode() -> str: + raise PromptError("prompt detail marker") + + return mcp + + +@pytest.mark.parametrize("mode", ALL_MODES) +async def test_resource_error_message_reaches_client( + erroring_server: FastMCP, mode: str +) -> None: + """A ResourceError's message must reach the wire on every era. + + The modern runner masks any handler exception that is not an MCPError or a + ValidationError as a generic "Internal server error", so a ResourceError + that escapes the handler becomes indistinguishable from a server bug. + """ + async with FastMCPClient(erroring_server, mode=mode) as client: + with pytest.raises(MCPError) as exc_info: + await client.read_resource("data://boom") + + assert "resource detail marker" in str(exc_info.value) + + +@pytest.mark.parametrize("mode", ALL_MODES) +async def test_prompt_error_message_reaches_client( + erroring_server: FastMCP, mode: str +) -> None: + """A PromptError's message must reach the wire on every era.""" + async with FastMCPClient(erroring_server, mode=mode) as client: + with pytest.raises(MCPError) as exc_info: + await client.get_prompt("explode") + + assert "prompt detail marker" in str(exc_info.value) + + +@pytest.mark.parametrize("mode", ALL_MODES) +async def test_resource_template_conversion_error_reaches_client( + erroring_server: FastMCP, mode: str +) -> None: + """A bad template argument is a client-input error, not a server fault. + + This is the path that originally exposed the masking: converting + ``item_id`` to an int fails, and the resulting error must name the problem + rather than surface as a generic internal error. + """ + async with FastMCPClient(erroring_server, mode=mode) as client: + with pytest.raises(MCPError) as exc_info: + await client.read_resource("data://items/not-an-int") + + assert "Internal server error" not in str(exc_info.value) + + +@pytest.mark.parametrize("mode", ALL_MODES) +async def test_resource_error_masked_when_masking_enabled(mode: str) -> None: + """Masking still applies: resources leak no more than tools already do.""" + mcp = FastMCP("masked", mask_error_details=True) + + @mcp.resource("data://boom") + def boom() -> str: + raise ValueError("secret internal detail") + + async with FastMCPClient(mcp, mode=mode) as client: + with pytest.raises(MCPError) as exc_info: + await client.read_resource("data://boom") + + assert "secret internal detail" not in str(exc_info.value)