Files
basicmachines-co-basic-memory/src/basic_memory/mcp/tools/move_note.py
T
Paul Hernandez 4791e19685 feat: add Logfire phased instrumentation (#692)
Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:39:42 -05:00

860 lines
35 KiB
Python

"""Move note tool for Basic Memory MCP server."""
from pathlib import Path, PureWindowsPath
from textwrap import dedent
from typing import Optional, Literal
from loguru import logger
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
from basic_memory.mcp.server import mcp
from basic_memory.mcp.project_context import get_project_client
from basic_memory.utils import validate_project_path
async def _detect_cross_project_move_attempt(
client, identifier: str, destination_path: str, current_project: str
) -> Optional[str]:
"""Detect potential cross-project move attempts and return guidance.
Args:
client: The AsyncClient instance
identifier: The note identifier being moved
destination_path: The destination path
current_project: The current active project
Returns:
Error message with guidance if cross-project move is detected, None otherwise
"""
try:
# Import here to avoid circular import
from basic_memory.mcp.clients import ProjectClient
# Use typed ProjectClient for API calls
project_client = ProjectClient(client)
project_list = await project_client.list_projects()
project_names = [p.name.lower() for p in project_list.projects]
# Check if destination path contains any project names
dest_lower = destination_path.lower()
path_parts = dest_lower.split("/")
# Look for project names in the destination path
for part in path_parts:
if part in project_names and part != current_project.lower():
# Found a different project name in the path
matching_project = next(
p.name for p in project_list.projects if p.name.lower() == part
)
return _format_cross_project_error_response(
identifier, destination_path, current_project, matching_project
)
# No other cross-project patterns detected
except Exception as e:
# If we can't detect, don't interfere with normal error handling
logger.debug(f"Could not check for cross-project move: {e}")
return None
return None
def _format_cross_project_error_response(
identifier: str, destination_path: str, current_project: str, target_project: str
) -> str:
"""Format error response for detected cross-project move attempts."""
return dedent(f"""
# Move Failed - Cross-Project Move Not Supported
Cannot move '{identifier}' to '{destination_path}' because it appears to reference a different project ('{target_project}').
**Current project:** {current_project}
**Target project:** {target_project}
## Cross-project moves are not supported directly
Notes can only be moved within the same project. To move content between projects, use this workflow:
### Recommended approach:
```
# 1. Read the note content from current project
read_note("{identifier}")
# 2. Create the note in the target project
write_note("Note Title", "content from step 1", "target-folder", project="{target_project}")
# 3. Delete the original note if desired
delete_note("{identifier}", project="{current_project}")
```
### Alternative: Stay in current project
If you want to move the note within the **{current_project}** project only:
```
move_note("{identifier}", "new-folder/new-name.md")
```
## Available projects:
Use `list_memory_projects()` to see all available projects.
""").strip()
def _format_potential_cross_project_guidance(
identifier: str, destination_path: str, current_project: str, available_projects: list[str]
) -> str:
"""Format guidance for potentially cross-project moves."""
other_projects = ", ".join(available_projects[:3]) # Show first 3 projects # pragma: no cover
if len(available_projects) > 3: # pragma: no cover
other_projects += f" (and {len(available_projects) - 3} others)" # pragma: no cover
return ( # pragma: no cover
dedent(f"""
# Move Failed - Check Project Context
Cannot move '{identifier}' to '{destination_path}' within the current project '{current_project}'.
## If you intended to move within the current project:
The destination path should be relative to the project root:
```
move_note("{identifier}", "folder/filename.md")
```
## If you intended to move to a different project:
Cross-project moves require switching projects first. Available projects: {other_projects}
### To move to another project:
```
# 1. Read the content
read_note("{identifier}")
# 2. Create note in target project
write_note("Title", "content", "folder", project="target-project-name")
# 3. Delete original if desired
delete_note("{identifier}", project="{current_project}")
```
### To see all projects:
```
list_memory_projects()
```
""").strip()
)
def _format_move_error_response(error_message: str, identifier: str, destination_path: str) -> str:
"""Format helpful error responses for move failures that guide users to successful moves."""
# Note not found errors
if "entity not found" in error_message.lower() or "not found" in error_message.lower():
search_term = identifier.split("/")[-1] if "/" in identifier else identifier
title_format = (
identifier.split("/")[-1].replace("-", " ").title() if "/" in identifier else identifier
)
permalink_format = identifier.lower().replace(" ", "-")
return dedent(f"""
# Move Failed - Note Not Found
The note '{identifier}' could not be found for moving. Move operations require an exact match (no fuzzy matching).
## Suggestions to try:
1. **Search for the note first**: Use `search_notes("{search_term}")` to find it with exact identifiers
2. **Try different exact identifier formats**:
- If you used a permalink like "folder/note-title", try the exact title: "{title_format}"
- If you used a title, try the exact permalink format: "{permalink_format}"
- Use `read_note()` first to verify the note exists and get the exact identifier
3. **List available notes**: Use `list_directory("/")` to see what notes exist in the current project
4. **List available notes**: Use `list_directory("/")` to see what notes exist
## Before trying again:
```
# First, verify the note exists:
search_notes("{identifier}")
# Then use the exact identifier from search results:
move_note("correct-identifier-here", "{destination_path}")
```
""").strip()
# Destination already exists errors
if "already exists" in error_message.lower() or "file exists" in error_message.lower():
return f"""# Move Failed - Destination Already Exists
Cannot move '{identifier}' to '{destination_path}' because a file already exists at that location.
## How to resolve:
1. **Choose a different destination**: Try a different filename or folder
- Add timestamp: `{destination_path.rsplit(".", 1)[0] if "." in destination_path else destination_path}-backup.md`
- Use different folder: `archive/{destination_path}` or `backup/{destination_path}`
2. **Check the existing file**: Use `read_note("{destination_path}")` to see what's already there
3. **Remove or rename existing**: If safe to do so, move the existing file first
## Try these alternatives:
```
# Option 1: Add timestamp to make unique
move_note("{identifier}", "{destination_path.rsplit(".", 1)[0] if "." in destination_path else destination_path}-backup.md")
# Option 2: Use archive folder
move_note("{identifier}", "archive/{destination_path}")
# Option 3: Check what's at destination first
read_note("{destination_path}")
```"""
# Invalid path errors
if "invalid" in error_message.lower() and "path" in error_message.lower():
return f"""# Move Failed - Invalid Destination Path
The destination path '{destination_path}' is not valid: {error_message}
## Path requirements:
1. **Relative paths only**: Don't start with `/` (use `notes/file.md` not `/notes/file.md`)
2. **Include file extension**: Add `.md` for markdown files
3. **Use forward slashes**: For folder separators (`folder/subfolder/file.md`)
4. **No special characters**: Avoid `\\`, `:`, `*`, `?`, `"`, `<`, `>`, `|`
## Valid path examples:
- `notes/my-note.md`
- `projects/2025/meeting-notes.md`
- `archive/old-projects/legacy-note.md`
## Try again with:
```
move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
```"""
# Permission/access errors
if (
"permission" in error_message.lower()
or "access" in error_message.lower()
or "forbidden" in error_message.lower()
):
return f"""# Move Failed - Permission Error
You don't have permission to move '{identifier}': {error_message}
## How to resolve:
1. **Check file permissions**: Ensure you have write access to both source and destination
2. **Verify project access**: Make sure you have edit permissions for this project
3. **Check file locks**: The file might be open in another application
## Alternative actions:
- List available projects: `list_memory_projects()`
- Try copying content instead: `read_note("{identifier}", project="project-name")` then `write_note()` to new location"""
# Source file not found errors
if "source" in error_message.lower() and (
"not found" in error_message.lower() or "missing" in error_message.lower()
):
return f"""# Move Failed - Source File Missing
The source file for '{identifier}' was not found on disk: {error_message}
This usually means the database and filesystem are out of sync.
## How to resolve:
1. **Check if note exists in database**: `read_note("{identifier}")`
2. **Run sync operation**: The file might need to be re-synced
3. **Recreate the file**: If data exists in database, recreate the physical file
## Troubleshooting steps:
```
# Check if note exists in Basic Memory
read_note("{identifier}")
# If it exists, the file is missing on disk - send a message to support@basicmachines.co
# If it doesn't exist, use search to find the correct identifier
search_notes("{identifier}")
```"""
# Server/filesystem errors
if (
"server error" in error_message.lower()
or "filesystem" in error_message.lower()
or "disk" in error_message.lower()
):
return f"""# Move Failed - System Error
A system error occurred while moving '{identifier}': {error_message}
## Immediate steps:
1. **Try again**: The error might be temporary
2. **Check disk space**: Ensure adequate storage is available
3. **Verify filesystem permissions**: Check if the destination directory is writable
## Alternative approaches:
- Copy content to new location: Use `read_note("{identifier}")` then `write_note()`
- Use a different destination folder that you know works
- Send a message to support@basicmachines.co if the problem persists
## Backup approach:
```
# Read current content
content = read_note("{identifier}")
# Create new note at desired location
write_note("New Note Title", content, "{destination_path.split("/")[0] if "/" in destination_path else "notes"}")
# Then delete original if successful
delete_note("{identifier}")
```"""
# Generic fallback
return ( # pragma: no cover
f"""# Move Failed
Error moving '{identifier}' to '{destination_path}': {error_message} # pragma: no cover
## General troubleshooting:
1. **Verify the note exists**: `read_note("{identifier}")` or `search_notes("{identifier}")`
2. **Check destination path**: Ensure it's a valid relative path with `.md` extension
3. **Verify permissions**: Make sure you can edit files in this project
4. **Try a simpler path**: Use a basic folder structure like `notes/filename.md`
## Step-by-step approach:
```
# 1. Confirm note exists
read_note("{identifier}")
# 2. Try a simple destination first
move_note("{identifier}", "notes/{destination_path.split("/")[-1] if "/" in destination_path else destination_path}")
# 3. If that works, then try your original destination
```
## Alternative approach:
If moving continues to fail, you can copy the content manually:
```
# Read current content
content = read_note("{identifier}")
# Create new note
write_note("Title", content, "target-folder")
# Delete original once confirmed
delete_note("{identifier}")
```"""
)
@mcp.tool(
description="Move a note or directory to a new location, updating database and maintaining links.",
annotations={"destructiveHint": False, "openWorldHint": False},
)
async def move_note(
identifier: str,
destination_path: str = "",
destination_folder: Optional[str] = None,
is_directory: bool = False,
project: Optional[str] = None,
workspace: Optional[str] = None,
output_format: Literal["text", "json"] = "text",
context: Context | None = None,
) -> str | dict:
"""Move a note or directory to a new location within the same project.
Moves a note or directory from one location to another within the project,
updating all database references and maintaining semantic content. Uses stateless
architecture - project parameter optional with server resolution.
Args:
identifier: For files: exact entity identifier (title, permalink, or memory:// URL).
For directories: the directory path (e.g., "docs", "projects/2025").
Must be an exact match - fuzzy matching is not supported for move operations.
Use search_notes() or list_directory() first to find the correct path if uncertain.
destination_path: For files: new path relative to project root (e.g., "work/meetings/note.md")
For directories: new directory path (e.g., "archive/docs")
Mutually exclusive with destination_folder.
destination_folder: Move the note into this folder, preserving the original filename.
Mutually exclusive with destination_path. Only for single-file moves.
is_directory: If True, moves an entire directory and all its contents.
When True, identifier and destination_path should be directory paths
(without file extensions). Defaults to False.
project: Project name to move within. Optional - server will resolve using hierarchy.
If unknown, use list_memory_projects() to discover available projects.
output_format: "text" returns existing markdown guidance/success text. "json"
returns machine-readable move metadata.
context: Optional FastMCP context for performance caching.
Returns:
Success message with move details and project information.
For directories, includes count of files moved and any errors.
Examples:
# Move a single note to new folder (exact title match)
move_note("My Note", "work/notes/my-note.md")
# Move by exact permalink
move_note("my-note-permalink", "archive/old-notes/my-note.md")
# Move note to archive folder (filename preserved automatically)
move_note("my-note", destination_folder="archive")
# Move with complex path structure
move_note("experiments/ml-results", "archive/2025/ml-experiments.md")
# Explicit project specification
move_note("My Note", "work/notes/my-note.md", project="work-project")
# Move entire directory
move_note("docs", "archive/docs", is_directory=True)
# Move nested directory
move_note("projects/2024", "archive/projects/2024", is_directory=True)
# If uncertain about identifier, search first:
# search_notes("my note") # Find available notes
# move_note("docs/my-note-2025", "archive/my-note.md") # Use exact result
Raises:
ToolError: If project doesn't exist, identifier is not found, or destination_path is invalid
Note:
This operation moves notes within the specified project only. Moving notes
between different projects is not currently supported.
The move operation:
- Updates the entity's file_path in the database
- Moves the physical file on the filesystem
- Optionally updates permalinks if configured
- Re-indexes the entity for search
- Maintains all observations and relations
"""
# --- Parameter Validation ---
# Trigger: both destination_path and destination_folder provided
# Why: they are mutually exclusive — one specifies full path, the other just the folder
# Outcome: early error before any entity resolution or API calls
if destination_folder and destination_path:
error_msg = (
"Cannot specify both destination_path and destination_folder. Use one or the other."
)
if output_format == "json":
return {
"moved": False,
"title": None,
"permalink": None,
"file_path": None,
"source": identifier,
"destination": None,
"error": "MUTUALLY_EXCLUSIVE_PARAMS",
}
return f"# Move Failed - Invalid Parameters\n\n{error_msg}"
if not destination_folder and not destination_path:
error_msg = "Either destination_path or destination_folder must be provided."
if output_format == "json":
return {
"moved": False,
"title": None,
"permalink": None,
"file_path": None,
"source": identifier,
"destination": None,
"error": "MISSING_DESTINATION",
}
return f"# Move Failed - Missing Destination\n\n{error_msg}"
# Trigger: destination_folder used with is_directory=True
# Why: destination_folder preserves a single file's name — meaningless for directory moves
if destination_folder and is_directory:
error_msg = (
"destination_folder is only supported for single-file moves, not directory moves."
)
if output_format == "json":
return {
"moved": False,
"title": None,
"permalink": None,
"file_path": None,
"source": identifier,
"destination": None,
"error": "DESTINATION_FOLDER_NOT_FOR_DIRECTORIES",
}
return f"# Move Failed - Invalid Parameters\n\n{error_msg}"
async with get_project_client(project, workspace, context) as (client, active_project):
destination_target = destination_folder or destination_path
logger.info(
f"MCP tool call tool=move_note project={active_project.name} "
f"identifier={identifier} destination={destination_target} "
f"is_directory={str(is_directory).lower()}"
)
# Validate destination path to prevent path traversal attacks
project_path = active_project.home
if not validate_project_path(destination_path, project_path):
logger.warning(
"Attempted path traversal attack blocked",
destination_path=destination_path,
project=active_project.name,
)
if output_format == "json":
return {
"moved": False,
"title": None,
"permalink": None,
"file_path": None,
"source": identifier,
"destination": destination_path,
"error": "SECURITY_VALIDATION_ERROR",
}
return f"""# Move Failed - Security Validation Error
The destination path '{destination_path}' is not allowed - paths must stay within project boundaries.
## Valid path examples:
- `notes/my-file.md`
- `projects/2025/meeting-notes.md`
- `archive/old-notes.md`
## Try again with a safe path:
```
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
knowledge_client = KnowledgeClient(client, active_project.external_id)
try:
result = await knowledge_client.move_directory(identifier, destination_path)
if output_format == "json":
return {
"moved": result.failed_moves == 0,
"title": None,
"permalink": None,
"file_path": None,
"source": identifier,
"destination": destination_path,
"is_directory": True,
"total_files": result.total_files,
"successful_moves": result.successful_moves,
"failed_moves": result.failed_moves,
}
# Build success message for directory move
result_lines = [
"# Directory Moved Successfully",
"",
f"**Source:** `{identifier}`",
f"**Destination:** `{destination_path}`",
"",
"## Summary",
f"- Total files: {result.total_files}",
f"- Successfully moved: {result.successful_moves}",
f"- Failed: {result.failed_moves}",
]
if result.moved_files:
result_lines.extend(["", "## Moved Files"])
for file_path in result.moved_files[:10]: # Show first 10
result_lines.append(f"- `{file_path}`")
if len(result.moved_files) > 10:
result_lines.append(f"- ... and {len(result.moved_files) - 10} more")
if result.errors: # pragma: no cover
result_lines.extend(["", "## Errors"])
for error in result.errors[:5]: # Show first 5 errors
result_lines.append(f"- `{error.path}`: {error.error}")
if len(result.errors) > 5:
result_lines.append(f"- ... and {len(result.errors) - 5} more errors")
result_lines.extend(["", f"<!-- Project: {active_project.name} -->"])
logger.info(
f"Directory move completed: {identifier} -> {destination_path}, "
f"moved={result.successful_moves}, failed={result.failed_moves}"
)
return "\n".join(result_lines)
except Exception as e: # pragma: no cover
logger.error(
f"Directory move failed for '{identifier}' to '{destination_path}': {e}"
)
if output_format == "json":
return {
"moved": False,
"title": None,
"permalink": None,
"file_path": None,
"source": identifier,
"destination": destination_path,
"is_directory": True,
"error": str(e),
}
return f"""# Directory Move Failed
Error moving directory '{identifier}' to '{destination_path}': {str(e)}
## Troubleshooting:
1. **Verify the directory exists**: Use `list_directory("{identifier}")` to check
2. **Check for conflicts**: The destination may already contain files
3. **Try individual moves**: Move files one at a time if bulk move fails
## Alternative approach:
```
# List directory contents first
list_directory("{identifier}")
# Then move individual files
move_note("path/to/file.md", "{destination_path}/file.md")
```"""
# Check for potential cross-project move attempts (file moves only)
cross_project_error = await _detect_cross_project_move_attempt(
client, identifier, destination_path, active_project.name
)
if cross_project_error:
logger.info(f"Detected cross-project move attempt: {identifier} -> {destination_path}")
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 cross_project_error
# 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)
# 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
source_entity = None
async def _ensure_resolved_entity_id() -> str:
"""Resolve and cache the source entity ID for the duration of this move."""
nonlocal resolved_entity_id
if resolved_entity_id is None:
resolved_entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
return resolved_entity_id
try:
resolved_entity_id = await _ensure_resolved_entity_id()
source_entity = await knowledge_client.get_entity(resolved_entity_id)
if "." in source_entity.file_path:
source_ext = source_entity.file_path.split(".")[-1]
except ToolError as e:
# Trigger: strict=True resolve_entity raised because the entity was not found.
# Why: fail fast with a formatted error instead of silently falling through
# to extension defaults and failing later with a confusing message.
# Outcome: move_note returns a user-facing not-found error immediately.
logger.error(f"Move failed for '{identifier}' to '{destination_path}': {e}")
if output_format == "json":
return {
"moved": False,
"title": None,
"permalink": None,
"file_path": None,
"source": identifier,
"destination": destination_path,
"error": str(e),
}
return _format_move_error_response(str(e), identifier, destination_path)
except Exception as e:
# If we can't fetch source metadata (e.g. get_entity or file_path parsing fails),
# continue with extension defaults — the entity was at least resolved.
logger.debug(f"Could not fetch source entity for extension check: {e}")
# --- Resolve destination_folder into destination_path ---
# Trigger: caller passed destination_folder instead of destination_path
# Why: extract the original filename from the resolved entity so callers
# don't need a separate read_note round-trip
# Outcome: destination_path is set to folder/original-filename.ext
if destination_folder is not None:
if source_entity is None:
error_msg = (
f"Could not resolve source entity '{identifier}' to extract filename "
f"for destination_folder. Use destination_path with an explicit filename instead."
)
if output_format == "json":
return {
"moved": False,
"title": None,
"permalink": None,
"file_path": None,
"source": identifier,
"destination": None,
"error": "ENTITY_RESOLUTION_FAILED",
}
return f"# Move Failed - Entity Resolution Failed\n\n{error_msg}"
source_filename = Path(source_entity.file_path).name
# Normalize backslashes to forward slashes for Windows compatibility,
# then strip leading/trailing separators
folder = PureWindowsPath(destination_folder).as_posix().strip("/")
destination_path = f"{folder}/{source_filename}" if folder else source_filename
# Validate resolved path to prevent path traversal via destination_folder
if not validate_project_path(destination_path, project_path):
logger.warning(
"Attempted path traversal attack blocked via destination_folder",
destination_folder=destination_folder,
project=active_project.name,
)
if output_format == "json":
return {
"moved": False,
"title": None,
"permalink": None,
"file_path": None,
"source": identifier,
"destination": destination_path,
"error": "SECURITY_VALIDATION_ERROR",
}
return f"""# Move Failed - Security Validation Error
The destination folder '{destination_folder}' is not allowed - paths must stay within project boundaries.
## Valid folder examples:
- `notes`
- `projects/2025`
- `archive/old-notes`
## Try again with a safe folder:
```
move_note("{identifier}", destination_folder="notes")
```"""
# Validate that destination path includes a file extension
if "." not in destination_path or not destination_path.split(".")[-1]:
logger.warning(f"Move failed - no file extension provided: {destination_path}")
if output_format == "json":
return {
"moved": False,
"title": None,
"permalink": None,
"file_path": None,
"source": identifier,
"destination": destination_path,
"error": "FILE_EXTENSION_REQUIRED",
}
return dedent(f"""
# Move Failed - File Extension Required
The destination path '{destination_path}' must include a file extension (e.g., '.md').
## Valid examples:
- `notes/my-note.md`
- `projects/meeting-2025.txt`
- `archive/old-program.sh`
## Try again with extension:
```
move_note("{identifier}", "{destination_path}.{source_ext}")
```
All examples in Basic Memory expect file extensions to be explicitly provided.
""").strip()
# Validate extension consistency when source metadata is available.
if source_entity is None:
try:
resolved_entity_id = await _ensure_resolved_entity_id()
source_entity = await knowledge_client.get_entity(resolved_entity_id)
except Exception as e:
logger.debug(f"Could not fetch source entity for extension check: {e}")
if source_entity is not None:
source_ext = (
source_entity.file_path.split(".")[-1] if "." in source_entity.file_path else ""
)
dest_ext = destination_path.split(".")[-1] if "." in destination_path else ""
# Check if extensions match
if source_ext and dest_ext and source_ext.lower() != dest_ext.lower():
logger.warning(
f"Move failed - file extension mismatch: source={source_ext}, dest={dest_ext}"
)
if output_format == "json":
return {
"moved": False,
"title": source_entity.title,
"permalink": source_entity.permalink,
"file_path": source_entity.file_path,
"source": identifier,
"destination": destination_path,
"error": "FILE_EXTENSION_MISMATCH",
}
return dedent(f"""
# Move Failed - File Extension Mismatch
The destination file extension '.{dest_ext}' does not match the source file extension '.{source_ext}'.
To preserve file type consistency, the destination must have the same extension as the source.
## Source file:
- Path: `{source_entity.file_path}`
- Extension: `.{source_ext}`
## Try again with matching extension:
```
move_note("{identifier}", "{destination_path.rsplit(".", 1)[0]}.{source_ext}")
```
""").strip()
try:
# Resolve identifier only if earlier checks could not.
resolved_entity_id = await _ensure_resolved_entity_id()
# Call the move API using KnowledgeClient
result = await knowledge_client.move_entity(resolved_entity_id, destination_path)
if output_format == "json":
return {
"moved": True,
"title": result.title,
"permalink": result.permalink,
"file_path": result.file_path,
"source": identifier,
"destination": destination_path,
}
# Build success message
result_lines = [
"✅ Note moved successfully",
"",
f"📁 **{identifier}** → **{result.file_path}**",
f"🔗 Permalink: {result.permalink}",
"📊 Database and search index updated",
"",
f"<!-- Project: {active_project.name} -->",
]
# Log the operation
logger.info(
f"MCP tool response: tool=move_note project={active_project.name} "
f"source={identifier} destination={result.file_path} permalink={result.permalink}"
)
return "\n".join(result_lines)
except Exception as e:
logger.error(f"Move failed for '{identifier}' to '{destination_path}': {e}")
if output_format == "json":
return {
"moved": False,
"title": None,
"permalink": None,
"file_path": None,
"source": identifier,
"destination": destination_path,
"error": str(e),
}
# Return formatted error message for better user experience
return _format_move_error_response(str(e), identifier, destination_path)