Skip to content

Commit 6500475

Browse files
fix mcp<>rest bridge
1 parent 721b481 commit 6500475

File tree

3 files changed

+92
-40
lines changed

3 files changed

+92
-40
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ $ touch .env && echo MONGODB_CONNECTION_STRING="$ConnectionString" > .env
3131
- Prod: `gunicorn -k uvicorn.workers.UvicornWorker src:app -b 0.0.0.0:3000`
3232

3333
## MCP Server
34-
- Infinity API automatically serves an MCP bridge at `/mcp` alongside the REST endpoints.
34+
- The MCP bridge is mounted directly on the FastAPI app and is available at `/mcp` alongside the REST API.
35+
- No extra process is required: `uvicorn src:app` serves both the REST routes and the MCP transport.
3536

3637
## Project structure
3738
```

src/api.py

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,49 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
5+
from contextlib import asynccontextmanager
16
from fastapi import FastAPI, Request, status
27
from fastapi.exceptions import RequestValidationError
38
from fastapi.openapi.utils import get_openapi
4-
from fastapi.responses import RedirectResponse, JSONResponse
9+
from fastapi.responses import JSONResponse, RedirectResponse
510

611
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
712
from opentelemetry.instrumentation.requests import RequestsInstrumentor
813

914
from src import logger, parse_error
10-
from src.routes import flight, environment, motor, rocket
11-
from src.utils import RocketPyGZipMiddleware
1215
from src.mcp.server import build_mcp
16+
from src.routes import environment, flight, motor, rocket
17+
from src.utils import RocketPyGZipMiddleware
1318

14-
app = FastAPI(
19+
log = logging.getLogger(__name__)
20+
21+
22+
# --- REST application -------------------------------------------------------
23+
24+
rest_app = FastAPI(
1525
title="Infinity API",
1626
swagger_ui_parameters={
1727
"defaultModelsExpandDepth": 0,
1828
"syntaxHighlight.theme": "obsidian",
1929
},
2030
)
21-
app.include_router(flight.router)
22-
app.include_router(environment.router)
23-
app.include_router(motor.router)
24-
app.include_router(rocket.router)
2531

26-
_mcp_server = build_mcp(app)
27-
app.mount('/mcp', _mcp_server.http_app())
32+
rest_app.include_router(flight.router)
33+
rest_app.include_router(environment.router)
34+
rest_app.include_router(motor.router)
35+
rest_app.include_router(rocket.router)
2836

29-
FastAPIInstrumentor.instrument_app(app)
37+
FastAPIInstrumentor.instrument_app(rest_app)
3038
RequestsInstrumentor().instrument()
3139

3240
# Compress responses above 1KB
33-
app.add_middleware(RocketPyGZipMiddleware, minimum_size=1000)
41+
rest_app.add_middleware(RocketPyGZipMiddleware, minimum_size=1000)
3442

3543

3644
def custom_openapi():
37-
if app.openapi_schema:
38-
return app.openapi_schema
45+
if rest_app.openapi_schema:
46+
return rest_app.openapi_schema
3947
openapi_schema = get_openapi(
4048
title="RocketPy Infinity-API",
4149
version="3.0.0",
@@ -52,40 +60,70 @@ def custom_openapi():
5260
"<p>Create, manage, and simulate rocket flights, environments, rockets, and motors.</p>"
5361
"<p>Please report any bugs at <a href='https://github.com/RocketPy-Team/infinity-api/issues/new/choose' style='text-decoration: none; color: #008CBA;'>GitHub Issues</a></p>"
5462
),
55-
routes=app.routes,
63+
routes=rest_app.routes,
5664
)
5765
openapi_schema["info"]["x-logo"] = {
5866
"url": "https://raw.githubusercontent.com/RocketPy-Team/RocketPy/master/docs/static/RocketPy_Logo_black.png"
5967
}
60-
app.openapi_schema = openapi_schema
61-
return app.openapi_schema
68+
rest_app.openapi_schema = openapi_schema
69+
return rest_app.openapi_schema
6270

6371

64-
app.openapi = custom_openapi
72+
rest_app.openapi = custom_openapi
6573

6674

6775
# Main
68-
@app.get("/", include_in_schema=False)
76+
@rest_app.get("/", include_in_schema=False)
6977
async def main_page():
70-
"""
71-
Redirects to API docs.
72-
"""
78+
"""Redirect to API docs."""
7379
return RedirectResponse(url="/redoc")
7480

7581

7682
# Additional routes
77-
@app.get("/health", status_code=status.HTTP_200_OK, include_in_schema=False)
83+
@rest_app.get(
84+
"/health", status_code=status.HTTP_200_OK, include_in_schema=False
85+
)
7886
async def __perform_healthcheck():
7987
return {"health": "Everything OK!"}
8088

8189

8290
# Global exception handler
83-
@app.exception_handler(RequestValidationError)
91+
@rest_app.exception_handler(RequestValidationError)
8492
async def validation_exception_handler(
8593
request: Request, exc: RequestValidationError
8694
):
8795
exc_str = parse_error(exc)
88-
logger.error(f"{request}: {exc_str}")
96+
logger.error("%s: %s", request, exc_str)
8997
return JSONResponse(
9098
content=exc_str, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY
9199
)
100+
101+
102+
# --- MCP server mounted under /mcp ------------------------------------------
103+
mcp_app = build_mcp(rest_app).http_app(path="/")
104+
105+
106+
def _combine_lifespans(rest_lifespan, mcp_lifespan):
107+
"""Combine FastAPI and MCP lifespans."""
108+
109+
@asynccontextmanager
110+
async def lifespan(app: FastAPI):
111+
async with rest_lifespan(app):
112+
async with mcp_lifespan(app):
113+
yield
114+
115+
return lifespan
116+
117+
118+
app = FastAPI(
119+
docs_url=None,
120+
redoc_url=None,
121+
openapi_url=None,
122+
lifespan=_combine_lifespans(
123+
rest_app.router.lifespan_context, mcp_app.lifespan
124+
),
125+
)
126+
app.mount("/mcp", mcp_app)
127+
app.mount("/", rest_app)
128+
129+
__all__ = ["app", "rest_app"]
Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,48 @@
11
from __future__ import annotations
22

3-
43
from unittest.mock import MagicMock, patch
54

65
import pytest
7-
86
from fastmcp.client import Client
97
from fastapi.routing import APIRoute
108

11-
from src.api import app
9+
from src.api import app, rest_app
1210
from src.mcp.server import build_mcp
1311

1412

1513
@pytest.fixture(autouse=True)
1614
def reset_mcp_state():
17-
if hasattr(app.state, 'mcp'):
18-
delattr(app.state, 'mcp')
15+
if hasattr(rest_app.state, 'mcp'):
16+
delattr(rest_app.state, 'mcp')
1917
yield
20-
if hasattr(app.state, 'mcp'):
21-
delattr(app.state, 'mcp')
18+
if hasattr(rest_app.state, 'mcp'):
19+
delattr(rest_app.state, 'mcp')
2220

2321

2422
def test_build_mcp_uses_fastapi_adapter():
2523
mock_mcp = MagicMock()
2624
with patch(
2725
'src.mcp.server.FastMCP.from_fastapi', return_value=mock_mcp
2826
) as mock_factory:
29-
result = build_mcp(app)
27+
result = build_mcp(rest_app)
3028
assert result is mock_mcp
31-
mock_factory.assert_called_once_with(app, name=app.title)
32-
# Subsequent calls reuse cached server
33-
again = build_mcp(app)
29+
mock_factory.assert_called_once_with(rest_app, name=rest_app.title)
30+
again = build_mcp(rest_app)
3431
assert again is mock_mcp
3532
mock_factory.assert_called_once()
3633

3734

3835
@pytest.mark.asyncio
3936
async def test_mcp_tools_cover_registered_routes():
40-
mcp_server = build_mcp(app)
37+
mcp_server = build_mcp(rest_app)
4138

4239
async with Client(mcp_server) as client:
4340
tools = await client.list_tools()
4441

4542
tool_by_name = {tool.name: tool for tool in tools}
4643

4744
expected = {}
48-
for route in app.routes:
45+
for route in rest_app.routes:
4946
if not isinstance(route, APIRoute) or not route.include_in_schema:
5047
continue
5148
tag = route.tags[0].lower()
@@ -60,7 +57,23 @@ async def test_mcp_tools_cover_registered_routes():
6057
schema = tool_by_name[tool_name].inputSchema or {}
6158
required = set(schema.get('required', []))
6259
path_params = {param.name for param in route.dependant.path_params}
63-
# Path parameters must be represented as required MCP tool arguments
6460
assert path_params.issubset(
6561
required
6662
), f"{tool_name} missing path params {path_params - required}"
63+
64+
65+
@pytest.mark.asyncio
66+
async def test_combined_app_serves_rest_and_mcp(monkeypatch):
67+
monkeypatch.setattr('src.mcp.server.FastMCP.from_fastapi', MagicMock())
68+
build_mcp(rest_app)
69+
70+
from httpx import ASGITransport, AsyncClient
71+
72+
transport = ASGITransport(app=app)
73+
async with AsyncClient(
74+
transport=transport, base_url='http://test'
75+
) as client:
76+
resp_rest = await client.get('/health')
77+
assert resp_rest.status_code == 200
78+
resp_docs = await client.get('/docs')
79+
assert resp_docs.status_code == 200

0 commit comments

Comments
 (0)