mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
aa635b8a8b
MCP clients may send explicit `null` for unused optional fields. `expected_replacements: int = 1` caused FastMCP's JSON Schema validation to reject null before the function body ran. Changed to `Optional[int] = None` with an effective default resolved inside the function body. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
347 lines
16 KiB
Python
347 lines
16 KiB
Python
"""Edit note tool for Basic Memory MCP server."""
|
|
|
|
from typing import Optional, Literal
|
|
|
|
from loguru import logger
|
|
from fastmcp import Context
|
|
|
|
from basic_memory.mcp.project_context import get_project_client, add_project_metadata
|
|
from basic_memory.mcp.server import mcp
|
|
|
|
|
|
def _format_error_response(
|
|
error_message: str,
|
|
operation: str,
|
|
identifier: str,
|
|
find_text: Optional[str] = None,
|
|
expected_replacements: int = 1,
|
|
project: Optional[str] = None,
|
|
) -> str:
|
|
"""Format helpful error responses for edit_note failures that guide the AI to retry successfully."""
|
|
|
|
# Entity not found errors
|
|
if "Entity not found" in error_message or "entity not found" in error_message.lower():
|
|
return f"""# Edit Failed - Note Not Found
|
|
|
|
The note with identifier '{identifier}' could not be found. Edit operations require an exact match (no fuzzy matching).
|
|
|
|
## Suggestions to try:
|
|
1. **Search for the note first**: Use `search_notes("{project or "project-name"}", "{identifier.split("/")[-1]}")` to find similar notes with exact identifiers
|
|
2. **Try different exact identifier formats**:
|
|
- If you used a permalink like "folder/note-title", try the exact title: "{identifier.split("/")[-1].replace("-", " ").title()}"
|
|
- If you used a title, try the exact permalink format: "{identifier.lower().replace(" ", "-")}"
|
|
- Use `read_note("{project or "project-name"}", "{identifier}")` first to verify the note exists and get the exact identifier
|
|
|
|
## Alternative approach:
|
|
Use `write_note("{project or "project-name"}", "title", "content", "folder")` to create the note first, then edit it."""
|
|
|
|
# Find/replace specific errors
|
|
if operation == "find_replace":
|
|
if "Text to replace not found" in error_message:
|
|
return f"""# Edit Failed - Text Not Found
|
|
|
|
The text '{find_text}' was not found in the note '{identifier}'.
|
|
|
|
## Suggestions to try:
|
|
1. **Read the note first**: Use `read_note("{project or "project-name"}", "{identifier}")` to see the current content
|
|
2. **Check for exact matches**: The search is case-sensitive and must match exactly
|
|
3. **Try a broader search**: Search for just part of the text you want to replace
|
|
4. **Use expected_replacements=0**: If you want to verify the text doesn't exist
|
|
|
|
## Alternative approaches:
|
|
- Use `append` or `prepend` to add new content instead
|
|
- Use `replace_section` if you're trying to update a specific section"""
|
|
|
|
if "Expected" in error_message and "occurrences" in error_message:
|
|
# Extract the actual count from error message if possible
|
|
import re
|
|
|
|
match = re.search(r"found (\d+)", error_message)
|
|
actual_count = match.group(1) if match else "a different number of"
|
|
|
|
return f"""# Edit Failed - Wrong Replacement Count
|
|
|
|
Expected {expected_replacements} occurrences of '{find_text}' but found {actual_count}.
|
|
|
|
## How to fix:
|
|
1. **Read the note first**: Use `read_note("{project or "project-name"}", "{identifier}")` to see how many times '{find_text}' appears
|
|
2. **Update expected_replacements**: Set expected_replacements={actual_count} in your edit_note call
|
|
3. **Be more specific**: If you only want to replace some occurrences, make your find_text more specific
|
|
|
|
## Example:
|
|
```
|
|
edit_note("{project or "project-name"}", "{identifier}", "find_replace", "new_text", find_text="{find_text}", expected_replacements={actual_count})
|
|
```"""
|
|
|
|
# Section replacement errors
|
|
if operation == "replace_section" and "Multiple sections" in error_message:
|
|
return f"""# Edit Failed - Duplicate Section Headers
|
|
|
|
Multiple sections found with the same header in note '{identifier}'.
|
|
|
|
## How to fix:
|
|
1. **Read the note first**: Use `read_note("{project or "project-name"}", "{identifier}")` to see the document structure
|
|
2. **Make headers unique**: Add more specific text to distinguish sections
|
|
3. **Use append instead**: Add content at the end rather than replacing a specific section
|
|
|
|
## Alternative approach:
|
|
Use `find_replace` to update specific text within the duplicate sections."""
|
|
|
|
# Generic server/request errors
|
|
if (
|
|
"Invalid request" in error_message or "malformed" in error_message.lower()
|
|
): # pragma: no cover
|
|
return f"""# Edit Failed - Request Error
|
|
|
|
There was a problem with the edit request to note '{identifier}': {error_message}.
|
|
|
|
## Common causes and fixes:
|
|
1. **Note doesn't exist**: Use `search_notes("{project or "project-name"}", "query")` or `read_note("{project or "project-name"}", "{identifier}")` to verify the note exists
|
|
2. **Invalid identifier format**: Try different identifier formats (title vs permalink)
|
|
3. **Empty or invalid content**: Check that your content is properly formatted
|
|
4. **Server error**: Try the operation again, or use `read_note()` first to verify the note state
|
|
|
|
## Troubleshooting steps:
|
|
1. Verify the note exists: `read_note("{project or "project-name"}", "{identifier}")`
|
|
2. If not found, search for it: `search_notes("{project or "project-name"}", "{identifier.split("/")[-1]}")`
|
|
3. Try again with the correct identifier from the search results"""
|
|
|
|
# Fallback for other errors
|
|
return f"""# Edit Failed
|
|
|
|
Error editing note '{identifier}': {error_message}
|
|
|
|
## General troubleshooting:
|
|
1. **Verify the note exists**: Use `read_note("{project or "project-name"}", "{identifier}")` to check
|
|
2. **Check your parameters**: Ensure all required parameters are provided correctly
|
|
3. **Read the note content first**: Use `read_note("{project or "project-name"}", "{identifier}")` to understand the current structure
|
|
4. **Try a simpler operation**: Start with `append` if other operations fail
|
|
|
|
## Need help?
|
|
- Use `search_notes("{project or "project-name"}", "query")` to find notes
|
|
- Use `read_note("{project or "project-name"}", "identifier")` to examine content before editing
|
|
- Check that identifiers, section headers, and find_text match exactly"""
|
|
|
|
|
|
@mcp.tool(
|
|
description="Edit an existing markdown note using various operations like append, prepend, find_replace, or replace_section.",
|
|
annotations={"destructiveHint": False, "openWorldHint": False},
|
|
)
|
|
async def edit_note(
|
|
identifier: str,
|
|
operation: str,
|
|
content: str,
|
|
project: Optional[str] = None,
|
|
workspace: Optional[str] = None,
|
|
section: Optional[str] = None,
|
|
find_text: Optional[str] = None,
|
|
expected_replacements: Optional[int] = None,
|
|
output_format: Literal["text", "json"] = "text",
|
|
context: Context | None = None,
|
|
) -> str | dict:
|
|
"""Edit an existing markdown note in the knowledge base.
|
|
|
|
Makes targeted changes to existing notes without rewriting the entire content.
|
|
|
|
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: The exact title, permalink, or memory:// URL of the note to edit.
|
|
Must be an exact match - fuzzy matching is not supported for edit operations.
|
|
Use search_notes() or read_note() first to find the correct identifier if uncertain.
|
|
operation: The editing operation to perform:
|
|
- "append": Add content to the end of the note
|
|
- "prepend": Add content to the beginning of the note
|
|
- "find_replace": Replace occurrences of find_text with content
|
|
- "replace_section": Replace content under a specific markdown header
|
|
content: The content to add or use for replacement
|
|
project: Project name to edit in. Optional - server will resolve using hierarchy.
|
|
If unknown, use list_memory_projects() to discover available projects.
|
|
section: For replace_section operation - the markdown header to replace content under (e.g., "## Notes", "### Implementation")
|
|
find_text: For find_replace operation - the text to find and replace
|
|
expected_replacements: For find_replace operation - the expected number of replacements (validation will fail if actual doesn't match)
|
|
output_format: "text" returns the existing markdown summary. "json" returns
|
|
machine-readable edit metadata.
|
|
context: Optional FastMCP context for performance caching.
|
|
|
|
Returns:
|
|
A markdown formatted summary of the edit operation and resulting semantic content,
|
|
including operation details, file path, observations, relations, and project metadata.
|
|
|
|
Examples:
|
|
# Add new content to end of note
|
|
edit_note("my-project", "project-planning", "append", "\\n## New Requirements\\n- Feature X\\n- Feature Y")
|
|
|
|
# Add timestamp at beginning (frontmatter-aware)
|
|
edit_note("work-docs", "meeting-notes", "prepend", "## 2025-05-25 Update\\n- Progress update...\\n\\n")
|
|
|
|
# Update version number (single occurrence)
|
|
edit_note("api-project", "config-spec", "find_replace", "v0.13.0", find_text="v0.12.0")
|
|
|
|
# Update version in multiple places with validation
|
|
edit_note("docs-project", "api-docs", "find_replace", "v2.1.0", find_text="v2.0.0", expected_replacements=3)
|
|
|
|
# Replace text that appears multiple times - validate count first
|
|
edit_note("team-docs", "docs/guide", "find_replace", "new-api", find_text="old-api", expected_replacements=5)
|
|
|
|
# Replace implementation section
|
|
edit_note("specs", "api-spec", "replace_section", "New implementation approach...\\n", section="## Implementation")
|
|
|
|
# Replace subsection with more specific header
|
|
edit_note("docs", "docs/setup", "replace_section", "Updated install steps\\n", section="### Installation")
|
|
|
|
# Using different identifier formats (must be exact matches)
|
|
edit_note("work-project", "Meeting Notes", "append", "\\n- Follow up on action items") # exact title
|
|
edit_note("work-project", "docs/meeting-notes", "append", "\\n- Follow up tasks") # exact permalink
|
|
|
|
# If uncertain about identifier, search first:
|
|
# search_notes("work-project", "meeting") # Find available notes
|
|
# edit_note("work-project", "docs/meeting-notes-2025", "append", "content") # Use exact result
|
|
|
|
# Add new section to document
|
|
edit_note("planning", "project-plan", "replace_section", "TBD - needs research\\n", section="## Future Work")
|
|
|
|
# Update status across document (expecting exactly 2 occurrences)
|
|
edit_note("reports", "status-report", "find_replace", "In Progress", find_text="Not Started", expected_replacements=2)
|
|
|
|
Raises:
|
|
HTTPError: If project doesn't exist or is inaccessible
|
|
ValueError: If operation is invalid or required parameters are missing
|
|
SecurityError: If identifier attempts path traversal
|
|
|
|
Note:
|
|
Edit operations require exact identifier matches. If unsure, use read_note() or
|
|
search_notes() first to find the correct identifier. The tool provides detailed
|
|
error messages with suggestions if operations fail.
|
|
"""
|
|
# 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
|
|
|
|
async with get_project_client(project, workspace, context) as (client, active_project):
|
|
logger.info("MCP tool call", tool="edit_note", identifier=identifier, operation=operation)
|
|
|
|
# Validate operation
|
|
valid_operations = ["append", "prepend", "find_replace", "replace_section"]
|
|
if operation not in valid_operations:
|
|
raise ValueError(
|
|
f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}"
|
|
)
|
|
|
|
# Validate required parameters for specific operations
|
|
if operation == "find_replace" and not find_text:
|
|
raise ValueError("find_text parameter is required for find_replace operation")
|
|
if operation == "replace_section" and not section:
|
|
raise ValueError("section parameter is required for replace_section operation")
|
|
|
|
# Use the PATCH endpoint to edit the entity
|
|
try:
|
|
# 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 identifier to entity ID
|
|
entity_id = await knowledge_client.resolve_entity(identifier)
|
|
|
|
# Prepare the edit request data
|
|
edit_data = {
|
|
"operation": operation,
|
|
"content": content,
|
|
}
|
|
|
|
# Add optional parameters
|
|
if section:
|
|
edit_data["section"] = section
|
|
if find_text:
|
|
edit_data["find_text"] = find_text
|
|
if effective_replacements != 1: # Only send if different from default
|
|
edit_data["expected_replacements"] = str(effective_replacements)
|
|
|
|
# Call the PATCH endpoint
|
|
result = await knowledge_client.patch_entity(entity_id, edit_data, fast=False)
|
|
|
|
# Format summary
|
|
summary = [
|
|
f"# Edited note ({operation})",
|
|
f"project: {active_project.name}",
|
|
f"file_path: {result.file_path}",
|
|
f"permalink: {result.permalink}",
|
|
f"checksum: {result.checksum[:8] if result.checksum else 'unknown'}",
|
|
]
|
|
|
|
# Add operation-specific details
|
|
if operation == "append":
|
|
lines_added = len(content.split("\n"))
|
|
summary.append(f"operation: Added {lines_added} lines to end of note")
|
|
elif operation == "prepend":
|
|
lines_added = len(content.split("\n"))
|
|
summary.append(f"operation: Added {lines_added} lines to beginning of note")
|
|
elif operation == "find_replace":
|
|
# For find_replace, we can't easily count replacements from here
|
|
# since we don't have the original content, but the server handled it
|
|
summary.append("operation: Find and replace operation completed")
|
|
elif operation == "replace_section":
|
|
summary.append(f"operation: Replaced content under section '{section}'")
|
|
|
|
# Count observations by category (reuse logic from write_note)
|
|
categories = {}
|
|
if result.observations:
|
|
for obs in result.observations:
|
|
categories[obs.category] = categories.get(obs.category, 0) + 1
|
|
|
|
summary.append("\n## Observations")
|
|
for category, count in sorted(categories.items()):
|
|
summary.append(f"- {category}: {count}")
|
|
|
|
# Count resolved/unresolved relations
|
|
unresolved = 0
|
|
resolved = 0
|
|
if result.relations:
|
|
unresolved = sum(1 for r in result.relations if not r.to_id)
|
|
resolved = len(result.relations) - unresolved
|
|
|
|
summary.append("\n## Relations")
|
|
summary.append(f"- Resolved: {resolved}")
|
|
if unresolved:
|
|
summary.append(f"- Unresolved: {unresolved}")
|
|
|
|
logger.info(
|
|
"MCP tool response",
|
|
tool="edit_note",
|
|
operation=operation,
|
|
project=active_project.name,
|
|
permalink=result.permalink,
|
|
observations_count=len(result.observations),
|
|
relations_count=len(result.relations),
|
|
)
|
|
|
|
if output_format == "json":
|
|
return {
|
|
"title": result.title,
|
|
"permalink": result.permalink,
|
|
"file_path": result.file_path,
|
|
"checksum": result.checksum,
|
|
"operation": operation,
|
|
}
|
|
|
|
summary_result = "\n".join(summary)
|
|
return add_project_metadata(summary_result, active_project.name)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error editing note: {e}")
|
|
if output_format == "json":
|
|
return {
|
|
"title": None,
|
|
"permalink": None,
|
|
"file_path": None,
|
|
"checksum": None,
|
|
"operation": operation,
|
|
"error": str(e),
|
|
}
|
|
return _format_error_response(
|
|
str(e), operation, identifier, find_text, effective_replacements, active_project.name
|
|
)
|