Skip to content

Commit 1630456

Browse files
jope-bmclaude
andcommitted
fix(mcp): report cloud projects as source="cloud" in factory mode
In factory mode (cloud MCP server), list_memory_projects passed cloud-fetched projects as local_list to _merge_projects(), causing all projects to report source="local" with no cloud metadata. Fix passes projects as cloud_list and resolves workspace metadata via the workspace provider, so cloud MCP clients see correct source labels and workspace info. Fixes basicmachines-co/basic-memory-cloud#479 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Joe P <joe@basicmemory.com>
1 parent c4cf0af commit 1630456

2 files changed

Lines changed: 139 additions & 7 deletions

File tree

src/basic_memory/mcp/tools/project_management.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,42 @@ async def list_memory_projects(
196196

197197
# --- Factory mode (cloud app) ---
198198
# Trigger: set_client_factory() was called (e.g., basic-memory-cloud)
199-
# Why: there is no local ASGI server; the factory IS the only source
200-
# Outcome: single fetch, no merge needed
199+
# Why: there is no local ASGI server; the factory IS the cloud source
200+
# Outcome: single fetch, projects reported as source="cloud" with workspace metadata
201201
if is_factory_mode():
202202
async with get_client() as client:
203203
project_client = ProjectClient(client)
204204
project_list = await project_client.list_projects()
205205

206-
merged = _merge_projects(project_list, None)
206+
# Resolve workspace metadata so cloud projects carry their workspace info
207+
cloud_ws_name: str | None = None
208+
cloud_ws_type: str | None = None
209+
cloud_ws_tenant_id: str | None = None
210+
try:
211+
from basic_memory.mcp.project_context import get_available_workspaces
212+
213+
workspaces = await get_available_workspaces(context)
214+
if workspaces:
215+
# In factory mode the user is authenticated to a single workspace;
216+
# use the explicit workspace param or fall back to the first available.
217+
matched = None
218+
if workspace:
219+
matched = next((ws for ws in workspaces if ws.tenant_id == workspace), None)
220+
if matched is None:
221+
matched = workspaces[0]
222+
cloud_ws_name = matched.name
223+
cloud_ws_type = matched.workspace_type
224+
cloud_ws_tenant_id = matched.tenant_id
225+
except Exception:
226+
pass # workspace lookup is best-effort
227+
228+
merged = _merge_projects(
229+
None,
230+
project_list,
231+
cloud_workspace_name=cloud_ws_name,
232+
cloud_workspace_type=cloud_ws_type,
233+
cloud_workspace_tenant_id=cloud_ws_tenant_id,
234+
)
207235
if output_format == "json":
208236
return _format_project_list_json(
209237
merged, project_list.default_project, constrained_project

tests/mcp/test_tool_project_management.py

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -214,10 +214,12 @@ async def test_list_memory_projects_cloud_failure_graceful(app, test_project):
214214

215215
@pytest.mark.asyncio
216216
async def test_list_memory_projects_factory_mode(app, test_project):
217-
"""In factory mode (cloud app), only the factory client is used — no cloud merge."""
217+
"""In factory mode (cloud app), projects are reported as cloud-sourced with workspace metadata."""
218218
factory_project = _make_project("cloud-proj", "/cloud-proj", is_default=True)
219219
factory_list = _make_list([factory_project], default="cloud-proj")
220220

221+
ws = _make_workspace("tenant-abc", "Personal", "personal")
222+
221223
with (
222224
patch(
223225
"basic_memory.mcp.tools.project_management.is_factory_mode",
@@ -228,12 +230,114 @@ async def test_list_memory_projects_factory_mode(app, test_project):
228230
new_callable=AsyncMock,
229231
return_value=factory_list,
230232
),
233+
patch(
234+
"basic_memory.mcp.project_context.get_available_workspaces",
235+
new_callable=AsyncMock,
236+
return_value=[ws],
237+
),
231238
):
232239
result = await list_memory_projects()
233240

234-
assert "• cloud-proj (local)" in result
235-
# has_cloud_credentials should not be called in factory mode
236-
# (no cloud merge attempt)
241+
assert "• cloud-proj (cloud)" in result
242+
243+
244+
@pytest.mark.asyncio
245+
async def test_list_memory_projects_factory_mode_json_includes_workspace(app, test_project):
246+
"""In factory mode, JSON output includes workspace metadata for cloud projects."""
247+
factory_project = _make_project("cloud-proj", "/cloud-proj", is_default=True)
248+
factory_list = _make_list([factory_project], default="cloud-proj")
249+
250+
ws = _make_workspace("tenant-abc", "My Org", "organization")
251+
252+
with (
253+
patch(
254+
"basic_memory.mcp.tools.project_management.is_factory_mode",
255+
return_value=True,
256+
),
257+
patch(
258+
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
259+
new_callable=AsyncMock,
260+
return_value=factory_list,
261+
),
262+
patch(
263+
"basic_memory.mcp.project_context.get_available_workspaces",
264+
new_callable=AsyncMock,
265+
return_value=[ws],
266+
),
267+
):
268+
result = await list_memory_projects(output_format="json")
269+
270+
assert isinstance(result, dict)
271+
projects = result["projects"]
272+
assert len(projects) == 1
273+
proj = projects[0]
274+
assert proj["source"] == "cloud"
275+
assert proj["cloud_path"] == "/cloud-proj"
276+
assert proj["local_path"] is None
277+
assert proj["workspace_name"] == "My Org"
278+
assert proj["workspace_type"] == "organization"
279+
assert proj["workspace_tenant_id"] == "tenant-abc"
280+
281+
282+
@pytest.mark.asyncio
283+
async def test_list_memory_projects_factory_mode_workspace_lookup_failure(app, test_project):
284+
"""In factory mode, workspace lookup failure still returns projects as cloud-sourced."""
285+
factory_project = _make_project("cloud-proj", "/cloud-proj", is_default=True)
286+
factory_list = _make_list([factory_project], default="cloud-proj")
287+
288+
with (
289+
patch(
290+
"basic_memory.mcp.tools.project_management.is_factory_mode",
291+
return_value=True,
292+
),
293+
patch(
294+
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
295+
new_callable=AsyncMock,
296+
return_value=factory_list,
297+
),
298+
patch(
299+
"basic_memory.mcp.project_context.get_available_workspaces",
300+
new_callable=AsyncMock,
301+
side_effect=RuntimeError("no user context"),
302+
),
303+
):
304+
result = await list_memory_projects()
305+
306+
# Still reported as cloud even without workspace metadata
307+
assert "• cloud-proj (cloud)" in result
308+
309+
310+
@pytest.mark.asyncio
311+
async def test_list_memory_projects_factory_mode_explicit_workspace(app, test_project):
312+
"""In factory mode, explicit workspace param selects the matching workspace."""
313+
factory_project = _make_project("cloud-proj", "/cloud-proj", is_default=True)
314+
factory_list = _make_list([factory_project], default="cloud-proj")
315+
316+
personal_ws = _make_workspace("tenant-personal", "Personal", "personal")
317+
org_ws = _make_workspace("tenant-org", "Acme Corp", "organization")
318+
319+
with (
320+
patch(
321+
"basic_memory.mcp.tools.project_management.is_factory_mode",
322+
return_value=True,
323+
),
324+
patch(
325+
"basic_memory.mcp.clients.project.ProjectClient.list_projects",
326+
new_callable=AsyncMock,
327+
return_value=factory_list,
328+
),
329+
patch(
330+
"basic_memory.mcp.project_context.get_available_workspaces",
331+
new_callable=AsyncMock,
332+
return_value=[personal_ws, org_ws],
333+
),
334+
):
335+
result = await list_memory_projects(output_format="json", workspace="tenant-org")
336+
337+
proj = result["projects"][0]
338+
assert proj["workspace_name"] == "Acme Corp"
339+
assert proj["workspace_type"] == "organization"
340+
assert proj["workspace_tenant_id"] == "tenant-org"
237341

238342

239343
@pytest.mark.asyncio

0 commit comments

Comments
 (0)