Files
basicmachines-co-basic-memory/src/basic_memory/mcp/tools/edit_note.py
T
Drew Cain c9770375e9 feat(mcp): add title and tags annotations to all MCP tool decorators (issue #826 phase 1)
Adds human-readable `title` and categorization `tags` to all ~24 @mcp.tool
decorators across the MCP tools package. FastMCP 3.3.1 (pinned in pyproject.toml)
supports both fields natively. Tags used: notes, search, projects, cloud, schema,
navigation, canvas, ui.

Extends test_tool_contracts.py with an async test that asserts every registered
tool has a non-empty title and at least one tag to prevent future regressions.

output_schema is explicitly deferred as a follow-up (phase 2): it requires
per-tool design decisions about which tools reliably return structured JSON and
how to handle tools that return str|dict depending on output_format.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-06-11 15:21:25 -05:00

738 lines
36 KiB
Python

"""Edit note tool for Basic Memory MCP server."""
from typing import TYPE_CHECKING, Annotated, Optional, Literal
import logfire
from httpx import HTTPStatusError
from loguru import logger
from fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
from pydantic import AliasChoices, Field
if TYPE_CHECKING: # pragma: no cover
from basic_memory.mcp.clients import KnowledgeClient
from basic_memory.config import ConfigManager
from basic_memory.ignore_utils import IGNORED_PATH_REJECTION_DETAIL
from basic_memory.mcp.project_context import (
_workspace_identifier_discovery_available,
detect_project_from_memory_url_prefix,
get_project_client,
add_project_metadata,
resolve_project_and_path,
)
from basic_memory.mcp.server import mcp
from basic_memory.mcp.tools.utils import _extract_response_data, _response_detail_text
from basic_memory.schemas.base import Entity
from basic_memory.schemas.response import EntityResponse
from basic_memory.services.link_resolver import (
detect_project_from_workspace_identifier_prefix,
is_workspace_qualified_plain_identifier,
)
from basic_memory.utils import normalize_project_reference, validate_project_path
def _parse_identifier_to_title_and_directory(identifier: str) -> tuple[str, str]:
"""Parse an identifier into (title, directory) for creating a new note.
Strips memory:// prefix if present, then splits on the last '/' to
separate the directory path from the note title.
Examples:
"conversations/my-note" → ("my-note", "conversations")
"my-note" → ("my-note", "")
"a/b/c/my-note" → ("my-note", "a/b/c")
"memory://a/b/note" → ("note", "a/b")
"""
cleaned = identifier
if cleaned.startswith("memory://"):
cleaned = cleaned[len("memory://") :]
if "/" in cleaned:
last_slash = cleaned.rfind("/")
directory = cleaned[:last_slash]
title = cleaned[last_slash + 1 :]
else:
directory = ""
title = cleaned
return title, directory
# Suffixes mimetypes maps to text/markdown (extension matching is case-insensitive),
# mirroring FileService.is_markdown which gates the sync-file endpoint server-side.
_MARKDOWN_SUFFIXES = (".md", ".markdown")
async def _resolve_after_disk_recovery(
knowledge_client: "KnowledgeClient",
identifier: str,
) -> Optional[str]:
"""Recover from a resolution miss when the note exists on disk but is not indexed.
Trigger: identifier resolution failed with "not found", but the identifier may map
to a markdown file written directly to disk before the watcher indexed it (#581).
Why: editing an on-disk note should not require a manual full sync or watcher restart.
Outcome: the single file is indexed server-side and resolution is retried exactly
once. Returns None when the identifier does not map to an indexable file on
disk, so the caller keeps its existing not-found handling.
"""
# Try the identifier as-is first so existing .markdown/.MD files are found; only
# fall back to appending markdown suffixes (".md" first, then ".markdown") when
# the identifier does not already carry one, so 'notes/foo.markdown' never becomes
# 'notes/foo.markdown.md' and a stem identifier still reaches 'notes/foo.markdown'.
candidates = [identifier]
if not identifier.lower().endswith(_MARKDOWN_SUFFIXES):
candidates.extend(f"{identifier}{suffix}" for suffix in _MARKDOWN_SUFFIXES)
for candidate in candidates:
try:
synced = await knowledge_client.sync_file(candidate)
except ToolError as sync_error:
# Trigger: the sync-file request failed
# Why: 400/404 are the expected "nothing to recover" rejections (missing
# file, traversal, non-markdown) — except the ignored-path 400, which
# means the file exists on disk but the ignore rules forbid indexing
# it, so falling through to auto-create would silently shadow the
# file. Anything else — auth, server, transport-level failures — is a
# real error that must not be masked as a not-found miss.
# Outcome: ignored-path rejections raise a clear ToolError; other expected
# rejections try the next candidate or fall through to the caller's
# existing not-found behavior; unexpected failures propagate.
cause = sync_error.__cause__
candidate_rejected = isinstance(
cause, HTTPStatusError
) and cause.response.status_code in (400, 404)
if not candidate_rejected:
raise
detail = _response_detail_text(_extract_response_data(cause.response)) or ""
if IGNORED_PATH_REJECTION_DETAIL in detail:
raise ToolError(
f"Note file '{candidate}' exists on disk but {IGNORED_PATH_REJECTION_DETAIL} "
"and will not be edited"
) from sync_error
logger.debug(f"edit_note disk recovery skipped for '{candidate}': {sync_error}")
continue
# Trigger: sync-file succeeded and returned the indexed entity.
# Why: the server may have canonicalized the path casing (notes/Disk-Note ->
# notes/disk-note.md), so strictly re-resolving the raw identifier can
# still miss the entity we just indexed.
# Outcome: use the entity identity from the sync-file response directly; only
# fall back to a strict re-resolve when an older server omits external_id,
# and let that re-resolve fail loudly instead of guessing.
if synced.external_id:
logger.info(
f"edit_note indexed unindexed file '{candidate}' as entity {synced.external_id}"
)
return synced.external_id
logger.info(f"edit_note indexed unindexed file '{candidate}'; retrying resolution")
return await knowledge_client.resolve_entity(identifier, strict=True)
return None
def _compose_workspace_project_route(
*,
workspace: Optional[str],
project: Optional[str],
project_id: Optional[str],
) -> Optional[str]:
"""Return the explicit project route requested by workspace/project args."""
if workspace is None:
return project
cleaned_workspace = workspace.strip().strip("/")
if not cleaned_workspace:
raise ValueError("workspace must not be empty when provided")
if "/" in cleaned_workspace:
raise ValueError("workspace must be a single workspace slug, name, or tenant_id")
if project_id is not None:
raise ValueError("workspace cannot be combined with project_id; use project_id alone")
if project is None or not project.strip().strip("/"):
raise ValueError("workspace requires an explicit project argument")
cleaned_project = project.strip().strip("/")
if "/" in cleaned_project:
raise ValueError(
"Use either workspace='workspace' with project='project', "
"or project='workspace/project', not both"
)
return f"{cleaned_workspace}/{cleaned_project}"
def _format_ambiguous_workspace_identifier_response(
*,
identifier: str,
detected_project: str,
) -> str:
"""Format the safe-stop response for ambiguous plain write identifiers."""
cleaned_identifier = identifier.strip()
normalized_identifier = normalize_project_reference(cleaned_identifier).strip("/")
workspace_hint, project_hint, note_identifier = normalized_identifier.split("/", 2)
return f"""# Edit Failed - Ambiguous Identifier
`{cleaned_identifier}` could refer to a local note path in the active project, or to a note in `{detected_project}`.
Because edit_note changes content, Basic Memory will not infer a workspace route from a plain path.
Retry with one of these explicit routes:
- `edit_note(identifier="{note_identifier}", project="{detected_project}", operation=..., content=...)`
- `edit_note(identifier="{note_identifier}", workspace="{workspace_hint}", project="{project_hint}", operation=..., content=...)`
- `edit_note(identifier="memory://{normalized_identifier}", operation=..., content=...)`
- `edit_note(identifier="{note_identifier}", project_id="<project external_id>", operation=..., content=...)`"""
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 — only reachable for find_replace/replace_section
# because append/prepend auto-create the note when it doesn't exist
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. The `find_replace` and `replace_section` operations require an existing note with content to modify.
**Tip:** `append` and `prepend` operations automatically create the note if it doesn't exist.
## Suggestions to try:
1. **Use append/prepend instead**: These operations will create the note automatically if it doesn't exist
2. **Search for the note first**: Use `search_notes("{project or "project-name"}", "{identifier.split("/")[-1]}")` to find similar notes with exact identifiers
3. **File exists on disk but is not indexed yet?**: edit_note indexes the file automatically when the identifier matches its path (e.g. 'folder/note' for 'folder/note.md'). If your identifier is a title or differs from the file path, run a sync (`basic-memory sync`) or wait for the file watcher, then retry
4. **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(
title="Edit Note",
description="Edit an existing markdown note using various operations like append, prepend, find_replace, replace_section, insert_before_section, or insert_after_section.",
tags={"notes"},
annotations={"destructiveHint": False, "openWorldHint": False},
)
async def edit_note(
identifier: str,
operation: str,
# Accept common replacement-content aliases. Models trained on diff/patch
# APIs reach for new_content/replacement/replace_with on first try.
content: Annotated[
str,
Field(
validation_alias=AliasChoices("content", "new_content", "replacement", "replace_with")
),
],
project: Optional[str] = None,
workspace: Optional[str] = None,
project_id: Optional[str] = None,
# Section/heading naming varies across tools; accept the descriptive forms.
section: Annotated[
Optional[str],
Field(
default=None,
validation_alias=AliasChoices("section", "section_heading", "heading"),
),
] = None,
# find_text is the highest-frequency miss per the issue: models reach for
# find/old_text/old_content/search before find_text every time.
find_text: Annotated[
Optional[str],
Field(
default=None,
validation_alias=AliasChoices("find_text", "find", "old_text", "old_content", "search"),
),
] = 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 (creates the note if it doesn't exist)
- "prepend": Add content to the beginning of the note (creates the note if it doesn't exist)
- "find_replace": Replace occurrences of find_text with content (note must exist)
- "replace_section": Replace content under a specific markdown header (note must exist)
- "insert_before_section": Insert content before a section heading without consuming it (note must exist)
- "insert_after_section": Insert content after a section heading without consuming it (note must exist)
content: The content to add or use for replacement
project: Project name to edit in. Optional - server will resolve using hierarchy.
Use "workspace/project" to route to a project in a specific cloud workspace.
If unknown, use list_memory_projects() to discover available projects.
workspace: Workspace slug, name, or tenant_id. When provided with `project`,
routes as `workspace/project`. Cannot be combined with `project_id`.
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().
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. When the identifier looks
like a file path and the file exists on disk but is not indexed yet, edit_note
indexes that file automatically and retries the edit. 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
project = _compose_workspace_project_route(
workspace=workspace,
project=project,
project_id=project_id,
)
# Resolve or reject routable identifier prefixes before selecting a client.
# Trigger: no explicit project/project_id was provided.
# Why: memory:// URLs are explicit routes, but plain three-segment identifiers
# are ambiguous for a mutating tool.
# Outcome: memory:// can route; plain workspace/project/path matches stop with
# guidance instead of silently editing another project.
if project is None and project_id is None:
config = ConfigManager().config
if identifier.strip().startswith("memory://"):
detected = await detect_project_from_memory_url_prefix(
identifier,
config,
context=context,
)
elif _workspace_identifier_discovery_available(
identifier,
config,
) and is_workspace_qualified_plain_identifier(identifier):
detected = await detect_project_from_workspace_identifier_prefix(
identifier,
config,
context=context,
)
if detected:
if output_format == "json":
return {
"title": None,
"permalink": None,
"file_path": None,
"checksum": None,
"operation": operation,
"fileCreated": False,
"error": "AMBIGUOUS_IDENTIFIER",
"project": detected,
}
return _format_ambiguous_workspace_identifier_response(
identifier=identifier,
detected_project=detected,
)
else:
detected = None
if detected:
project = detected
with logfire.span(
"mcp.tool.edit_note",
entrypoint="mcp",
tool_name="edit_note",
requested_project=project,
requested_project_id=project_id,
edit_operation=operation,
output_format=output_format,
has_section=bool(section),
has_find_text=bool(find_text),
expected_replacements=effective_replacements,
):
async with get_project_client(project, context=context, project_id=project_id) as (
client,
active_project,
):
logger.info(
f"MCP tool call tool=edit_note project={active_project.name} "
f"identifier={identifier} operation={operation} output_format={output_format}"
)
# Validate operation
valid_operations = [
"append",
"prepend",
"find_replace",
"replace_section",
"insert_before_section",
"insert_after_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")
section_ops = ("replace_section", "insert_before_section", "insert_after_section")
if operation in section_ops and not section:
raise ValueError("section parameter is required for section-based operations")
# 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)
_, entity_identifier, _ = await resolve_project_and_path(
client,
identifier,
active_project.name,
context,
)
file_created = False
entity_id = ""
result: EntityResponse | None = None
# Try to resolve the entity; for append/prepend, create it if not found
try:
entity_id = await knowledge_client.resolve_entity(
entity_identifier,
strict=True,
)
except Exception as resolve_error:
error_msg = str(resolve_error).lower()
is_not_found = "entity not found" in error_msg or "not found" in error_msg
# Trigger: resolution missed but the file may already exist on disk
# Why: files written directly to disk are invisible to identifier
# resolution until indexed; editing them should just work (#581)
# Outcome: the single file is indexed and resolution retried once
recovered_entity_id: str | None = None
if is_not_found:
recovered_entity_id = await _resolve_after_disk_recovery(
knowledge_client, entity_identifier
)
if recovered_entity_id is not None:
entity_id = recovered_entity_id
elif is_not_found and operation in ("append", "prepend"):
# Trigger: entity does not exist yet (on disk or in the index)
# Why: append/prepend can meaningfully create a new note from the
# content, while find_replace/replace_section require existing
# content to modify
# Outcome: note is created via the same path as write_note
title, directory = _parse_identifier_to_title_and_directory(identifier)
# Validate directory path (same security check as write_note)
project_path = active_project.home
if directory and not validate_project_path(directory, project_path):
logger.warning(
"Attempted path traversal attack blocked",
directory=directory,
project=active_project.name,
)
if output_format == "json":
return {
"title": title,
"permalink": None,
"file_path": None,
"checksum": None,
"operation": operation,
"fileCreated": False,
"error": "SECURITY_VALIDATION_ERROR",
}
return f"# Error\n\nDirectory path '{directory}' is not allowed - paths must stay within project boundaries"
entity = Entity(
title=title,
directory=directory,
content_type="text/markdown",
content=content,
)
logger.info(
"Creating note via edit_note auto-create",
title=title,
directory=directory,
operation=operation,
)
result = await knowledge_client.create_entity(entity.model_dump())
file_created = True
else:
# find_replace/replace_section require existing content — re-raise
raise resolve_error
# --- Standard edit path (entity already existed) ---
if not file_created:
# 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)
# --- Format response ---
# result is always set: either by create_entity (auto-create) or patch_entity (edit)
assert result is not None
if file_created:
summary = [
f"# Created 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'}",
"fileCreated: true",
]
lines_added = len(content.split("\n"))
summary.append(f"operation: Created note with {lines_added} lines")
else:
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}'")
elif operation == "insert_before_section":
summary.append(f"operation: Inserted content before section '{section}'")
elif operation == "insert_after_section":
summary.append(f"operation: Inserted content after 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(
f"MCP tool response: tool=edit_note project={active_project.name} "
f"operation={operation} permalink={result.permalink} "
f"observations_count={len(result.observations)} "
f"relations_count={len(result.relations)} "
f"file_created={str(file_created).lower()}"
)
if output_format == "json":
return {
"title": result.title,
"permalink": result.permalink,
"file_path": result.file_path,
"checksum": result.checksum,
"operation": operation,
"fileCreated": file_created,
}
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,
"fileCreated": False,
"error": str(e),
}
return _format_error_response(
str(e),
operation,
identifier,
find_text,
effective_replacements,
active_project.name,
)