mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 04:24:17 +02:00
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:
parent
fcae127664
commit
68e76fea2e
2 changed files with 126 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue