mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
4791e19685
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
860 lines
35 KiB
Python
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)
|