Skip to content

Commit c6fa185

Browse files
authored
fix(mcp): route edit_note workspace-qualified permalinks (#813)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 415c2b3 commit c6fa185

5 files changed

Lines changed: 532 additions & 23 deletions

File tree

src/basic_memory/mcp/tools/edit_note.py

Lines changed: 108 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from basic_memory.config import ConfigManager
1111
from basic_memory.mcp.project_context import (
12+
_cloud_workspace_discovery_available,
1213
detect_project_from_memory_url_prefix,
1314
get_project_client,
1415
add_project_metadata,
@@ -17,7 +18,11 @@
1718
from basic_memory.mcp.server import mcp
1819
from basic_memory.schemas.base import Entity
1920
from basic_memory.schemas.response import EntityResponse
20-
from basic_memory.utils import validate_project_path
21+
from basic_memory.services.link_resolver import (
22+
detect_project_from_workspace_identifier_prefix,
23+
is_workspace_qualified_plain_identifier,
24+
)
25+
from basic_memory.utils import normalize_project_reference, validate_project_path
2126

2227

2328
def _parse_identifier_to_title_and_directory(identifier: str) -> tuple[str, str]:
@@ -47,6 +52,58 @@ def _parse_identifier_to_title_and_directory(identifier: str) -> tuple[str, str]
4752
return title, directory
4853

4954

55+
def _compose_workspace_project_route(
56+
*,
57+
workspace: Optional[str],
58+
project: Optional[str],
59+
project_id: Optional[str],
60+
) -> Optional[str]:
61+
"""Return the explicit project route requested by workspace/project args."""
62+
if workspace is None:
63+
return project
64+
65+
cleaned_workspace = workspace.strip().strip("/")
66+
if not cleaned_workspace:
67+
raise ValueError("workspace must not be empty when provided")
68+
if "/" in cleaned_workspace:
69+
raise ValueError("workspace must be a single workspace slug, name, or tenant_id")
70+
if project_id is not None:
71+
raise ValueError("workspace cannot be combined with project_id; use project_id alone")
72+
if project is None or not project.strip().strip("/"):
73+
raise ValueError("workspace requires an explicit project argument")
74+
75+
cleaned_project = project.strip().strip("/")
76+
if "/" in cleaned_project:
77+
raise ValueError(
78+
"Use either workspace='workspace' with project='project', "
79+
"or project='workspace/project', not both"
80+
)
81+
return f"{cleaned_workspace}/{cleaned_project}"
82+
83+
84+
def _format_ambiguous_workspace_identifier_response(
85+
*,
86+
identifier: str,
87+
detected_project: str,
88+
) -> str:
89+
"""Format the safe-stop response for ambiguous plain write identifiers."""
90+
cleaned_identifier = identifier.strip()
91+
normalized_identifier = normalize_project_reference(cleaned_identifier).strip("/")
92+
workspace_hint, project_hint, note_identifier = normalized_identifier.split("/", 2)
93+
94+
return f"""# Edit Failed - Ambiguous Identifier
95+
96+
`{cleaned_identifier}` could refer to a local note path in the active project, or to a note in `{detected_project}`.
97+
98+
Because edit_note changes content, Basic Memory will not infer a workspace route from a plain path.
99+
100+
Retry with one of these explicit routes:
101+
- `edit_note(identifier="{note_identifier}", project="{detected_project}", operation=..., content=...)`
102+
- `edit_note(identifier="{note_identifier}", workspace="{workspace_hint}", project="{project_hint}", operation=..., content=...)`
103+
- `edit_note(identifier="memory://{normalized_identifier}", operation=..., content=...)`
104+
- `edit_note(identifier="{note_identifier}", project_id="<project external_id>", operation=..., content=...)`"""
105+
106+
50107
def _format_error_response(
51108
error_message: str,
52109
operation: str,
@@ -181,6 +238,7 @@ async def edit_note(
181238
),
182239
],
183240
project: Optional[str] = None,
241+
workspace: Optional[str] = None,
184242
project_id: Optional[str] = None,
185243
# Section/heading naming varies across tools; accept the descriptive forms.
186244
section: Annotated[
@@ -224,7 +282,10 @@ async def edit_note(
224282
- "insert_after_section": Insert content after a section heading without consuming it (note must exist)
225283
content: The content to add or use for replacement
226284
project: Project name to edit in. Optional - server will resolve using hierarchy.
285+
Use "workspace/project" to route to a project in a specific cloud workspace.
227286
If unknown, use list_memory_projects() to discover available projects.
287+
workspace: Workspace slug, name, or tenant_id. When provided with `project`,
288+
routes as `workspace/project`. Cannot be combined with `project_id`.
228289
project_id: Project external_id (UUID). Prefer this over `project` when known —
229290
it routes to the exact project regardless of name collisions across cloud
230291
workspaces. Takes precedence over `project`. Get from list_memory_projects().
@@ -287,18 +348,52 @@ async def edit_note(
287348
"""
288349
# Resolve effective default: allow MCP clients to send null for optional int field
289350
effective_replacements = expected_replacements if expected_replacements is not None else 1
290-
291-
# Detect project from memory URL prefix before routing
292-
# Trigger: identifier starts with memory:// and no explicit project/project_id was provided
293-
# Why: only gate on memory:// to avoid misrouting plain paths like "research/note"
294-
# where "research" is a directory, not a project name
295-
# Outcome: project is set from the URL prefix, routing goes to the correct project
296-
if project is None and project_id is None and identifier.strip().startswith("memory://"):
297-
detected = await detect_project_from_memory_url_prefix(
298-
identifier,
299-
ConfigManager().config,
300-
context=context,
301-
)
351+
project = _compose_workspace_project_route(
352+
workspace=workspace,
353+
project=project,
354+
project_id=project_id,
355+
)
356+
357+
# Resolve or reject routable identifier prefixes before selecting a client.
358+
# Trigger: no explicit project/project_id was provided.
359+
# Why: memory:// URLs are explicit routes, but plain three-segment identifiers
360+
# are ambiguous for a mutating tool.
361+
# Outcome: memory:// can route; plain workspace/project/path matches stop with
362+
# guidance instead of silently editing another project.
363+
if project is None and project_id is None:
364+
config = ConfigManager().config
365+
if identifier.strip().startswith("memory://"):
366+
detected = await detect_project_from_memory_url_prefix(
367+
identifier,
368+
config,
369+
context=context,
370+
)
371+
elif _cloud_workspace_discovery_available(
372+
config
373+
) and is_workspace_qualified_plain_identifier(identifier):
374+
detected = await detect_project_from_workspace_identifier_prefix(
375+
identifier,
376+
config,
377+
context=context,
378+
)
379+
if detected:
380+
if output_format == "json":
381+
return {
382+
"title": None,
383+
"permalink": None,
384+
"file_path": None,
385+
"checksum": None,
386+
"operation": operation,
387+
"fileCreated": False,
388+
"error": "AMBIGUOUS_IDENTIFIER",
389+
"project": detected,
390+
}
391+
return _format_ambiguous_workspace_identifier_response(
392+
identifier=identifier,
393+
detected_project=detected,
394+
)
395+
else:
396+
detected = None
302397
if detected:
303398
project = detected
304399

src/basic_memory/services/link_resolver.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
"""Service for resolving markdown links to permalinks."""
1+
"""Service and helpers for resolving markdown links and permalink-like identifiers."""
22

33
import uuid as uuid_mod
4-
from typing import Optional, Tuple, Dict
4+
from typing import Any, Optional, Tuple, Dict
55

66
from loguru import logger
77

@@ -20,6 +20,42 @@
2020
from basic_memory.workspace_context import current_workspace_permalink_context
2121

2222

23+
def is_workspace_qualified_plain_identifier(identifier: str) -> bool:
24+
"""Return True for plain ``<workspace>/<project>/<path>`` identifiers."""
25+
stripped = identifier.strip()
26+
if stripped.startswith("memory://"):
27+
return False
28+
29+
normalized = normalize_project_reference(stripped).strip("/")
30+
return len(normalized.split("/", 2)) == 3
31+
32+
33+
async def detect_project_from_workspace_identifier_prefix(
34+
identifier: str,
35+
config: BasicMemoryConfig,
36+
context: Any | None = None,
37+
) -> Optional[str]:
38+
"""Resolve a project route from a plain workspace-qualified identifier."""
39+
if not is_workspace_qualified_plain_identifier(identifier):
40+
return None
41+
42+
from basic_memory.mcp.project_context import (
43+
_cloud_workspace_discovery_available,
44+
resolve_workspace_qualified_identifier,
45+
)
46+
47+
if not _cloud_workspace_discovery_available(config):
48+
return None
49+
50+
workspace_resolution = await resolve_workspace_qualified_identifier(
51+
identifier,
52+
context=context,
53+
)
54+
if workspace_resolution is None:
55+
return None
56+
return workspace_resolution.project_identifier
57+
58+
2359
class LinkResolver:
2460
"""Service for resolving markdown links to permalinks.
2561

tests/mcp/test_tool_contracts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"operation",
3838
"content",
3939
"project",
40+
"workspace",
4041
"project_id",
4142
"section",
4243
"find_text",

0 commit comments

Comments
 (0)