Skip to content

Commit f2ddd2e

Browse files
removes unnecessary hybrid lifespan
1 parent 326db29 commit f2ddd2e

File tree

4 files changed

+34
-51
lines changed

4 files changed

+34
-51
lines changed

src/api.py

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import logging
44

5-
from contextlib import asynccontextmanager
65
from fastapi import FastAPI, Request, status
76
from fastapi.exceptions import RequestValidationError
87
from fastapi.openapi.utils import get_openapi
@@ -19,8 +18,6 @@
1918
log = logging.getLogger(__name__)
2019

2120

22-
# --- REST application -------------------------------------------------------
23-
2421
rest_app = FastAPI(
2522
title="Infinity API",
2623
swagger_ui_parameters={
@@ -99,31 +96,15 @@ async def validation_exception_handler(
9996
)
10097

10198

102-
# --- MCP server mounted under /mcp ------------------------------------------
99+
# --- MCP server mounted under /mcp -------
103100
mcp_app = build_mcp(rest_app).http_app(path="/")
104101

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-
118102
app = FastAPI(
119103
docs_url=None,
120104
redoc_url=None,
121105
openapi_url=None,
122-
lifespan=_combine_lifespans(
123-
rest_app.router.lifespan_context, mcp_app.lifespan
124-
),
106+
lifespan=mcp_app.lifespan,
125107
)
108+
126109
app.mount("/mcp", mcp_app)
127110
app.mount("/", rest_app)
128-
129-
__all__ = ["app", "rest_app"]

src/mcp/server.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
def build_mcp(app: FastAPI) -> FastMCP:
1010
"""
1111
Create or return a cached FastMCP server that mirrors the given FastAPI app.
12-
12+
1313
Parameters:
1414
app (FastAPI): FastAPI application to mirror; the created FastMCP instance is cached on `app.state.mcp`.
15-
15+
1616
Returns:
1717
FastMCP: The FastMCP instance corresponding to the provided FastAPI app.
1818
"""
@@ -23,4 +23,4 @@ def build_mcp(app: FastAPI) -> FastMCP:
2323
settings.experimental.enable_new_openapi_parser = True
2424
mcp = FastMCP.from_fastapi(app, name=app.title)
2525
app.state.mcp = mcp # type: ignore[attr-defined]
26-
return mcp
26+
return mcp

src/repositories/interface.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,18 @@ def __init__(self):
3737
def repository_exception_handler(method):
3838
"""
3939
Decorator that standardizes error handling and logging for repository coroutine methods.
40-
40+
4141
Parameters:
4242
method (Callable): The asynchronous repository method to wrap.
43-
43+
4444
Returns:
4545
wrapper (Callable): An async wrapper that:
4646
- re-raises PyMongoError after logging the exception,
4747
- re-raises RepositoryNotInitializedException after logging the exception,
4848
- logs any other exception and raises an HTTPException with status 500 and detail 'Unexpected error ocurred',
4949
- always logs completion of the repository method call with the repository name, method name, and kwargs.
5050
"""
51+
5152
@functools.wraps(method)
5253
async def wrapper(self, *args, **kwargs):
5354
try:
@@ -96,9 +97,9 @@ class RepositoryInterface:
9697
def __new__(cls, *args, **kwargs):
9798
"""
9899
Ensure a single thread-safe instance exists for the subclass.
99-
100+
100101
Creates and returns the singleton instance for this subclass, creating it if absent while holding a global thread lock to prevent concurrent instantiation.
101-
102+
102103
Returns:
103104
The singleton instance of the subclass.
104105
"""
@@ -111,11 +112,11 @@ def __new__(cls, *args, **kwargs):
111112
def __init__(self, model: ApiBaseModel, *, max_pool_size: int = 3):
112113
"""
113114
Initialize the repository instance for a specific API model and configure its connection pool.
114-
115+
115116
Parameters:
116117
model (ApiBaseModel): The API model used for validation and to determine the repository's collection.
117118
max_pool_size (int, optional): Maximum size of the MongoDB connection pool. Defaults to 3.
118-
119+
119120
Notes:
120121
If the instance is already initialized, this constructor will not reconfigure it. Initialization of the underlying connection is started asynchronously.
121122
"""
@@ -129,7 +130,7 @@ def __init__(self, model: ApiBaseModel, *, max_pool_size: int = 3):
129130
async def _async_init(self):
130131
"""
131132
Perform idempotent, retry-safe asynchronous initialization of the repository instance.
132-
133+
133134
Ensures a per-instance asyncio.Lock exists and acquires it to run initialization exactly once; on success it marks the instance as initialized and sets the internal _initialized_event so awaiters can proceed. If initialization fails, the original exception from _initialize_connection is propagated after logging.
134135
"""
135136
if getattr(self, '_initialized', False):
@@ -155,7 +156,7 @@ async def _async_init(self):
155156
def _initialize(self):
156157
"""
157158
Ensure the repository's asynchronous initializer is executed: run it immediately if no event loop is active, otherwise schedule it on the running loop.
158-
159+
159160
If there is no running asyncio event loop, this method runs self._async_init() to completion on the current thread, blocking until it finishes. If an event loop is running, it schedules self._async_init() as a background task on that loop and returns immediately.
160161
"""
161162
try:
@@ -168,7 +169,7 @@ def _initialize(self):
168169
async def __aenter__(self):
169170
"""
170171
Waits for repository initialization to complete and returns the repository instance.
171-
172+
172173
Returns:
173174
RepositoryInterface: The initialized repository instance.
174175
"""
@@ -181,9 +182,9 @@ async def __aexit__(self, exc_type, exc_value, traceback):
181182
def _initialize_connection(self):
182183
"""
183184
Initialize the MongoDB async client, store the connection string, and bind the collection for this repository instance.
184-
185+
185186
This method fetches the MongoDB connection string from secrets, creates an AsyncMongoClient configured with pool and timeout settings, and sets self._collection to the repository's collection named by the model. On success it logs the initialized client; on failure it raises a ConnectionError.
186-
187+
187188
Raises:
188189
ConnectionError: If the client or collection cannot be initialized.
189190
"""
@@ -284,4 +285,4 @@ async def find_by_query(self, query: dict):
284285
parsed_model = self.model.model_validate(read_data)
285286
parsed_model.set_id(str(read_data["_id"]))
286287
parsed_models.append(parsed_model)
287-
return parsed_models
288+
return parsed_models

tests/unit/test_mcp/test_mcp_server.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,48 +6,49 @@
66
from fastmcp.client import Client
77
from fastapi.routing import APIRoute
88

9-
from src.api import app, rest_app
9+
from src.api import app
1010
from src.mcp.server import build_mcp
1111

1212

1313
@pytest.fixture(autouse=True)
1414
def reset_mcp_state():
1515
"""
1616
Ensure the FastAPI app has no lingering MCP state before and after a test.
17-
18-
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.
17+
This fixture deletes app.state.mcp if it exists,
18+
yields control to the test, and then deletes app.state.mcp
19+
again to guarantee the MCP state is cleared between tests.
1920
"""
20-
if hasattr(rest_app.state, 'mcp'):
21-
delattr(rest_app.state, 'mcp')
21+
if hasattr(app.state, 'mcp'):
22+
delattr(app.state, 'mcp')
2223
yield
23-
if hasattr(rest_app.state, 'mcp'):
24-
delattr(rest_app.state, 'mcp')
24+
if hasattr(app.state, 'mcp'):
25+
delattr(app.state, 'mcp')
2526

2627

2728
def test_build_mcp_uses_fastapi_adapter():
2829
mock_mcp = MagicMock()
2930
with patch(
3031
'src.mcp.server.FastMCP.from_fastapi', return_value=mock_mcp
3132
) as mock_factory:
32-
result = build_mcp(rest_app)
33+
result = build_mcp(app)
3334
assert result is mock_mcp
34-
mock_factory.assert_called_once_with(rest_app, name=rest_app.title)
35-
again = build_mcp(rest_app)
35+
mock_factory.assert_called_once_with(app, name=app.title)
36+
again = build_mcp(app)
3637
assert again is mock_mcp
3738
mock_factory.assert_called_once()
3839

3940

4041
@pytest.mark.asyncio
4142
async def test_mcp_tools_cover_registered_routes():
42-
mcp_server = build_mcp(rest_app)
43+
mcp_server = build_mcp(app)
4344

4445
async with Client(mcp_server) as client:
4546
tools = await client.list_tools()
4647

4748
tool_by_name = {tool.name: tool for tool in tools}
4849

4950
expected = {}
50-
for route in rest_app.routes:
51+
for route in app.routes:
5152
if not isinstance(route, APIRoute) or not route.include_in_schema:
5253
continue
5354
tag = route.tags[0].lower()
@@ -70,7 +71,7 @@ async def test_mcp_tools_cover_registered_routes():
7071
@pytest.mark.asyncio
7172
async def test_combined_app_serves_rest_and_mcp(monkeypatch):
7273
monkeypatch.setattr('src.mcp.server.FastMCP.from_fastapi', MagicMock())
73-
build_mcp(rest_app)
74+
build_mcp(app)
7475

7576
from httpx import ASGITransport, AsyncClient
7677

@@ -81,4 +82,4 @@ async def test_combined_app_serves_rest_and_mcp(monkeypatch):
8182
resp_rest = await client.get('/health')
8283
assert resp_rest.status_code == 200
8384
resp_docs = await client.get('/docs')
84-
assert resp_docs.status_code == 200
85+
assert resp_docs.status_code == 200

0 commit comments

Comments
 (0)