fix(mcp): route edit_note workspace-qualified permalinks (#813)

Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
Paul Hernandez
2026-05-11 12:56:26 -05:00
committed by GitHub
parent 415c2b3d6e
commit c6fa185bf3
5 changed files with 531 additions and 22 deletions
+107 -12
View File
@@ -9,6 +9,7 @@ from pydantic import AliasChoices, Field
from basic_memory.config import ConfigManager
from basic_memory.mcp.project_context import (
_cloud_workspace_discovery_available,
detect_project_from_memory_url_prefix,
get_project_client,
add_project_metadata,
@@ -17,7 +18,11 @@ from basic_memory.mcp.project_context import (
from basic_memory.mcp.server import mcp
from basic_memory.schemas.base import Entity
from basic_memory.schemas.response import EntityResponse
from basic_memory.utils import validate_project_path
from basic_memory.services.link_resolver import (
detect_project_from_workspace_identifier_prefix,
is_workspace_qualified_plain_identifier,
)
from basic_memory.utils import normalize_project_reference, validate_project_path
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]
return title, directory
def _compose_workspace_project_route(
*,
workspace: Optional[str],
project: Optional[str],
project_id: Optional[str],
) -> Optional[str]:
"""Return the explicit project route requested by workspace/project args."""
if workspace is None:
return project
cleaned_workspace = workspace.strip().strip("/")
if not cleaned_workspace:
raise ValueError("workspace must not be empty when provided")
if "/" in cleaned_workspace:
raise ValueError("workspace must be a single workspace slug, name, or tenant_id")
if project_id is not None:
raise ValueError("workspace cannot be combined with project_id; use project_id alone")
if project is None or not project.strip().strip("/"):
raise ValueError("workspace requires an explicit project argument")
cleaned_project = project.strip().strip("/")
if "/" in cleaned_project:
raise ValueError(
"Use either workspace='workspace' with project='project', "
"or project='workspace/project', not both"
)
return f"{cleaned_workspace}/{cleaned_project}"
def _format_ambiguous_workspace_identifier_response(
*,
identifier: str,
detected_project: str,
) -> str:
"""Format the safe-stop response for ambiguous plain write identifiers."""
cleaned_identifier = identifier.strip()
normalized_identifier = normalize_project_reference(cleaned_identifier).strip("/")
workspace_hint, project_hint, note_identifier = normalized_identifier.split("/", 2)
return f"""# Edit Failed - Ambiguous Identifier
`{cleaned_identifier}` could refer to a local note path in the active project, or to a note in `{detected_project}`.
Because edit_note changes content, Basic Memory will not infer a workspace route from a plain path.
Retry with one of these explicit routes:
- `edit_note(identifier="{note_identifier}", project="{detected_project}", operation=..., content=...)`
- `edit_note(identifier="{note_identifier}", workspace="{workspace_hint}", project="{project_hint}", operation=..., content=...)`
- `edit_note(identifier="memory://{normalized_identifier}", operation=..., content=...)`
- `edit_note(identifier="{note_identifier}", project_id="<project external_id>", operation=..., content=...)`"""
def _format_error_response(
error_message: str,
operation: str,
@@ -181,6 +238,7 @@ async def edit_note(
),
],
project: Optional[str] = None,
workspace: Optional[str] = None,
project_id: Optional[str] = None,
# Section/heading naming varies across tools; accept the descriptive forms.
section: Annotated[
@@ -224,7 +282,10 @@ async def edit_note(
- "insert_after_section": Insert content after a section heading without consuming it (note must exist)
content: The content to add or use for replacement
project: Project name to edit in. Optional - server will resolve using hierarchy.
Use "workspace/project" to route to a project in a specific cloud workspace.
If unknown, use list_memory_projects() to discover available projects.
workspace: Workspace slug, name, or tenant_id. When provided with `project`,
routes as `workspace/project`. Cannot be combined with `project_id`.
project_id: Project external_id (UUID). Prefer this over `project` when known —
it routes to the exact project regardless of name collisions across cloud
workspaces. Takes precedence over `project`. Get from list_memory_projects().
@@ -287,18 +348,52 @@ async def edit_note(
"""
# Resolve effective default: allow MCP clients to send null for optional int field
effective_replacements = expected_replacements if expected_replacements is not None else 1
project = _compose_workspace_project_route(
workspace=workspace,
project=project,
project_id=project_id,
)
# Detect project from memory URL prefix before routing
# Trigger: identifier starts with memory:// and no explicit project/project_id was provided
# Why: only gate on memory:// to avoid misrouting plain paths like "research/note"
# where "research" is a directory, not a project name
# Outcome: project is set from the URL prefix, routing goes to the correct project
if project is None and project_id is None and identifier.strip().startswith("memory://"):
detected = await detect_project_from_memory_url_prefix(
identifier,
ConfigManager().config,
context=context,
)
# Resolve or reject routable identifier prefixes before selecting a client.
# Trigger: no explicit project/project_id was provided.
# Why: memory:// URLs are explicit routes, but plain three-segment identifiers
# are ambiguous for a mutating tool.
# Outcome: memory:// can route; plain workspace/project/path matches stop with
# guidance instead of silently editing another project.
if project is None and project_id is None:
config = ConfigManager().config
if identifier.strip().startswith("memory://"):
detected = await detect_project_from_memory_url_prefix(
identifier,
config,
context=context,
)
elif _cloud_workspace_discovery_available(
config
) and is_workspace_qualified_plain_identifier(identifier):
detected = await detect_project_from_workspace_identifier_prefix(
identifier,
config,
context=context,
)
if detected:
if output_format == "json":
return {
"title": None,
"permalink": None,
"file_path": None,
"checksum": None,
"operation": operation,
"fileCreated": False,
"error": "AMBIGUOUS_IDENTIFIER",
"project": detected,
}
return _format_ambiguous_workspace_identifier_response(
identifier=identifier,
detected_project=detected,
)
else:
detected = None
if detected:
project = detected
+38 -2
View File
@@ -1,7 +1,7 @@
"""Service for resolving markdown links to permalinks."""
"""Service and helpers for resolving markdown links and permalink-like identifiers."""
import uuid as uuid_mod
from typing import Optional, Tuple, Dict
from typing import Any, Optional, Tuple, Dict
from loguru import logger
@@ -20,6 +20,42 @@ from basic_memory.utils import (
from basic_memory.workspace_context import current_workspace_permalink_context
def is_workspace_qualified_plain_identifier(identifier: str) -> bool:
"""Return True for plain ``<workspace>/<project>/<path>`` identifiers."""
stripped = identifier.strip()
if stripped.startswith("memory://"):
return False
normalized = normalize_project_reference(stripped).strip("/")
return len(normalized.split("/", 2)) == 3
async def detect_project_from_workspace_identifier_prefix(
identifier: str,
config: BasicMemoryConfig,
context: Any | None = None,
) -> Optional[str]:
"""Resolve a project route from a plain workspace-qualified identifier."""
if not is_workspace_qualified_plain_identifier(identifier):
return None
from basic_memory.mcp.project_context import (
_cloud_workspace_discovery_available,
resolve_workspace_qualified_identifier,
)
if not _cloud_workspace_discovery_available(config):
return None
workspace_resolution = await resolve_workspace_qualified_identifier(
identifier,
context=context,
)
if workspace_resolution is None:
return None
return workspace_resolution.project_identifier
class LinkResolver:
"""Service for resolving markdown links to permalinks.