fix: forward custom_route endpoints from mounted servers (#3462)

* 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 <voidborne-d@users.noreply.github.com>
Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
This commit is contained in:
d 🔹 2026-03-14 08:37:22 +08:00 committed by GitHub
commit 68e76fea2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 126 additions and 7 deletions

View file

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

View file

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