"""Tests for the edit_note MCP tool.""" from pathlib import Path from unittest.mock import patch import httpx import pytest from mcp.server.fastmcp.exceptions import ToolError from basic_memory.mcp.clients import KnowledgeClient from basic_memory.mcp.tools.edit_note import _resolve_after_disk_recovery, edit_note from basic_memory.mcp.tools.read_note import read_note from basic_memory.mcp.tools.write_note import write_note def test_edit_note_workspace_project_route_helper(): """workspace/project routing should be explicit and deterministic.""" import importlib edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") assert ( edit_note_module._compose_workspace_project_route( workspace=None, project="docs/setup", project_id=None, ) == "docs/setup" ) assert ( edit_note_module._compose_workspace_project_route( workspace="docs", project="setup", project_id=None, ) == "docs/setup" ) @pytest.mark.parametrize( ("route_kwargs", "message"), [ ( {"workspace": " ", "project": "setup", "project_id": None}, "workspace must not be empty", ), ( {"workspace": "docs/setup", "project": "setup", "project_id": None}, "workspace must be a single workspace", ), ( {"workspace": "docs", "project": "setup", "project_id": "project-id"}, "workspace cannot be combined with project_id", ), ( {"workspace": "docs", "project": None, "project_id": None}, "workspace requires an explicit project", ), ( {"workspace": "docs", "project": "setup/install", "project_id": None}, "not both", ), ], ) def test_edit_note_workspace_project_route_helper_rejects_invalid_inputs( route_kwargs, message, ): """Ambiguous workspace/project argument combinations should fail before routing.""" import importlib edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") with pytest.raises(ValueError, match=message): edit_note_module._compose_workspace_project_route(**route_kwargs) @pytest.mark.asyncio async def test_edit_note_append_operation(client, test_project): """Test appending content to an existing note.""" # Create initial note await write_note( project=test_project.name, title="Test Note", directory="test", content="# Test Note\nOriginal content here.", ) # Append content result = await edit_note( project=test_project.name, identifier="test/test-note", operation="append", content="\n## New Section\nAppended content here.", ) assert isinstance(result, str) assert "Edited note (append)" in result assert f"project: {test_project.name}" in result assert "file_path: test/Test Note.md" in result assert f"permalink: {test_project.name}/test/test-note" in result assert "Added 3 lines to end of note" in result assert f"[Session: Using project '{test_project.name}']" in result @pytest.mark.asyncio async def test_edit_note_prepend_operation(client, test_project): """Test prepending content to an existing note.""" # Create initial note await write_note( project=test_project.name, title="Meeting Notes", directory="meetings", content="# Meeting Notes\nExisting content.", ) # Prepend content result = await edit_note( project=test_project.name, identifier="meetings/meeting-notes", operation="prepend", content="## 2025-05-25 Update\nNew meeting notes.\n", ) assert isinstance(result, str) assert "Edited note (prepend)" in result assert f"project: {test_project.name}" in result assert "file_path: meetings/Meeting Notes.md" in result assert f"permalink: {test_project.name}/meetings/meeting-notes" in result assert "Added 3 lines to beginning of note" in result assert f"[Session: Using project '{test_project.name}']" in result @pytest.mark.asyncio async def test_edit_note_find_replace_operation(client, test_project): """Test find and replace operation.""" # Create initial note with version info await write_note( project=test_project.name, title="Config Document", directory="config", content="# Configuration\nVersion: v0.12.0\nSettings for v0.12.0 release.", ) # Replace version - expecting 2 replacements result = await edit_note( project=test_project.name, identifier="config/config-document", operation="find_replace", content="v0.13.0", find_text="v0.12.0", expected_replacements=2, ) assert isinstance(result, str) assert "Edited note (find_replace)" in result assert f"project: {test_project.name}" in result assert "file_path: config/Config Document.md" in result assert "operation: Find and replace operation completed" in result assert f"[Session: Using project '{test_project.name}']" in result @pytest.mark.asyncio async def test_edit_note_replace_section_operation(client, test_project): """Test replacing content under a specific section.""" # Create initial note with sections await write_note( project=test_project.name, title="API Specification", directory="specs", content="# API Spec\n\n## Overview\nAPI overview here.\n\n## Implementation\nOld implementation details.\n\n## Testing\nTest info here.", ) # Replace implementation section result = await edit_note( project=test_project.name, identifier="specs/api-specification", operation="replace_section", content="New implementation approach using FastAPI.\nImproved error handling.\n", section="## Implementation", ) assert isinstance(result, str) assert "Edited note (replace_section)" in result assert f"project: {test_project.name}" in result assert "file_path: specs/API Specification.md" in result assert "Replaced content under section '## Implementation'" in result assert f"[Session: Using project '{test_project.name}']" in result @pytest.mark.asyncio async def test_edit_note_nonexistent_note_find_replace(client, test_project): """Test find_replace on a note that doesn't exist - should return helpful guidance.""" result = await edit_note( project=test_project.name, identifier="nonexistent/note", operation="find_replace", content="replacement", find_text="old text", ) assert isinstance(result, str) assert "# Edit Failed" in result assert "search_notes" in result # Should suggest searching assert "append" in result # Should suggest using append/prepend instead @pytest.mark.asyncio async def test_edit_note_nonexistent_note_replace_section(client, test_project): """Test replace_section on a note that doesn't exist - should return helpful guidance.""" result = await edit_note( project=test_project.name, identifier="nonexistent/note", operation="replace_section", content="new section content", section="## Missing Section", ) assert isinstance(result, str) assert "# Edit Failed" in result assert "search_notes" in result # Should suggest searching @pytest.mark.asyncio async def test_edit_note_append_creates_note_if_not_found(client, test_project): """append to a non-existent note should create it automatically.""" result = await edit_note( project=test_project.name, identifier="auto-created-note", operation="append", content="# New Note\n\nCreated via append.", ) assert isinstance(result, str) assert "Created note (append)" in result assert "fileCreated: true" in result assert f"project: {test_project.name}" in result @pytest.mark.asyncio async def test_edit_note_prepend_creates_note_if_not_found(client, test_project): """prepend to a non-existent note should create it automatically.""" result = await edit_note( project=test_project.name, identifier="auto-created-prepend", operation="prepend", content="# Prepended Note\n\nCreated via prepend.", ) assert isinstance(result, str) assert "Created note (prepend)" in result assert "fileCreated: true" in result assert f"project: {test_project.name}" in result @pytest.mark.asyncio async def test_edit_note_append_creates_with_directory_from_identifier(client, test_project): """Identifier 'conversations/my-note' should create in conversations/ directory.""" result = await edit_note( project=test_project.name, identifier="conversations/my-note", operation="append", content="# My Note\n\nCreated in conversations directory.", ) assert isinstance(result, str) assert "Created note (append)" in result assert "fileCreated: true" in result assert "conversations/" in result @pytest.mark.asyncio async def test_edit_note_append_creates_at_root_when_no_directory(client, test_project): """Identifier 'my-note' (no slash) should create at project root.""" result = await edit_note( project=test_project.name, identifier="root-level-note", operation="append", content="# Root Note\n\nCreated at root.", ) assert isinstance(result, str) assert "Created note (append)" in result assert "fileCreated: true" in result @pytest.mark.asyncio async def test_edit_note_append_creates_json_format(client, test_project): """JSON output should include fileCreated: true when note is auto-created.""" result = await edit_note( project=test_project.name, identifier="json-auto-create", operation="append", content="# JSON Test\n\nAuto-created.", output_format="json", ) assert isinstance(result, dict) assert result["fileCreated"] is True assert result["title"] is not None assert result["operation"] == "append" @pytest.mark.asyncio async def test_edit_note_existing_note_json_includes_file_created_false(client, test_project): """JSON output for editing an existing note should include fileCreated: false.""" # Create the note first await write_note( project=test_project.name, title="Existing JSON Note", directory="test", content="# Existing Note\nOriginal content.", ) result = await edit_note( project=test_project.name, identifier="test/existing-json-note", operation="append", content="\nAppended content.", output_format="json", ) assert isinstance(result, dict) assert result["fileCreated"] is False assert result["title"] == "Existing JSON Note" assert result["operation"] == "append" @pytest.mark.asyncio async def test_edit_note_invalid_operation(client, test_project): """Test using an invalid operation.""" # Create a note first await write_note( project=test_project.name, title="Test Note", directory="test", content="# Test\nContent here.", ) with pytest.raises(ValueError) as exc_info: await edit_note( project=test_project.name, identifier="test/test-note", operation="invalid_op", content="Some content", ) assert "Invalid operation 'invalid_op'" in str(exc_info.value) @pytest.mark.asyncio async def test_edit_note_find_replace_missing_find_text(client, test_project): """Test find_replace operation without find_text parameter.""" # Create a note first await write_note( project=test_project.name, title="Test Note", directory="test", content="# Test\nContent here.", ) with pytest.raises(ValueError) as exc_info: await edit_note( project=test_project.name, identifier="test/test-note", operation="find_replace", content="replacement", ) assert "find_text parameter is required for find_replace operation" in str(exc_info.value) @pytest.mark.asyncio async def test_edit_note_replace_section_missing_section(client, test_project): """Test replace_section operation without section parameter.""" # Create a note first await write_note( project=test_project.name, title="Test Note", directory="test", content="# Test\nContent here.", ) with pytest.raises(ValueError) as exc_info: await edit_note( project=test_project.name, identifier="test/test-note", operation="replace_section", content="new content", ) assert "section parameter is required for section-based operations" in str(exc_info.value) @pytest.mark.asyncio async def test_edit_note_replace_section_nonexistent_section(client, test_project): """Test replacing a section that doesn't exist - should append it.""" # Create initial note without the target section await write_note( project=test_project.name, title="Document", directory="docs", content="# Document\n\n## Existing Section\nSome content here.", ) # Try to replace non-existent section result = await edit_note( project=test_project.name, identifier="docs/document", operation="replace_section", content="New section content here.\n", section="## New Section", ) assert isinstance(result, str) assert "Edited note (replace_section)" in result assert f"project: {test_project.name}" in result assert "file_path: docs/Document.md" in result assert f"[Session: Using project '{test_project.name}']" in result # Should succeed - the section gets appended if it doesn't exist @pytest.mark.asyncio async def test_edit_note_with_observations_and_relations(client, test_project): """Test editing a note that contains observations and relations.""" # Create note with semantic content await write_note( project=test_project.name, title="Feature Spec", directory="features", content="# Feature Spec\n\n- [design] Initial design thoughts #architecture\n- implements [[Base System]]\n\nOriginal content.", ) # Append more semantic content result = await edit_note( project=test_project.name, identifier="features/feature-spec", operation="append", content="\n## Updates\n\n- [implementation] Added new feature #development\n- relates_to [[User Guide]]", ) assert isinstance(result, str) assert "Edited note (append)" in result assert "## Observations" in result assert "## Relations" in result @pytest.mark.asyncio async def test_edit_note_identifier_variations(client, test_project): """Test that various identifier formats work.""" # Create a note await write_note( project=test_project.name, title="Test Document", directory="docs", content="# Test Document\nOriginal content.", ) # Test different identifier formats identifiers_to_test = [ "docs/test-document", # permalink "Test Document", # title "docs/Test Document", # folder/title ] for identifier in identifiers_to_test: result = await edit_note( project=test_project.name, identifier=identifier, operation="append", content=f"\n## Update via {identifier}", ) assert isinstance(result, str) assert "Edited note (append)" in result assert f"project: {test_project.name}" in result assert "file_path: docs/Test Document.md" in result @pytest.mark.asyncio async def test_edit_note_find_replace_no_matches(client, test_project): """Test find_replace when the find_text doesn't exist - should return error.""" # Create initial note await write_note( project=test_project.name, title="Test Note", directory="test", content="# Test Note\nSome content here.", ) # Try to replace text that doesn't exist - should fail with default expected_replacements=1 result = await edit_note( project=test_project.name, identifier="test/test-note", operation="find_replace", content="replacement", find_text="nonexistent_text", ) assert isinstance(result, str) assert "# Edit Failed - Text Not Found" in result assert "read_note" in result # Should suggest reading the note first assert "Alternative approaches" in result # Should suggest alternatives @pytest.mark.asyncio async def test_edit_note_empty_content_operations(client, test_project): """Test operations with empty content.""" # Create initial note await write_note( project=test_project.name, title="Test Note", directory="test", content="# Test Note\nOriginal content.", ) # Test append with empty content result = await edit_note( project=test_project.name, identifier="test/test-note", operation="append", content="" ) assert isinstance(result, str) assert "Edited note (append)" in result # Should still work, just adding empty content @pytest.mark.asyncio async def test_edit_note_find_replace_wrong_count(client, test_project): """Test find_replace when replacement count doesn't match expected.""" # Create initial note with version info await write_note( project=test_project.name, title="Config Document", directory="config", content="# Configuration\nVersion: v0.12.0\nSettings for v0.12.0 release.", ) # Try to replace expecting 1 occurrence, but there are actually 2 result = await edit_note( project=test_project.name, identifier="config/config-document", operation="find_replace", content="v0.13.0", find_text="v0.12.0", expected_replacements=1, # Wrong! There are actually 2 occurrences ) assert isinstance(result, str) assert "# Edit Failed - Wrong Replacement Count" in result assert "Expected 1 occurrences" in result assert "but found 2" in result assert "Update expected_replacements" in result # Should suggest the fix assert "expected_replacements=2" in result # Should suggest the exact fix @pytest.mark.asyncio async def test_edit_note_replace_section_multiple_sections(client, test_project): """Test replace_section with multiple sections having same header - should return helpful error.""" # Create note with duplicate section headers await write_note( project=test_project.name, title="Sample Note", directory="docs", content="# Main Title\n\n## Section 1\nFirst instance\n\n## Section 2\nSome content\n\n## Section 1\nSecond instance", ) # Try to replace section when multiple exist result = await edit_note( project=test_project.name, identifier="docs/sample-note", operation="replace_section", content="New content", section="## Section 1", ) assert isinstance(result, str) assert "# Edit Failed - Duplicate Section Headers" in result assert "Multiple sections found" in result assert "read_note" in result # Should suggest reading the note first assert "Make headers unique" in result # Should suggest making headers unique @pytest.mark.asyncio async def test_edit_note_find_replace_empty_find_text(client, test_project): """Test find_replace with empty/whitespace find_text - should return helpful error.""" # Create initial note await write_note( project=test_project.name, title="Test Note", directory="test", content="# Test Note\nSome content here.", ) # Try with whitespace-only find_text - this should be caught by service validation result = await edit_note( project=test_project.name, identifier="test/test-note", operation="find_replace", content="replacement", find_text=" ", # whitespace only ) assert isinstance(result, str) assert "# Edit Failed" in result # Should contain helpful guidance about the error @pytest.mark.asyncio async def test_edit_note_append_with_null_optional_fields(client, test_project): """Regression test: MCP clients may send explicit null for unused optional fields. When an MCP client sends find_text=None, section=None, expected_replacements=None for an append operation, the tool should accept them without validation errors. """ # Create initial note await write_note( project=test_project.name, title="Null Fields Test", directory="test", content="# Null Fields Test\nOriginal content.", ) # Call edit_note with explicit None for all optional fields (simulates MCP null) result = await edit_note( project=test_project.name, identifier="test/null-fields-test", operation="append", content="\nAppended content.", find_text=None, section=None, expected_replacements=None, ) assert isinstance(result, str) assert "Edited note (append)" in result assert f"project: {test_project.name}" in result assert "file_path: test/Null Fields Test.md" in result assert f"[Session: Using project '{test_project.name}']" in result @pytest.mark.asyncio async def test_edit_note_preserves_permalink_when_frontmatter_missing(client, test_project): """Test that editing a note preserves the permalink when frontmatter doesn't contain one. This is a regression test for issue #170 where edit_note would fail with a validation error because the permalink was being set to None when the markdown file didn't have a permalink in its frontmatter. """ # Create initial note await write_note( project=test_project.name, title="Test Note", directory="test", content="# Test Note\nOriginal content here.", ) # Verify the note was created with a permalink first_result = await edit_note( project=test_project.name, identifier="test/test-note", operation="append", content="\nFirst edit.", ) assert isinstance(first_result, str) assert f"permalink: {test_project.name}/test/test-note" in first_result # Perform another edit - this should preserve the permalink even if the # file doesn't have a permalink in its frontmatter second_result = await edit_note( project=test_project.name, identifier="test/test-note", operation="append", content="\nSecond edit.", ) assert isinstance(second_result, str) assert "Edited note (append)" in second_result assert f"project: {test_project.name}" in second_result assert f"permalink: {test_project.name}/test/test-note" in second_result assert f"[Session: Using project '{test_project.name}']" in second_result # The edit should succeed without validation errors @pytest.mark.asyncio async def test_edit_note_find_replace_rejects_fuzzy_match(client, test_project): """find_replace must reject nonexistent identifiers, not fuzzy-match to a similar note.""" # Create two notes that could be fuzzy-matched await write_note( project=test_project.name, title="Routing Test A", directory="test", content="# Routing Test A\nContent A.", ) await write_note( project=test_project.name, title="Routing Test B", directory="test", content="# Routing Test B\nContent B.", ) # Attempt to edit a nonexistent note — should error, not silently edit A or B result = await edit_note( project=test_project.name, identifier="Routing Test NONEXISTENT", operation="find_replace", content="replaced", find_text="Content", ) assert isinstance(result, str) assert "# Edit Failed" in result # Verify neither A nor B was modified content_a = await read_note("Routing Test A", project=test_project.name) assert "Content A" in content_a content_b = await read_note("Routing Test B", project=test_project.name) assert "Content B" in content_b @pytest.mark.asyncio async def test_edit_note_append_autocreate_not_fuzzy_match(client, test_project): """append to a nonexistent note should auto-create it, not fuzzy-match an existing note.""" await write_note( project=test_project.name, title="Existing Note Alpha", directory="test", content="# Existing Note Alpha\nOriginal content.", ) # Append to a nonexistent note — should create a new note, not edit "Existing Note Alpha" result = await edit_note( project=test_project.name, identifier="Existing Note ZZZZZ", operation="append", content="# New Note\nBrand new content.", ) assert isinstance(result, str) assert "Created note (append)" in result assert "fileCreated: true" in result # Verify original note was NOT modified content = await read_note("Existing Note Alpha", project=test_project.name) assert "Original content" in content assert "Brand new content" not in content @pytest.mark.asyncio async def test_edit_note_insert_before_section_operation(client, test_project): """Test inserting content before a section heading.""" # Create initial note with sections await write_note( project=test_project.name, title="Insert Before Doc", directory="docs", content="# Doc\n\n## Overview\nOverview content.\n\n## Details\nDetail content.", ) result = await edit_note( project=test_project.name, identifier="docs/insert-before-doc", operation="insert_before_section", content="--- inserted divider ---", section="## Details", ) assert isinstance(result, str) assert "Edited note (insert_before_section)" in result assert f"project: {test_project.name}" in result assert "Inserted content before section '## Details'" in result assert f"[Session: Using project '{test_project.name}']" in result @pytest.mark.asyncio async def test_edit_note_insert_after_section_operation(client, test_project): """Test inserting content after a section heading.""" # Create initial note with sections await write_note( project=test_project.name, title="Insert After Doc", directory="docs", content="# Doc\n\n## Overview\nOverview content.\n\n## Details\nDetail content.", ) result = await edit_note( project=test_project.name, identifier="docs/insert-after-doc", operation="insert_after_section", content="Inserted after overview heading", section="## Overview", ) assert isinstance(result, str) assert "Edited note (insert_after_section)" in result assert f"project: {test_project.name}" in result assert "Inserted content after section '## Overview'" in result assert f"[Session: Using project '{test_project.name}']" in result @pytest.mark.asyncio async def test_edit_note_insert_before_section_missing_section(client, test_project): """Test insert_before_section without section parameter raises ValueError.""" await write_note( project=test_project.name, title="Test Note", directory="test", content="# Test\nContent here.", ) with pytest.raises(ValueError, match="section parameter is required"): await edit_note( project=test_project.name, identifier="test/test-note", operation="insert_before_section", content="new content", ) @pytest.mark.asyncio async def test_edit_note_insert_before_section_not_found(client, test_project): """Test insert_before_section when section doesn't exist returns error.""" await write_note( project=test_project.name, title="Test Note", directory="test", content="# Test\n\n## Existing\nContent here.", ) result = await edit_note( project=test_project.name, identifier="test/test-note", operation="insert_before_section", content="new content", section="## Nonexistent", ) assert isinstance(result, str) assert "# Edit Failed" in result @pytest.mark.asyncio async def test_edit_note_detects_project_from_memory_url(client, test_project): """edit_note should detect project from memory:// URL prefix when project=None.""" # Create a note first await write_note( project=test_project.name, title="URL Detection Note", directory="test", content="# URL Detection Note\nOriginal content.", ) # Edit using memory:// URL with project=None — should auto-detect project # The memory URL uses the permalink (which includes project prefix) result = await edit_note( identifier=f"memory://{test_project.name}/test/url-detection-note", operation="append", content="\nAppended via memory URL.", project=None, ) assert isinstance(result, str) # Should route to the correct project and succeed (either edit or create) assert f"project: {test_project.name}" in result @pytest.mark.asyncio async def test_edit_note_workspace_qualified_memory_url_keeps_complete_permalink( client, test_project, ): from basic_memory.workspace_context import workspace_permalink_context expected_permalink = f"team-paul/{test_project.name}/team/tool-edit-note" with workspace_permalink_context(workspace_slug="team-paul", workspace_type="organization"): await write_note( project=test_project.name, title="Tool Edit Note", directory="team", content="# Tool Edit Note\nOriginal content.", ) result = await edit_note( project=test_project.name, identifier=f"memory://{expected_permalink}", operation="append", content="\nAppended in team workspace.", ) assert isinstance(result, str) assert "Edited note (append)" in result assert f"permalink: {expected_permalink}" in result @pytest.mark.asyncio async def test_edit_note_workspace_qualified_plain_permalink_requires_explicit_route( monkeypatch, test_project, ): """Plain workspace-qualified write identifiers should stop before mutating.""" import importlib from contextlib import asynccontextmanager edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") workspace_slug = "team-acme" qualified_identifier = f"{workspace_slug}/{test_project.name}/team/plain-edit-note" detected_identifiers: list[str] = [] async def detect_workspace_project(identifier, config, context=None): detected_identifiers.append(identifier) return f"{workspace_slug}/{test_project.name}" @asynccontextmanager async def fail_if_called(*args, **kwargs): raise AssertionError("ambiguous plain identifiers should not select a project client") yield monkeypatch.setattr( edit_note_module, "_workspace_identifier_discovery_available", lambda identifier, config: True, ) monkeypatch.setattr( edit_note_module, "detect_project_from_workspace_identifier_prefix", detect_workspace_project, raising=False, ) monkeypatch.setattr(edit_note_module, "get_project_client", fail_if_called) result = await edit_note( identifier=qualified_identifier, operation="append", content="\nAppended via plain workspace-qualified permalink.", project=None, ) assert detected_identifiers == [qualified_identifier] assert isinstance(result, str) assert "# Edit Failed - Ambiguous Identifier" in result assert f"`{qualified_identifier}` could refer to a local note path" in result assert f'project="{workspace_slug}/{test_project.name}"' in result assert f"memory://{qualified_identifier}" in result @pytest.mark.asyncio async def test_edit_note_workspace_qualified_plain_permalink_json_error( monkeypatch, test_project, ): """Ambiguous plain write identifiers should stay machine-readable in JSON mode.""" import importlib edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") workspace_slug = "team-acme" qualified_identifier = f"{workspace_slug}/{test_project.name}/team/plain-edit-note" async def detect_workspace_project(identifier, config, context=None): assert identifier == qualified_identifier return f"{workspace_slug}/{test_project.name}" monkeypatch.setattr( edit_note_module, "_workspace_identifier_discovery_available", lambda identifier, config: True, ) monkeypatch.setattr( edit_note_module, "detect_project_from_workspace_identifier_prefix", detect_workspace_project, raising=False, ) result = await edit_note( identifier=qualified_identifier, operation="append", content="\nAppended via plain workspace-qualified permalink.", output_format="json", project=None, ) assert isinstance(result, dict) assert result["error"] == "AMBIGUOUS_IDENTIFIER" assert result["project"] == f"{workspace_slug}/{test_project.name}" assert result["fileCreated"] is False @pytest.mark.asyncio async def test_edit_note_ambiguous_namespace_identifier_returns_guidance( monkeypatch, test_project, ): """Namespace-style workspace identifiers should still return ambiguity guidance.""" import importlib edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") workspace_slug = "team-acme" identifier = f"{workspace_slug}::{test_project.name}/team/plain-edit-note" normalized_identifier = f"{workspace_slug}/{test_project.name}/team/plain-edit-note" async def detect_workspace_project(raw_identifier, config, context=None): assert raw_identifier == identifier return f"{workspace_slug}/{test_project.name}" monkeypatch.setattr( edit_note_module, "_workspace_identifier_discovery_available", lambda identifier, config: True, ) monkeypatch.setattr( edit_note_module, "detect_project_from_workspace_identifier_prefix", detect_workspace_project, raising=False, ) result = await edit_note( identifier=identifier, operation="append", content="\nAppended via namespace-style plain identifier.", project=None, ) assert isinstance(result, str) assert "# Edit Failed - Ambiguous Identifier" in result assert f"`{identifier}` could refer to a local note path" in result assert f"memory://{normalized_identifier}" in result @pytest.mark.asyncio async def test_edit_note_workspace_project_args_compose_explicit_route(monkeypatch): """workspace plus project should route like project='workspace/project'.""" from contextlib import asynccontextmanager from types import SimpleNamespace import importlib edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") captured_routes: list[tuple[str | None, str | None]] = [] @asynccontextmanager async def fake_get_project_client(project, context=None, project_id=None): captured_routes.append((project, project_id)) yield object(), SimpleNamespace(name="setup") monkeypatch.setattr(edit_note_module, "get_project_client", fake_get_project_client) with pytest.raises(ValueError, match="Invalid operation"): await edit_note( identifier="install", operation="invalid", content="content", workspace="docs", project="setup", ) assert captured_routes == [("docs/setup", None)] @pytest.mark.asyncio async def test_edit_note_plain_workspace_route_returns_guidance_with_local_config( monkeypatch, config_manager, test_project, ): """Mixed local+cloud configs should still stop ambiguous plain write routes.""" from contextlib import asynccontextmanager import importlib import basic_memory.mcp.project_context as project_context from basic_memory.config import ProjectEntry from basic_memory.mcp.project_context import ( WorkspaceProjectEntry, _build_workspace_project_index, ) from basic_memory.schemas.cloud import WorkspaceInfo from basic_memory.schemas.project_info import ProjectItem edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") config = config_manager.load_config() config.projects["hermes-memory"] = ProjectEntry( path=str(config_manager.config_dir.parent / "hermes-memory") ) config.cloud_api_key = "bmc_test123" config_manager.save_config(config) personal = WorkspaceInfo( tenant_id="personal-tenant", workspace_type="personal", slug="personal", name="Personal", role="owner", is_default=True, ) index = _build_workspace_project_index( (personal,), ( WorkspaceProjectEntry( workspace=personal, project=ProjectItem( id=1, external_id="11111111-1111-1111-1111-111111111111", name="main", path="/tmp/main", is_default=False, ), ), ), ) async def fake_index(context=None): return index @asynccontextmanager async def fail_if_called(*args, **kwargs): raise AssertionError("ambiguous plain identifiers should not select a project client") yield monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index) monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False) monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False) monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False) monkeypatch.setattr(edit_note_module, "get_project_client", fail_if_called) result = await edit_note( identifier="personal/main/team/plain-edit-note", operation="append", content="\nAppended via plain workspace-qualified permalink.", project=None, ) assert isinstance(result, str) assert "# Edit Failed - Ambiguous Identifier" in result assert 'project="personal/main"' in result @pytest.mark.asyncio async def test_edit_note_three_segment_plain_path_stays_local_without_workspace_discovery( monkeypatch, client, test_project, ): """Three-segment local paths should stay on the active project without discovery.""" import importlib edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") await write_note( project=test_project.name, title="Local Three Segment", directory="folder/subdir", content="# Local Three Segment\nOriginal content.", ) async def fail_if_called(*args, **kwargs): raise AssertionError("local three-segment paths should not trigger workspace detection") monkeypatch.setattr( edit_note_module, "_workspace_identifier_discovery_available", lambda identifier, config: False, ) monkeypatch.setattr( edit_note_module, "detect_project_from_workspace_identifier_prefix", fail_if_called, ) result = await edit_note( identifier="folder/subdir/local-three-segment", operation="append", content="\nAppended locally.", project=None, ) assert isinstance(result, str) assert "Edited note (append)" in result assert f"project: {test_project.name}" in result updated = await read_note( identifier="folder/subdir/local-three-segment", project=test_project.name, ) assert "Appended locally." in updated @pytest.mark.asyncio async def test_edit_note_skips_detection_for_plain_path(client, test_project): """edit_note should NOT call detect_project_from_url_prefix for plain path identifiers. A plain path like 'research/note' should not be misrouted to a project named 'research' — the 'research' segment is a directory, not a project. """ with ( patch("basic_memory.mcp.tools.edit_note.detect_project_from_memory_url_prefix") as mock_url, patch( "basic_memory.mcp.tools.edit_note.detect_project_from_workspace_identifier_prefix" ) as mock_workspace, ): # Use a plain path (no memory:// prefix) — detection should not be called await edit_note( identifier="test/some-note", operation="append", content="content", project=None, ) mock_url.assert_not_called() mock_workspace.assert_not_called() @pytest.mark.asyncio async def test_edit_note_skips_detection_when_project_provided(client, test_project): """edit_note should skip URL detection when project is explicitly provided.""" with ( patch("basic_memory.mcp.tools.edit_note.detect_project_from_memory_url_prefix") as mock_url, patch( "basic_memory.mcp.tools.edit_note.detect_project_from_workspace_identifier_prefix" ) as mock_workspace, ): await edit_note( identifier=f"memory://{test_project.name}/test/some-note", operation="append", content="content", project=test_project.name, ) mock_url.assert_not_called() mock_workspace.assert_not_called() @pytest.mark.asyncio async def test_edit_note_skips_detection_when_project_id_provided( monkeypatch, client, test_project, ): """project_id is authoritative, so memory URL discovery must not run first.""" await write_note( project=test_project.name, title="Project ID Memory URL Edit", directory="test", content="# Project ID Memory URL Edit\nOriginal content.", ) async def fail_if_called(*args, **kwargs): raise AssertionError("project_id routing should bypass URL discovery") import importlib edit_note_module = importlib.import_module("basic_memory.mcp.tools.edit_note") monkeypatch.setattr(edit_note_module, "detect_project_from_memory_url_prefix", fail_if_called) monkeypatch.setattr( edit_note_module, "detect_project_from_workspace_identifier_prefix", fail_if_called, ) result = await edit_note( identifier=f"memory://{test_project.name}/test/project-id-memory-url-edit", project_id=test_project.external_id, operation="append", content="\nAppended via project_id.", ) assert isinstance(result, str) assert "Edited note (append)" in result assert f"project: {test_project.name}" in result @pytest.mark.asyncio async def test_edit_note_find_replace_recovers_file_on_disk_not_indexed(client, test_project): """find_replace should index and edit a file written directly to disk (#581).""" note_path = Path(test_project.path) / "notes" / "disk-note.md" note_path.parent.mkdir(parents=True, exist_ok=True) note_path.write_text("# Disk Note\n\nstatus: draft\n", encoding="utf-8") result = await edit_note( project=test_project.name, identifier="notes/disk-note", operation="find_replace", content="status: final", find_text="status: draft", ) assert isinstance(result, str) assert "Edited note (find_replace)" in result assert "status: final" in note_path.read_text(encoding="utf-8") @pytest.mark.asyncio async def test_edit_note_recovers_identifier_with_md_extension(client, test_project): """An identifier already ending in .md should recover via the exact file path (#581).""" note_path = Path(test_project.path) / "notes" / "exact-path.md" note_path.parent.mkdir(parents=True, exist_ok=True) note_path.write_text("# Exact Path\n\nversion: v1\n", encoding="utf-8") result = await edit_note( project=test_project.name, identifier="notes/exact-path.md", operation="find_replace", content="version: v2", find_text="version: v1", ) assert isinstance(result, str) assert "Edited note (find_replace)" in result assert "version: v2" in note_path.read_text(encoding="utf-8") @pytest.mark.asyncio async def test_edit_note_recovers_identifier_with_markdown_extension(client, test_project): """A .markdown identifier must recover via the exact path, not '.markdown.md' (#581).""" note_path = Path(test_project.path) / "notes" / "alt-suffix.markdown" note_path.parent.mkdir(parents=True, exist_ok=True) note_path.write_text("# Alt Suffix\n\nstate: pending\n", encoding="utf-8") result = await edit_note( project=test_project.name, identifier="notes/alt-suffix.markdown", operation="find_replace", content="state: done", find_text="state: pending", ) assert isinstance(result, str) assert "Edited note (find_replace)" in result assert "state: done" in note_path.read_text(encoding="utf-8") @pytest.mark.asyncio async def test_edit_note_refuses_ignored_on_disk_file(client, test_project): """An on-disk file matched by .gitignore must be refused, not shadowed by auto-create.""" project_path = Path(test_project.path) (project_path / ".gitignore").write_text("private/\n", encoding="utf-8") note_path = project_path / "private" / "secret.md" note_path.parent.mkdir(parents=True, exist_ok=True) original_content = "# Secret\n\nGitignored content.\n" note_path.write_text(original_content, encoding="utf-8") result = await edit_note( project=test_project.name, identifier="private/secret", operation="append", content="\nShould never be written.", ) assert isinstance(result, str) assert "ignore rules" in result assert "will not be edited" in result assert "Edited note" not in result assert "Created note" not in result # The ignored file is untouched and auto-create did not shadow it with a new entity assert note_path.read_text(encoding="utf-8") == original_content assert [entry.name for entry in (project_path / "private").iterdir()] == ["secret.md"] @pytest.mark.asyncio async def test_edit_note_append_recovers_file_on_disk_instead_of_autocreate(client, test_project): """append to an unindexed on-disk file should edit it, not auto-create a replacement (#581).""" note_path = Path(test_project.path) / "notes" / "disk-append.md" note_path.parent.mkdir(parents=True, exist_ok=True) note_path.write_text("# Disk Append\n\nOriginal disk content.\n", encoding="utf-8") result = await edit_note( project=test_project.name, identifier="notes/disk-append", operation="append", content="\nAppended line.", ) assert isinstance(result, str) assert "Edited note (append)" in result assert "Created note" not in result final_content = note_path.read_text(encoding="utf-8") assert "Original disk content." in final_content assert "Appended line." in final_content @pytest.mark.asyncio async def test_edit_note_append_recovers_markdown_suffix_file_from_stem(client, test_project): """A stem identifier for an on-disk .markdown file edits it, not auto-creates .md (#581). Recovery probes the identifier as-is, then '.md', then '.markdown'; without the '.markdown' probe, append would auto-create 'notes/alt-stem.md' next to the real file instead of editing it. """ note_path = Path(test_project.path) / "notes" / "alt-stem.markdown" note_path.parent.mkdir(parents=True, exist_ok=True) note_path.write_text("# Alt Stem\n\nOriginal markdown-suffix content.\n", encoding="utf-8") result = await edit_note( project=test_project.name, identifier="notes/alt-stem", operation="append", content="\nAppended line.", ) assert isinstance(result, str) assert "Edited note (append)" in result assert "Created note" not in result final_content = note_path.read_text(encoding="utf-8") assert "Original markdown-suffix content." in final_content assert "Appended line." in final_content # The real file was edited in place; no shadow .md entity was created beside it assert [entry.name for entry in note_path.parent.iterdir()] == ["alt-stem.markdown"] @pytest.mark.asyncio async def test_edit_note_append_recovers_wrong_cased_identifier(client, test_project): """A wrong-cased identifier edits the canonical on-disk file after recovery (#581). The sync-file endpoint canonicalizes casing by matching real directory entries, so syncing 'notes/Disk-Note.md' indexes 'notes/disk-note.md' identically on case-sensitive (CI) and case-insensitive (macOS) filesystems — no filesystem probe is needed here. The regression: the retry used to strictly re-resolve the raw wrong-cased identifier, which can miss the just-indexed canonical entity; the fix returns the entity identity straight from the sync-file response. """ note_path = Path(test_project.path) / "notes" / "disk-note.md" note_path.parent.mkdir(parents=True, exist_ok=True) note_path.write_text("# Disk Note\n\nOriginal cased content.\n", encoding="utf-8") result = await edit_note( project=test_project.name, identifier="notes/Disk-Note", operation="append", content="\nAppended line.", ) assert isinstance(result, str) assert "Edited note (append)" in result assert "Created note" not in result final_content = note_path.read_text(encoding="utf-8") assert "Original cased content." in final_content assert "Appended line." in final_content # The canonical file was edited; no wrong-cased duplicate was created beside it assert [entry.name for entry in note_path.parent.iterdir()] == ["disk-note.md"] @pytest.mark.asyncio async def test_resolve_after_disk_recovery_falls_back_to_strict_resolve(): """Older servers that omit external_id from sync-file trigger a strict re-resolve. The recovery path prefers the entity identity from the sync-file response; when a server predates that field, the only safe option is a strict re-resolve of the raw identifier (which fails loudly on a miss instead of guessing). """ def handler(request: httpx.Request) -> httpx.Response: if request.url.path.endswith("/sync-file"): return httpx.Response( 200, json={ "permalink": "notes/old-server-note", "title": "Old Server Note", "file_path": "notes/old-server-note.md", "note_type": "note", "content_type": "text/markdown", "observations": [], "relations": [], "created_at": "2024-01-01T00:00:00", "updated_at": "2024-01-01T00:00:00", }, ) assert request.url.path.endswith("/resolve") return httpx.Response(200, json={"external_id": "resolved-entity-uuid"}) transport = httpx.MockTransport(handler) async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: knowledge_client = KnowledgeClient(http_client, "project-external-id") result = await _resolve_after_disk_recovery(knowledge_client, "notes/old-server-note") assert result == "resolved-entity-uuid" @pytest.mark.asyncio async def test_resolve_after_disk_recovery_propagates_unexpected_errors(): """Server-side failures during disk recovery must not be masked as a not-found miss. Only 400/404 sync-file rejections mean "nothing to recover"; a 500 (or auth failure) would otherwise be swallowed and edit_note would continue into auto-create with a misleading not-found error. """ def server_error(request: httpx.Request) -> httpx.Response: return httpx.Response(500, json={"detail": "boom"}) transport = httpx.MockTransport(server_error) async with httpx.AsyncClient(transport=transport, base_url="http://test") as http_client: knowledge_client = KnowledgeClient(http_client, "project-external-id") with pytest.raises(ToolError, match="boom"): await _resolve_after_disk_recovery(knowledge_client, "notes/unlucky-note") @pytest.mark.asyncio async def test_edit_note_append_traversal_identifier_is_blocked(client, test_project): """A traversal identifier must be rejected by both disk recovery and auto-create.""" result = await edit_note( project=test_project.name, identifier="../escape-note", operation="append", content="should never be written", ) assert isinstance(result, str) assert "# Error" in result assert "paths must stay within project boundaries" in result assert not (Path(test_project.path).parent / "escape-note.md").exists() @pytest.mark.asyncio async def test_edit_note_append_traversal_identifier_json_error(client, test_project): """JSON mode reports a structured security error for traversal identifiers.""" result = await edit_note( project=test_project.name, identifier="../escape-json-note", operation="append", content="should never be written", output_format="json", ) assert isinstance(result, dict) assert result["error"] == "SECURITY_VALIDATION_ERROR" assert result["fileCreated"] is False