Skip to content

Commit 49148fe

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

13 files changed

Lines changed: 1223 additions & 271 deletions

src/basic_memory/mcp/project_context.py

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

src/basic_memory/mcp/tools/project_management.py

Lines changed: 156 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"
@@ -248,39 +358,49 @@ async def list_memory_projects(
248358

249359
# Fetch cloud projects when credentials are available
250360
cloud_list: ProjectList | None = None
361+
cloud_entries: tuple[WorkspaceProjectEntry, ...] = ()
251362
cloud_ws_name: str | None = None
252363
cloud_ws_type: str | None = None
253364
cloud_ws_tenant_id: str | None = None
365+
cloud_ws_slug: str | None = None
366+
cloud_ws_is_default = False
254367
config = ConfigManager().config
255368
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
369+
if workspace:
370+
active_workspace = await resolve_workspace_parameter(workspace, context)
371+
cloud_list = await _fetch_cloud_projects(active_workspace.tenant_id, context)
372+
cloud_ws_name = active_workspace.name
373+
cloud_ws_type = active_workspace.workspace_type
374+
cloud_ws_tenant_id = active_workspace.tenant_id
375+
cloud_ws_slug = active_workspace.slug
376+
cloud_ws_is_default = active_workspace.is_default
377+
else:
263378
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,
379+
workspace_index = await _ensure_workspace_project_index(context=context)
380+
cloud_entries = workspace_index.entries
381+
except Exception as exc:
382+
logger.warning(
383+
f"Cloud workspace project index discovery failed while listing projects; "
384+
f"showing local-only project list: {exc}"
270385
)
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-
)
386+
if context: # pragma: no cover
387+
await context.info(
388+
"Cloud workspace project discovery failed while listing projects; "
389+
"showing local projects only"
390+
)
391+
392+
if cloud_entries:
393+
merged = _merge_workspace_projects(local_list, cloud_entries)
394+
else:
395+
merged = _merge_projects(
396+
local_list,
397+
cloud_list,
398+
cloud_workspace_name=cloud_ws_name,
399+
cloud_workspace_type=cloud_ws_type,
400+
cloud_workspace_tenant_id=cloud_ws_tenant_id,
401+
cloud_workspace_slug=cloud_ws_slug,
402+
cloud_workspace_is_default=cloud_ws_is_default,
403+
)
284404
default_project = local_list.default_project
285405

286406
if output_format == "json":
@@ -390,6 +510,9 @@ async def create_memory_project(
390510
)
391511

392512
status_response = await project_client.create_project(project_request.model_dump())
513+
from basic_memory.mcp.project_context import invalidate_workspace_project_index
514+
515+
await invalidate_workspace_project_index(context)
393516

394517
if output_format == "json":
395518
new_project = status_response.new_project
@@ -479,6 +602,9 @@ async def delete_project(project_name: str, context: Context | None = None) -> s
479602

480603
# Delete project using project external_id
481604
status_response = await project_client.delete_project(target_project.external_id)
605+
from basic_memory.mcp.project_context import invalidate_workspace_project_index
606+
607+
await invalidate_workspace_project_index(context)
482608

483609
result = f"✓ {status_response.message}\n\n"
484610

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

src/basic_memory/mcp/tools/workspaces.py

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,42 @@
66

77
from basic_memory.mcp.project_context import get_available_workspaces
88
from basic_memory.mcp.server import mcp
9+
from basic_memory.schemas.cloud import WorkspaceInfo, WorkspaceListResponse
10+
11+
12+
def _personal_workspace() -> WorkspaceInfo:
13+
"""Return a display-only personal workspace when discovery has no rows.
14+
15+
This keeps list_workspaces friendly for non-teams or local-only users. It is not
16+
the cloud routing source of truth; project-scoped routing still depends on real
17+
workspace discovery and the workspace project index in project_context.
18+
"""
19+
return WorkspaceInfo(
20+
tenant_id="personal",
21+
workspace_type="personal",
22+
slug="personal",
23+
name="Personal",
24+
role="owner",
25+
is_default=True,
26+
has_active_subscription=True,
27+
)
28+
29+
30+
def _workspace_list_response(workspaces: list[WorkspaceInfo]) -> WorkspaceListResponse:
31+
"""Build the structured MCP response from the shared cloud workspace schema."""
32+
if not workspaces:
33+
workspaces = [_personal_workspace()]
34+
35+
default_workspace_id = next(
36+
(workspace.tenant_id for workspace in workspaces if workspace.is_default),
37+
None,
38+
)
39+
return WorkspaceListResponse(
40+
workspaces=workspaces,
41+
count=len(workspaces),
42+
default_workspace_id=default_workspace_id,
43+
current_workspace_id=None,
44+
)
945

1046

1147
@mcp.tool(
@@ -24,40 +60,18 @@ async def list_workspaces(
2460
context: Optional FastMCP context for progress/status logging.
2561
"""
2662
workspaces = await get_available_workspaces(context=context)
63+
response = _workspace_list_response(workspaces)
2764

2865
if output_format == "json":
29-
return {
30-
"workspaces": [
31-
{
32-
"tenant_id": ws.tenant_id,
33-
"name": ws.name,
34-
"workspace_type": ws.workspace_type,
35-
"role": ws.role,
36-
"organization_id": ws.organization_id,
37-
"has_active_subscription": ws.has_active_subscription,
38-
}
39-
for ws in workspaces
40-
],
41-
"count": len(workspaces),
42-
}
43-
44-
if not workspaces:
45-
return (
46-
"# No Workspaces Available\n\n"
47-
"No accessible workspaces were found for this account. "
48-
"Ensure the account has an active subscription and tenant access."
49-
)
66+
return response.model_dump(mode="json")
5067

51-
lines = [
52-
f"# Available Workspaces ({len(workspaces)})",
53-
"",
54-
"Use `workspace` as either the `tenant_id` or unique `name` in project-scoped tool calls.",
55-
"",
56-
]
57-
for workspace in workspaces:
68+
lines = [f"# Available Workspaces ({response.count})", ""]
69+
for workspace in response.workspaces:
70+
default_label = ", default" if workspace.is_default else ""
5871
lines.append(
5972
f"- {workspace.name} "
60-
f"(type={workspace.workspace_type}, role={workspace.role}, tenant_id={workspace.tenant_id})"
73+
f"(slug={workspace.slug}, type={workspace.workspace_type}, "
74+
f"role={workspace.role}{default_label}, tenant_id={workspace.tenant_id})"
6175
)
6276

6377
return "\n".join(lines)

src/basic_memory/schemas/cloud.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,10 @@ class WorkspaceInfo(BaseModel):
6363

6464
tenant_id: str = Field(..., description="Workspace tenant identifier")
6565
workspace_type: str = Field(..., description="Workspace type (personal or organization)")
66+
slug: str = Field(..., description="Stable workspace slug for qualified project routing")
6667
name: str = Field(..., description="Workspace display name")
6768
role: str = Field(..., description="Current user's role in the workspace")
69+
is_default: bool = Field(..., description="Whether this is the default cloud workspace")
6870
organization_id: str | None = Field(None, description="Organization ID for org workspaces")
6971
has_active_subscription: bool = Field(
7072
default=False, description="Whether the workspace has an active subscription"
@@ -78,6 +80,9 @@ class WorkspaceListResponse(BaseModel):
7880
default_factory=list, description="Available workspaces"
7981
)
8082
count: int = Field(default=0, description="Number of available workspaces")
83+
default_workspace_id: str | None = Field(
84+
default=None, description="Default workspace tenant ID when available"
85+
)
8186
current_workspace_id: str | None = Field(
8287
default=None, description="Current workspace tenant ID when available"
8388
)

0 commit comments

Comments
 (0)