Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 108 additions & 13 deletions src/basic_memory/mcp/tools/edit_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

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,
Expand All @@ -17,7 +18,11 @@
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]:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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[
Expand Down Expand Up @@ -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().
Expand Down Expand Up @@ -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

# 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,
)
project = _compose_workspace_project_route(
workspace=workspace,
project=project,
project_id=project_id,
)

# 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(
Comment thread
phernandez marked this conversation as resolved.
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

Expand Down
40 changes: 38 additions & 2 deletions src/basic_memory/services/link_resolver.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -20,6 +20,42 @@
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.

Expand Down
1 change: 1 addition & 0 deletions tests/mcp/test_tool_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"operation",
"content",
"project",
"workspace",
"project_id",
"section",
"find_text",
Expand Down
Loading
Loading