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
36 changes: 27 additions & 9 deletions src/basic_memory/mcp/project_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -1298,13 +1298,22 @@ async def resolve_project_and_path(
headers: HeaderTypes | None = None,
*,
strict_project_routing: bool = False,
allow_missing_project_fallback: bool = False,
cache_resolved_project: bool = True,
) -> tuple[ProjectItem, str, bool]:
"""Resolve project and normalized path for memory:// identifiers.

Args:
strict_project_routing: Reject a memory URL whose leading project-like
segment cannot be resolved. Mutating tools use this to prevent a
failed route from falling back to the active project.
allow_missing_project_fallback: When strict routing is enabled, still
allow a genuinely missing project prefix to be treated as an active-
project path. This is safe only for mutations that require an existing
target and cannot create content.
cache_resolved_project: Persist a project resolved from the memory URL in
MCP context. Set this to false for validation-only routing that may
reject a resolved cross-project source.

Returns:
Tuple of (active_project, normalized_path, is_memory_url)
Expand Down Expand Up @@ -1407,15 +1416,23 @@ async def resolve_project_and_path(
)
resolved = ProjectResolveResponse.model_validate(response.json())
except ToolError as exc:
if "project not found" not in str(exc).lower():
error_message = str(exc).lower()
project_route_missing = "project not found" in error_message
project_route_hidden_by_scope = (
"does not have access to this project" in error_message
and cached_project is not None
and _project_matches_identifier(cached_project, project)
and not strict_project_routing
Comment thread
phernandez marked this conversation as resolved.
Comment thread
phernandez marked this conversation as resolved.
)
if not project_route_missing and not project_route_hidden_by_scope:
raise
if strict_project_routing:
# Trigger: a mutating tool supplied a memory URL whose leading
# project-like segment did not resolve.
# Why: falling back would reinterpret the full route as a path
# in the active project, allowing append/prepend to create a
# phantom note in the wrong project (#1066).
# Outcome: stop before entity resolution or file creation.
if strict_project_routing and not (
project_route_missing and allow_missing_project_fallback
):
# Mutations that can create content must not reinterpret a
# missing project route as an active-project path (#1066).
# Existing-target mutations may opt into that legacy path
# fallback, while scope-hidden routes always fail above.
raise UnresolvedProjectRouteError(identifier, project_prefix) from exc
else:
resolved_project = await resolve_project_parameter(project_prefix, context=context)
Expand All @@ -1433,7 +1450,8 @@ async def resolve_project_and_path(
path=resolved.path,
is_default=resolved.is_default,
)
await _set_cached_active_project(context, active_project)
if cache_resolved_project:
await _set_cached_active_project(context, active_project)

resolved_path = _canonical_memory_path_for_active_route(
active_project,
Expand Down
16 changes: 14 additions & 2 deletions src/basic_memory/mcp/tools/delete_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,8 @@ async def delete_note(
identifier,
active_project.name,
context,
strict_project_routing=True,
allow_missing_project_fallback=True,
Comment thread
phernandez marked this conversation as resolved.
)

# Handle directory deletes
Expand All @@ -323,7 +325,7 @@ async def delete_note(
result = await knowledge_client.delete_directory(directory_identifier)
if output_format == "json":
response = {
"deleted": result.failed_deletes == 0,
"deleted": result.total_files > 0 and result.failed_deletes == 0,
"is_directory": True,
"identifier": identifier,
"total_files": result.total_files,
Expand All @@ -332,13 +334,23 @@ async def delete_note(
"deleted_files": result.deleted_files,
"errors": [error.model_dump() for error in result.errors],
}
if result.failed_deletes > 0:
if result.total_files == 0:
response["error"] = "Directory not found or empty: no files matched"
elif result.failed_deletes > 0:
response["error"] = (
"Directory delete incomplete: "
f"{result.failed_deletes} of {result.total_files} file(s) failed"
)
return response

if result.total_files == 0:
return f"""# Directory Delete Failed - No Files Found

No files found for directory `{identifier}`.
Total files: 0.

<!-- Project: {active_project.name} -->"""

# Build success message for directory delete
result_lines = [
"# Directory Deleted Successfully",
Expand Down
146 changes: 93 additions & 53 deletions src/basic_memory/mcp/tools/move_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,42 @@
from mcp.server.fastmcp.exceptions import ToolError
from pydantic import AliasChoices, Field

from basic_memory.config import ConfigManager
from basic_memory.mcp.server import mcp
from basic_memory.mcp.project_context import get_project_client, resolve_project_and_path
from basic_memory.utils import validate_project_path
from basic_memory.schemas.project_info import ProjectItem
from basic_memory.utils import (
generate_permalink,
normalize_project_reference,
validate_project_path,
)
from basic_memory.workspace_context import current_workspace_permalink_context


def _directory_path_for_move(
resolved_identifier: str,
active_project: ProjectItem,
*,
include_project_prefix: bool,
) -> str:
"""Return the project-relative source directory expected by the move API."""
directory = normalize_project_reference(resolved_identifier).strip("/")
project_permalink = active_project.permalink

route_prefixes: list[str] = []
workspace_context = current_workspace_permalink_context()
if workspace_context and workspace_context.should_prefix_permalinks:
route_prefixes.append(
f"{generate_permalink(workspace_context.workspace_slug)}/{project_permalink}"
)
if include_project_prefix:
route_prefixes.append(project_permalink)

for route_prefix in route_prefixes:
if directory.startswith(f"{route_prefix}/"):
return directory.removeprefix(f"{route_prefix}/")

return directory


async def _detect_cross_project_move_attempt(
Expand Down Expand Up @@ -561,18 +594,57 @@ async def move_note(
move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
```"""

# Handle directory moves
if is_directory:
# Import here to avoid circular import
from basic_memory.mcp.clients import KnowledgeClient
# Resolve every source before branching so file and directory mutations share
# the same fail-closed project boundary.
from basic_memory.mcp.clients import KnowledgeClient

knowledge_client = KnowledgeClient(client, active_project.external_id)
source_project, resolved_identifier, is_memory_url = await resolve_project_and_path(
client,
identifier,
active_project.name,
Comment thread
phernandez marked this conversation as resolved.
context,
strict_project_routing=True,
allow_missing_project_fallback=True,
Comment thread
phernandez marked this conversation as resolved.
cache_resolved_project=False,
)

knowledge_client = KnowledgeClient(client, active_project.external_id)
# move_note only supports moves inside the active project.
if source_project.external_id != active_project.external_id:
logger.info(
f"Move rejected: source '{identifier}' resolves to project "
f"'{source_project.name}', not the active project '{active_project.name}'"
)
if output_format == "json":
return {
"moved": False,
"title": None,
"permalink": None,
"file_path": None,
"source": identifier,
"destination": destination_path,
"error": "CROSS_PROJECT_MOVE_NOT_SUPPORTED",
}
return _format_cross_project_error_response(
identifier, destination_path, active_project.name, source_project.name
)

# Handle directory moves
if is_directory:
try:
result = await knowledge_client.move_directory(identifier, destination_path)
source_directory = (
_directory_path_for_move(
resolved_identifier,
active_project,
include_project_prefix=ConfigManager().config.permalinks_include_project,
)
if is_memory_url
else resolved_identifier
)
result = await knowledge_client.move_directory(source_directory, destination_path)
if output_format == "json":
return {
"moved": result.failed_moves == 0,
"moved": result.total_files > 0 and result.failed_moves == 0,
"title": None,
"permalink": None,
"file_path": None,
Expand All @@ -582,8 +654,21 @@ async def move_note(
"total_files": result.total_files,
"successful_moves": result.successful_moves,
"failed_moves": result.failed_moves,
**(
{"error": "Directory not found or empty: no files matched"}
if result.total_files == 0
else {}
),
}

if result.total_files == 0:
return f"""# Directory Move Failed - No Files Found

No files found for source directory `{identifier}`.
Total files: 0.

<!-- Project: {active_project.name} -->"""

# Build success message for directory move
result_lines = [
"# Directory Moved Successfully",
Expand Down Expand Up @@ -653,51 +738,6 @@ async def move_note(
move_note("path/to/file.md", "{destination_path}/file.md")
```"""

# Import here to avoid circular import
from basic_memory.mcp.clients import KnowledgeClient

# Use typed KnowledgeClient for API calls
knowledge_client = KnowledgeClient(client, active_project.external_id)

# --- Normalize the identifier for memory:// URLs ---
# Trigger: caller passed a "memory://..." URL (the docstring advertises this, and
# read_note/edit_note/delete_note all accept it).
# Why: resolve_entity / the /resolve endpoint expect a bare permalink or title;
# the literal "memory://" scheme prefix is not stripped by link resolution and
# 404s. resolve_project_and_path strips the prefix and normalizes the path the
# same way the sibling tools do.
# Outcome: move_note resolves memory:// URLs identically to read/edit/delete.
source_project, resolved_identifier, _ = await resolve_project_and_path(
client, identifier, active_project.name, context
)

# Trigger: a memory:// identifier whose project prefix resolves to a DIFFERENT project
# than the one move_note is operating on (e.g. "memory://other-project/...").
# Why: get_project_client already bound knowledge_client to the active project, so the
# resolve_entity below runs against the active project regardless of the URL's
# project. Honoring the cross-project prefix would misroute (path normalized for
# the other project, looked up in the active one); move_note cannot move across
# projects.
# Outcome: reject up front with the cross-project guidance instead of misrouting.
if source_project.external_id != active_project.external_id:
logger.info(
f"Move rejected: source '{identifier}' resolves to project "
f"'{source_project.name}', not the active project '{active_project.name}'"
)
if output_format == "json":
return {
"moved": False,
"title": None,
"permalink": None,
"file_path": None,
"source": identifier,
"destination": destination_path,
"error": "CROSS_PROJECT_MOVE_NOT_SUPPORTED",
}
return _format_cross_project_error_response(
identifier, destination_path, active_project.name, source_project.name
)

# Resolve once and reuse the entity ID across extension validation and move.
source_ext = "md" # Default to .md if we can't determine source extension
resolved_entity_id: str | None = None
Expand Down
Loading
Loading