Skip to content

Commit bd5923a

Browse files
phernandezclaude
andauthored
feat: add overwrite guard to write_note tool (#632)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4ea5396 commit bd5923a

9 files changed

Lines changed: 275 additions & 6 deletions

src/basic_memory/config.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,16 @@ class BasicMemoryConfig(BaseSettings):
245245
description="Disable automatic permalink generation in frontmatter. When enabled, new notes won't have permalinks added and sync won't update permalinks. Existing permalinks will still work for reading.",
246246
)
247247

248+
write_note_overwrite_default: bool = Field(
249+
default=False,
250+
description=(
251+
"Default value for write_note's overwrite parameter. "
252+
"When False (default), write_note errors if note already exists. "
253+
"Set to True to restore pre-v0.20 upsert behavior. "
254+
"Env: BASIC_MEMORY_WRITE_NOTE_OVERWRITE_DEFAULT"
255+
),
256+
)
257+
248258
ensure_frontmatter_on_sync: bool = Field(
249259
default=True,
250260
description="Ensure markdown files have frontmatter during sync by adding derived title/type/permalink when missing. When combined with disable_permalinks=True, this setting takes precedence for missing-frontmatter files and still writes permalinks.",

src/basic_memory/mcp/tools/write_note.py

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Write note tool for Basic Memory MCP server."""
22

3+
import textwrap
34
from typing import List, Union, Optional, Literal
45

56
from loguru import logger
67

8+
from basic_memory.config import ConfigManager
79
from basic_memory.mcp.project_context import get_project_client, add_project_metadata
810
from basic_memory.mcp.server import mcp
911
from fastmcp import Context
@@ -15,8 +17,8 @@
1517

1618

1719
@mcp.tool(
18-
description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.",
19-
annotations={"destructiveHint": False, "idempotentHint": True, "openWorldHint": False},
20+
description="Create a markdown note. If the note already exists, returns an error by default — pass overwrite=True to replace.",
21+
annotations={"destructiveHint": True, "idempotentHint": False, "openWorldHint": False},
2022
)
2123
async def write_note(
2224
title: str,
@@ -27,12 +29,15 @@ async def write_note(
2729
tags: list[str] | str | None = None,
2830
note_type: str = "note",
2931
metadata: dict | None = None,
32+
overwrite: bool | None = None,
3033
output_format: Literal["text", "json"] = "text",
3134
context: Context | None = None,
3235
) -> str | dict:
3336
"""Write a markdown note to the knowledge base.
3437
35-
Creates or updates a markdown note with semantic observations and relations.
38+
Creates a markdown note with semantic observations and relations.
39+
If the note already exists, returns an error by default. Pass overwrite=True
40+
to replace the existing note. For incremental updates, use edit_note instead.
3641
3742
Project Resolution:
3843
Server resolves projects using a unified priority chain (same in local and cloud modes):
@@ -74,6 +79,8 @@ async def write_note(
7479
metadata: Optional dict of extra frontmatter fields merged into entity_metadata.
7580
Useful for schema notes or any note that needs custom YAML frontmatter
7681
beyond title/type/tags. Nested dicts are supported.
82+
overwrite: If True, replace existing note on conflict. If False, error on conflict.
83+
If None (default), consult write_note_overwrite_default config setting.
7784
output_format: "text" returns the existing markdown summary. "json" returns
7885
machine-readable metadata.
7986
context: Optional FastMCP context for performance caching.
@@ -106,12 +113,13 @@ async def write_note(
106113
note_type="guide"
107114
)
108115
109-
# Update existing note (same title/directory)
116+
# Overwrite an existing note explicitly
110117
write_note(
111118
project="my-research",
112119
title="Meeting Notes",
113120
directory="meetings",
114-
content="# Weekly Standup\\n\\n- [decision] Use PostgreSQL instead #tech"
121+
content="# Weekly Standup\\n\\n- [decision] Use PostgreSQL instead #tech",
122+
overwrite=True
115123
)
116124
117125
# Create a schema note with custom frontmatter via metadata
@@ -132,6 +140,14 @@ async def write_note(
132140
HTTPError: If project doesn't exist or is inaccessible
133141
SecurityError: If directory path attempts path traversal
134142
"""
143+
# Resolve overwrite flag: explicit parameter > config default
144+
# Trigger: caller omitted the parameter (None)
145+
# Why: lets users set a global default without breaking per-call overrides
146+
effective_overwrite = (
147+
overwrite if overwrite is not None
148+
else ConfigManager().config.write_note_overwrite_default
149+
)
150+
135151
async with get_project_client(project, workspace, context) as (client, active_project):
136152
logger.info(
137153
f"MCP tool call tool=write_note project={active_project.name} directory={directory}, title={title}, tags={tags}"
@@ -199,6 +215,25 @@ async def write_note(
199215
or "conflict" in str(e).lower()
200216
or "already exists" in str(e).lower()
201217
):
218+
# Guard: block overwrite unless explicitly enabled
219+
if not effective_overwrite:
220+
logger.warning(
221+
f"write_note blocked: note already exists (overwrite not enabled) "
222+
f"permalink={entity.permalink}"
223+
)
224+
if output_format == "json":
225+
return {
226+
"title": title,
227+
"permalink": entity.permalink,
228+
"file_path": None,
229+
"checksum": None,
230+
"action": "conflict",
231+
"error": "NOTE_ALREADY_EXISTS",
232+
}
233+
return _format_overwrite_error(
234+
title, entity.permalink, active_project.name
235+
)
236+
202237
logger.debug(f"Entity exists, updating instead permalink={entity.permalink}")
203238
try:
204239
if not entity.permalink:
@@ -270,3 +305,23 @@ async def write_note(
270305

271306
summary_result = "\n".join(summary)
272307
return add_project_metadata(summary_result, active_project.name)
308+
309+
310+
def _format_overwrite_error(title: str, permalink: str | None, project_name: str) -> str:
311+
"""Format a helpful error when write_note is blocked by the overwrite guard."""
312+
return textwrap.dedent(f"""\
313+
# Error: Note already exists
314+
315+
**"{title}"** already exists (permalink: `{permalink}`).
316+
317+
`write_note` does not overwrite by default. Choose an option:
318+
319+
| Goal | Action |
320+
|------|--------|
321+
| Append content | `edit_note("{permalink}", operation="append", content="...")` |
322+
| Prepend content | `edit_note("{permalink}", operation="prepend", content="...")` |
323+
| Replace a section | `edit_note("{permalink}", operation="replace_section", section="...", content="...")` |
324+
| Full replace | `write_note("{title}", ..., overwrite=True)` |
325+
| Inspect first | `read_note("{permalink}")` |
326+
327+
Project: {project_name}""")

test-int/mcp/test_write_note_integration.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ async def test_write_note_update_existing(mcp_server, app, test_project):
8888

8989
assert "# Created note" in result1.content[0].text # pyright: ignore [reportAttributeAccessIssue]
9090

91-
# Update the same note
91+
# Update the same note (explicit overwrite)
9292
result2 = await client.call_tool(
9393
"write_note",
9494
{
@@ -97,6 +97,7 @@ async def test_write_note_update_existing(mcp_server, app, test_project):
9797
"directory": "test",
9898
"content": "# Update Test\n\nUpdated content with changes.",
9999
"tags": "updated,modified",
100+
"overwrite": True,
100101
},
101102
)
102103

@@ -475,3 +476,49 @@ async def test_write_note_project_path_validation(mcp_server, app, test_project)
475476
# Should successfully create without path validation errors
476477
assert "# Created note" in response_text
477478
assert "not allowed" not in response_text
479+
480+
481+
@pytest.mark.asyncio
482+
async def test_write_note_overwrite_guard_via_mcp_client(mcp_server, app, test_project):
483+
"""End-to-end test: overwrite guard works through the MCP Client protocol."""
484+
485+
async with Client(mcp_server) as client:
486+
# Create initial note
487+
result1 = await client.call_tool(
488+
"write_note",
489+
{
490+
"project": test_project.name,
491+
"title": "MCP Guard Test",
492+
"directory": "guard",
493+
"content": "# MCP Guard Test\n\nOriginal content via MCP.",
494+
},
495+
)
496+
assert "# Created note" in result1.content[0].text # pyright: ignore [reportAttributeAccessIssue]
497+
498+
# Second write without overwrite should be blocked
499+
result2 = await client.call_tool(
500+
"write_note",
501+
{
502+
"project": test_project.name,
503+
"title": "MCP Guard Test",
504+
"directory": "guard",
505+
"content": "# MCP Guard Test\n\nReplacement content via MCP.",
506+
},
507+
)
508+
response_text = result2.content[0].text # pyright: ignore [reportAttributeAccessIssue]
509+
assert "# Error: Note already exists" in response_text
510+
assert "edit_note" in response_text
511+
512+
# Overwrite with explicit flag should succeed
513+
result3 = await client.call_tool(
514+
"write_note",
515+
{
516+
"project": test_project.name,
517+
"title": "MCP Guard Test",
518+
"directory": "guard",
519+
"content": "# MCP Guard Test\n\nReplacement content via MCP.",
520+
"overwrite": True,
521+
},
522+
)
523+
response_text3 = result3.content[0].text # pyright: ignore [reportAttributeAccessIssue]
524+
assert "# Updated note" in response_text3

tests/mcp/test_obsidian_yaml_formatting.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ async def test_write_note_update_preserves_yaml_format(app, project_config, test
143143
directory="test",
144144
content="Updated content",
145145
tags=["updated", "new-tag", "format"],
146+
overwrite=True,
146147
)
147148

148149
# Should be an update, not a new creation

tests/mcp/test_permalink_collision_file_overwrite.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ async def test_notes_with_similar_titles_maintain_separate_files(app, test_proje
168168
title=title,
169169
directory=folder,
170170
content=f"# {title}\n\nUnique content for {title}",
171+
overwrite=True,
171172
)
172173

173174
permalink = None

tests/mcp/test_tool_contracts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
"tags",
100100
"note_type",
101101
"metadata",
102+
"overwrite",
102103
"output_format",
103104
],
104105
}

tests/mcp/test_tool_json_output_modes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ async def test_write_note_text_and_json_modes(app, test_project):
3838
directory="mode-tests",
3939
content="# Mode Write Note\n\nupdated",
4040
output_format="json",
41+
overwrite=True,
4142
)
4243
assert isinstance(json_result, dict)
4344
assert json_result["title"] == "Mode Write Note"

0 commit comments

Comments
 (0)