[Python] FastMCP @custom_route and @tool decorators never register routes (always return 404)...

Discussão em 'Python' iniciado por Stack, Junho 4, 2025.

  1. Stack

    Stack Membro Participativo

    I’m trying to integrate FastMCP into a FastAPI application so that I can serve both regular FastAPI routes and MCP tools (via JSON-RPC and custom HTTP endpoints). I’ve defined a “main” app and a separate module (routers/mcp.py) where I decorate MCP tools and a /health endpoint with FastMCP. However, whenever I curl the /health endpoint under the mount, I always get a 404.

    Below is a minimal repro of my setup. I’ve included the relevant import order, debug prints, and the exact console output I see.

    File structure


    project_root/
    ├── main.py
    └── routers/
    └── mcp.py

    routers/mcp.py


    # routers/mcp.py

    from fastmcp import FastMCP
    from starlette.requests import Request
    from starlette.responses import PlainTextResponse

    from main import custom_mcp # the same FastMCP instance

    @custom_mcp.tool()
    def greet(name: str) -> str:
    return f"Hello, {name}!"

    @custom_mcp.tool()
    def add(a: int, b: int) -> int:
    return a + b

    @custom_mcp.custom_route("/health", methods=["GET"])
    async def health_check(request: Request) -> PlainTextResponse:
    return PlainTextResponse("OK")

    main.py


    import re
    import uuid
    import asyncio
    import logging
    from io import StringIO
    from dotenv import load_dotenv

    from fastapi.routing import APIRoute
    from fastmcp import FastMCP
    from starlette.routing import Mount

    load_dotenv(stream=StringIO("ENV=prod"))
    from core.config import settings
    from fastapi import FastAPI, status
    from routers import add_routes, CustomHeaderMiddleware
    from routers.websocket import notify_user_listener
    from schema import init_db
    from fastapi.exceptions import RequestValidationError
    from starlette.exceptions import HTTPException as StarletteHTTPException
    from fastapi.responses import JSONResponse
    from core import ExceptionWrapper
    from contextlib import asynccontextmanager

    # 1) Create a shared FastMCP instance
    custom_mcp= FastMCP("CustomMCP")

    # 2) IMPORT the module containing @custom_mcp.tool and @custom_mcp.custom_route
    import routers.mcp # MUST run before `http_app()` is called

    # 3) Build the MCP HTTP app
    mcp_app = custom_mcp.http_app()

    # 4) FastAPI app & lifespan
    @asynccontextmanager
    async def lifespan(app: FastAPI):
    task = asyncio.create_task(notify_user_listener())
    init_db(drop_tables=False, create_tables=False, load_data=False)
    async with mcp_app.lifespan(app):
    yield
    task.cancel()

    app = FastAPI(
    title="Custom API",
    lifespan=lifespan,
    openapi_url=None,
    docs_url=None,
    redoc_url=None
    )
    add_routes(app)
    app.add_middleware(CustomHeaderMiddleware)

    # 5) Mount the FastMCP sub-app at /api/v1/mcp
    app.mount("/api/v1/mcp", mcp_app)

    # 6) Exception handlers (omitted for brevity)...

    def main():
    import uvicorn

    # Debug: print top-level routes
    print("\n— final app.routes —")
    for r in app.routes:
    if isinstance(r, APIRoute):
    print(f"FASTAPI → {r.path} {r.methods}")
    elif isinstance(r, Mount):
    print(f"MOUNT → {r.path} ⇒ {type(r.app).__name__}")
    print("— end routes —\n")

    uvicorn.run("main:test", reload=True, port=8090, host="127.0.0.1")

    if __name__ == "__main__":
    main()

    What I see in the console


    When I start Uvicorn, I see:

    — final app.routes —
    MOUNT → /api/v1/mcp ⇒ StarletteWithLifespan
    — end routes —


    Notice that there is no FASTAPI → /api/v1/mcp/health or any other MCP route.

    I also added a debug print inside mcp_app.router.routes:

    print("\n––– inside mcp_app.router.routes –––")
    from starlette.routing import Mount, Route, WebSocketRoute
    for r in mcp_app.router.routes:
    if isinstance(r, Mount):
    print(f"Mount at {r.path} → {type(r.app).__name__}")
    elif isinstance(r, Route) or isinstance(r, WebSocketRoute):
    print(f"{type(r).__name__}: {r.path} {getattr(r,'methods',['?'])}")
    else:
    print(f"{type(r).__name__}: {getattr(r,'path','?')}")
    print("––– end mcp_app.router.routes –––\n")


    But I only see:


    MOUNT → /api/v1/mcp ⇒ StarletteWithLifespan



    There is no /health under mcp_app.router.routes either. When I attempt:

    curl -i http://localhost:8000/api/v1/mcp/health


    I get:

    HTTP/1.1 404 Not Found

    What I’ve tried


    1. Ensured import order:
      • I import routers.mcp before calling custom_mcp.http_app().
      • I printed mcp_app.router.routes immediately after building the app to see if /health was registered.

    2. Checked trailing slashes:
      • I tried both /api/v1/mcp/health and /api/v1/mcp/health/.

    3. Verified no duplicate FastMCP instances:
      • I only create custom_mcp= FastMCP("CustomMCP") once, and the decorators reference the same instance.

    4. Confirmed that Uvicorn is restarting:
      • I see the debug print each time I restart, but it never includes /health.
    Desired outcome

    • When I run curl http://localhost:8000/api/v1/mcp/health, I expect a 200 OK with body "OK".
    • When I POST a JSON-RPC request to /api/v1/mcp/mcp/, I expect FastMCP to call the greet or add tool and return a JSON-RPC response.
    What am I missing?


    Why does FastMCP not register the @custom_route("/health") or @tool decorators in mcp_app? How do I ensure the routes actually appear under /api/v1/mcp/*?

    Continue reading...

Compartilhe esta Página