diff --git a/src/basic_memory/mcp/tools/edit_note.py b/src/basic_memory/mcp/tools/edit_note.py index 79122a6a3..f45fa79c7 100644 --- a/src/basic_memory/mcp/tools/edit_note.py +++ b/src/basic_memory/mcp/tools/edit_note.py @@ -1,6 +1,6 @@ """Edit note tool for Basic Memory MCP server.""" -from typing import TYPE_CHECKING, Annotated, Optional, Literal +from typing import TYPE_CHECKING, Annotated, Literal, Optional import logfire from httpx import HTTPStatusError @@ -31,6 +31,15 @@ ) from basic_memory.utils import normalize_project_reference, validate_project_path +EDIT_OPERATIONS = ( + "append", + "prepend", + "find_replace", + "replace_section", + "insert_before_section", + "insert_after_section", +) + def _parse_identifier_to_title_and_directory(identifier: str) -> tuple[str, str]: """Parse an identifier into (title, directory) for creating a new note. @@ -318,7 +327,7 @@ def _format_error_response( ) async def edit_note( identifier: str, - operation: str, + operation: Annotated[str, Field(json_schema_extra={"enum": list(EDIT_OPERATIONS)})], # Accept common replacement-content aliases. Models trained on diff/patch # APIs reach for new_content/replacement/replace_with on first try. content: Annotated[ @@ -512,17 +521,9 @@ async def edit_note( ) # Validate operation - valid_operations = [ - "append", - "prepend", - "find_replace", - "replace_section", - "insert_before_section", - "insert_after_section", - ] - if operation not in valid_operations: + if operation not in EDIT_OPERATIONS: raise ValueError( - f"Invalid operation '{operation}'. Must be one of: {', '.join(valid_operations)}" + f"Invalid operation '{operation}'. Must be one of: {', '.join(EDIT_OPERATIONS)}" ) # Validate required parameters for specific operations diff --git a/tests/mcp/test_tool_contracts.py b/tests/mcp/test_tool_contracts.py index bd4cc9f98..8c4d71e72 100644 --- a/tests/mcp/test_tool_contracts.py +++ b/tests/mcp/test_tool_contracts.py @@ -165,6 +165,15 @@ "search_notes_ui": {"readOnlyHint": True, "destructiveHint": False}, } +EXPECTED_EDIT_NOTE_OPERATIONS = [ + "append", + "prepend", + "find_replace", + "replace_section", + "insert_before_section", + "insert_after_section", +] + TOOL_FUNCTIONS: dict[str, object] = { "build_context": tools.build_context, @@ -250,6 +259,19 @@ async def test_mcp_tool_annotations_meet_directory_requirements(): ) +@pytest.mark.asyncio +async def test_edit_note_operation_schema_exposes_supported_operations(): + """The edit operation is a fixed choice set, not a free-form string.""" + tool_list = await mcp.list_tools() + edit_note_tool = next(tool for tool in tool_list if tool.name == "edit_note") + + input_schema = edit_note_tool.to_mcp_tool().inputSchema + operation_schema = input_schema["properties"]["operation"] + + assert operation_schema["type"] == "string" + assert operation_schema["enum"] == EXPECTED_EDIT_NOTE_OPERATIONS + + @pytest.mark.asyncio async def test_mcp_tools_have_title_and_tags(): """Every registered MCP tool must declare a human-readable title and at least one tag.