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