Skip to content

Commit 7ee910c

Browse files
committed
fix(mcp): isolate workspace index failures
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 6b0f19c commit 7ee910c

4 files changed

Lines changed: 196 additions & 20 deletions

File tree

src/basic_memory/mcp/project_context.py

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ class WorkspaceProjectIndex:
5757
workspaces: tuple[WorkspaceInfo, ...]
5858
entries: tuple[WorkspaceProjectEntry, ...]
5959
entries_by_permalink: dict[str, tuple[WorkspaceProjectEntry, ...]]
60+
failed_workspaces: tuple[WorkspaceInfo, ...] = ()
6061

6162

6263
def set_workspace_provider(provider: Callable[[], Awaitable[list[WorkspaceInfo]]]) -> None:
@@ -300,6 +301,12 @@ def _workspace_project_index_from_state(raw: object) -> WorkspaceProjectIndex |
300301
return None
301302

302303
workspaces = tuple(WorkspaceInfo.model_validate(item) for item in workspaces_raw)
304+
failed_workspaces_raw = raw_mapping.get("failed_workspaces")
305+
failed_workspaces = (
306+
tuple(WorkspaceInfo.model_validate(item) for item in failed_workspaces_raw)
307+
if isinstance(failed_workspaces_raw, list)
308+
else ()
309+
)
303310
entries_list: list[WorkspaceProjectEntry] = []
304311
for item in entries_raw:
305312
if not isinstance(item, dict):
@@ -316,13 +323,18 @@ def _workspace_project_index_from_state(raw: object) -> WorkspaceProjectIndex |
316323
)
317324
)
318325
entries = tuple(entries_list)
319-
return _build_workspace_project_index(workspaces, entries)
326+
return _build_workspace_project_index(
327+
workspaces,
328+
entries,
329+
failed_workspaces=failed_workspaces,
330+
)
320331

321332

322333
def _workspace_project_index_to_state(index: WorkspaceProjectIndex) -> dict:
323334
"""Serialize a workspace project index for MCP context state."""
324335
return {
325336
"workspaces": [workspace.model_dump() for workspace in index.workspaces],
337+
"failed_workspaces": [workspace.model_dump() for workspace in index.failed_workspaces],
326338
"entries": [
327339
{
328340
"workspace": entry.workspace.model_dump(),
@@ -336,6 +348,8 @@ def _workspace_project_index_to_state(index: WorkspaceProjectIndex) -> dict:
336348
def _build_workspace_project_index(
337349
workspaces: tuple[WorkspaceInfo, ...],
338350
entries: tuple[WorkspaceProjectEntry, ...],
351+
*,
352+
failed_workspaces: tuple[WorkspaceInfo, ...] = (),
339353
) -> WorkspaceProjectIndex:
340354
"""Build the permalink lookup table for workspace-project entries."""
341355
grouped: dict[str, list[WorkspaceProjectEntry]] = {}
@@ -349,6 +363,7 @@ def _build_workspace_project_index(
349363
permalink: tuple(items)
350364
for permalink, items in sorted(grouped.items(), key=lambda item: item[0])
351365
},
366+
failed_workspaces=failed_workspaces,
352367
)
353368

354369

@@ -469,11 +484,50 @@ async def _ensure_workspace_project_index(
469484
"Ensure you have an active subscription and tenant access."
470485
)
471486

472-
fetched_entries = await asyncio.gather(
473-
*[_fetch_workspace_project_entries(workspace, context=context) for workspace in workspaces]
487+
fetched_results = await asyncio.gather(
488+
*[_fetch_workspace_project_entries(workspace, context=context) for workspace in workspaces],
489+
return_exceptions=True,
490+
)
491+
entries_list: list[WorkspaceProjectEntry] = []
492+
failed_workspaces: list[WorkspaceInfo] = []
493+
successful_fetches = 0
494+
for workspace, result in zip(workspaces, fetched_results, strict=True):
495+
if isinstance(result, BaseException):
496+
if not isinstance(result, Exception):
497+
raise result
498+
# Trigger: one workspace project listing failed during a multi-workspace index.
499+
# Why: a transient or unauthorized tenant should not break qualified routing for
500+
# healthy workspaces, but unqualified routing still needs to know the index is partial.
501+
# Outcome: keep successful workspace entries and record the failed workspace.
502+
failed_workspaces.append(workspace)
503+
logger.warning(
504+
f"Cloud project discovery failed for workspace {workspace.slug} "
505+
f"({workspace.tenant_id}): {result}"
506+
)
507+
if context: # pragma: no cover
508+
await context.info(
509+
f"Cloud project discovery failed for workspace {workspace.slug}; "
510+
"continuing with other workspaces"
511+
)
512+
continue
513+
514+
workspace_entries = cast(tuple[WorkspaceProjectEntry, ...], result)
515+
successful_fetches += 1
516+
entries_list.extend(workspace_entries)
517+
518+
if failed_workspaces and successful_fetches == 0:
519+
failed_labels = ", ".join(workspace.slug for workspace in failed_workspaces)
520+
raise ValueError(
521+
"Unable to discover projects in any accessible workspace. "
522+
f"Failed workspaces: {failed_labels}"
523+
)
524+
525+
entries = tuple(entries_list)
526+
index = _build_workspace_project_index(
527+
workspaces,
528+
entries,
529+
failed_workspaces=tuple(failed_workspaces),
474530
)
475-
entries = tuple(entry for workspace_entries in fetched_entries for entry in workspace_entries)
476-
index = _build_workspace_project_index(workspaces, entries)
477531

478532
if context:
479533
await context.set_state(
@@ -520,6 +574,14 @@ async def resolve_workspace_project_identifier(
520574
if entry.workspace.tenant_id == workspace.tenant_id
521575
]
522576
if not matches:
577+
if any(
578+
failed_workspace.tenant_id == workspace.tenant_id
579+
for failed_workspace in index.failed_workspaces
580+
):
581+
raise ValueError(
582+
f"Projects for workspace '{workspace.name}' ({workspace.slug}) "
583+
"could not be loaded. Retry after workspace discovery recovers."
584+
)
523585
available = ", ".join(
524586
entry.qualified_name
525587
for entry in index.entries
@@ -533,10 +595,17 @@ async def resolve_workspace_project_identifier(
533595

534596
matches = index.entries_by_permalink.get(project_permalink, ())
535597
if not matches:
598+
failed_note = ""
599+
if index.failed_workspaces:
600+
failed = ", ".join(workspace.slug for workspace in index.failed_workspaces)
601+
failed_note = (
602+
f" Project discovery failed for workspace(s): {failed}; "
603+
"retry or use a qualified project from an indexed workspace."
604+
)
536605
available = ", ".join(entry.qualified_name for entry in index.entries)
537606
raise ValueError(
538-
f"Project '{project}' was not found in any accessible cloud workspace. "
539-
f"Available projects: {available}"
607+
f"Project '{project}' was not found in indexed cloud workspaces. "
608+
f"Available projects: {available}.{failed_note}"
540609
)
541610

542611
cached_workspace = await _get_cached_active_workspace(context)
@@ -557,6 +626,15 @@ async def resolve_workspace_project_identifier(
557626
f"Project '{project}' exists in multiple workspaces. Use: {choices}\n{details}"
558627
)
559628

629+
if index.failed_workspaces:
630+
qualified_name = matches[0].qualified_name
631+
failed = ", ".join(workspace.slug for workspace in index.failed_workspaces)
632+
raise ValueError(
633+
f"Project '{project}' was found as {qualified_name}, but project discovery "
634+
f"failed for workspace(s): {failed}. Use '{qualified_name}' to route "
635+
"explicitly, or retry after discovery recovers."
636+
)
637+
560638
return matches[0]
561639

562640

src/basic_memory/mcp/tools/project_management.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ def _format_project_list_text(merged: list[dict]) -> str:
250250
source = project["source"]
251251
qualified_name = project.get("qualified_name")
252252
qualified_suffix = f" [{qualified_name}]" if qualified_name else ""
253-
result += f" {label} ({source}){qualified_suffix}\n"
253+
result += f"- {label} ({source}){qualified_suffix}\n"
254254

255255
result += "\n" + "─" * 40 + "\n"
256256
result += "Next: Ask which project to use for this session.\n"

tests/mcp/test_project_context.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ async def get_state(self, key: str):
2424
async def set_state(self, key: str, value: object, **kwargs) -> None:
2525
self._state[key] = value
2626

27+
async def info(self, message: str) -> None:
28+
self._state["info_message"] = message
29+
2730

2831
def _ctx(context: _ContextState) -> Any:
2932
return cast(Any, context)
@@ -425,6 +428,101 @@ async def fake_fetch_workspace_project_entries(workspace, context=None):
425428
assert calls == ["personal", "acme", "personal", "acme"]
426429

427430

431+
@pytest.mark.asyncio
432+
async def test_workspace_project_index_keeps_successes_when_workspace_fetch_fails(
433+
monkeypatch,
434+
):
435+
import basic_memory.mcp.project_context as project_context
436+
from basic_memory.mcp.project_context import (
437+
WorkspaceProjectEntry,
438+
_ensure_workspace_project_index,
439+
resolve_workspace_project_identifier,
440+
)
441+
442+
context = _ContextState()
443+
personal = _workspace(
444+
tenant_id="personal-tenant",
445+
workspace_type="personal",
446+
slug="personal",
447+
name="Personal",
448+
role="owner",
449+
is_default=True,
450+
)
451+
acme = _workspace(
452+
tenant_id="acme-tenant",
453+
workspace_type="organization",
454+
slug="acme",
455+
name="Acme",
456+
role="editor",
457+
)
458+
project = _project("Meeting Notes", id=7, external_id="personal-meeting-notes")
459+
460+
async def fake_get_available_workspaces(context=None):
461+
return [personal, acme]
462+
463+
async def fake_fetch_workspace_project_entries(workspace, context=None):
464+
if workspace.slug == "acme":
465+
raise RuntimeError("acme unavailable")
466+
return (WorkspaceProjectEntry(workspace=workspace, project=project),)
467+
468+
monkeypatch.setattr(project_context, "get_available_workspaces", fake_get_available_workspaces)
469+
monkeypatch.setattr(
470+
project_context,
471+
"_fetch_workspace_project_entries",
472+
fake_fetch_workspace_project_entries,
473+
)
474+
475+
index = await _ensure_workspace_project_index(context=_ctx(context))
476+
477+
assert [entry.qualified_name for entry in index.entries] == ["personal/meeting-notes"]
478+
assert [workspace.slug for workspace in index.failed_workspaces] == ["acme"]
479+
480+
resolved = await resolve_workspace_project_identifier(
481+
"personal/meeting-notes",
482+
context=_ctx(context),
483+
)
484+
assert resolved.project.external_id == "personal-meeting-notes"
485+
486+
with pytest.raises(ValueError, match="Use 'personal/meeting-notes'"):
487+
await resolve_workspace_project_identifier(
488+
"meeting-notes",
489+
context=_ctx(context),
490+
)
491+
492+
493+
@pytest.mark.asyncio
494+
async def test_workspace_project_index_raises_when_all_workspace_fetches_fail(
495+
monkeypatch,
496+
):
497+
import basic_memory.mcp.project_context as project_context
498+
from basic_memory.mcp.project_context import _ensure_workspace_project_index
499+
500+
personal = _workspace(
501+
tenant_id="personal-tenant",
502+
workspace_type="personal",
503+
slug="personal",
504+
name="Personal",
505+
role="owner",
506+
is_default=True,
507+
)
508+
509+
async def fake_get_available_workspaces(context=None):
510+
return [personal]
511+
512+
async def fake_fetch_workspace_project_entries(workspace, context=None):
513+
raise RuntimeError("tenant unavailable")
514+
515+
monkeypatch.setattr(project_context, "get_available_workspaces", fake_get_available_workspaces)
516+
monkeypatch.setattr(
517+
project_context,
518+
"_fetch_workspace_project_entries",
519+
fake_fetch_workspace_project_entries,
520+
)
521+
522+
with pytest.raises(ValueError, match="Unable to discover projects"):
523+
await _ensure_workspace_project_index()
524+
525+
428526
@pytest.mark.asyncio
429527
async def test_fetch_workspace_project_entries_copies_default_project(monkeypatch):
430528
import basic_memory.mcp.async_client as async_client

tests/mcp/test_tool_project_management.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def _make_list(projects: list[ProjectItem], default: str | None = None) -> Proje
4747
async def test_list_memory_projects_unconstrained(app, test_project):
4848
result = await list_memory_projects()
4949
assert "Available projects:" in result
50-
assert f" {test_project.name}" in result
50+
assert f"- {test_project.name}" in result
5151

5252

5353
@pytest.mark.asyncio
@@ -77,9 +77,9 @@ async def test_list_memory_projects_shows_display_name(app, client, test_project
7777
result = await list_memory_projects()
7878

7979
# Regular project shows name with source label
80-
assert " main (local)" in result
80+
assert "- main (local)" in result
8181
# Private project shows display_name with slug in parentheses, then source
82-
assert " My Notes (private-fb83af23) (local)" in result
82+
assert "- My Notes (private-fb83af23) (local)" in result
8383

8484

8585
@pytest.mark.asyncio
@@ -95,7 +95,7 @@ async def test_list_memory_projects_no_display_name_shows_name_only(app, client,
9595
):
9696
result = await list_memory_projects()
9797

98-
assert " my-project (local)" in result
98+
assert "- my-project (local)" in result
9999

100100

101101
@pytest.mark.asyncio
@@ -176,11 +176,11 @@ async def test_list_memory_projects_local_and_cloud_merge(app, test_project):
176176
result = await list_memory_projects()
177177

178178
# Both local+cloud project shows merged source
179-
assert " main (local+cloud)" in result
179+
assert "- main (local+cloud)" in result
180180
# Local-only project
181-
assert " specs (local)" in result
181+
assert "- specs (local)" in result
182182
# Cloud-only project
183-
assert " basic-memory-llc (cloud)" in result
183+
assert "- basic-memory-llc (cloud)" in result
184184

185185

186186
@pytest.mark.asyncio
@@ -193,7 +193,7 @@ async def test_list_memory_projects_no_cloud_credentials(app, test_project):
193193
result = await list_memory_projects()
194194

195195
assert "Available projects:" in result
196-
assert f" {test_project.name} (local)" in result
196+
assert f"- {test_project.name} (local)" in result
197197
# No cloud source labels
198198
assert "cloud)" not in result
199199

@@ -215,7 +215,7 @@ async def test_list_memory_projects_cloud_failure_graceful(app, test_project):
215215
result = await list_memory_projects()
216216

217217
assert "Available projects:" in result
218-
assert f" {test_project.name} (local)" in result
218+
assert f"- {test_project.name} (local)" in result
219219

220220

221221
@pytest.mark.asyncio
@@ -244,7 +244,7 @@ async def test_list_memory_projects_factory_mode(app, test_project):
244244
):
245245
result = await list_memory_projects()
246246

247-
assert " cloud-proj (cloud)" in result
247+
assert "- cloud-proj (cloud)" in result
248248

249249

250250
@pytest.mark.asyncio
@@ -313,7 +313,7 @@ async def test_list_memory_projects_factory_mode_workspace_lookup_failure(app, t
313313
result = await list_memory_projects()
314314

315315
# Still reported as cloud even without workspace metadata
316-
assert " cloud-proj (cloud)" in result
316+
assert "- cloud-proj (cloud)" in result
317317

318318

319319
@pytest.mark.asyncio
@@ -579,7 +579,7 @@ async def test_list_memory_projects_aggregates_without_config_workspace(app, tes
579579
result = await list_memory_projects()
580580

581581
mock_index.assert_awaited_once()
582-
assert " cloud-proj (cloud) [default/cloud-proj]" in result
582+
assert "- cloud-proj (cloud) [default/cloud-proj]" in result
583583

584584

585585
@pytest.mark.asyncio

0 commit comments

Comments
 (0)