Skip to content

Commit bc59980

Browse files
committed
fix(mcp): preinitialize local ASGI database
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 60ec672 commit bc59980

2 files changed

Lines changed: 76 additions & 6 deletions

File tree

src/basic_memory/mcp/async_client.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,8 @@ def _build_timeout() -> Timeout:
3434
)
3535

3636

37-
def _asgi_client(timeout: Timeout) -> AsyncClient:
38-
"""Create a local ASGI client."""
39-
# Import on first local-client use so CLI help/version paths can import
40-
# routing helpers without constructing the full FastAPI router graph.
41-
from basic_memory.api.app import app as fastapi_app
37+
def _build_asgi_client(fastapi_app, timeout: Timeout) -> AsyncClient:
38+
"""Create a local ASGI client for an already-prepared FastAPI app."""
4239
from basic_memory.workspace_context import workspace_permalink_headers
4340

4441
return AsyncClient(
@@ -51,6 +48,33 @@ def _asgi_client(timeout: Timeout) -> AsyncClient:
5148
)
5249

5350

51+
async def _prepare_local_asgi_database(fastapi_app) -> None:
52+
"""Initialize local ASGI database state before the first request."""
53+
from basic_memory import db
54+
55+
config = ConfigManager().config
56+
engine, session_maker = await db.get_or_create_db(config.database_path)
57+
fastapi_app.state.engine = engine
58+
fastapi_app.state.session_maker = session_maker
59+
60+
61+
@asynccontextmanager
62+
async def _asgi_client(timeout: Timeout) -> AsyncIterator[AsyncClient]:
63+
"""Create a local ASGI client."""
64+
# Import on first local-client use so CLI help/version paths can import
65+
# routing helpers without constructing the full FastAPI router graph.
66+
from basic_memory.api.app import app as fastapi_app
67+
68+
# Trigger: local ASGITransport does not execute FastAPI lifespan startup.
69+
# Why: letting request dependencies initialize Postgres can run asyncpg DDL
70+
# under Starlette's request loop and trigger CPython's empty-ready-queue race.
71+
# Outcome: request handling sees the same app.state database objects as API
72+
# lifespan startup would have provided.
73+
await _prepare_local_asgi_database(fastapi_app)
74+
async with _build_asgi_client(fastapi_app, timeout) as client:
75+
yield client
76+
77+
5478
async def _resolve_cloud_token(config) -> str:
5579
"""Resolve cloud token with API key preferred, OAuth fallback."""
5680
with logfire.span(
@@ -260,7 +284,12 @@ def create_client() -> AsyncClient:
260284

261285
if _force_local_mode() or not _force_cloud_mode():
262286
logger.info("Creating ASGI client for local Basic Memory API")
263-
return _asgi_client(timeout)
287+
# Deprecated sync path: create_client() cannot await the local ASGI
288+
# pre-initialization used by get_client(), so callers that need proper
289+
# resource setup should use the async context manager instead.
290+
from basic_memory.api.app import app as fastapi_app
291+
292+
return _build_asgi_client(fastapi_app, timeout)
264293

265294
logger.info("Creating HTTP client for cloud proxy (legacy create_client path)")
266295
config = ConfigManager().config

tests/mcp/test_async_client_modes.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,47 @@ async def test_get_client_default_uses_local_asgi_transport(config_manager):
5151
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
5252

5353

54+
@pytest.mark.asyncio
55+
async def test_get_client_preinitializes_local_asgi_database(config_manager, monkeypatch):
56+
"""Local ASGI routing initializes DB state before request handling."""
57+
from basic_memory import db
58+
from basic_memory.api.app import app as fastapi_app
59+
60+
cfg = config_manager.load_config()
61+
config_manager.save_config(cfg)
62+
63+
previous_engine = getattr(fastapi_app.state, "engine", None)
64+
previous_session_maker = getattr(fastapi_app.state, "session_maker", None)
65+
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
66+
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
67+
68+
engine = object()
69+
session_maker = object()
70+
calls = []
71+
72+
async def fake_get_or_create_db(db_path):
73+
calls.append(db_path)
74+
return engine, session_maker
75+
76+
monkeypatch.setattr(db, "get_or_create_db", fake_get_or_create_db)
77+
78+
try:
79+
async with get_client() as client:
80+
assert isinstance(client._transport, httpx.ASGITransport) # pyright: ignore[reportPrivateUsage]
81+
assert calls == [cfg.database_path]
82+
assert fastapi_app.state.engine is engine
83+
assert fastapi_app.state.session_maker is session_maker
84+
finally:
85+
if previous_engine is None:
86+
fastapi_app.state._state.pop("engine", None) # pyright: ignore[reportPrivateUsage]
87+
else:
88+
fastapi_app.state.engine = previous_engine
89+
if previous_session_maker is None:
90+
fastapi_app.state._state.pop("session_maker", None) # pyright: ignore[reportPrivateUsage]
91+
else:
92+
fastapi_app.state.session_maker = previous_session_maker
93+
94+
5495
@pytest.mark.asyncio
5596
async def test_get_client_explicit_cloud_uses_api_key(config_manager, monkeypatch):
5697
cfg = config_manager.load_config()

0 commit comments

Comments
 (0)