Skip to content

Commit 2bd6552

Browse files
committed
fix(mcp): address PR review feedback (P2 + multi-project search)
Two follow-up fixes on the same branch: 1. **Purge sqlite-vec embeddings during project delete** (Codex P2) sqlite-vec stores vectors in a vec0 virtual table keyed by chunk rowid with no cascade. The previous purge removed search_vector_chunks but left the embeddings behind; `_run_vector_query` then keeps returning stale vectors that crowd live results. ProjectRepository.delete now deletes embeddings first (using the same rowid-IN-chunks pattern as SQLiteSearchRepository.delete_project_vector_rows), then the chunk rows. Both deletes are skipped if the underlying table is absent on a given install. New test test_remove_project_purges_vector_embeddings covers the happy path and skips cleanly when the embeddings table isn't initialized. 2. **Fix search_all_projects=True on local installs** `_search_all_projects` recurses into search_notes with both project= and project_id= set. project_id (external UUID) routes through the cloud v2 API path, which 401s on local installs because there's no JWT to present — so the inner calls silently failed and the merged result list stayed empty. The fan-out now mirrors get_project_client's cloud_available composite (factory mode OR explicit --cloud OR has_cloud_credentials). When that composite is false we forward project= only and take the name-routed local-ASGI path. Cloud disambiguation still works because the project name in project_ref is already the workspace/project qualified_name. The existing cloud-style fan-out tests now go through a cloud_routing fixture that pins the three signals; a new local_routing test confirms project_id is dropped when no cloud route is available. Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 1768de8 commit 2bd6552

4 files changed

Lines changed: 264 additions & 11 deletions

File tree

src/basic_memory/mcp/tools/search.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@
1010
from fastmcp import Context
1111
from pydantic import AliasChoices, BeforeValidator, Field
1212

13-
from basic_memory.config import ConfigManager
13+
from basic_memory.config import ConfigManager, has_cloud_credentials
1414
from basic_memory.utils import build_canonical_permalink, coerce_dict, coerce_list
15+
from basic_memory.mcp.async_client import (
16+
_explicit_routing,
17+
_force_local_mode,
18+
is_factory_mode,
19+
)
1520
from basic_memory.mcp.container import get_container
1621
from basic_memory.mcp.project_context import (
1722
detect_project_from_identifier_prefix,
@@ -479,12 +484,30 @@ async def _search_all_projects(
479484
total = 0
480485
any_project_has_more = False
481486

487+
# Trigger: caller asked for an account-wide search.
488+
# Why: project_id (external UUID) routes through the cloud v2 API path,
489+
# which 401s on local installs because there's no JWT to present.
490+
# Project names route through the local-ASGI path and work for both
491+
# backends — cloud disambiguates names via the workspace/project
492+
# qualified_name already baked into project_ref["project"].
493+
# Outcome: forward project_id only when the same signals get_project_client
494+
# uses to pick a cloud route are present. Mirrors the cloud_available
495+
# composite in project_context.get_project_client (single source of
496+
# truth for "can we route to cloud?").
497+
config = ConfigManager().config
498+
use_cloud_routing = (
499+
is_factory_mode()
500+
or (_explicit_routing() and not _force_local_mode())
501+
or has_cloud_credentials(config)
502+
)
503+
482504
for project_ref in project_refs:
505+
recursive_project_id = project_ref["project_id"] if use_cloud_routing else None
483506
try:
484507
results = await search_notes(
485508
query=query,
486509
project=project_ref["project"],
487-
project_id=project_ref["project_id"],
510+
project_id=recursive_project_id,
488511
page=1,
489512
page_size=per_project_page_size,
490513
search_type=search_type,

src/basic_memory/repository/project_repository.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,15 @@ async def delete(self, entity_id: int) -> bool:
133133
inherits the previous tenant's content. search_vector_chunks is a real
134134
table on both backends but only carries the FK on Postgres.
135135
136-
search_index is created at runtime by SearchRepository.init_search_index
137-
and search_vector_chunks only appears once semantic search initializes,
138-
so each table may be absent on minimal test DBs. Inspect first and
139-
skip whichever table isn't there.
136+
sqlite-vec stores embeddings in a separate vec0 virtual table keyed by
137+
chunk rowid with no cascade, so embeddings must be purged before the
138+
chunk rows or `_run_vector_query` will keep returning stale vectors
139+
that crowd out live results.
140+
141+
Each derived table — search_index, search_vector_chunks,
142+
search_vector_embeddings — is created lazily on first use, so any of
143+
them may be absent on minimal test DBs or installs without semantic
144+
search. Inspect the connection once and skip whichever is missing.
140145
"""
141146
async with db.scoped_session(self.session_maker) as session:
142147
try:
@@ -150,12 +155,29 @@ async def delete(self, entity_id: int) -> bool:
150155
existing_tables = await session.run_sync(
151156
lambda sync_session: set(sa_inspect(sync_session.connection()).get_table_names())
152157
)
153-
for table in ("search_index", "search_vector_chunks"):
154-
if table in existing_tables:
158+
159+
if "search_index" in existing_tables:
160+
await session.execute(
161+
text("DELETE FROM search_index WHERE project_id = :project_id"),
162+
{"project_id": entity_id},
163+
)
164+
165+
if "search_vector_chunks" in existing_tables:
166+
if "search_vector_embeddings" in existing_tables:
167+
# sqlite-vec has no CASCADE — drop embeddings first while the
168+
# chunk rows that name them still exist.
155169
await session.execute(
156-
text(f"DELETE FROM {table} WHERE project_id = :project_id"),
170+
text(
171+
"DELETE FROM search_vector_embeddings WHERE rowid IN ("
172+
"SELECT id FROM search_vector_chunks "
173+
"WHERE project_id = :project_id)"
174+
),
157175
{"project_id": entity_id},
158176
)
177+
await session.execute(
178+
text("DELETE FROM search_vector_chunks WHERE project_id = :project_id"),
179+
{"project_id": entity_id},
180+
)
159181

160182
await session.delete(project)
161183
return True

tests/mcp/tools/test_search_notes_multi_project.py

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,38 @@
88
from basic_memory.schemas.search import SearchItemType, SearchResponse, SearchResult
99

1010

11+
def _stub_routing_mode(monkeypatch, *, cloud: bool) -> None:
12+
"""Pin the three cloud-route signals search.py reads.
13+
14+
`_search_all_projects` only forwards project_id (external UUID) when a
15+
cloud route is available. The composite mirrors get_project_client:
16+
factory mode OR explicit --cloud OR has_cloud_credentials. Tests stub
17+
all three so a dev box with OAuth tokens on disk can't bleed into the
18+
local-mode case.
19+
"""
20+
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
21+
monkeypatch.setattr(search_mod, "is_factory_mode", lambda: False)
22+
monkeypatch.setattr(search_mod, "_explicit_routing", lambda: cloud)
23+
monkeypatch.setattr(search_mod, "_force_local_mode", lambda: False)
24+
monkeypatch.setattr(search_mod, "has_cloud_credentials", lambda config: cloud)
25+
26+
27+
@pytest.fixture
28+
def cloud_routing(monkeypatch):
29+
"""Force the cloud-routing path for multi-project search tests."""
30+
_stub_routing_mode(monkeypatch, cloud=True)
31+
32+
33+
@pytest.fixture
34+
def local_routing(monkeypatch):
35+
"""Force the local-routing path for multi-project search tests."""
36+
_stub_routing_mode(monkeypatch, cloud=False)
37+
38+
1139
@pytest.mark.asyncio
12-
async def test_search_notes_search_all_projects_qualifies_result_permalinks(monkeypatch):
40+
async def test_search_notes_search_all_projects_qualifies_result_permalinks(
41+
monkeypatch, cloud_routing
42+
):
1343
"""Multi-project search belongs to search_notes and keeps result ids routable."""
1444
clients_mod = importlib.import_module("basic_memory.mcp.clients")
1545
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
@@ -169,7 +199,7 @@ async def fail_get_project_client(*args, **kwargs):
169199

170200
@pytest.mark.asyncio
171201
async def test_search_notes_search_all_projects_continues_after_project_failure(
172-
monkeypatch,
202+
monkeypatch, cloud_routing
173203
):
174204
"""One failing project should not discard successful all-project search results."""
175205
clients_mod = importlib.import_module("basic_memory.mcp.clients")
@@ -254,3 +284,85 @@ async def search(self, payload, page, page_size):
254284
assert result["total"] == 1
255285
assert any("team-paul/main" in warning for warning in warnings)
256286
assert any("team index unavailable" in warning for warning in warnings)
287+
288+
289+
@pytest.mark.asyncio
290+
async def test_search_notes_search_all_projects_local_omits_project_id(
291+
monkeypatch, local_routing
292+
):
293+
"""Without a cloud route, fan-out must address each project by name only.
294+
295+
project_id (external UUID) routes through the cloud v2 API path, which
296+
returns 401 on local installs because there's no JWT to present. Local
297+
fan-out has to fall back to the name-routed path so each per-project
298+
search actually returns results instead of silently failing.
299+
"""
300+
clients_mod = importlib.import_module("basic_memory.mcp.clients")
301+
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
302+
303+
project_refs = [
304+
{
305+
"project": "alpha",
306+
"project_id": "11111111-1111-1111-1111-111111111111",
307+
},
308+
{
309+
"project": "beta",
310+
"project_id": "22222222-2222-2222-2222-222222222222",
311+
},
312+
]
313+
searched_projects: list[tuple[str | None, str | None]] = []
314+
315+
async def fake_load_search_project_refs(context=None):
316+
return project_refs
317+
318+
class StubProject:
319+
def __init__(self, name: str | None, external_id: str | None):
320+
self.name = name or "main"
321+
self.external_id = external_id or "local-main"
322+
323+
@asynccontextmanager
324+
async def fake_get_project_client(project=None, context=None, project_id=None):
325+
searched_projects.append((project, project_id))
326+
yield object(), StubProject(project, project_id)
327+
328+
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
329+
return StubProject(project, None), identifier, False
330+
331+
class MockSearchClient:
332+
def __init__(self, client, project_id):
333+
self.project_id = project_id
334+
335+
async def search(self, payload, page, page_size):
336+
return SearchResponse(
337+
results=[
338+
SearchResult(
339+
title=f"Note in {self.project_id or 'local'}",
340+
permalink="notes/example",
341+
content="",
342+
type=SearchItemType.ENTITY,
343+
score=0.5,
344+
file_path="/notes/example.md",
345+
)
346+
],
347+
current_page=page,
348+
page_size=page_size,
349+
total=1,
350+
)
351+
352+
monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs)
353+
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
354+
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
355+
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
356+
357+
result = await search_mod.search_notes(
358+
query="anything",
359+
search_all_projects=True,
360+
output_format="json",
361+
)
362+
363+
assert isinstance(result, dict)
364+
assert searched_projects == [("alpha", None), ("beta", None)], (
365+
"Local fan-out must omit project_id so the recursive search_notes calls "
366+
"take the name-routed path."
367+
)
368+
assert result["total"] == 2

tests/services/test_project_removal_bug.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,16 @@ async def test_remove_project_with_related_entities(project_service: ProjectServ
140140
await project_service.repository.delete(project.id)
141141

142142

143+
async def _table_exists(session_maker, table: str) -> bool:
144+
"""Return True if the named table is present on the current connection."""
145+
from sqlalchemy import inspect as sa_inspect
146+
147+
async with db.scoped_session(session_maker) as session:
148+
return await session.run_sync(
149+
lambda sync_session: table in sa_inspect(sync_session.connection()).get_table_names()
150+
)
151+
152+
143153
@pytest.mark.asyncio
144154
async def test_remove_project_purges_search_rows(project_service: ProjectService):
145155
"""Project deletion must sweep the derived search tables.
@@ -242,3 +252,89 @@ async def test_remove_project_purges_search_rows(project_service: ProjectService
242252
f"search_vector_chunks still has {post_chunks} rows for deleted "
243253
f"project_id={project_id}."
244254
)
255+
256+
257+
@pytest.mark.asyncio
258+
async def test_remove_project_purges_vector_embeddings(project_service: ProjectService):
259+
"""Project deletion must also drop sqlite-vec embeddings keyed by chunk rowid.
260+
261+
sqlite-vec stores vectors in a vec0 virtual table that has no cascade
262+
behavior. If embeddings linger after the chunks they reference are gone,
263+
`_run_vector_query` pulls them as top-k candidates and crowds out live
264+
results. The test only runs when the embeddings table is present, which
265+
matches the install path that exercises semantic search.
266+
"""
267+
test_project_name = f"test-vec-cleanup-{os.urandom(4).hex()}"
268+
session_maker = project_service.repository.session_maker
269+
270+
# The embeddings table only exists once semantic search has initialized.
271+
# Skipping when it's absent keeps this test honest on minimal CI DBs.
272+
if not await _table_exists(session_maker, "search_vector_embeddings"):
273+
pytest.skip("search_vector_embeddings is not present on this connection")
274+
275+
with tempfile.TemporaryDirectory() as temp_dir:
276+
test_project_path = str(Path(temp_dir) / "test-vec-cleanup")
277+
os.makedirs(test_project_path, exist_ok=True)
278+
279+
await project_service.add_project(test_project_name, test_project_path)
280+
project = await project_service.get_project(test_project_name)
281+
assert project is not None
282+
project_id = project.id
283+
284+
async with db.scoped_session(session_maker) as session:
285+
await session.execute(
286+
text(
287+
"INSERT INTO search_vector_chunks "
288+
"(id, entity_id, project_id, chunk_key, chunk_text, source_hash, "
289+
" entity_fingerprint, embedding_model) "
290+
"VALUES (:id, :entity_id, :project_id, :chunk_key, :chunk_text, "
291+
" :source_hash, :entity_fingerprint, :embedding_model)"
292+
),
293+
{
294+
"id": 999_201,
295+
"entity_id": 999_201,
296+
"project_id": project_id,
297+
"chunk_key": "vec-canary",
298+
"chunk_text": "vec canary",
299+
"source_hash": "abc",
300+
"entity_fingerprint": "",
301+
"embedding_model": "",
302+
},
303+
)
304+
# vec0 requires a vector matching the configured dimensions, but the
305+
# delete path filters by rowid; a non-existing dimension would block
306+
# this seed step. Skip the insert if the embeddings DDL hasn't run.
307+
try:
308+
await session.execute(
309+
text(
310+
"INSERT INTO search_vector_embeddings (rowid, embedding) "
311+
"VALUES (:rowid, :embedding)"
312+
),
313+
{"rowid": 999_201, "embedding": "[" + ",".join(["0.0"] * 384) + "]"},
314+
)
315+
except Exception:
316+
pytest.skip("search_vector_embeddings rejected the synthetic seed row")
317+
318+
async with db.scoped_session(session_maker) as session:
319+
pre = (
320+
await session.execute(
321+
text("SELECT COUNT(*) FROM search_vector_embeddings WHERE rowid = :rowid"),
322+
{"rowid": 999_201},
323+
)
324+
).scalar_one()
325+
assert pre >= 1, "seed embedding should exist before removal"
326+
327+
await project_service.remove_project(test_project_name)
328+
329+
async with db.scoped_session(session_maker) as session:
330+
post = (
331+
await session.execute(
332+
text("SELECT COUNT(*) FROM search_vector_embeddings WHERE rowid = :rowid"),
333+
{"rowid": 999_201},
334+
)
335+
).scalar_one()
336+
337+
assert post == 0, (
338+
f"search_vector_embeddings still has {post} rows for rowid 999_201 "
339+
"— project deletion did not sweep the sqlite-vec embeddings table."
340+
)

0 commit comments

Comments
 (0)