from textwrap import dedent from typing import Annotated, Optional, Literal from loguru import logger from fastmcp import Context from mcp.server.fastmcp.exceptions import ToolError from pydantic import AliasChoices, Field from basic_memory.config import ConfigManager from basic_memory.mcp.project_context import ( detect_project_from_memory_url_prefix, get_project_client, resolve_project_and_path, ) from basic_memory.mcp.server import mcp from basic_memory.schemas.project_info import ProjectItem from basic_memory.utils import generate_permalink, normalize_project_reference from basic_memory.workspace_context import current_workspace_permalink_context def _format_delete_error_response(project: str, error_message: str, identifier: str) -> str: """Format helpful error responses for delete failures that guide users to successful deletions.""" # 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""" # Delete Failed - Note Not Found The note '{identifier}' could not be found for deletion in {project}. ## This might mean: 1. **Already deleted**: The note may have been deleted previously 2. **Wrong identifier**: The identifier format might be incorrect 3. **Different project**: The note might be in a different project ## How to verify: 1. **Search for the note**: Use `search_notes("{project}", "{search_term}")` to find it 2. **Try different formats**: - If you used a permalink like "folder/note-title", try just the title: "{title_format}" - If you used a title, try the permalink format: "{permalink_format}" 3. **Check if already deleted**: Use `list_directory("/")` to see what notes exist 4. **List notes in project**: Use `list_directory("/")` to see what notes exist in the current project ## If the note actually exists: ``` # First, find the correct identifier: search_notes("{project}", "{identifier}") # Then delete using the correct identifier: delete_note("{project}", "correct-identifier-from-search") ``` ## If you want to delete multiple similar notes: Use search to find all related notes and delete them one by one. """).strip() # Permission/access errors if ( "permission" in error_message.lower() or "access" in error_message.lower() or "forbidden" in error_message.lower() ): return f"""# Delete Failed - Permission Error You don't have permission to delete '{identifier}': {error_message} ## How to resolve: 1. **Check permissions**: Verify you have delete/write access to this project 2. **File locks**: The note might be open in another application 3. **Project access**: Ensure you're in the correct project with proper permissions ## Alternative actions: - List available projects: `list_memory_projects()` - Specify the correct project: `delete_note("{identifier}", project="project-name")` - Verify note exists first: `read_note("{identifier}", project="project-name")` ## If you have read-only access: Ask someone with write access to delete the note.""" # Server/filesystem errors if ( "server error" in error_message.lower() or "filesystem" in error_message.lower() or "disk" in error_message.lower() ): return f"""# Delete Failed - System Error A system error occurred while deleting '{identifier}': {error_message} ## Immediate steps: 1. **Try again**: The error might be temporary 2. **Check file status**: Verify the file isn't locked or in use 3. **Check disk space**: Ensure the system has adequate storage ## Troubleshooting: - Verify note exists: `read_note("{project}","{identifier}")` - Try again in a few moments ## If problem persists: Send a message to support@basicmachines.co - there may be a filesystem or database issue.""" # Database/sync errors if "database" in error_message.lower() or "sync" in error_message.lower(): return f"""# Delete Failed - Database Error A database error occurred while deleting '{identifier}': {error_message} ## This usually means: 1. **Sync conflict**: The file system and database are out of sync 2. **Database lock**: Another operation is accessing the database 3. **Corrupted entry**: The database entry might be corrupted ## Steps to resolve: 1. **Try again**: Wait a moment and retry the deletion 2. **Check note status**: `read_note("{project}","{identifier}")` to see current state 3. **Manual verification**: Use `list_directory()` to see if file still exists ## If the note appears gone but database shows it exists: Send a message to support@basicmachines.co - a manual database cleanup may be needed.""" # Generic fallback return f"""# Delete Failed Error deleting note '{identifier}': {error_message} ## General troubleshooting: 1. **Verify the note exists**: `read_note("{project}", "{identifier}")` or `search_notes("{project}", "{identifier}")` 2. **Check permissions**: Ensure you can edit/delete files in this project 3. **Try again**: The error might be temporary 4. **Check project**: Make sure you're in the correct project ## Step-by-step approach: ``` # 1. Confirm note exists and get correct identifier search_notes("{project}", "{identifier}") # 2. Read the note to verify access read_note("{project}", "correct-identifier-from-search") # 3. Try deletion with correct identifier delete_note("{project}", "correct-identifier-from-search") ``` ## Alternative approaches: - Check what notes exist: `list_directory("{project}", "/")` ## Need help? If the note should be deleted but the operation keeps failing, send a message to support@basicmemory.com.""" def _directory_path_for_delete( target_identifier: str, active_project: ProjectItem, *, include_project_prefix: bool, ) -> str: """Return the project-relative directory path expected by the delete API.""" directory = normalize_project_reference(target_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 @mcp.tool( description="Delete a note or directory by title, permalink, or path", annotations={"destructiveHint": True, "openWorldHint": False}, ) async def delete_note( identifier: str, is_directory: Annotated[ bool, Field(default=False, validation_alias=AliasChoices("is_directory", "is_dir")), ] = False, project: Optional[str] = None, project_id: Optional[str] = None, output_format: Literal["text", "json"] = "text", context: Context | None = None, ) -> bool | str | dict: """Delete a note or directory from the knowledge base. Permanently removes a note or directory from the specified project. For single notes, they are identified by title or permalink. For directories, use is_directory=True and provide the directory path. If the note/directory doesn't exist, the operation returns False without error. If deletion fails, helpful error messages are provided. Project Resolution: Server resolves projects in this order: Single Project Mode → project parameter → default project. If project unknown, use list_memory_projects() or recent_activity() first. Args: identifier: For files: note title or permalink to delete. For directories: the directory path (e.g., "docs", "projects/2025"). Can be a title like "Meeting Notes" or permalink like "notes/meeting-notes" is_directory: If True, deletes an entire directory and all its contents. When True, identifier should be a directory path (without file extensions). Defaults to False. project: Project name to delete from. Optional - server will resolve using hierarchy. If unknown, use list_memory_projects() to discover available projects. 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(). output_format: "text" preserves existing behavior (bool/string). "json" returns machine-readable deletion metadata. context: Optional FastMCP context for performance caching. Returns: True if note was successfully deleted, False if note was not found. For directories, returns a formatted summary of deleted files. On errors, returns a formatted string with helpful troubleshooting guidance. Examples: # Delete by title delete_note("Meeting Notes: Project Planning") # Delete by permalink delete_note("notes/project-planning") # Delete with explicit project delete_note("experiments/ml-model-results", project="research") # Delete entire directory delete_note("docs", is_directory=True) # Delete nested directory delete_note("projects/2024", is_directory=True) # Common usage pattern if delete_note("old-draft"): print("Note deleted successfully") else: print("Note not found or already deleted") Raises: HTTPError: If project doesn't exist or is inaccessible SecurityError: If identifier attempts path traversal Warning: This operation is permanent and cannot be undone. The note/directory files will be removed from the filesystem and all references will be lost. Note: If the note is not found, this function provides helpful error messages with suggestions for finding the correct identifier, including search commands and alternative formats to try. """ # 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, ) if detected: project = detected async with get_project_client(project, context=context, project_id=project_id) as ( client, active_project, ): logger.debug( f"Deleting {'directory' if is_directory else 'note'}: {identifier} in project: {active_project.name}" ) # 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) _, target_identifier, is_memory_url = await resolve_project_and_path( client, identifier, active_project.name, context, ) # Handle directory deletes if is_directory: try: # Trigger: directory input was routed from a memory:// URL. # Why: resolve_project_and_path returns canonical permalinks, while # delete_directory filters by project-relative file_path prefixes. # Outcome: strip only the route prefix before calling the delete API. directory_identifier = ( _directory_path_for_delete( target_identifier, active_project, include_project_prefix=ConfigManager().config.permalinks_include_project, ) if is_memory_url else target_identifier ) result = await knowledge_client.delete_directory(directory_identifier) if output_format == "json": return { "deleted": result.failed_deletes == 0, "is_directory": True, "identifier": identifier, "total_files": result.total_files, "successful_deletes": result.successful_deletes, "failed_deletes": result.failed_deletes, } # Build success message for directory delete result_lines = [ "# Directory Deleted Successfully", "", f"**Directory:** `{identifier}`", "", "## Summary", f"- Total files: {result.total_files}", f"- Successfully deleted: {result.successful_deletes}", f"- Failed: {result.failed_deletes}", ] if result.deleted_files: result_lines.extend(["", "## Deleted Files"]) for file_path in result.deleted_files[:10]: # Show first 10 result_lines.append(f"- `{file_path}`") if len(result.deleted_files) > 10: result_lines.append(f"- ... and {len(result.deleted_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""]) logger.info( f"Directory delete completed: {identifier}, " f"deleted={result.successful_deletes}, failed={result.failed_deletes}" ) return "\n".join(result_lines) except Exception as e: # pragma: no cover logger.error(f"Directory delete failed for '{identifier}': {e}") if output_format == "json": return { "deleted": False, "is_directory": True, "identifier": identifier, "total_files": 0, "successful_deletes": 0, "failed_deletes": 0, "error": str(e), } return f"""# Directory Delete Failed Error deleting directory '{identifier}': {str(e)} ## Troubleshooting: 1. **Verify the directory exists**: Use `list_directory("{identifier}")` to check 2. **Check for permission issues**: Ensure you have delete access to the project 3. **Try individual deletes**: Delete files one at a time if bulk delete fails ## Alternative approach: ``` # List directory contents first list_directory("{identifier}") # Then delete individual files delete_note("path/to/file.md") ```""" # Handle single note deletes note_title = None note_permalink = None note_file_path = None try: # Resolve identifier to entity ID entity_id = await knowledge_client.resolve_entity(target_identifier, strict=True) if output_format == "json": entity = await knowledge_client.get_entity(entity_id) note_title = entity.title note_permalink = entity.permalink note_file_path = entity.file_path except ToolError as e: # If entity not found, return False (note doesn't exist) if "Entity not found" in str(e) or "not found" in str(e).lower(): logger.warning(f"Note not found for deletion: {identifier}") if output_format == "json": return { "deleted": False, "title": None, "permalink": None, "file_path": None, } return False # For other resolution errors, return formatted error message logger.error( # pragma: no cover f"Delete failed for '{identifier}': {e}, project: {active_project.name}" ) if output_format == "json": return { "deleted": False, "title": None, "permalink": None, "file_path": None, "error": str(e), } return _format_delete_error_response( # pragma: no cover active_project.name, str(e), identifier ) try: # Call the DELETE endpoint result = await knowledge_client.delete_entity(entity_id) if result.deleted: logger.info( f"Successfully deleted note: {identifier} in project: {active_project.name}" ) if output_format == "json": return { "deleted": True, "title": note_title, "permalink": note_permalink, "file_path": note_file_path, } return True else: logger.warning( # pragma: no cover f"Delete operation completed but note was not deleted: {identifier}" ) if output_format == "json": return { "deleted": False, "title": note_title, "permalink": note_permalink, "file_path": note_file_path, } return False # pragma: no cover except Exception as e: # pragma: no cover logger.error(f"Delete failed for '{identifier}': {e}, project: {active_project.name}") if output_format == "json": return { "deleted": False, "title": note_title, "permalink": note_permalink, "file_path": note_file_path, "error": str(e), } # Return formatted error message for better user experience return _format_delete_error_response(active_project.name, str(e), identifier)