Skip to content

Commit f3e46d7

Browse files
authored
feat(mcp): discover projects across workspaces (#757)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 56d6f1b commit f3e46d7

13 files changed

Lines changed: 1506 additions & 282 deletions

src/basic_memory/mcp/project_context.py

Lines changed: 472 additions & 80 deletions
Large diffs are not rendered by default.

src/basic_memory/mcp/tools/project_management.py

Lines changed: 176 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212

1313
from basic_memory.config import ConfigManager, has_cloud_credentials
1414
from basic_memory.mcp.async_client import get_client, get_cloud_proxy_client, is_factory_mode
15+
from basic_memory.mcp.project_context import (
16+
WorkspaceProjectEntry,
17+
ensure_workspace_project_index,
18+
resolve_workspace_parameter,
19+
)
1520
from basic_memory.mcp.server import mcp
1621
from basic_memory.schemas.project_info import ProjectInfoRequest, ProjectItem, ProjectList
1722
from basic_memory.utils import generate_permalink
@@ -26,7 +31,8 @@ async def _fetch_cloud_projects(
2631
) -> ProjectList | None:
2732
"""Fetch projects from the cloud API, returning None on failure.
2833
29-
Logs warnings on failure so the caller can fall back to local-only results.
34+
Logs warnings on failure so list_memory_projects can fall back to local-only
35+
results. Project-scoped routing does not use this listing fallback.
3036
"""
3137
try:
3238
from basic_memory.mcp.clients import ProjectClient
@@ -38,9 +44,14 @@ async def _fetch_cloud_projects(
3844
await context.info(f"Discovered {len(cloud_list.projects)} cloud projects")
3945
return cloud_list
4046
except Exception as exc:
41-
logger.warning(f"Cloud project discovery failed: {exc}")
47+
logger.warning(
48+
f"Cloud project discovery failed while listing projects; "
49+
f"showing local-only project list: {exc}"
50+
)
4251
if context: # pragma: no cover
43-
await context.info("Cloud project discovery failed, showing local projects only")
52+
await context.info(
53+
"Cloud project discovery failed while listing projects; showing local projects only"
54+
)
4455
return None
4556

4657

@@ -51,6 +62,8 @@ def _merge_projects(
5162
cloud_workspace_name: str | None = None,
5263
cloud_workspace_type: str | None = None,
5364
cloud_workspace_tenant_id: str | None = None,
65+
cloud_workspace_slug: str | None = None,
66+
cloud_workspace_is_default: bool = False,
5467
) -> list[dict]:
5568
"""Merge local and cloud project lists by permalink.
5669
@@ -126,21 +139,118 @@ def _merge_projects(
126139
"workspace_name": ws_name,
127140
"workspace_type": ws_type,
128141
"workspace_tenant_id": ws_tenant_id,
142+
"workspace_slug": cloud_workspace_slug if cloud_proj else None,
143+
"workspace_is_default": cloud_workspace_is_default if cloud_proj else False,
144+
"qualified_name": (
145+
f"{cloud_workspace_slug}/{permalink}"
146+
if cloud_proj and cloud_workspace_slug
147+
else None
148+
),
129149
}
130150
)
131151

132152
return merged
133153

134154

155+
def _merge_workspace_projects(
156+
local_list: ProjectList | None,
157+
cloud_entries: tuple[WorkspaceProjectEntry, ...],
158+
) -> list[dict]:
159+
"""Merge local projects with cloud projects from every accessible workspace."""
160+
local_by_permalink: dict[str, ProjectItem] = {}
161+
if local_list:
162+
for project in local_list.projects:
163+
local_by_permalink[project.permalink] = project
164+
165+
cloud_permalinks = {entry.project.permalink for entry in cloud_entries}
166+
merged: list[dict] = []
167+
168+
for entry in sorted(
169+
cloud_entries,
170+
key=lambda item: (
171+
not item.workspace.is_default,
172+
item.workspace.workspace_type != "personal",
173+
item.workspace.name.casefold(),
174+
item.project.permalink,
175+
),
176+
):
177+
permalink = entry.project.permalink
178+
local_proj = local_by_permalink.get(permalink)
179+
cloud_proj = entry.project
180+
source = "local+cloud" if local_proj else "cloud"
181+
local_path = local_proj.path if local_proj else None
182+
cloud_path = cloud_proj.path
183+
184+
merged.append(
185+
{
186+
"name": cloud_proj.name,
187+
"path": local_path or cloud_path,
188+
"local_path": local_path,
189+
"cloud_path": cloud_path,
190+
"source": source,
191+
"is_default": bool((local_proj and local_proj.is_default) or cloud_proj.is_default),
192+
"is_private": cloud_proj.is_private,
193+
"display_name": cloud_proj.display_name,
194+
"workspace_name": entry.workspace.name,
195+
"workspace_type": entry.workspace.workspace_type,
196+
"workspace_tenant_id": entry.workspace.tenant_id,
197+
"workspace_slug": entry.workspace.slug,
198+
"workspace_is_default": entry.workspace.is_default,
199+
"qualified_name": entry.qualified_name,
200+
}
201+
)
202+
203+
if local_list:
204+
for project in sorted(local_list.projects, key=lambda item: item.permalink):
205+
if project.permalink in cloud_permalinks:
206+
continue
207+
merged.append(
208+
{
209+
"name": project.name,
210+
"path": project.path,
211+
"local_path": project.path,
212+
"cloud_path": None,
213+
"source": "local",
214+
"is_default": project.is_default,
215+
"is_private": project.is_private,
216+
"display_name": project.display_name,
217+
"workspace_name": None,
218+
"workspace_type": None,
219+
"workspace_tenant_id": None,
220+
"workspace_slug": None,
221+
"workspace_is_default": False,
222+
"qualified_name": None,
223+
}
224+
)
225+
226+
return merged
227+
228+
135229
def _format_project_list_text(merged: list[dict]) -> str:
136230
"""Format merged project list as human-readable text."""
137231
result = "Available projects:\n"
232+
233+
current_workspace: tuple[str | None, str | None] | None = None
138234
for project in merged:
235+
workspace_slug = project.get("workspace_slug")
236+
workspace_name = project.get("workspace_name")
237+
if workspace_slug:
238+
workspace_key = (workspace_slug, workspace_name)
239+
if workspace_key != current_workspace:
240+
default_label = " default" if project.get("workspace_is_default") else ""
241+
result += f"\nWorkspace: {workspace_name} ({workspace_slug}{default_label})\n"
242+
current_workspace = workspace_key
243+
elif current_workspace is not None:
244+
result += "\nLocal projects:\n"
245+
current_workspace = None
246+
139247
display_name = project["display_name"]
140248
name = project["name"]
141249
label = f"{display_name} ({name})" if display_name else name
142250
source = project["source"]
143-
result += f"• {label} ({source})\n"
251+
qualified_name = project.get("qualified_name")
252+
qualified_suffix = f" [{qualified_name}]" if qualified_name else ""
253+
result += f"- {label} ({source}){qualified_suffix}\n"
144254

145255
result += "\n" + "─" * 40 + "\n"
146256
result += "Next: Ask which project to use for this session.\n"
@@ -207,6 +317,8 @@ async def list_memory_projects(
207317
cloud_ws_name: str | None = None
208318
cloud_ws_type: str | None = None
209319
cloud_ws_tenant_id: str | None = None
320+
cloud_ws_slug: str | None = None
321+
cloud_ws_is_default = False
210322
try:
211323
from basic_memory.mcp.project_context import get_available_workspaces
212324

@@ -222,6 +334,8 @@ async def list_memory_projects(
222334
cloud_ws_name = matched.name
223335
cloud_ws_type = matched.workspace_type
224336
cloud_ws_tenant_id = matched.tenant_id
337+
cloud_ws_slug = matched.slug
338+
cloud_ws_is_default = matched.is_default
225339
except Exception:
226340
pass # workspace lookup is best-effort
227341

@@ -231,6 +345,8 @@ async def list_memory_projects(
231345
cloud_workspace_name=cloud_ws_name,
232346
cloud_workspace_type=cloud_ws_type,
233347
cloud_workspace_tenant_id=cloud_ws_tenant_id,
348+
cloud_workspace_slug=cloud_ws_slug,
349+
cloud_workspace_is_default=cloud_ws_is_default,
234350
)
235351
if output_format == "json":
236352
return _format_project_list_json(
@@ -248,39 +364,63 @@ async def list_memory_projects(
248364

249365
# Fetch cloud projects when credentials are available
250366
cloud_list: ProjectList | None = None
367+
cloud_entries: tuple[WorkspaceProjectEntry, ...] = ()
251368
cloud_ws_name: str | None = None
252369
cloud_ws_type: str | None = None
253370
cloud_ws_tenant_id: str | None = None
371+
cloud_ws_slug: str | None = None
372+
cloud_ws_is_default = False
254373
config = ConfigManager().config
255374
if has_cloud_credentials(config):
256-
# Use explicit workspace, fall back to config default
257-
effective_workspace = workspace or config.default_workspace
258-
cloud_list = await _fetch_cloud_projects(effective_workspace, context)
259-
260-
# Resolve workspace metadata so each cloud project carries its workspace info
261-
if cloud_list:
262-
cloud_ws_tenant_id = effective_workspace
375+
if workspace:
263376
try:
264-
from basic_memory.mcp.project_context import get_available_workspaces
265-
266-
workspaces = await get_available_workspaces(context)
267-
matched = next(
268-
(ws for ws in workspaces if ws.tenant_id == effective_workspace),
269-
None,
377+
active_workspace = await resolve_workspace_parameter(workspace, context)
378+
except Exception as exc:
379+
logger.warning(
380+
f"Cloud workspace discovery failed while listing projects for "
381+
f"workspace '{workspace}'; trying direct workspace routing before "
382+
f"falling back to local-only project list: {exc}"
270383
)
271-
if matched:
272-
cloud_ws_name = matched.name
273-
cloud_ws_type = matched.workspace_type
274-
except Exception:
275-
pass # workspace lookup is best-effort
276-
277-
merged = _merge_projects(
278-
local_list,
279-
cloud_list,
280-
cloud_workspace_name=cloud_ws_name,
281-
cloud_workspace_type=cloud_ws_type,
282-
cloud_workspace_tenant_id=cloud_ws_tenant_id,
283-
)
384+
if context: # pragma: no cover
385+
await context.info(
386+
"Cloud workspace discovery failed while listing projects; "
387+
"trying direct workspace routing"
388+
)
389+
cloud_list = await _fetch_cloud_projects(workspace, context)
390+
else:
391+
cloud_list = await _fetch_cloud_projects(active_workspace.tenant_id, context)
392+
cloud_ws_name = active_workspace.name
393+
cloud_ws_type = active_workspace.workspace_type
394+
cloud_ws_tenant_id = active_workspace.tenant_id
395+
cloud_ws_slug = active_workspace.slug
396+
cloud_ws_is_default = active_workspace.is_default
397+
else:
398+
try:
399+
workspace_index = await ensure_workspace_project_index(context=context)
400+
cloud_entries = workspace_index.entries
401+
except Exception as exc:
402+
logger.warning(
403+
f"Cloud workspace project index discovery failed while listing projects; "
404+
f"showing local-only project list: {exc}"
405+
)
406+
if context: # pragma: no cover
407+
await context.info(
408+
"Cloud workspace project discovery failed while listing projects; "
409+
"showing local projects only"
410+
)
411+
412+
if cloud_entries:
413+
merged = _merge_workspace_projects(local_list, cloud_entries)
414+
else:
415+
merged = _merge_projects(
416+
local_list,
417+
cloud_list,
418+
cloud_workspace_name=cloud_ws_name,
419+
cloud_workspace_type=cloud_ws_type,
420+
cloud_workspace_tenant_id=cloud_ws_tenant_id,
421+
cloud_workspace_slug=cloud_ws_slug,
422+
cloud_workspace_is_default=cloud_ws_is_default,
423+
)
284424
default_project = local_list.default_project
285425

286426
if output_format == "json":
@@ -390,6 +530,9 @@ async def create_memory_project(
390530
)
391531

392532
status_response = await project_client.create_project(project_request.model_dump())
533+
from basic_memory.mcp.project_context import invalidate_workspace_project_index
534+
535+
await invalidate_workspace_project_index(context)
393536

394537
if output_format == "json":
395538
new_project = status_response.new_project
@@ -479,6 +622,9 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
479622

480623
# Delete project using project external_id
481624
status_response = await project_client.delete_project(target_project.external_id)
625+
from basic_memory.mcp.project_context import invalidate_workspace_project_index
626+
627+
await invalidate_workspace_project_index(context)
482628

483629
result = f"✓ {status_response.message}\n\n"
484630

src/basic_memory/mcp/tools/utils.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
"""
66

77
import typing
8-
from contextlib import contextmanager
98
from typing import Any, Optional
109

1110
import logfire

0 commit comments

Comments
 (0)