mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
fix: enforce strict entity resolution in destructive MCP tools (#650)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -318,7 +318,7 @@ delete_note("path/to/file.md")
|
||||
note_file_path = None
|
||||
try:
|
||||
# Resolve identifier to entity ID
|
||||
entity_id = await knowledge_client.resolve_entity(identifier)
|
||||
entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
|
||||
if output_format == "json":
|
||||
entity = await knowledge_client.get_entity(entity_id)
|
||||
note_title = entity.title
|
||||
|
||||
@@ -293,7 +293,7 @@ async def edit_note(
|
||||
|
||||
# Try to resolve the entity; for append/prepend, create it if not found
|
||||
try:
|
||||
entity_id = await knowledge_client.resolve_entity(identifier)
|
||||
entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
|
||||
except Exception as resolve_error:
|
||||
# Trigger: entity does not exist yet
|
||||
# Why: append/prepend can meaningfully create a new note from the content,
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import Optional, Literal
|
||||
|
||||
from loguru import logger
|
||||
from fastmcp import Context
|
||||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
|
||||
from basic_memory.mcp.server import mcp
|
||||
from basic_memory.mcp.project_context import get_project_client
|
||||
@@ -637,7 +638,7 @@ move_note("path/to/file.md", "{destination_path}/file.md")
|
||||
"""Resolve and cache the source entity ID for the duration of this move."""
|
||||
nonlocal resolved_entity_id
|
||||
if resolved_entity_id is None:
|
||||
resolved_entity_id = await knowledge_client.resolve_entity(identifier)
|
||||
resolved_entity_id = await knowledge_client.resolve_entity(identifier, strict=True)
|
||||
return resolved_entity_id
|
||||
|
||||
try:
|
||||
@@ -645,8 +646,26 @@ move_note("path/to/file.md", "{destination_path}/file.md")
|
||||
source_entity = await knowledge_client.get_entity(resolved_entity_id)
|
||||
if "." in source_entity.file_path:
|
||||
source_ext = source_entity.file_path.split(".")[-1]
|
||||
except ToolError as e:
|
||||
# Trigger: strict=True resolve_entity raised because the entity was not found.
|
||||
# Why: fail fast with a formatted error instead of silently falling through
|
||||
# to extension defaults and failing later with a confusing message.
|
||||
# Outcome: move_note returns a user-facing not-found error immediately.
|
||||
logger.error(f"Move failed for '{identifier}' to '{destination_path}': {e}")
|
||||
if output_format == "json":
|
||||
return {
|
||||
"moved": False,
|
||||
"title": None,
|
||||
"permalink": None,
|
||||
"file_path": None,
|
||||
"source": identifier,
|
||||
"destination": destination_path,
|
||||
"error": str(e),
|
||||
}
|
||||
return _format_move_error_response(str(e), identifier, destination_path)
|
||||
except Exception as e:
|
||||
# If we can't fetch source metadata, continue with extension defaults.
|
||||
# If we can't fetch source metadata (e.g. get_entity or file_path parsing fails),
|
||||
# continue with extension defaults — the entity was at least resolved.
|
||||
logger.debug(f"Could not fetch source entity for extension check: {e}")
|
||||
|
||||
# --- Resolve destination_folder into destination_path ---
|
||||
|
||||
@@ -140,10 +140,12 @@ def validate_timeframe(timeframe: str) -> str:
|
||||
if parsed > now:
|
||||
raise ValueError("Timeframe cannot be in the future") # pragma: no cover
|
||||
|
||||
# Could format the duration back to our standard format
|
||||
days = (now - parsed).days
|
||||
# Round to nearest day to handle DST transitions where an hour shift
|
||||
# can cause e.g. "7d" to compute as 6 days + 23 hours
|
||||
total_seconds = (now - parsed).total_seconds()
|
||||
days = round(total_seconds / 86400)
|
||||
|
||||
# Could enforce reasonable limits
|
||||
# Enforce reasonable limits
|
||||
if days > 365:
|
||||
raise ValueError("Timeframe should be <= 1 year")
|
||||
|
||||
|
||||
@@ -307,8 +307,13 @@ async def test_delete_note_by_file_path(mcp_server, app, test_project):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_note_case_insensitive(mcp_server, app, test_project):
|
||||
"""Test that note deletion is case insensitive for titles."""
|
||||
async def test_delete_note_rejects_case_mismatch(mcp_server, app, test_project):
|
||||
"""Test that delete_note with wrong case does not fuzzy-match to an existing note.
|
||||
|
||||
Strict resolution (#649) prevents destructive operations from silently
|
||||
resolving to a different note via fuzzy search. Case-mismatched titles
|
||||
should be rejected, not resolved to the nearest match.
|
||||
"""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create a note with mixed case
|
||||
@@ -323,7 +328,7 @@ async def test_delete_note_case_insensitive(mcp_server, app, test_project):
|
||||
},
|
||||
)
|
||||
|
||||
# Try to delete with different case
|
||||
# Try to delete with different case — should NOT find the note
|
||||
delete_result = await client.call_tool(
|
||||
"delete_note",
|
||||
{
|
||||
@@ -332,8 +337,28 @@ async def test_delete_note_case_insensitive(mcp_server, app, test_project):
|
||||
},
|
||||
)
|
||||
|
||||
# Should return True for successful deletion
|
||||
assert "true" in delete_result.content[0].text.lower()
|
||||
# Should return False (not found) — strict mode rejects fuzzy matches
|
||||
assert "false" in delete_result.content[0].text.lower()
|
||||
|
||||
# Verify the note still exists using the exact title
|
||||
read_result = await client.call_tool(
|
||||
"read_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "CamelCase Note Title",
|
||||
},
|
||||
)
|
||||
assert "Testing case sensitivity" in read_result.content[0].text
|
||||
|
||||
# Delete with exact title should succeed
|
||||
delete_result2 = await client.call_tool(
|
||||
"delete_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "CamelCase Note Title",
|
||||
},
|
||||
)
|
||||
assert "true" in delete_result2.content[0].text.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -710,3 +710,81 @@ async def test_edit_note_using_different_identifiers(mcp_server, app, test_proje
|
||||
assert "Edited by title." in content
|
||||
assert "Edited by permalink." in content
|
||||
assert "Edited by folder/title." in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_note_append_autocreate_does_not_fuzzy_match(mcp_server, app, test_project):
|
||||
"""Reproduces #649: edit_note append must auto-create, not fuzzy-match to an existing note.
|
||||
|
||||
Creates two notes, then attempts to append to a nonexistent identifier.
|
||||
The tool should create a new note, and neither existing note should be modified.
|
||||
"""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create two notes that could be fuzzy-matched
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Routing Test A",
|
||||
"directory": "test",
|
||||
"content": "# Routing Test A\n\nContent A.",
|
||||
},
|
||||
)
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Routing Test B",
|
||||
"directory": "test",
|
||||
"content": "# Routing Test B\n\nContent B.",
|
||||
},
|
||||
)
|
||||
|
||||
# Attempt to edit a nonexistent note — should error, not silently edit A or B
|
||||
edit_result = await client.call_tool(
|
||||
"edit_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "Routing Test NONEXISTENT",
|
||||
"operation": "append",
|
||||
"content": "\n\nThis should NOT appear in any note.",
|
||||
},
|
||||
)
|
||||
|
||||
edit_text = edit_result.content[0].text
|
||||
# append to nonexistent creates a new note — verify it did NOT edit A or B
|
||||
assert "Created note (append)" in edit_text
|
||||
assert "fileCreated: true" in edit_text
|
||||
|
||||
# Verify neither A nor B was modified
|
||||
read_a = await client.call_tool(
|
||||
"read_note",
|
||||
{"project": test_project.name, "identifier": "Routing Test A"},
|
||||
)
|
||||
content_a = read_a.content[0].text
|
||||
assert "Content A" in content_a
|
||||
assert "This should NOT appear" not in content_a
|
||||
|
||||
read_b = await client.call_tool(
|
||||
"read_note",
|
||||
{"project": test_project.name, "identifier": "Routing Test B"},
|
||||
)
|
||||
content_b = read_b.content[0].text
|
||||
assert "Content B" in content_b
|
||||
assert "This should NOT appear" not in content_b
|
||||
|
||||
# Now test find_replace on nonexistent — should error
|
||||
edit_result2 = await client.call_tool(
|
||||
"edit_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "Routing Test NONEXISTENT AGAIN",
|
||||
"operation": "find_replace",
|
||||
"content": "replaced",
|
||||
"find_text": "Content",
|
||||
},
|
||||
)
|
||||
|
||||
error_text = edit_result2.content[0].text
|
||||
assert "Edit Failed" in error_text
|
||||
|
||||
@@ -716,3 +716,56 @@ async def test_move_note_destination_folder_mutually_exclusive(mcp_server, app,
|
||||
error_text = move_result.content[0].text
|
||||
assert "# Move Failed - Invalid Parameters" in error_text
|
||||
assert "Cannot specify both" in error_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_strict_resolution_rejects_fuzzy_match(mcp_server, app, test_project):
|
||||
"""move_note must not fuzzy-match a nonexistent identifier to an existing note (#649)."""
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
# Create two notes that could be fuzzy-matched
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Move Strict Test A",
|
||||
"directory": "test",
|
||||
"content": "# Move Strict Test A\n\nContent A.",
|
||||
},
|
||||
)
|
||||
await client.call_tool(
|
||||
"write_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"title": "Move Strict Test B",
|
||||
"directory": "test",
|
||||
"content": "# Move Strict Test B\n\nContent B.",
|
||||
},
|
||||
)
|
||||
|
||||
# Attempt to move a nonexistent note — should error, not move A or B
|
||||
move_result = await client.call_tool(
|
||||
"move_note",
|
||||
{
|
||||
"project": test_project.name,
|
||||
"identifier": "Move Strict Test NONEXISTENT",
|
||||
"destination_path": "archive/Moved.md",
|
||||
},
|
||||
)
|
||||
|
||||
assert len(move_result.content) == 1
|
||||
error_text = move_result.content[0].text
|
||||
assert "# Move Failed" in error_text
|
||||
|
||||
# Verify neither A nor B was moved
|
||||
read_a = await client.call_tool(
|
||||
"read_note",
|
||||
{"project": test_project.name, "identifier": "Move Strict Test A"},
|
||||
)
|
||||
assert "Content A" in read_a.content[0].text
|
||||
|
||||
read_b = await client.call_tool(
|
||||
"read_note",
|
||||
{"project": test_project.name, "identifier": "Move Strict Test B"},
|
||||
)
|
||||
assert "Content B" in read_b.content[0].text
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""Tests for delete_note MCP tool."""
|
||||
|
||||
from basic_memory.mcp.tools.delete_note import _format_delete_error_response
|
||||
import pytest
|
||||
|
||||
from basic_memory.mcp.tools.delete_note import delete_note, _format_delete_error_response
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.mcp.tools.write_note import write_note
|
||||
|
||||
|
||||
class TestDeleteNoteErrorFormatting:
|
||||
@@ -94,5 +98,25 @@ class TestDeleteNoteErrorFormatting:
|
||||
assert "folder/note-title" in result # Permalink format
|
||||
|
||||
|
||||
# Integration tests removed to focus on error formatting coverage
|
||||
# The error formatting tests above provide the necessary coverage for MCP tool error messaging
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_note_rejects_fuzzy_match(client, test_project):
|
||||
"""delete_note must reject nonexistent identifiers, not fuzzy-match to a similar note."""
|
||||
await write_note(
|
||||
project=test_project.name,
|
||||
title="Delete Target Note",
|
||||
directory="test",
|
||||
content="# Delete Target Note\nShould not be deleted.",
|
||||
)
|
||||
|
||||
# Attempt to delete a nonexistent note — should return False, not silently delete the existing note
|
||||
result = await delete_note(
|
||||
project=test_project.name,
|
||||
identifier="Delete Target NONEXISTENT",
|
||||
)
|
||||
|
||||
# Should indicate not found (False or error string)
|
||||
assert result is False or (isinstance(result, str) and "not found" in result.lower())
|
||||
|
||||
# Verify the existing note was NOT deleted
|
||||
content = await read_note("Delete Target Note", project=test_project.name)
|
||||
assert "Should not be deleted" in content
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Tests for the edit_note MCP tool."""
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from basic_memory.mcp.tools.edit_note import edit_note
|
||||
from basic_memory.mcp.tools.read_note import read_note
|
||||
from basic_memory.mcp.tools.write_note import write_note
|
||||
|
||||
|
||||
@@ -613,6 +615,70 @@ async def test_edit_note_preserves_permalink_when_frontmatter_missing(client, te
|
||||
# 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."""
|
||||
|
||||
@@ -590,6 +590,31 @@ async def test_move_note_preserves_frontmatter(app, client, test_project):
|
||||
assert "Content with custom metadata" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_note_rejects_fuzzy_match(client, test_project):
|
||||
"""move_note must reject nonexistent identifiers, not fuzzy-match to a similar note."""
|
||||
await write_note(
|
||||
project=test_project.name,
|
||||
title="Move Target Note",
|
||||
directory="source",
|
||||
content="# Move Target Note\nShould not be moved.",
|
||||
)
|
||||
|
||||
# Attempt to move a nonexistent note — should error, not silently move the existing note
|
||||
result = await move_note(
|
||||
project=test_project.name,
|
||||
identifier="Move Target NONEXISTENT",
|
||||
destination_path="target/Moved.md",
|
||||
)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "# Move Failed" in result
|
||||
|
||||
# Verify the existing note was NOT moved
|
||||
content = await read_note("Move Target Note", project=test_project.name)
|
||||
assert "Should not be moved" in content
|
||||
|
||||
|
||||
class TestMoveNoteErrorFormatting:
|
||||
"""Test move note error formatting for better user experience."""
|
||||
|
||||
|
||||
@@ -431,7 +431,7 @@ class TestTimeframeParsing:
|
||||
result_1d = parse_timeframe("1d")
|
||||
expected_1d = now - timedelta(days=1)
|
||||
diff = abs((result_1d - expected_1d).total_seconds())
|
||||
assert diff < 3600 # Within 1 hour tolerance (accounts for DST transitions)
|
||||
assert diff <= 3610 # Within 1 hour tolerance + execution margin (DST transitions)
|
||||
assert result_1d.tzinfo is not None
|
||||
|
||||
# Test yesterday - should be yesterday at same time
|
||||
@@ -444,7 +444,7 @@ class TestTimeframeParsing:
|
||||
result_week = parse_timeframe("1 week ago")
|
||||
expected_week = now - timedelta(weeks=1)
|
||||
diff = abs((result_week - expected_week).total_seconds())
|
||||
assert diff < 3600 # Within 1 hour tolerance
|
||||
assert diff <= 3610 # Within 1 hour tolerance + execution margin (DST transitions)
|
||||
assert result_week.tzinfo is not None
|
||||
|
||||
def test_parse_timeframe_invalid(self):
|
||||
|
||||
Reference in New Issue
Block a user