Skip to content

Commit 014cbd3

Browse files
committed
fix(mcp): list factory projects across workspaces
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 0b33547 commit 014cbd3

2 files changed

Lines changed: 84 additions & 77 deletions

File tree

src/basic_memory/mcp/tools/project_management.py

Lines changed: 10 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -284,45 +284,20 @@ async def list_memory_projects(
284284
# --- Factory mode (cloud app) ---
285285
# Trigger: set_client_factory() was called (e.g., basic-memory-cloud)
286286
# Why: there is no local ASGI server; the factory IS the cloud source
287-
# Outcome: single fetch, projects reported as source="cloud" with workspace metadata
287+
# Outcome: fetch every accessible workspace so callers can discover cross-workspace IDs
288288
if is_factory_mode():
289-
async with get_client() as client:
290-
project_client = ProjectClient(client)
291-
project_list = await project_client.list_projects()
292-
293-
# Resolve workspace metadata so cloud projects carry their workspace info
294-
cloud_ws_name: str | None = None
295-
cloud_ws_type: str | None = None
296-
cloud_ws_tenant_id: str | None = None
297-
cloud_ws_slug: str | None = None
298-
cloud_ws_is_default = False
299-
try:
300-
from basic_memory.mcp.project_context import get_available_workspaces
301-
302-
workspaces = await get_available_workspaces(context)
303-
if workspaces:
304-
matched = workspaces[0]
305-
cloud_ws_name = matched.name
306-
cloud_ws_type = matched.workspace_type
307-
cloud_ws_tenant_id = matched.tenant_id
308-
cloud_ws_slug = matched.slug
309-
cloud_ws_is_default = matched.is_default
310-
except Exception:
311-
pass # workspace lookup is best-effort
312-
313-
merged = _merge_projects(
289+
workspace_index = await ensure_workspace_project_index(context=context)
290+
merged = _merge_workspace_projects(None, workspace_index.entries)
291+
default_project = next(
292+
(
293+
entry.project.name
294+
for entry in workspace_index.entries
295+
if entry.workspace.is_default and entry.project.is_default
296+
),
314297
None,
315-
project_list,
316-
cloud_workspace_name=cloud_ws_name,
317-
cloud_workspace_type=cloud_ws_type,
318-
cloud_workspace_tenant_id=cloud_ws_tenant_id,
319-
cloud_workspace_slug=cloud_ws_slug,
320-
cloud_workspace_is_default=cloud_ws_is_default,
321298
)
322299
if output_format == "json":
323-
return _format_project_list_json(
324-
merged, project_list.default_project, constrained_project
325-
)
300+
return _format_project_list_json(merged, default_project, constrained_project)
326301
if constrained_project:
327302
return _format_constrained_text(constrained_project)
328303
return _format_project_list_text(merged)

tests/mcp/test_tool_project_management.py

Lines changed: 74 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -220,63 +220,105 @@ async def test_list_memory_projects_cloud_failure_graceful(app, test_project):
220220

221221
@pytest.mark.asyncio
222222
async def test_list_memory_projects_factory_mode(app, test_project):
223-
"""In factory mode (cloud app), projects are reported as cloud-sourced with workspace metadata."""
224-
factory_project = _make_project("cloud-proj", "/cloud-proj", is_default=True)
225-
factory_list = _make_list([factory_project], default="cloud-proj")
226-
227-
ws = _make_workspace("tenant-abc", "Personal", "personal")
223+
"""Factory mode lists projects from every accessible workspace."""
224+
personal_project = _make_project(
225+
"personal-main",
226+
"/personal-main",
227+
is_default=True,
228+
external_id="personal-project-uuid",
229+
)
230+
team_project = _make_project(
231+
"team-specs",
232+
"/team-specs",
233+
id=2,
234+
external_id="team-project-uuid",
235+
)
236+
personal_ws = _make_workspace(
237+
"personal-tenant",
238+
"Personal",
239+
slug="personal",
240+
is_default=True,
241+
)
242+
team_ws = _make_workspace(
243+
"team-tenant",
244+
"Team Paul",
245+
"organization",
246+
slug="team-paul",
247+
)
248+
workspace_index = _make_workspace_index(
249+
[
250+
(personal_ws, [personal_project]),
251+
(team_ws, [team_project]),
252+
]
253+
)
228254

229255
with (
230256
patch(
231257
"basic_memory.mcp.tools.project_management.is_factory_mode",
232258
return_value=True,
233259
),
234260
patch(
235-
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
236-
new_callable=AsyncMock,
237-
return_value=factory_list,
238-
),
239-
patch(
240-
"basic_memory.mcp.project_context.get_available_workspaces",
261+
"basic_memory.mcp.tools.project_management.ensure_workspace_project_index",
241262
new_callable=AsyncMock,
242-
return_value=[ws],
243-
),
263+
return_value=workspace_index,
264+
) as mock_index,
244265
):
245266
result = await list_memory_projects()
246267

247-
assert "- cloud-proj (cloud)" in result
268+
mock_index.assert_awaited_once()
269+
assert "Workspace: Personal (personal default)" in result
270+
assert "Workspace: Team Paul (team-paul)" in result
271+
assert "- personal-main (cloud) [personal-project-uuid]" in result
272+
assert "- team-specs (cloud) [team-project-uuid]" in result
248273

249274

250275
@pytest.mark.asyncio
251276
async def test_list_memory_projects_factory_mode_json_includes_workspace(app, test_project):
252-
"""In factory mode, JSON output includes workspace metadata for cloud projects."""
253-
factory_project = _make_project("cloud-proj", "/cloud-proj", is_default=True)
254-
factory_list = _make_list([factory_project], default="cloud-proj")
255-
256-
ws = _make_workspace("tenant-abc", "My Org", "organization")
277+
"""In factory mode, JSON output includes workspace metadata for all cloud projects."""
278+
default_project = _make_project(
279+
"personal-main",
280+
"/personal-main",
281+
is_default=True,
282+
external_id="personal-project-uuid",
283+
)
284+
org_project = _make_project(
285+
"cloud-proj",
286+
"/cloud-proj",
287+
id=2,
288+
external_id="org-project-uuid",
289+
)
290+
personal_ws = _make_workspace(
291+
"personal-tenant",
292+
"Personal",
293+
slug="personal",
294+
is_default=True,
295+
)
296+
org_ws = _make_workspace("tenant-abc", "My Org", "organization")
297+
workspace_index = _make_workspace_index(
298+
[
299+
(personal_ws, [default_project]),
300+
(org_ws, [org_project]),
301+
]
302+
)
257303

258304
with (
259305
patch(
260306
"basic_memory.mcp.tools.project_management.is_factory_mode",
261307
return_value=True,
262308
),
263309
patch(
264-
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
265-
new_callable=AsyncMock,
266-
return_value=factory_list,
267-
),
268-
patch(
269-
"basic_memory.mcp.project_context.get_available_workspaces",
310+
"basic_memory.mcp.tools.project_management.ensure_workspace_project_index",
270311
new_callable=AsyncMock,
271-
return_value=[ws],
312+
return_value=workspace_index,
272313
),
273314
):
274315
result = await list_memory_projects(output_format="json")
275316

276317
assert isinstance(result, dict)
318+
assert result["default_project"] == "personal-main"
277319
projects = result["projects"]
278-
assert len(projects) == 1
279-
proj = projects[0]
320+
assert len(projects) == 2
321+
proj = {project["name"]: project for project in projects}["cloud-proj"]
280322
assert proj["source"] == "cloud"
281323
assert proj["cloud_path"] == "/cloud-proj"
282324
assert proj["local_path"] is None
@@ -290,30 +332,20 @@ async def test_list_memory_projects_factory_mode_json_includes_workspace(app, te
290332

291333
@pytest.mark.asyncio
292334
async def test_list_memory_projects_factory_mode_workspace_lookup_failure(app, test_project):
293-
"""In factory mode, workspace lookup failure still returns projects as cloud-sourced."""
294-
factory_project = _make_project("cloud-proj", "/cloud-proj", is_default=True)
295-
factory_list = _make_list([factory_project], default="cloud-proj")
296-
335+
"""In factory mode, workspace discovery failures are surfaced to the caller."""
297336
with (
298337
patch(
299338
"basic_memory.mcp.tools.project_management.is_factory_mode",
300339
return_value=True,
301340
),
302341
patch(
303-
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
304-
new_callable=AsyncMock,
305-
return_value=factory_list,
306-
),
307-
patch(
308-
"basic_memory.mcp.project_context.get_available_workspaces",
342+
"basic_memory.mcp.tools.project_management.ensure_workspace_project_index",
309343
new_callable=AsyncMock,
310344
side_effect=RuntimeError("no user context"),
311345
),
312346
):
313-
result = await list_memory_projects()
314-
315-
# Still reported as cloud even without workspace metadata
316-
assert "- cloud-proj (cloud)" in result
347+
with pytest.raises(RuntimeError, match="no user context"):
348+
await list_memory_projects()
317349

318350

319351
@pytest.mark.asyncio

0 commit comments

Comments
 (0)