fix(mcp): preinitialize local ASGI database#838
Conversation
Signed-off-by: phernandez <paul@basicmachines.co>
|
Claude finished @phernandez's task in 1m 53s —— View job PR Review:
|
| Item | Status |
|---|---|
| Coding conventions (CLAUDE.md) | ✅ |
| Python 3.12+ type annotations | fastapi_app params untyped |
| SQLAlchemy 2.0 best practices | ✅ |
| FastAPI/Typer conventions | ✅ |
| 100-char line length | ✅ |
| No commented-out code | ✅ |
| Unit tests for new functions | ✅ |
| 100% test coverage | ✅ |
| Decision-point comments | ✅ Excellent Trigger/Why/Outcome comments |
| No hardcoded secrets | ✅ |
| Fail-fast error handling | ✅ |
| No sensitive data in logs | ✅ |
| Windows path compatibility | ✅ N/A |
| No emoji/unicode in output | ✅ |
Verdict
Approve with minor fix: the missing FastAPI type annotation on fastapi_app in _build_asgi_client and _prepare_local_asgi_database is the only item that needs to be addressed before merge. Everything else — the fix itself, the architecture split, the decision-point comments, and the test — is solid.
Signed-off-by: phernandez <paul@basicmachines.co>
|
Claude finished @phernandez's task in 53s —— View job PR Review:
|
| Item | Status |
|---|---|
| Coding conventions (CLAUDE.md) | ✅ |
| Python 3.12+ type annotations | ✅ Fixed |
| Async patterns (SQLAlchemy 2.0) | ✅ |
| FastAPI/Typer conventions | ✅ |
| 100-char line length | ✅ |
| No commented-out code | ✅ |
| Trigger/Why/Outcome decision comments | ✅ Excellent (async_client.py:69–73) |
Testing & Documentation
| Item | Status |
|---|---|
| Unit tests for new functions | ✅ test_get_client_preinitializes_local_asgi_database |
| Edge cases covered | ✅ factory injection, per-project routing, explicit override |
| 100% test coverage | ✅ create_client() sync limitation documented in comment |
app.state isolation (try/finally) |
✅ |
monkeypatch.setattr on db.get_or_create_db |
✅ |
Architecture
The _build_asgi_client (pure construction) / _prepare_local_asgi_database (async side-effect) split is clean and independently testable. Pre-init replicates only the DB subset of lifespan (engine + session_maker), correctly leaving sync coordinator and ApiContainer to MCP's container.py. Idempotent due to module-level caching in get_or_create_db.
Security & Performance
- No hardcoded secrets ✅
get_or_create_dbis cached at the module level — calling_prepare_local_asgi_databaseon everyget_client()invocation is safe and cheap after first call ✅- No sensitive data exposed in logs ✅
Compatibility
- No Windows path comparisons introduced ✅
- No emoji/unicode in log output ✅
LGTM — all checklist items pass. Ready to merge.
Signed-off-by: phernandez <paul@basicmachines.co>
|
Claude finished @phernandez's task in 1m 24s —— View job PR Review:
|
| Item | Status |
|---|---|
| Coding conventions (CLAUDE.md) | ✅ |
| Python 3.12+ type annotations | ✅ app: FastAPI, LocalDatabaseState alias, TYPE_CHECKING guard |
| Async patterns (SQLAlchemy 2.0) | ✅ |
| FastAPI/Typer conventions | ✅ app.dependency_overrides, app.state used correctly |
| 100-char line length | ✅ |
| No commented-out code | ✅ |
| Trigger/Why/Outcome decision comments | ✅ async_client.py:109–116 |
Notable design details that are correct:
_MISSING_STATE_VALUE = object()sentinel correctly distinguishes "attribute was absent" from "attribute wasNone" — needed for accurate state teardown in_prepared_local_asgi_database.isawaitablecheck in_resolve_local_asgi_databasecleanly handles both sync and async dependency overrides.cast(LocalDatabaseState, result)is appropriate here since the override return type is opaque.
Testing & Documentation
| Item | Status |
|---|---|
| Unit tests for new functions | ✅ Two tests added |
| Edge cases covered | ✅ Default DB path + dependency override path |
| 100% test coverage | ✅ |
app.state isolation (try/finally) |
✅ Both tests |
_state.pop + # pyright: ignore documented |
✅ |
| Async override path tested | test_get_client_uses_existing_local_asgi_database_override |
The async override branch (when isawaitable(result) is true) is not exercised by any test. The path is simple enough (await result) that this is acceptable and not blocking, but worth a note.
Basic Memory Architecture
| Item | Status |
|---|---|
| Atomic/composable MCP tool design | ✅ |
| No schema/migration changes needed | ✅ N/A |
| Local-first architecture preserved | ✅ |
create_client() limitation documented |
✅ Clear comment on deprecated sync path |
The _build_asgi_client (pure construction) / _resolve_local_asgi_database (resolver) / _prepared_local_asgi_database (context manager side-effect) / _asgi_client (composer) split is clean and each function is independently testable.
Security & Performance
| Item | Status |
|---|---|
| No hardcoded secrets | ✅ |
| No sensitive data in logs | ✅ |
get_or_create_db is module-level cached |
✅ Repeated get_client() calls are cheap after first init |
Compatibility
| Item | Status |
|---|---|
| Windows path compatibility | ✅ Uses config.database_path (Path object, no raw string comparison) |
| No emoji/unicode in log output | ✅ |
LGTM — all checklist items pass. The async override path in _resolve_local_asgi_database is the only untested branch, but it's too simple to block merge. Ready to merge.
Signed-off-by: phernandez <paul@basicmachines.co>
|
Claude finished @phernandez's task in 1m 10s —— View job PR Review:
|
| Item | Status |
|---|---|
| Coding conventions (CLAUDE.md) | ✅ |
| Python 3.12+ type annotations | ✅ app: FastAPI, LocalDatabaseState alias under TYPE_CHECKING |
| Async patterns (SQLAlchemy 2.0) | ✅ |
| FastAPI/Typer conventions | ✅ app.dependency_overrides, app.state used correctly |
| 100-char line length | ✅ |
| No commented-out code | ✅ |
| Trigger/Why/Outcome decision comments | ✅ async_client.py:109–116 |
Notable design details that are correct:
_MISSING_STATE_VALUE = object()sentinel (async_client.py:17) correctly distinguishes "attribute was absent" from "attribute wasNone", which matters for accurate state teardown in_prepared_local_asgi_database.LocalDatabaseStateuses string-quoted type forms ("AsyncEngine","async_sessionmaker[AsyncSession]") so the alias is safe at runtime even though the imports are gated behindTYPE_CHECKING.cast(LocalDatabaseState, result)is a no-op at runtime as expected.- The
_prepared_local_asgi_databaseteardown (async_client.py:89–99) correctly handles all four cases: absent→absent (delattr), absent→set (restored to absent), present→any (restored to previous value).
Testing & Documentation
| Item | Status |
|---|---|
| Unit tests for new functions | ✅ Two new tests |
| Async override path tested | ✅ Fixed — parametrize("async_override", [False, True]) on test_get_client_uses_existing_local_asgi_database_override |
| Edge cases covered | ✅ Default DB path, sync override, async override |
| 100% test coverage | ✅ |
app.state isolation (try/finally) |
✅ Both tests |
dependency_overrides restore in finally |
✅ test_async_client_modes.py:143 |
_state.pop + # pyright: ignore documented |
✅ |
Basic Memory Architecture
| Item | Status |
|---|---|
| Atomic/composable MCP tool design | ✅ |
| No schema/migration changes needed | ✅ N/A |
| Local-first architecture preserved | ✅ |
create_client() limitation documented |
✅ Clear comment on deprecated sync path |
The _build_asgi_client / _resolve_local_asgi_database / _prepared_local_asgi_database / _asgi_client layering is clean and each function is independently testable. Pre-init correctly replicates only the DB subset of lifespan (engine + session_maker), leaving sync coordinator and ApiContainer to MCP's container.py.
Security & Performance
| Item | Status |
|---|---|
| No hardcoded secrets | ✅ |
| No sensitive data in logs | ✅ |
get_or_create_db module-level caching |
✅ Repeated get_client() calls are cheap after first init |
Compatibility
| Item | Status |
|---|---|
| Windows path compatibility | ✅ Uses config.database_path (Path object) |
| No emoji/unicode in log output | ✅ |
LGTM — all checklist items pass. Ready to merge.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8c084e27dc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Signed-off-by: phernandez <paul@basicmachines.co>
|
Claude finished @phernandez's task in 1m 19s —— View job PR Review:
|
| Item | Status |
|---|---|
| Coding conventions (CLAUDE.md) | ✅ |
| Python 3.12+ type annotations | ✅ app: FastAPI, LocalDatabaseState alias, TYPE_CHECKING guard |
| Async patterns (SQLAlchemy 2.0) | ✅ |
| FastAPI/Typer conventions | ✅ |
| 100-char line length | ✅ |
| No commented-out code | ✅ |
| Trigger/Why/Outcome decision comments | ✅ async_client.py:172–177 |
Notable: _MISSING_STATE_VALUE = object() sentinel (async_client.py:19) correctly distinguishes absent from None for state teardown. _PreparedLocalAsgiDatabase dataclass is clean and directly models the ref-counting intent.
Testing & Documentation
| Item | Status |
|---|---|
| Tests for overlapping contexts | ✅ test_get_client_keeps_local_asgi_database_during_overlapping_contexts |
| Default DB pre-init test | ✅ test_get_client_preinitializes_local_asgi_database |
| Sync + async override tested | ✅ @pytest.mark.parametrize("async_override", [False, True]) |
app.state isolation (try/finally) |
✅ All three new tests |
dependency_overrides restore in finally |
✅ test_async_client_modes.py:145 |
_prepared_local_asgi_databases.clear() in autouse fixture |
✅ test_async_client_modes.py:19 — prevents cross-test contamination |
| 100% coverage | ✅ |
The overlap test correctly verifies that after context A exits (with context B still open), app.state.engine is still set (async_client_modes.py:202-203), and only after B exits is it removed (:208-209). This is exactly the scenario the Codex review flagged.
Basic Memory Architecture
| Item | Status |
|---|---|
| Atomic/composable MCP tool design | ✅ |
| No schema/migration changes | ✅ N/A |
| Local-first architecture preserved | ✅ |
create_client() sync limitation documented |
✅ async_client.py:392–394 |
Security & Performance
| Item | Status |
|---|---|
| No hardcoded secrets | ✅ |
| No sensitive data in logs | ✅ |
get_or_create_db module-level cached |
✅ Concurrent _resolve calls during init are cheap after first |
RLock for thread safety |
✅ _prepared_local_asgi_database_lock guards _prepared_local_asgi_databases mutations |
Compatibility
| Item | Status |
|---|---|
| Windows path compatibility | ✅ Uses config.database_path (Path object) |
| No emoji/unicode in log output | ✅ |
LGTM — all checklist items pass including the Codex P1 overlapping-context concern. The ref-counting implementation is correct, the new test exercises the exact race scenario, and the lock guards all mutations. Ready to merge.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 97181c5a1d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Signed-off-by: phernandez <paul@basicmachines.co>
|
Claude finished @phernandez's task in 1m 43s —— View job PR Review:
|
| Item | Status |
|---|---|
| Coding conventions (CLAUDE.md) | ✅ |
| Python 3.12+ type annotations | ✅ app: FastAPI, LocalDatabaseState alias, TYPE_CHECKING guard, dict[FastAPI, ...] type hints |
| Async patterns (SQLAlchemy 2.0) | ✅ |
| FastAPI/Typer conventions | ✅ app.dependency_overrides, app.state used correctly |
| 100-char line length | ✅ |
| No commented-out code | ✅ |
| Trigger/Why/Outcome decision comments | ✅ async_client.py:229–232 |
| Fail-fast error handling | ✅ RuntimeError on solved.errors and on double-install |
Notable: _MISSING_STATE_VALUE = object() sentinel (async_client.py:19) correctly distinguishes absent from None for teardown. LocalDatabaseState uses string-quoted forms ("AsyncEngine", "async_sessionmaker[AsyncSession]") so the alias is safe at runtime under the TYPE_CHECKING guard.
Testing & Documentation
| Item | Status |
|---|---|
| Default DB pre-init | ✅ test_get_client_preinitializes_local_asgi_database |
| Sync + async overrides | ✅ @pytest.mark.parametrize("async_override", [False, True]) |
| Request-taking async-generator override | ✅ test_get_client_resolves_local_asgi_database_override_with_fastapi_di |
| Cleanup runs on context exit | ✅ cleanup == ["closed"] asserted after async with get_client() |
request.app correctly bound |
✅ calls == [fastapi_app] |
| Overlapping contexts | ✅ test_get_client_keeps_local_asgi_database_during_overlapping_contexts |
app.state isolation (try/finally) |
✅ All tests |
dependency_overrides restore in finally |
✅ test_async_client_modes.py:148, 204 |
_prepared_local_asgi_databases.clear() in autouse fixture |
✅ test_async_client_modes.py:20–21 — prevents cross-test contamination |
| 100% coverage | ✅ |
Basic Memory Architecture
| Item | Status |
|---|---|
| Atomic/composable MCP tool design | ✅ |
| No schema/migration changes | ✅ N/A |
| Local-first architecture preserved | ✅ |
create_client() sync limitation documented |
✅ async_client.py:442–444 |
The _build_asgi_client / _resolve_local_asgi_database / _retain / _install / _release / _prepared_local_asgi_database / _asgi_client layering is clean and each function is independently testable. Pre-init correctly replicates only the DB subset of lifespan, leaving sync coordinator and ApiContainer to MCP's container.py.
Security & Performance
| Item | Status |
|---|---|
| No hardcoded secrets | ✅ |
| No sensitive data in logs | ✅ |
get_or_create_db module-level cached |
✅ Repeated get_client() calls are cheap after first init |
RLock guards module-level dicts |
✅ _prepared_local_asgi_database_lock |
asyncio.Lock serializes first-time prep per app |
✅ _get_prepared_local_asgi_database_prepare_lock |
Minor note: _prepared_local_asgi_database_prepare_locks entries are never evicted (one Lock per FastAPI instance). For the normal case — one long-lived fastapi_app singleton — this is a non-issue.
Compatibility
| Item | Status |
|---|---|
| Windows path compatibility | ✅ Uses config.database_path (Path object) |
| No emoji/unicode in log output | ✅ |
LGTM — all checklist items pass. Codex P2 is cleanly resolved via FastAPI's own DI machinery; the new test exercises the exact Request-injection + async-generator-cleanup scenario. Ready to merge.
Summary
create_client()path keeps its existing return contract.app.state.engineandapp.state.session_makerbefore requests run.Root Cause
httpx.ASGITransport(app=fastapi_app)does not run FastAPI lifespan startup. For local CLI/MCP calls, that meant request dependencies could fall back todb.get_or_create_db()inside request handling. With Postgres + asyncpg, the first request can run vector extension/index initialization inside Starlette/anyio request machinery, which reproduced the reportedIndexError: pop from an empty dequefailure.Verification
UV_PYTHON=.venv/bin/python just lintUV_PYTHON=.venv/bin/python just typecheck.venv/bin/python -m pytest -p pytest_mock -v --no-cov tests/mcp/test_async_client_modes.pypgvector/pgvector:pg16on Python 3.12.12, then confirmedproject list --local --jsoncompleted 5 consecutive runs withoutpop from an empty deque.