From 68e76fea2eae2850e33f13b22fe7d9bcd35738fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?d=20=F0=9F=94=B9?= Date: Sat, 14 Mar 2026 08:37:22 +0800 Subject: [PATCH] fix: forward custom_route endpoints from mounted servers (#3462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: forward custom_route endpoints from mounted servers When a child server with custom HTTP routes (registered via @server.custom_route()) is mounted onto a parent, the routes were silently dropped because _get_additional_http_routes() only returned self._additional_http_routes without recursing into mounted providers. This caused 404s for endpoints like /readyz health checks that worked in v2 but broke in v3 (regression). The fix updates _get_additional_http_routes() to traverse providers, unwrap _WrappedProvider layers (from namespace transforms), find FastMCPProvider instances, and recursively collect their server's custom routes. Fixes #3457 * fix: narrow type annotation from BaseRoute to Route All items in _additional_http_routes are Route objects (created via Route(...) in custom_route()). Using list[Route] instead of list[BaseRoute] fixes the ty type checker failure where .path is accessed on BaseRoute which doesn't have that attribute. Removes unused BaseRoute imports from both server.py and transport.py. * fix: revert route type to list[BaseRoute] to fix ty errors The previous commit narrowed _additional_http_routes from list[BaseRoute] to list[Route], which broke: - component_manager appending Mount objects (Mount is BaseRoute, not Route) - tests assigning list[BaseRoute] variables (generics are invariant) Revert to list[BaseRoute] and use isinstance(r, Route) guards in tests for type-safe .path access. * fix: remove unused import and fix import grouping - Remove unused `Route` import from server.py - Fix import grouping in test_advanced.py (ruff check) * Address review: move imports to module root, type Provider, add collision note * fix: sort imports in transport.py --------- Co-authored-by: voidborne-d Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com> Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> --- src/fastmcp/server/mixins/transport.py | 36 ++++++++-- tests/server/mount/test_advanced.py | 97 +++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 7 deletions(-) diff --git a/src/fastmcp/server/mixins/transport.py b/src/fastmcp/server/mixins/transport.py index 8f94c0b1e..10223f38a 100644 --- a/src/fastmcp/server/mixins/transport.py +++ b/src/fastmcp/server/mixins/transport.py @@ -22,6 +22,9 @@ from fastmcp.server.http import ( create_sse_app, create_streamable_http_app, ) +from fastmcp.server.providers.base import Provider +from fastmcp.server.providers.fastmcp_provider import FastMCPProvider +from fastmcp.server.providers.wrapped_provider import _WrappedProvider from fastmcp.utilities.cli import log_server_banner from fastmcp.utilities.logging import get_logger, temporary_log_level @@ -145,15 +148,38 @@ class TransportMixin: return decorator def _get_additional_http_routes(self: FastMCP) -> list[BaseRoute]: - """Get all additional HTTP routes including from providers. + """Get all additional HTTP routes including from mounted servers. - Returns a list of all custom HTTP routes from this server and - from all providers that have HTTP routes (e.g., FastMCPProvider). + Collects custom HTTP routes registered via ``@server.custom_route()`` + from this server **and** from any FastMCP servers reachable through + mounted providers (recursively). This ensures that routes defined on + a child server are forwarded to the parent's HTTP app when using + ``server.mount(child)``. + + Note: + When path collisions occur between a parent and a mounted child, + the parent's routes take precedence because they appear first in + the returned list. Returns: - List of Starlette BaseRoute objects + List of Starlette Route objects """ - return list(self._additional_http_routes) + routes: list[BaseRoute] = list(self._additional_http_routes) + + def _unwrap_provider(provider: Provider) -> Provider: + """Unwrap _WrappedProvider layers to find the inner provider.""" + while isinstance(provider, _WrappedProvider): + provider = provider._inner + return provider + + for provider in self.providers: + inner = _unwrap_provider(provider) + if isinstance(inner, FastMCPProvider): + # Recurse into the mounted server to collect its routes + # (and any routes from servers mounted on *it*). + routes.extend(inner.server._get_additional_http_routes()) + + return routes async def run_stdio_async( self: FastMCP, diff --git a/tests/server/mount/test_advanced.py b/tests/server/mount/test_advanced.py index 3835ceca9..f9697b527 100644 --- a/tests/server/mount/test_advanced.py +++ b/tests/server/mount/test_advanced.py @@ -2,6 +2,7 @@ import pytest from mcp.types import TextContent +from starlette.routing import Route from fastmcp import FastMCP from fastmcp.client import Client @@ -82,7 +83,7 @@ class TestCustomRouteForwarding: routes = server._get_additional_http_routes() assert len(routes) == 1 - assert hasattr(routes[0], "path") + assert isinstance(routes[0], Route) assert routes[0].path == "/test" async def test_mounted_servers_tracking(self): @@ -145,10 +146,102 @@ class TestCustomRouteForwarding: routes = server._get_additional_http_routes() assert len(routes) == 2 - route_paths = [route.path for route in routes if hasattr(route, "path")] + route_paths = [route.path for route in routes if isinstance(route, Route)] assert "/route1" in route_paths assert "/route2" in route_paths + async def test_mounted_server_custom_routes_forwarded(self): + """Test that custom routes from a mounted server appear in the parent. + + Regression test for https://github.com/PrefectHQ/fastmcp/issues/3457 + where custom_route endpoints defined on a child server were silently + dropped when the child was mounted onto a parent, resulting in 404s. + """ + parent = FastMCP("Parent") + child = FastMCP("Child") + + @child.custom_route("/readyz", methods=["GET"]) + async def readiness_check(request): + from starlette.responses import JSONResponse + + return JSONResponse({"status": "ok"}) + + parent.mount(child) + + routes = parent._get_additional_http_routes() + assert len(routes) == 1 + assert isinstance(routes[0], Route) + assert routes[0].path == "/readyz" + + async def test_mounted_server_custom_routes_with_namespace(self): + """Test that custom routes from a namespaced mount are forwarded.""" + parent = FastMCP("Parent") + child = FastMCP("Child") + + @child.custom_route("/health", methods=["GET"]) + async def health(request): + from starlette.responses import JSONResponse + + return JSONResponse({"status": "ok"}) + + parent.mount(child, namespace="child") + + routes = parent._get_additional_http_routes() + assert len(routes) == 1 + assert isinstance(routes[0], Route) + assert routes[0].path == "/health" + + async def test_deeply_nested_custom_routes_forwarded(self): + """Test that custom routes from deeply nested mounts are collected.""" + root = FastMCP("Root") + middle = FastMCP("Middle") + leaf = FastMCP("Leaf") + + @leaf.custom_route("/leaf-health", methods=["GET"]) + async def leaf_health(request): + from starlette.responses import JSONResponse + + return JSONResponse({"status": "ok"}) + + @middle.custom_route("/middle-health", methods=["GET"]) + async def middle_health(request): + from starlette.responses import JSONResponse + + return JSONResponse({"status": "ok"}) + + middle.mount(leaf) + root.mount(middle) + + routes = root._get_additional_http_routes() + route_paths = [r.path for r in routes if isinstance(r, Route)] + assert "/leaf-health" in route_paths + assert "/middle-health" in route_paths + assert len(route_paths) == 2 + + async def test_mounted_custom_routes_http_app_integration(self): + """End-to-end: custom routes from mounted servers are reachable via http_app. + + This reproduces the exact scenario from issue #3457. + """ + from starlette.testclient import TestClient + + parent = FastMCP("Parent") + child = FastMCP("Child") + + @child.custom_route("/readyz", methods=["GET"]) + async def readiness_check(request): + from starlette.responses import JSONResponse + + return JSONResponse({"status": "ok"}) + + parent.mount(child) + + app = parent.http_app() + client = TestClient(app) + response = client.get("/readyz") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + class TestDeeplyNestedMount: """Test deeply nested mount scenarios (3+ levels deep).