Skip to content

Commit a93fa16

Browse files
committed
fix(mcp): keep all-project search resilient
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent f21e2ee commit a93fa16

3 files changed

Lines changed: 125 additions & 23 deletions

File tree

src/basic_memory/mcp/project_context.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,6 @@ def _canonical_memory_path_for_workspace(
428428
workspace_type: str,
429429
project_permalink: str,
430430
remainder: str,
431-
include_project: bool,
432431
) -> str:
433432
"""Return the stored canonical path for a workspace-qualified memory URL."""
434433
normalized_remainder = remainder.strip("/")
@@ -523,7 +522,6 @@ async def resolve_workspace_qualified_memory_url(
523522
workspace_type=entry.workspace.workspace_type,
524523
project_permalink=entry.project.permalink,
525524
remainder=remainder,
526-
include_project=ConfigManager().config.permalinks_include_project,
527525
)
528526
return WorkspaceMemoryUrlResolution(entry=entry, canonical_path=canonical_path)
529527

@@ -1071,7 +1069,6 @@ async def resolve_project_and_path(
10711069
workspace_type=cached_workspace.workspace_type,
10721070
project_permalink=cached_project.permalink,
10731071
remainder=remainder,
1074-
include_project=bool(include_project),
10751072
)
10761073
return cached_project, resolved_path, True
10771074

@@ -1094,7 +1091,6 @@ async def resolve_project_and_path(
10941091
workspace_type=workspace_context.workspace_type,
10951092
project_permalink=project_permalink,
10961093
remainder=remainder,
1097-
include_project=bool(include_project),
10981094
)
10991095
return active_project, resolved_path, True
11001096

src/basic_memory/mcp/tools/search.py

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,11 @@ def _result_total(results: dict[str, Any], raw_results: list[SearchResult | dict
436436
return len(raw_results) + (1 if results.get("has_more") is True else 0)
437437

438438

439+
def _project_ref_label(project_ref: dict[str, str | None]) -> str:
440+
"""Return a stable log label for a project search ref."""
441+
return project_ref.get("project") or project_ref.get("project_id") or "<unknown project>"
442+
443+
439444
async def _search_all_projects(
440445
*,
441446
query: str | None,
@@ -474,27 +479,39 @@ async def _search_all_projects(
474479
any_project_has_more = False
475480

476481
for project_ref in project_refs:
477-
results = await search_notes(
478-
query=query,
479-
project=project_ref["project"],
480-
project_id=project_ref["project_id"],
481-
page=1,
482-
page_size=per_project_page_size,
483-
search_type=search_type,
484-
output_format="json",
485-
note_types=note_types or None,
486-
entity_types=entity_types or None,
487-
after_date=after_date,
488-
metadata_filters=metadata_filters,
489-
tags=tags,
490-
status=status,
491-
min_similarity=min_similarity,
492-
search_all_projects=False,
493-
context=context,
494-
)
482+
try:
483+
results = await search_notes(
484+
query=query,
485+
project=project_ref["project"],
486+
project_id=project_ref["project_id"],
487+
page=1,
488+
page_size=per_project_page_size,
489+
search_type=search_type,
490+
output_format="json",
491+
note_types=note_types or None,
492+
entity_types=entity_types or None,
493+
after_date=after_date,
494+
metadata_filters=metadata_filters,
495+
tags=tags,
496+
status=status,
497+
min_similarity=min_similarity,
498+
search_all_projects=False,
499+
context=context,
500+
)
501+
except Exception as exc:
502+
logger.warning(
503+
f"Multi-project search failed for project {_project_ref_label(project_ref)}: {exc}"
504+
)
505+
continue
495506

496507
if isinstance(results, str):
497-
return results
508+
if not results.startswith("# Search Failed"):
509+
return results
510+
logger.warning(
511+
"Multi-project search failed for project "
512+
f"{_project_ref_label(project_ref)}: {results}"
513+
)
514+
continue
498515

499516
raw_results = _raw_results_from_search_payload(results)
500517
total += _result_total(results, raw_results)

tests/mcp/tools/test_search_notes_multi_project.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,92 @@ async def fail_get_project_client(*args, **kwargs):
165165
"total": 0,
166166
"has_more": False,
167167
}
168+
169+
170+
@pytest.mark.asyncio
171+
async def test_search_notes_search_all_projects_continues_after_project_failure(
172+
monkeypatch,
173+
):
174+
"""One failing project should not discard successful all-project search results."""
175+
clients_mod = importlib.import_module("basic_memory.mcp.clients")
176+
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
177+
178+
project_refs = [
179+
{
180+
"project": "personal/main",
181+
"project_id": "11111111-1111-1111-1111-111111111111",
182+
},
183+
{
184+
"project": "team-paul/main",
185+
"project_id": "22222222-2222-2222-2222-222222222222",
186+
},
187+
]
188+
warnings: list[str] = []
189+
190+
async def fake_load_search_project_refs(context=None):
191+
return project_refs
192+
193+
class StubProject:
194+
def __init__(self, name: str | None, external_id: str | None):
195+
self.name = name or "main"
196+
self.external_id = external_id or "local-main"
197+
198+
@asynccontextmanager
199+
async def fake_get_project_client(project=None, context=None, project_id=None):
200+
yield object(), StubProject(project, project_id)
201+
202+
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
203+
return StubProject(project, None), identifier, False
204+
205+
class FakeLogger:
206+
def debug(self, *args, **kwargs):
207+
pass
208+
209+
def error(self, *args, **kwargs):
210+
pass
211+
212+
def warning(self, message, *args, **kwargs):
213+
warnings.append(str(message))
214+
215+
class MockSearchClient:
216+
def __init__(self, client, project_id):
217+
self.project_id = project_id
218+
219+
async def search(self, payload, page, page_size):
220+
if self.project_id == "22222222-2222-2222-2222-222222222222":
221+
raise RuntimeError("team index unavailable")
222+
return SearchResponse(
223+
results=[
224+
SearchResult(
225+
title="Personal MCP Test Note",
226+
permalink="main/tests/mcp-test-note",
227+
content="MCP content",
228+
type=SearchItemType.ENTITY,
229+
score=0.5,
230+
file_path="/main/tests/mcp-test-note.md",
231+
)
232+
],
233+
current_page=page,
234+
page_size=page_size,
235+
total=1,
236+
)
237+
238+
monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs)
239+
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
240+
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
241+
monkeypatch.setattr(search_mod, "logger", FakeLogger())
242+
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
243+
244+
result = await search_mod.search_notes(
245+
query="MCP Test Note",
246+
search_all_projects=True,
247+
output_format="json",
248+
)
249+
250+
assert isinstance(result, dict)
251+
assert [item["permalink"] for item in result["results"]] == [
252+
"personal/main/tests/mcp-test-note",
253+
]
254+
assert result["total"] == 1
255+
assert any("team-paul/main" in warning for warning in warnings)
256+
assert any("team index unavailable" in warning for warning in warnings)

0 commit comments

Comments
 (0)