Skip to content

Commit 9e40e86

Browse files
Merge branch 'mcp_server' into coderabbitai/docstrings/f18358b
2 parents 5413027 + 6500475 commit 9e40e86

File tree

4 files changed

+95
-42
lines changed

4 files changed

+95
-42
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"]

src/mcp/server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from fastapi import FastAPI
6-
from fastmcp import FastMCP
6+
from fastmcp import FastMCP, settings
77

88

99
def build_mcp(app: FastAPI) -> FastMCP:
@@ -20,6 +20,7 @@ def build_mcp(app: FastAPI) -> FastMCP:
2020
if hasattr(app.state, 'mcp'):
2121
return app.state.mcp # type: ignore[attr-defined]
2222

23+
settings.experimental.enable_new_openapi_parser = True
2324
mcp = FastMCP.from_fastapi(app, name=app.title)
2425
app.state.mcp = mcp # type: ignore[attr-defined]
2526
return mcp
Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
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

@@ -19,38 +17,37 @@ def reset_mcp_state():
1917
2018
This fixture deletes app.state.mcp if it exists, yields control to the test, and then deletes app.state.mcp again to guarantee the MCP state is cleared between tests.
2119
"""
22-
if hasattr(app.state, 'mcp'):
23-
delattr(app.state, 'mcp')
20+
if hasattr(rest_app.state, 'mcp'):
21+
delattr(rest_app.state, 'mcp')
2422
yield
25-
if hasattr(app.state, 'mcp'):
26-
delattr(app.state, 'mcp')
23+
if hasattr(rest_app.state, 'mcp'):
24+
delattr(rest_app.state, 'mcp')
2725

2826

2927
def test_build_mcp_uses_fastapi_adapter():
3028
mock_mcp = MagicMock()
3129
with patch(
3230
'src.mcp.server.FastMCP.from_fastapi', return_value=mock_mcp
3331
) as mock_factory:
34-
result = build_mcp(app)
32+
result = build_mcp(rest_app)
3533
assert result is mock_mcp
36-
mock_factory.assert_called_once_with(app, name=app.title)
37-
# Subsequent calls reuse cached server
38-
again = build_mcp(app)
34+
mock_factory.assert_called_once_with(rest_app, name=rest_app.title)
35+
again = build_mcp(rest_app)
3936
assert again is mock_mcp
4037
mock_factory.assert_called_once()
4138

4239

4340
@pytest.mark.asyncio
4441
async def test_mcp_tools_cover_registered_routes():
45-
mcp_server = build_mcp(app)
42+
mcp_server = build_mcp(rest_app)
4643

4744
async with Client(mcp_server) as client:
4845
tools = await client.list_tools()
4946

5047
tool_by_name = {tool.name: tool for tool in tools}
5148

5249
expected = {}
53-
for route in app.routes:
50+
for route in rest_app.routes:
5451
if not isinstance(route, APIRoute) or not route.include_in_schema:
5552
continue
5653
tag = route.tags[0].lower()
@@ -65,7 +62,23 @@ async def test_mcp_tools_cover_registered_routes():
6562
schema = tool_by_name[tool_name].inputSchema or {}
6663
required = set(schema.get('required', []))
6764
path_params = {param.name for param in route.dependant.path_params}
68-
# Path parameters must be represented as required MCP tool arguments
6965
assert path_params.issubset(
7066
required
71-
), f"{tool_name} missing path params {path_params - required}"
67+
), f"{tool_name} missing path params {path_params - required}"
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_combined_app_serves_rest_and_mcp(monkeypatch):
72+
monkeypatch.setattr('src.mcp.server.FastMCP.from_fastapi', MagicMock())
73+
build_mcp(rest_app)
74+
75+
from httpx import ASGITransport, AsyncClient
76+
77+
transport = ASGITransport(app=app)
78+
async with AsyncClient(
79+
transport=transport, base_url='http://test'
80+
) as client:
81+
resp_rest = await client.get('/health')
82+
assert resp_rest.status_code == 200
83+
resp_docs = await client.get('/docs')
84+
assert resp_docs.status_code == 200

0 commit comments

Comments
 (0)