Skip to content

Commit 17b4142

Browse files
phernandezclaude
andcommitted
Resolve projects by external_id, remove workspace from MCP tools
Workspaces are an implementation detail that should not be exposed to MCP tool callers. This change: - 🔑 Adds external_id (UUID) lookup to project resolution so callers can reference projects unambiguously without knowing which workspace owns them - 🗑️ Removes the `workspace` parameter from all 15 MCP tool signatures - 🔄 Falls back to the default workspace when a project name exists in multiple workspaces (instead of erroring) - 📋 Includes external_id in list_memory_projects output so LLMs can discover and use project UUIDs Closes basicmachines-co/basic-memory-cloud#673 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2fccc74 commit 17b4142

19 files changed

Lines changed: 86 additions & 359 deletions

src/basic_memory/mcp/project_context.py

Lines changed: 29 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@
1010

1111
import asyncio
1212
from contextlib import asynccontextmanager
13-
from dataclasses import dataclass
13+
from dataclasses import dataclass, field
1414
from typing import AsyncIterator, Awaitable, Callable, Optional, List, Tuple, cast
15+
from uuid import UUID
1516

1617
from httpx import AsyncClient
1718
from httpx._types import (
@@ -52,11 +53,12 @@ def qualified_name(self) -> str:
5253

5354
@dataclass(frozen=True)
5455
class WorkspaceProjectIndex:
55-
"""Session-local cloud project lookup index keyed by project permalink."""
56+
"""Session-local cloud project lookup index keyed by project permalink and external_id."""
5657

5758
workspaces: tuple[WorkspaceInfo, ...]
5859
entries: tuple[WorkspaceProjectEntry, ...]
5960
entries_by_permalink: dict[str, tuple[WorkspaceProjectEntry, ...]]
61+
entries_by_external_id: dict[str, WorkspaceProjectEntry] = field(default_factory=dict)
6062
failed_workspaces: tuple[WorkspaceInfo, ...] = ()
6163

6264

@@ -351,10 +353,12 @@ def _build_workspace_project_index(
351353
*,
352354
failed_workspaces: tuple[WorkspaceInfo, ...] = (),
353355
) -> WorkspaceProjectIndex:
354-
"""Build the permalink lookup table for workspace-project entries."""
356+
"""Build the permalink and external_id lookup tables for workspace-project entries."""
355357
grouped: dict[str, list[WorkspaceProjectEntry]] = {}
358+
by_external_id: dict[str, WorkspaceProjectEntry] = {}
356359
for entry in entries:
357360
grouped.setdefault(entry.project.permalink, []).append(entry)
361+
by_external_id[entry.project.external_id] = entry
358362

359363
return WorkspaceProjectIndex(
360364
workspaces=workspaces,
@@ -363,6 +367,7 @@ def _build_workspace_project_index(
363367
permalink: tuple(items)
364368
for permalink, items in sorted(grouped.items(), key=lambda item: item[0])
365369
},
370+
entries_by_external_id=by_external_id,
366371
failed_workspaces=failed_workspaces,
367372
)
368373

@@ -549,8 +554,18 @@ async def resolve_workspace_project_identifier(
549554
project: str,
550555
context: Optional[Context] = None,
551556
) -> WorkspaceProjectEntry:
552-
"""Resolve an unqualified or ``<workspace>/<project>`` cloud project identifier."""
557+
"""Resolve a project by external_id (UUID), qualified name, or unqualified name."""
553558
index = await _ensure_workspace_project_index(context=context)
559+
560+
# Fast path: direct lookup by external_id when the identifier is a UUID
561+
try:
562+
UUID(project)
563+
entry = index.entries_by_external_id.get(project)
564+
if entry:
565+
return entry
566+
except ValueError:
567+
pass
568+
554569
workspace_slug, project_identifier = _split_qualified_project_identifier(project)
555570
project_permalink = generate_permalink(project_identifier)
556571

@@ -617,6 +632,13 @@ async def resolve_workspace_project_identifier(
617632
return cached_matches[0]
618633

619634
if len(matches) > 1:
635+
# Prefer the project in the default workspace when name is ambiguous
636+
default_match = next(
637+
(entry for entry in matches if entry.workspace.is_default), None
638+
)
639+
if default_match:
640+
return default_match
641+
620642
choices = _format_qualified_choices(matches)
621643
details = "\n".join(
622644
f"- {entry.workspace.name} ({entry.workspace.slug}): {entry.qualified_name}"
@@ -956,7 +978,6 @@ def detect_project_from_url_prefix(identifier: str, config: BasicMemoryConfig) -
956978
@asynccontextmanager
957979
async def get_project_client(
958980
project: Optional[str] = None,
959-
workspace: Optional[str] = None,
960981
context: Optional[Context] = None,
961982
) -> AsyncIterator[Tuple[AsyncClient, ProjectItem]]:
962983
"""Resolve project, create correctly-routed client, and validate project.
@@ -972,15 +993,8 @@ async def get_project_client(
972993
3. Cloud project mode → resolve project through workspace/project index
973994
4. Otherwise → local ASGI client
974995
975-
Workspace resolution priority (when cloud routing):
976-
1. Explicit ``workspace`` parameter
977-
2. Per-project ``workspace_id`` from config
978-
3. Qualified project identifier (``<workspace-slug>/<project>``)
979-
4. Workspace/project index lookup with collision detection
980-
981996
Args:
982-
project: Optional explicit project parameter
983-
workspace: Optional cloud workspace selector (tenant_id or unique name)
997+
project: Optional explicit project parameter (name, permalink, or external_id UUID)
984998
context: Optional FastMCP context for caching
985999
9861000
Yields:
@@ -1050,37 +1064,14 @@ async def get_project_client(
10501064
project_entry = config.projects.get(resolved_project)
10511065
project_mode = config.get_project_mode(resolved_project)
10521066

1053-
# Trigger: workspace provided for a local project (without explicit --cloud)
1054-
# Why: workspace selection is a cloud routing concern only
1055-
# Outcome: fail fast with a deterministic guidance message
1056-
if (
1057-
not factory_mode
1058-
and project_mode != ProjectMode.CLOUD
1059-
and workspace is not None
1060-
and not _explicit_routing()
1061-
):
1062-
raise ValueError(
1063-
f"Workspace '{workspace}' cannot be used with local project '{resolved_project}'. "
1064-
"Workspace selection is only supported for cloud-mode projects."
1065-
)
1066-
10671067
if factory_mode or project_mode == ProjectMode.CLOUD or explicit_cloud_routing:
10681068
route_mode = "factory" if factory_mode else "cloud_proxy"
10691069
active_ws: WorkspaceInfo | None = None
10701070
workspace_id: str
10711071
project_for_api = _unqualified_project_identifier(resolved_project)
10721072

1073-
# Trigger: a script or config entry pins the tenant explicitly
1074-
# Why: explicit tenant configuration remains the escape hatch during migration
1075-
# Outcome: route to that workspace, but validate the project name inside it
1076-
if workspace is not None:
1077-
active_ws = await resolve_workspace_parameter(workspace=workspace, context=context)
1078-
workspace_id = active_ws.tenant_id
1079-
elif project_entry and project_entry.workspace_id:
1080-
# Trigger: the local project config already stores the cloud tenant id.
1081-
# Why: routing can send that id directly; requiring workspace discovery here
1082-
# would turn a control-plane listing outage into a project routing failure.
1083-
# Outcome: preserve project-scoped routing even when discovery is unavailable.
1073+
if project_entry and project_entry.workspace_id:
1074+
# Per-project config stores the cloud tenant id directly
10841075
workspace_id = project_entry.workspace_id
10851076
else:
10861077
resolved_entry = cloud_default_entry

src/basic_memory/mcp/tools/build_context.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,6 @@ async def build_context(
139139
Field(validation_alias=AliasChoices("url", "uri", "memory_url")),
140140
],
141141
project: Optional[str] = None,
142-
workspace: Optional[str] = None,
143142
depth: str | int | None = 1,
144143
timeframe: Annotated[
145144
Optional[TimeFrame],
@@ -228,7 +227,6 @@ async def build_context(
228227
entrypoint="mcp",
229228
tool_name="build_context",
230229
requested_project=project,
231-
workspace_id=workspace,
232230
depth=depth or 1,
233231
timeframe=timeframe,
234232
page=page,
@@ -237,7 +235,7 @@ async def build_context(
237235
output_format=output_format,
238236
is_memory_url=str(url).startswith("memory://"),
239237
):
240-
async with get_project_client(project, workspace, context) as (client, active_project):
238+
async with get_project_client(project, context=context) as (client, active_project):
241239
logger.info(
242240
f"MCP tool call tool=build_context project={active_project.name} "
243241
f"url={url} depth={depth} timeframe={timeframe} output_format={output_format}"

src/basic_memory/mcp/tools/canvas.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ async def canvas(
2929
Field(validation_alias=AliasChoices("directory", "folder", "dir", "path")),
3030
],
3131
project: Optional[str] = None,
32-
workspace: Optional[str] = None,
3332
context: Context | None = None,
3433
) -> str:
3534
"""Create an Obsidian canvas file with the provided nodes and edges.
@@ -100,7 +99,7 @@ async def canvas(
10099
Raises:
101100
ToolError: If project doesn't exist or directory path is invalid
102101
"""
103-
async with get_project_client(project, workspace, context) as (client, active_project):
102+
async with get_project_client(project, context=context) as (client, active_project):
104103
# Ensure path has .canvas extension
105104
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
106105
file_path = f"{directory}/{file_title}"

src/basic_memory/mcp/tools/delete_note.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,6 @@ async def delete_note(
159159
Field(default=False, validation_alias=AliasChoices("is_directory", "is_dir")),
160160
] = False,
161161
project: Optional[str] = None,
162-
workspace: Optional[str] = None,
163162
output_format: Literal["text", "json"] = "text",
164163
context: Context | None = None,
165164
) -> bool | str | dict:
@@ -237,7 +236,7 @@ async def delete_note(
237236
if detected:
238237
project = detected
239238

240-
async with get_project_client(project, workspace, context) as (client, active_project):
239+
async with get_project_client(project, context=context) as (client, active_project):
241240
logger.debug(
242241
f"Deleting {'directory' if is_directory else 'note'}: {identifier} in project: {active_project.name}"
243242
)

src/basic_memory/mcp/tools/edit_note.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,6 @@ async def edit_note(
182182
),
183183
],
184184
project: Optional[str] = None,
185-
workspace: Optional[str] = None,
186185
# Section/heading naming varies across tools; accept the descriptive forms.
187186
section: Annotated[
188187
Optional[str],
@@ -303,14 +302,13 @@ async def edit_note(
303302
entrypoint="mcp",
304303
tool_name="edit_note",
305304
requested_project=project,
306-
workspace_id=workspace,
307305
edit_operation=operation,
308306
output_format=output_format,
309307
has_section=bool(section),
310308
has_find_text=bool(find_text),
311309
expected_replacements=effective_replacements,
312310
):
313-
async with get_project_client(project, workspace, context) as (client, active_project):
311+
async with get_project_client(project, context=context) as (client, active_project):
314312
logger.info(
315313
f"MCP tool call tool=edit_note project={active_project.name} "
316314
f"identifier={identifier} operation={operation} output_format={output_format}"

src/basic_memory/mcp/tools/list_directory.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ async def list_directory(
3232
),
3333
] = None,
3434
project: Optional[str] = None,
35-
workspace: Optional[str] = None,
3635
context: Context | None = None,
3736
) -> str:
3837
"""List directory contents from the knowledge base with optional filtering.
@@ -77,7 +76,7 @@ async def list_directory(
7776
Raises:
7877
ToolError: If project doesn't exist or directory path is invalid
7978
"""
80-
async with get_project_client(project, workspace, context) as (client, active_project):
79+
async with get_project_client(project, context=context) as (client, active_project):
8180
logger.debug(
8281
f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
8382
)

src/basic_memory/mcp/tools/move_note.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,6 @@ async def move_note(
371371
Field(default=False, validation_alias=AliasChoices("is_directory", "is_dir")),
372372
] = False,
373373
project: Optional[str] = None,
374-
workspace: Optional[str] = None,
375374
output_format: Literal["text", "json"] = "text",
376375
context: Context | None = None,
377376
) -> str | dict:
@@ -495,7 +494,7 @@ async def move_note(
495494
"error": "DESTINATION_FOLDER_NOT_FOR_DIRECTORIES",
496495
}
497496
return f"# Move Failed - Invalid Parameters\n\n{error_msg}"
498-
async with get_project_client(project, workspace, context) as (client, active_project):
497+
async with get_project_client(project, context=context) as (client, active_project):
499498
destination_target = destination_folder or destination_path
500499
logger.info(
501500
f"MCP tool call tool=move_note project={active_project.name} "

0 commit comments

Comments
 (0)