Skip to content

Commit 25be29e

Browse files
committed
Add project_id parameter to MCP tools for cloud workspace disambiguation
Adds optional `project_id: Optional[str] = None` (UUID) parameter to all 14 project-scoped MCP tools so callers can address projects unambiguously across cloud workspaces without depending on name resolution. What changed: - Canonicalize UUID input via str(UUID(...)) in resolve_workspace_project_identifier so uppercase, brace-wrapped, urn:uuid, and unhyphenated forms all match the stored external_id (Codex P2) - Add project_id parameter to: read_note, write_note, search_notes, edit_note, delete_note, view_note, read_content, move_note, list_directory, canvas, recent_activity, build_context, schema_validate/infer/diff, search_notes_ui, read_note_ui. project_id takes precedence over project when both are passed. - Update get_project_client to route UUID identifiers correctly in pure local mode. Without this, get_project_mode(uuid) defaults to CLOUD and fails for users without cloud credentials. Fix gates the cloud-by-default branch on cloud actually being available, and skips per-project routing for UUIDs in Step 4 (local mode routes everything via the same ASGI client; the API resolves the UUID via /v2/projects/resolve) - Wire read_note._search_candidates to pass project_id=active_project.external_id to the inner search_notes call so workspace selection from the outer get_project_client is preserved across re-resolution. Without this, names that collide across workspaces could re-resolve to a different tenant via the default-workspace fallback in CLI/context=None paths (Codex P1) - Switch resolve_project_and_path callers (read_note, read_content, search) to pass active_project.name instead of the raw project arg so cache hits even when project_id was used or project name was wrong/ambiguous Documentation: - AI assistant guide (live MCP resource) explains project vs project_id and when to prefer the UUID - Extended guide adds a dedicated `project` vs `project_id` section - list_memory_projects docstring points LLMs at external_id for unambiguous addressing - Per-tool project_id docstring is directive ("Prefer this over `project` when known") rather than conditional ("Use this when...") Tests: - 6 new unit tests in test_project_context.py covering UUID lookup, four normalization variants, project_id precedence, and pure-local UUID routing - 2 new integration tests verify project_id end-to-end through MCP transport - Tool signature contract test and telemetry tests updated for new parameter Closes basicmachines-co/basic-memory-cloud#673 Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 4e8a62b commit 25be29e

22 files changed

Lines changed: 504 additions & 39 deletions

docs/ai-assistant-guide-extended.md

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,17 +91,19 @@ SQLite Database (Index)
9191
# List all projects
9292
projects = await list_memory_projects()
9393

94-
# Response structure:
94+
# Response structure (each entry includes external_id you can pass as project_id):
9595
# [
9696
# {
9797
# "name": "main",
98+
# "external_id": "550e8400-e29b-41d4-a716-446655440000",
9899
# "path": "/Users/name/notes",
99100
# "is_default": True,
100101
# "note_count": 156,
101102
# "last_synced": "2025-01-15T10:30:00Z"
102103
# },
103104
# {
104105
# "name": "work",
106+
# "external_id": "9f86d081-884c-42a3-b5e3-1c0c5b4c8e52",
105107
# "path": "/Users/name/work-notes",
106108
# "is_default": False,
107109
# "note_count": 89,
@@ -164,6 +166,44 @@ active_project = "main"
164166
results = await search_notes(query="topic", project=active_project)
165167
```
166168

169+
### `project` vs `project_id`
170+
171+
Every project has two identifiers:
172+
173+
- **`project`** — human-readable name (e.g., `"main"`). Easy to use, but can collide across cloud workspaces.
174+
- **`project_id`** — stable `external_id` UUID. Always unambiguous; takes precedence over `project` when both are passed.
175+
176+
**When to prefer `project_id`:**
177+
178+
1. **Cloud multi-workspace setups.** If the user belongs to more than one workspace (personal + organization, or several organizations) and the same project name might exist in more than one of them, pass `project_id` to route to the exact project. Without it, name resolution falls back to the default workspace, which may not be the one the user means.
179+
2. **After `list_memory_projects()`.** Once you have the `external_id`, prefer using it — it's the same number of characters in JSON and saves a name-resolution round-trip.
180+
3. **When persisting a project choice across a long session.** UUIDs are stable; names can be renamed.
181+
182+
**When `project` (name) is fine:**
183+
184+
- Local single-workspace setups (no collision risk).
185+
- One-off operations where the name is clearly visible to the user (e.g., quick `search_notes(project="main", ...)`).
186+
- The user explicitly references a project by name in their message.
187+
188+
**Example — cloud multi-workspace pattern:**
189+
190+
```python
191+
# Discover and pick the right project for this user
192+
projects = await list_memory_projects()
193+
target = next(p for p in projects if p["name"] == "research" and p["workspace"]["slug"] == "acme")
194+
195+
# Use the UUID for all subsequent operations — no ambiguity
196+
await write_note(
197+
title="Meeting Notes",
198+
content="...",
199+
folder="meetings",
200+
project_id=target["external_id"],
201+
)
202+
results = await search_notes(query="kickoff", project_id=target["external_id"])
203+
```
204+
205+
**Precedence rule:** When both are passed, `project_id` wins. This lets you safely supply `project="main"` for backward compatibility while still routing precisely with `project_id`.
206+
167207
### Cross-Project Operations
168208

169209
**Some tools work across all projects when project parameter omitted:**

src/basic_memory/mcp/project_context.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -558,9 +558,11 @@ async def resolve_workspace_project_identifier(
558558
index = await _ensure_workspace_project_index(context=context)
559559

560560
# Fast path: direct lookup by external_id when the identifier is a UUID
561+
# Canonicalize via str(UUID(...)) so uppercase, brace-wrapped, or urn:uuid forms
562+
# all hash to the same lowercase-hyphenated key as the stored external_ids.
561563
try:
562-
UUID(project)
563-
entry = index.entries_by_external_id.get(project)
564+
canonical_external_id = str(UUID(project))
565+
entry = index.entries_by_external_id.get(canonical_external_id)
564566
if entry:
565567
return entry
566568
except ValueError:
@@ -977,6 +979,7 @@ def detect_project_from_url_prefix(identifier: str, config: BasicMemoryConfig) -
977979
async def get_project_client(
978980
project: Optional[str] = None,
979981
context: Optional[Context] = None,
982+
project_id: Optional[str] = None,
980983
) -> AsyncIterator[Tuple[AsyncClient, ProjectItem]]:
981984
"""Resolve project, create correctly-routed client, and validate project.
982985
@@ -992,8 +995,12 @@ async def get_project_client(
992995
4. Otherwise → local ASGI client
993996
994997
Args:
995-
project: Optional explicit project parameter (name, permalink, or external_id UUID)
998+
project: Optional explicit project parameter (name or permalink)
996999
context: Optional FastMCP context for caching
1000+
project_id: Optional project external_id (UUID). When provided, takes
1001+
precedence over ``project`` and disambiguates the project across
1002+
workspaces. Use this when the same project name exists in multiple
1003+
cloud workspaces.
9971004
9981005
Yields:
9991006
Tuple of (client, active_project)
@@ -1010,8 +1017,12 @@ async def get_project_client(
10101017
is_factory_mode,
10111018
)
10121019

1020+
# When project_id (UUID) is provided, prefer it as the resolution identifier.
1021+
# external_id is unambiguous across workspaces; project name can collide.
1022+
project_identifier = project_id if project_id else project
1023+
10131024
# Step 1: Resolve project name from config (no network call)
1014-
resolved_project = await resolve_project_parameter(project, context=context)
1025+
resolved_project = await resolve_project_parameter(project_identifier, context=context)
10151026
config = ConfigManager().config
10161027
factory_mode = is_factory_mode()
10171028
explicit_cloud_routing = _explicit_routing() and not _force_local_mode()
@@ -1062,6 +1073,15 @@ async def get_project_client(
10621073
project_entry = config.projects.get(resolved_project)
10631074
project_mode = config.get_project_mode(resolved_project)
10641075

1076+
# Trigger: identifier is a UUID (project_id) but local config keys by name only
1077+
# Why: get_project_mode defaults to CLOUD for unknown identifiers; a UUID is
1078+
# never registered in local config, so it would always falsely route cloud
1079+
# Outcome: in pure local mode, treat UUID identifiers as local routing; cloud
1080+
# discovery still happens when factory/explicit/credentials are present
1081+
cloud_available = factory_mode or explicit_cloud_routing or has_cloud_credentials(config)
1082+
if project_id and not cloud_available:
1083+
project_mode = ProjectMode.LOCAL
1084+
10651085
if factory_mode or project_mode == ProjectMode.CLOUD or explicit_cloud_routing:
10661086
route_mode = "factory" if factory_mode else "cloud_proxy"
10671087
active_ws: WorkspaceInfo | None = None
@@ -1109,6 +1129,12 @@ async def get_project_client(
11091129
route_mode=route_mode,
11101130
):
11111131
logger.debug("Using default local ASGI routing for project client")
1112-
async with get_client(project_name=resolved_project) as client:
1132+
# Trigger: UUID identifiers won't match name-keyed local config entries.
1133+
# Why: get_client(project_name=<uuid>) would consult get_project_mode and
1134+
# default to CLOUD for unknown identifiers, breaking pure-local routing.
1135+
# Outcome: skip per-project routing for UUIDs — local mode routes every
1136+
# project through the same ASGI client; the API resolves the UUID below.
1137+
client_kwargs = {} if project_id else {"project_name": resolved_project}
1138+
async with get_client(**client_kwargs) as client:
11131139
active_project = await get_active_project(client, resolved_project, context)
11141140
yield client, active_project

src/basic_memory/mcp/resources/ai_assistant_guide.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ Basic Memory creates a semantic knowledge graph from markdown files. Focus on bu
1818

1919
**Resolution priority:**
2020
1. CLI constraint: `BASIC_MEMORY_MCP_PROJECT` env var (highest priority)
21-
2. Explicit parameter: `project="name"` in tool calls
21+
2. Explicit parameter: `project_id="<uuid>"` (preferred when known) or `project="name"` in tool calls
2222
3. Default project: `default_project` in config (fallback)
2323

24+
**`project` vs `project_id`:** Every project has a stable `external_id` (UUID) returned by `list_memory_projects()`. Pass it as `project_id=...` to address a project unambiguously — required when the same project name exists in multiple cloud workspaces. For local single-project setups, the `project` name is fine.
25+
2426
### Quick Setup Check
2527

2628
```python
27-
# Discover projects
29+
# Discover projects (each entry includes external_id you can pass as project_id)
2830
projects = await list_memory_projects()
2931
```
3032

@@ -169,6 +171,14 @@ await write_note(
169171
**Multi-project users:**
170172
- Always specify project explicitly in tool calls
171173

174+
**Cloud multi-workspace users:** project names can collide across workspaces. After calling `list_memory_projects()`, prefer the project's `external_id` via `project_id=...` for any subsequent tool calls — it routes to the exact project regardless of name collisions. The `project` name parameter falls back to the default workspace on ambiguity, which may not be what you want.
175+
176+
```python
177+
# Cloud / multi-workspace: prefer project_id (UUID) once you've discovered it
178+
projects = await list_memory_projects()
179+
results = await search_notes(query="auth", project_id=projects[0]["external_id"])
180+
```
181+
172182
**Discovery:**
173183
```python
174184
# Start with discovery

src/basic_memory/mcp/tools/build_context.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ async def build_context(
139139
Field(validation_alias=AliasChoices("url", "uri", "memory_url")),
140140
],
141141
project: Optional[str] = None,
142+
project_id: Optional[str] = None,
142143
depth: str | int | None = 1,
143144
timeframe: Annotated[
144145
Optional[TimeFrame],
@@ -178,6 +179,9 @@ async def build_context(
178179
Args:
179180
project: Project name to build context from. Optional - server will resolve using hierarchy.
180181
If unknown, use list_memory_projects() to discover available projects.
182+
project_id: Project external_id (UUID). Prefer this over `project` when known —
183+
it routes to the exact project regardless of name collisions across cloud
184+
workspaces. Takes precedence over `project`. Get from list_memory_projects().
181185
url: memory:// URI pointing to discussion content (e.g. memory://specs/search)
182186
depth: How many relation hops to traverse (1-3 recommended for performance)
183187
timeframe: How far back to look. Supports natural language like "2 days ago", "last week"
@@ -227,6 +231,7 @@ async def build_context(
227231
entrypoint="mcp",
228232
tool_name="build_context",
229233
requested_project=project,
234+
requested_project_id=project_id,
230235
depth=depth or 1,
231236
timeframe=timeframe,
232237
page=page,
@@ -235,7 +240,10 @@ async def build_context(
235240
output_format=output_format,
236241
is_memory_url=str(url).startswith("memory://"),
237242
):
238-
async with get_project_client(project, context=context) as (client, active_project):
243+
async with get_project_client(project, context=context, project_id=project_id) as (
244+
client,
245+
active_project,
246+
):
239247
logger.info(
240248
f"MCP tool call tool=build_context project={active_project.name} "
241249
f"url={url} depth={depth} timeframe={timeframe} output_format={output_format}"

src/basic_memory/mcp/tools/canvas.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ async def canvas(
2929
Field(validation_alias=AliasChoices("directory", "folder", "dir", "path")),
3030
],
3131
project: Optional[str] = None,
32+
project_id: Optional[str] = None,
3233
context: Context | None = None,
3334
) -> str:
3435
"""Create an Obsidian canvas file with the provided nodes and edges.
@@ -45,6 +46,9 @@ async def canvas(
4546
Args:
4647
project: Project name to create canvas in. Optional - server will resolve using hierarchy.
4748
If unknown, use list_memory_projects() to discover available projects.
49+
project_id: Project external_id (UUID). Prefer this over `project` when known —
50+
it routes to the exact project regardless of name collisions across cloud
51+
workspaces. Takes precedence over `project`. Get from list_memory_projects().
4852
nodes: List of node objects following JSON Canvas 1.0 spec
4953
edges: List of edge objects following JSON Canvas 1.0 spec
5054
title: The title of the canvas (will be saved as title.canvas)
@@ -99,7 +103,10 @@ async def canvas(
99103
Raises:
100104
ToolError: If project doesn't exist or directory path is invalid
101105
"""
102-
async with get_project_client(project, context=context) as (client, active_project):
106+
async with get_project_client(project, context=context, project_id=project_id) as (
107+
client,
108+
active_project,
109+
):
103110
# Ensure path has .canvas extension
104111
file_title = title if title.endswith(".canvas") else f"{title}.canvas"
105112
file_path = f"{directory}/{file_title}"

src/basic_memory/mcp/tools/delete_note.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ async def delete_note(
159159
Field(default=False, validation_alias=AliasChoices("is_directory", "is_dir")),
160160
] = False,
161161
project: Optional[str] = None,
162+
project_id: Optional[str] = None,
162163
output_format: Literal["text", "json"] = "text",
163164
context: Context | None = None,
164165
) -> bool | str | dict:
@@ -182,6 +183,9 @@ async def delete_note(
182183
(without file extensions). Defaults to False.
183184
project: Project name to delete from. Optional - server will resolve using hierarchy.
184185
If unknown, use list_memory_projects() to discover available projects.
186+
project_id: Project external_id (UUID). Prefer this over `project` when known —
187+
it routes to the exact project regardless of name collisions across cloud
188+
workspaces. Takes precedence over `project`. Get from list_memory_projects().
185189
output_format: "text" preserves existing behavior (bool/string). "json"
186190
returns machine-readable deletion metadata.
187191
context: Optional FastMCP context for performance caching.
@@ -236,7 +240,10 @@ async def delete_note(
236240
if detected:
237241
project = detected
238242

239-
async with get_project_client(project, context=context) as (client, active_project):
243+
async with get_project_client(project, context=context, project_id=project_id) as (
244+
client,
245+
active_project,
246+
):
240247
logger.debug(
241248
f"Deleting {'directory' if is_directory else 'note'}: {identifier} in project: {active_project.name}"
242249
)

src/basic_memory/mcp/tools/edit_note.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ async def edit_note(
180180
),
181181
],
182182
project: Optional[str] = None,
183+
project_id: Optional[str] = None,
183184
# Section/heading naming varies across tools; accept the descriptive forms.
184185
section: Annotated[
185186
Optional[str],
@@ -223,6 +224,9 @@ async def edit_note(
223224
content: The content to add or use for replacement
224225
project: Project name to edit in. Optional - server will resolve using hierarchy.
225226
If unknown, use list_memory_projects() to discover available projects.
227+
project_id: Project external_id (UUID). Prefer this over `project` when known —
228+
it routes to the exact project regardless of name collisions across cloud
229+
workspaces. Takes precedence over `project`. Get from list_memory_projects().
226230
section: For replace_section operation - the markdown header to replace content under (e.g., "## Notes", "### Implementation")
227231
find_text: For find_replace operation - the text to find and replace
228232
expected_replacements: For find_replace operation - the expected number of replacements (validation will fail if actual doesn't match)
@@ -298,13 +302,17 @@ async def edit_note(
298302
entrypoint="mcp",
299303
tool_name="edit_note",
300304
requested_project=project,
305+
requested_project_id=project_id,
301306
edit_operation=operation,
302307
output_format=output_format,
303308
has_section=bool(section),
304309
has_find_text=bool(find_text),
305310
expected_replacements=effective_replacements,
306311
):
307-
async with get_project_client(project, context=context) as (client, active_project):
312+
async with get_project_client(project, context=context, project_id=project_id) as (
313+
client,
314+
active_project,
315+
):
308316
logger.info(
309317
f"MCP tool call tool=edit_note project={active_project.name} "
310318
f"identifier={identifier} operation={operation} output_format={output_format}"

src/basic_memory/mcp/tools/list_directory.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ async def list_directory(
3232
),
3333
] = None,
3434
project: Optional[str] = None,
35+
project_id: Optional[str] = None,
3536
context: Context | None = None,
3637
) -> str:
3738
"""List directory contents from the knowledge base with optional filtering.
@@ -49,6 +50,9 @@ async def list_directory(
4950
Examples: "*.md", "*meeting*", "project_*"
5051
project: Project name to list directory from. Optional - server will resolve using hierarchy.
5152
If unknown, use list_memory_projects() to discover available projects.
53+
project_id: Project external_id (UUID). Prefer this over `project` when known —
54+
it routes to the exact project regardless of name collisions across cloud
55+
workspaces. Takes precedence over `project`. Get from list_memory_projects().
5256
context: Optional FastMCP context for performance caching.
5357
5458
Returns:
@@ -76,7 +80,10 @@ async def list_directory(
7680
Raises:
7781
ToolError: If project doesn't exist or directory path is invalid
7882
"""
79-
async with get_project_client(project, context=context) as (client, active_project):
83+
async with get_project_client(project, context=context, project_id=project_id) as (
84+
client,
85+
active_project,
86+
):
8087
logger.debug(
8188
f"Listing directory '{dir_name}' in project {project} with depth={depth}, glob='{file_name_glob}'"
8289
)

src/basic_memory/mcp/tools/move_note.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ async def move_note(
371371
Field(default=False, validation_alias=AliasChoices("is_directory", "is_dir")),
372372
] = False,
373373
project: Optional[str] = None,
374+
project_id: Optional[str] = None,
374375
output_format: Literal["text", "json"] = "text",
375376
context: Context | None = None,
376377
) -> str | dict:
@@ -395,6 +396,9 @@ async def move_note(
395396
(without file extensions). Defaults to False.
396397
project: Project name to move within. Optional - server will resolve using hierarchy.
397398
If unknown, use list_memory_projects() to discover available projects.
399+
project_id: Project external_id (UUID). Prefer this over `project` when known —
400+
it routes to the exact project regardless of name collisions across cloud
401+
workspaces. Takes precedence over `project`. Get from list_memory_projects().
398402
output_format: "text" returns existing markdown guidance/success text. "json"
399403
returns machine-readable move metadata.
400404
context: Optional FastMCP context for performance caching.
@@ -494,7 +498,10 @@ async def move_note(
494498
"error": "DESTINATION_FOLDER_NOT_FOR_DIRECTORIES",
495499
}
496500
return f"# Move Failed - Invalid Parameters\n\n{error_msg}"
497-
async with get_project_client(project, context=context) as (client, active_project):
501+
async with get_project_client(project, context=context, project_id=project_id) as (
502+
client,
503+
active_project,
504+
):
498505
destination_target = destination_folder or destination_path
499506
logger.info(
500507
f"MCP tool call tool=move_note project={active_project.name} "

src/basic_memory/mcp/tools/project_management.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,11 @@ async def list_memory_projects(
264264
Shows projects from both local and cloud sources when cloud credentials
265265
are available, merging by permalink to give a unified view.
266266
267+
Each project entry includes an `external_id` (UUID). Pass that value as the
268+
`project_id` parameter on other tools to address a specific project
269+
unambiguously across cloud workspaces — useful when the same project name
270+
exists in more than one workspace.
271+
267272
Args:
268273
output_format: "text" returns the existing human-readable project list.
269274
"json" returns structured project metadata.

0 commit comments

Comments
 (0)