fix(mcp): list factory projects across workspaces (#778)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-05-02 09:17:25 -05:00
committed by GitHub
parent 0b335476d6
commit b4bf14ebf7
2 changed files with 84 additions and 77 deletions
@@ -284,45 +284,20 @@ async def list_memory_projects(
# --- Factory mode (cloud app) ---
# Trigger: set_client_factory() was called (e.g., basic-memory-cloud)
# Why: there is no local ASGI server; the factory IS the cloud source
# Outcome: single fetch, projects reported as source="cloud" with workspace metadata
# Outcome: fetch every accessible workspace so callers can discover cross-workspace IDs
if is_factory_mode():
async with get_client() as client:
project_client = ProjectClient(client)
project_list = await project_client.list_projects()
# Resolve workspace metadata so cloud projects carry their workspace info
cloud_ws_name: str | None = None
cloud_ws_type: str | None = None
cloud_ws_tenant_id: str | None = None
cloud_ws_slug: str | None = None
cloud_ws_is_default = False
try:
from basic_memory.mcp.project_context import get_available_workspaces
workspaces = await get_available_workspaces(context)
if workspaces:
matched = workspaces[0]
cloud_ws_name = matched.name
cloud_ws_type = matched.workspace_type
cloud_ws_tenant_id = matched.tenant_id
cloud_ws_slug = matched.slug
cloud_ws_is_default = matched.is_default
except Exception:
pass # workspace lookup is best-effort
merged = _merge_projects(
workspace_index = await ensure_workspace_project_index(context=context)
merged = _merge_workspace_projects(None, workspace_index.entries)
default_project = next(
(
entry.project.name
for entry in workspace_index.entries
if entry.workspace.is_default and entry.project.is_default
),
None,
project_list,
cloud_workspace_name=cloud_ws_name,
cloud_workspace_type=cloud_ws_type,
cloud_workspace_tenant_id=cloud_ws_tenant_id,
cloud_workspace_slug=cloud_ws_slug,
cloud_workspace_is_default=cloud_ws_is_default,
)
if output_format == "json":
return _format_project_list_json(
merged, project_list.default_project, constrained_project
)
return _format_project_list_json(merged, default_project, constrained_project)
if constrained_project:
return _format_constrained_text(constrained_project)
return _format_project_list_text(merged)
+74 -42
View File
@@ -220,11 +220,37 @@ async def test_list_memory_projects_cloud_failure_graceful(app, test_project):
@pytest.mark.asyncio
async def test_list_memory_projects_factory_mode(app, test_project):
"""In factory mode (cloud app), projects are reported as cloud-sourced with workspace metadata."""
factory_project = _make_project("cloud-proj", "/cloud-proj", is_default=True)
factory_list = _make_list([factory_project], default="cloud-proj")
ws = _make_workspace("tenant-abc", "Personal", "personal")
"""Factory mode lists projects from every accessible workspace."""
personal_project = _make_project(
"personal-main",
"/personal-main",
is_default=True,
external_id="personal-project-uuid",
)
team_project = _make_project(
"team-specs",
"/team-specs",
id=2,
external_id="team-project-uuid",
)
personal_ws = _make_workspace(
"personal-tenant",
"Personal",
slug="personal",
is_default=True,
)
team_ws = _make_workspace(
"team-tenant",
"Team Paul",
"organization",
slug="team-paul",
)
workspace_index = _make_workspace_index(
[
(personal_ws, [personal_project]),
(team_ws, [team_project]),
]
)
with (
patch(
@@ -232,28 +258,48 @@ async def test_list_memory_projects_factory_mode(app, test_project):
return_value=True,
),
patch(
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
"basic_memory.mcp.tools.project_management.ensure_workspace_project_index",
new_callable=AsyncMock,
return_value=factory_list,
),
patch(
"basic_memory.mcp.project_context.get_available_workspaces",
new_callable=AsyncMock,
return_value=[ws],
),
return_value=workspace_index,
) as mock_index,
):
result = await list_memory_projects()
assert "- cloud-proj (cloud)" in result
mock_index.assert_awaited_once()
assert "Workspace: Personal (personal default)" in result
assert "Workspace: Team Paul (team-paul)" in result
assert "- personal-main (cloud) [personal-project-uuid]" in result
assert "- team-specs (cloud) [team-project-uuid]" in result
@pytest.mark.asyncio
async def test_list_memory_projects_factory_mode_json_includes_workspace(app, test_project):
"""In factory mode, JSON output includes workspace metadata for cloud projects."""
factory_project = _make_project("cloud-proj", "/cloud-proj", is_default=True)
factory_list = _make_list([factory_project], default="cloud-proj")
ws = _make_workspace("tenant-abc", "My Org", "organization")
"""In factory mode, JSON output includes workspace metadata for all cloud projects."""
default_project = _make_project(
"personal-main",
"/personal-main",
is_default=True,
external_id="personal-project-uuid",
)
org_project = _make_project(
"cloud-proj",
"/cloud-proj",
id=2,
external_id="org-project-uuid",
)
personal_ws = _make_workspace(
"personal-tenant",
"Personal",
slug="personal",
is_default=True,
)
org_ws = _make_workspace("tenant-abc", "My Org", "organization")
workspace_index = _make_workspace_index(
[
(personal_ws, [default_project]),
(org_ws, [org_project]),
]
)
with (
patch(
@@ -261,22 +307,18 @@ async def test_list_memory_projects_factory_mode_json_includes_workspace(app, te
return_value=True,
),
patch(
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
"basic_memory.mcp.tools.project_management.ensure_workspace_project_index",
new_callable=AsyncMock,
return_value=factory_list,
),
patch(
"basic_memory.mcp.project_context.get_available_workspaces",
new_callable=AsyncMock,
return_value=[ws],
return_value=workspace_index,
),
):
result = await list_memory_projects(output_format="json")
assert isinstance(result, dict)
assert result["default_project"] == "personal-main"
projects = result["projects"]
assert len(projects) == 1
proj = projects[0]
assert len(projects) == 2
proj = {project["name"]: project for project in projects}["cloud-proj"]
assert proj["source"] == "cloud"
assert proj["cloud_path"] == "/cloud-proj"
assert proj["local_path"] is None
@@ -290,30 +332,20 @@ async def test_list_memory_projects_factory_mode_json_includes_workspace(app, te
@pytest.mark.asyncio
async def test_list_memory_projects_factory_mode_workspace_lookup_failure(app, test_project):
"""In factory mode, workspace lookup failure still returns projects as cloud-sourced."""
factory_project = _make_project("cloud-proj", "/cloud-proj", is_default=True)
factory_list = _make_list([factory_project], default="cloud-proj")
"""In factory mode, workspace discovery failures are surfaced to the caller."""
with (
patch(
"basic_memory.mcp.tools.project_management.is_factory_mode",
return_value=True,
),
patch(
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
new_callable=AsyncMock,
return_value=factory_list,
),
patch(
"basic_memory.mcp.project_context.get_available_workspaces",
"basic_memory.mcp.tools.project_management.ensure_workspace_project_index",
new_callable=AsyncMock,
side_effect=RuntimeError("no user context"),
),
):
result = await list_memory_projects()
# Still reported as cloud even without workspace metadata
assert "- cloud-proj (cloud)" in result
with pytest.raises(RuntimeError, match="no user context"):
await list_memory_projects()
@pytest.mark.asyncio