Skip to content

Commit 20bb19f

Browse files
fix(mcp): resolve write-note overwrite conflicts
1 parent f6565b9 commit 20bb19f

3 files changed

Lines changed: 176 additions & 2 deletions

File tree

src/basic_memory/mcp/tools/write_note.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Write note tool for Basic Memory MCP server."""
22

33
import textwrap
4+
from pathlib import Path
45
from typing import Annotated, List, Union, Optional, Literal
56

67
import logfire
@@ -269,7 +270,19 @@ async def write_note(
269270
raise ValueError(
270271
"Entity permalink is required for updates"
271272
) # pragma: no cover
272-
entity_id = await knowledge_client.resolve_entity(entity.permalink)
273+
# Resolve the conflicting entity by file_path with strict=True.
274+
# The 409 came from a file_service.exists(file_path) check, so this
275+
# file_path is the authoritative key for the canonical row. Resolving
276+
# by permalink with fuzzy fallback (the previous behavior) could pick
277+
# an orphan with a similar permalink — especially in workspace-prefixed
278+
# palaces where the client-built permalink omits the workspace slug —
279+
# causing the update to write to the wrong row and the next call to
280+
# mint a -1/-2 suffix on the canonical entity.
281+
# POSIX-normalize so Windows clients send the same form the server stores.
282+
file_path_identifier = Path(entity.file_path).as_posix()
283+
entity_id = await knowledge_client.resolve_entity(
284+
file_path_identifier, strict=True
285+
)
273286
result = await knowledge_client.update_entity(
274287
entity_id, entity.model_dump()
275288
)

test-int/mcp/test_write_note_integration.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,25 @@
55
tag handling, error conditions, and edge cases from bug reports.
66
"""
77

8+
import json
9+
from pathlib import Path
810
from textwrap import dedent
11+
from typing import Any
912

1013
import pytest
1114
from fastmcp import Client
1215

1316
from basic_memory.config import ConfigManager
1417
from basic_memory.schemas.project_info import ProjectItem
15-
from pathlib import Path
18+
19+
20+
def _json_content(tool_result) -> dict[str, Any]:
21+
"""Parse a FastMCP tool result content block into a JSON object."""
22+
assert len(tool_result.content) == 1
23+
assert tool_result.content[0].type == "text"
24+
payload = json.loads(tool_result.content[0].text) # pyright: ignore [reportAttributeAccessIssue]
25+
assert isinstance(payload, dict)
26+
return payload
1627

1728

1829
@pytest.mark.asyncio
@@ -113,6 +124,83 @@ async def test_write_note_update_existing(mcp_server, app, test_project):
113124
assert f"[Session: Using project '{test_project.name}']" in response_text
114125

115126

127+
@pytest.mark.asyncio
128+
async def test_write_note_overwrite_resolves_conflict_by_file_path_when_permalink_changes(
129+
mcp_server, app, test_project, monkeypatch
130+
):
131+
"""Overwrite resolves the conflict by strict file path through the MCP client stack."""
132+
from basic_memory.mcp.clients import knowledge as knowledge_mod
133+
134+
original_resolve = knowledge_mod.KnowledgeClient.resolve_entity
135+
captured_resolve: dict[str, Any] = {}
136+
137+
async def spy_resolve(self, identifier: str, *, strict: bool = False) -> str:
138+
captured_resolve["identifier"] = identifier
139+
captured_resolve["strict"] = strict
140+
return await original_resolve(self, identifier, strict=strict)
141+
142+
monkeypatch.setattr(knowledge_mod.KnowledgeClient, "resolve_entity", spy_resolve)
143+
144+
async with Client(mcp_server) as client:
145+
created = await client.call_tool(
146+
"write_note",
147+
{
148+
"project": test_project.name,
149+
"title": "Overwrite Permalink Change",
150+
"directory": "overwrite-conflicts",
151+
"content": "# Overwrite Permalink Change\n\nOriginal body.",
152+
"output_format": "json",
153+
},
154+
)
155+
created_payload = _json_content(created)
156+
assert created_payload["permalink"] == (
157+
f"{test_project.name}/overwrite-conflicts/overwrite-permalink-change"
158+
)
159+
160+
replacement = dedent("""
161+
---
162+
permalink: overwrite-conflicts/custom-overwrite-permalink
163+
---
164+
165+
# Overwrite Permalink Change
166+
167+
Replacement body.
168+
""").strip()
169+
170+
updated = await client.call_tool(
171+
"write_note",
172+
{
173+
"project": test_project.name,
174+
"title": "Overwrite Permalink Change",
175+
"directory": "overwrite-conflicts",
176+
"content": replacement,
177+
"overwrite": True,
178+
"output_format": "json",
179+
},
180+
)
181+
updated_payload = _json_content(updated)
182+
assert updated_payload["action"] == "updated"
183+
assert updated_payload["permalink"] == "overwrite-conflicts/custom-overwrite-permalink"
184+
assert updated_payload["file_path"] == "overwrite-conflicts/Overwrite Permalink Change.md"
185+
assert captured_resolve == {
186+
"identifier": "overwrite-conflicts/Overwrite Permalink Change.md",
187+
"strict": True,
188+
}
189+
190+
read_updated = await client.call_tool(
191+
"read_note",
192+
{
193+
"project": test_project.name,
194+
"identifier": "overwrite-conflicts/custom-overwrite-permalink",
195+
"output_format": "json",
196+
},
197+
)
198+
read_payload = _json_content(read_updated)
199+
assert read_payload["permalink"] == "overwrite-conflicts/custom-overwrite-permalink"
200+
assert "Replacement body." in read_payload["content"]
201+
assert "Original body." not in read_payload["content"]
202+
203+
116204
@pytest.mark.asyncio
117205
async def test_write_note_tag_array(mcp_server, app, test_project):
118206
"""Test creating a note with tag array (Issue #38 regression test)."""

tests/mcp/test_tool_write_note.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,3 +1311,76 @@ async def test_write_note_new_note_unaffected(self, app, test_project):
13111311
assert "# Created note" in result
13121312
assert f"project: {test_project.name}" in result
13131313
assert "file_path: guard/Brand New Note.md" in result
1314+
1315+
@pytest.mark.asyncio
1316+
async def test_write_note_overwrite_resolves_by_file_path_strictly(
1317+
self, app, test_project, entity_repository, monkeypatch
1318+
):
1319+
"""Regression: overwrite=True must resolve the conflicting entity by
1320+
file_path with strict=True, not by permalink with fuzzy fallback.
1321+
1322+
Bug shape: in workspace-prefixed palaces the client-built permalink
1323+
omits the workspace slug, so resolve_entity(permalink) with the default
1324+
strict=False would fall through to fuzzy search and could pick an
1325+
orphan row sharing tokens with the canonical permalink. The update
1326+
then wrote to the orphan, the canonical row stayed stale, and the
1327+
next overwrite minted a -1/-2 suffix because the permalink uniqueness
1328+
check found duplicate rows.
1329+
1330+
The 409 we catch came from a file_service.exists(file_path) check,
1331+
so file_path is the authoritative key — strict resolution against it
1332+
is safe even when permalinks are workspace-prefixed elsewhere.
1333+
"""
1334+
# Spy on the resolve_entity call to assert the identifier and strict flag.
1335+
from basic_memory.mcp.clients import knowledge as knowledge_mod
1336+
1337+
original_resolve = knowledge_mod.KnowledgeClient.resolve_entity
1338+
captured: dict[str, Any] = {}
1339+
1340+
async def spy_resolve(self, identifier, *, strict=False):
1341+
captured["identifier"] = identifier
1342+
captured["strict"] = strict
1343+
return await original_resolve(self, identifier, strict=strict)
1344+
1345+
monkeypatch.setattr(knowledge_mod.KnowledgeClient, "resolve_entity", spy_resolve)
1346+
1347+
# Create then overwrite the canonical note.
1348+
await write_note(
1349+
project=test_project.name,
1350+
title="Overview",
1351+
directory="features/foo",
1352+
content="# Overview\n\nVersion A",
1353+
)
1354+
canonical_permalink = f"{test_project.name}/features/foo/overview"
1355+
canonical = await entity_repository.get_by_permalink(canonical_permalink)
1356+
assert canonical is not None
1357+
canonical_id = canonical.id
1358+
1359+
result = await write_note(
1360+
project=test_project.name,
1361+
title="Overview",
1362+
directory="features/foo",
1363+
content="# Overview\n\nVersion B",
1364+
overwrite=True,
1365+
)
1366+
assert "# Updated note" in result
1367+
1368+
# The overwrite path resolved by file_path with strict=True — not by
1369+
# permalink with the default fuzzy fallback.
1370+
assert captured.get("identifier") == "features/foo/Overview.md"
1371+
assert captured.get("strict") is True
1372+
1373+
# And the canonical row was updated in place — no duplicate -1/-2 row.
1374+
canonical_after = await entity_repository.get_by_permalink(canonical_permalink)
1375+
assert canonical_after is not None
1376+
assert canonical_after.id == canonical_id
1377+
1378+
content = await read_note(canonical_permalink, project=test_project.name)
1379+
assert "Version B" in content
1380+
assert "Version A" not in content
1381+
1382+
for suffix in ("-1", "-2"):
1383+
stray = await entity_repository.get_by_permalink(f"{canonical_permalink}{suffix}")
1384+
assert stray is None, (
1385+
f"overwrite=True minted a stray '{suffix}' suffix on the canonical permalink"
1386+
)

0 commit comments

Comments
 (0)