Skip to content

Commit a29d747

Browse files
committed
fix(mcp): preserve ASGI test database state
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent d40cf53 commit a29d747

2 files changed

Lines changed: 94 additions & 7 deletions

File tree

src/basic_memory/mcp/async_client.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
from contextlib import AbstractAsyncContextManager, asynccontextmanager
3-
from typing import AsyncIterator, Callable, Optional
3+
from inspect import isawaitable
4+
from typing import TYPE_CHECKING, AsyncIterator, Callable, Optional, cast
45

56
from fastapi import FastAPI
67
from httpx import ASGITransport, AsyncClient, Timeout
@@ -9,6 +10,12 @@
910
import logfire
1011
from basic_memory.config import ConfigManager, ProjectMode
1112

13+
if TYPE_CHECKING:
14+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
15+
16+
LocalDatabaseState = tuple["AsyncEngine", "async_sessionmaker[AsyncSession]"]
17+
_MISSING_STATE_VALUE = object()
18+
1219

1320
def _force_local_mode() -> bool:
1421
"""Check if local mode is forced via environment variable."""
@@ -49,15 +56,48 @@ def _build_asgi_client(app: FastAPI, timeout: Timeout) -> AsyncClient:
4956
)
5057

5158

52-
async def _prepare_local_asgi_database(app: FastAPI) -> None:
53-
"""Initialize local ASGI database state before the first request."""
59+
async def _resolve_local_asgi_database(app: FastAPI) -> LocalDatabaseState:
60+
"""Resolve database state for a local ASGI request."""
61+
from basic_memory.deps import get_engine_factory
62+
63+
override = app.dependency_overrides.get(get_engine_factory)
64+
if override is not None:
65+
result = override()
66+
if isawaitable(result):
67+
result = await result
68+
return cast(LocalDatabaseState, result)
69+
5470
from basic_memory import db
5571

5672
config = ConfigManager().config
57-
engine, session_maker = await db.get_or_create_db(config.database_path)
73+
return await db.get_or_create_db(config.database_path)
74+
75+
76+
@asynccontextmanager
77+
async def _prepared_local_asgi_database(app: FastAPI) -> AsyncIterator[None]:
78+
"""Initialize local ASGI database state before the first request."""
79+
previous_engine = getattr(app.state, "engine", _MISSING_STATE_VALUE)
80+
previous_session_maker = getattr(app.state, "session_maker", _MISSING_STATE_VALUE)
81+
82+
engine, session_maker = await _resolve_local_asgi_database(app)
5883
app.state.engine = engine
5984
app.state.session_maker = session_maker
6085

86+
try:
87+
yield
88+
finally:
89+
if previous_engine is _MISSING_STATE_VALUE:
90+
if hasattr(app.state, "engine"):
91+
delattr(app.state, "engine")
92+
else:
93+
app.state.engine = previous_engine
94+
95+
if previous_session_maker is _MISSING_STATE_VALUE:
96+
if hasattr(app.state, "session_maker"):
97+
delattr(app.state, "session_maker")
98+
else:
99+
app.state.session_maker = previous_session_maker
100+
61101

62102
@asynccontextmanager
63103
async def _asgi_client(timeout: Timeout) -> AsyncIterator[AsyncClient]:
@@ -71,9 +111,9 @@ async def _asgi_client(timeout: Timeout) -> AsyncIterator[AsyncClient]:
71111
# under Starlette's request loop and trigger CPython's empty-ready-queue race.
72112
# Outcome: request handling sees the same app.state database objects as API
73113
# lifespan startup would have provided.
74-
await _prepare_local_asgi_database(fastapi_app)
75-
async with _build_asgi_client(fastapi_app, timeout) as client:
76-
yield client
114+
async with _prepared_local_asgi_database(fastapi_app):
115+
async with _build_asgi_client(fastapi_app, timeout) as client:
116+
yield client
77117

78118

79119
async def _resolve_cloud_token(config) -> str:

tests/mcp/test_async_client_modes.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ async def fake_get_or_create_db(db_path):
8181
assert calls == [cfg.database_path]
8282
assert fastapi_app.state.engine is engine
8383
assert fastapi_app.state.session_maker is session_maker
84+
assert not hasattr(fastapi_app.state, "engine")
85+
assert not hasattr(fastapi_app.state, "session_maker")
8486
finally:
8587
if previous_engine is None:
8688
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
@@ -92,6 +94,51 @@ async def fake_get_or_create_db(db_path):
9294
fastapi_app.state.session_maker = previous_session_maker
9395

9496

97+
@pytest.mark.asyncio
98+
async def test_get_client_uses_existing_local_asgi_database_override(config_manager):
99+
"""Local ASGI routing honors FastAPI test dependency overrides."""
100+
from basic_memory.api.app import app as fastapi_app
101+
from basic_memory.deps import get_engine_factory
102+
103+
cfg = config_manager.load_config()
104+
config_manager.save_config(cfg)
105+
106+
previous_overrides = dict(fastapi_app.dependency_overrides)
107+
previous_engine = getattr(fastapi_app.state, "engine", None)
108+
previous_session_maker = getattr(fastapi_app.state, "session_maker", None)
109+
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
110+
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
111+
112+
engine = object()
113+
session_maker = object()
114+
calls = []
115+
116+
def override_engine_factory():
117+
calls.append("override")
118+
return engine, session_maker
119+
120+
fastapi_app.dependency_overrides[get_engine_factory] = override_engine_factory
121+
122+
try:
123+
async with get_client() as client:
124+
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
125+
assert calls == ["override"]
126+
assert fastapi_app.state.engine is engine
127+
assert fastapi_app.state.session_maker is session_maker
128+
assert not hasattr(fastapi_app.state, "engine")
129+
assert not hasattr(fastapi_app.state, "session_maker")
130+
finally:
131+
fastapi_app.dependency_overrides = previous_overrides
132+
if previous_engine is None:
133+
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
134+
else:
135+
fastapi_app.state.engine = previous_engine
136+
if previous_session_maker is None:
137+
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
138+
else:
139+
fastapi_app.state.session_maker = previous_session_maker
140+
141+
95142
@pytest.mark.asyncio
96143
async def test_get_client_explicit_cloud_uses_api_key(config_manager, monkeypatch):
97144
cfg = config_manager.load_config()

0 commit comments

Comments
 (0)