Skip to content

fix(mcp): edit_note reports a refused write as an error - #2

Closed
sammywachtel wants to merge 26 commits into
mainfrom
fix/edit-note-refusal-is-an-error
Closed

sammywachtel wants to merge 26 commits into
mainfrom
fix/edit-note-refusal-is-an-error

Conversation

@sammywachtel

Copy link
Copy Markdown
Owner

Review copy. On the personal fork for review before it goes to basicmachines-co/basic-memory. Merging it here is not the goal.

Proposed upstream title: fix(mcp): edit_note reports a refused write as an error. The body below is the draft upstream description, with upstream references written out so nothing on this fork links or closes anything.

This fork's main is behind upstream, so the Files tab here also shows 25 upstream commits. The change under review is the one commit on top: d4ef89e

Why

When two clients append to the same note at the same moment, the engine sometimes has to turn one of them away: the db_version compare-and-set finds that the other edit landed first, and the API answers 409 with "The note was modified concurrently. Reload the latest content and retry." That check works as designed. Every refused append is missing from the note, and every accepted one is present.

The problem is how edit_note reports the refusal. It catches the ToolError and returns the "Edit Failed" text as an ordinary result, so the MCP result has isError false. A client that checks the error flag, which is what most programmatic clients do, counts the refused append as written and never retries it. The text says "Edit Failed"; the flag says success.

This is the same defect basicmachines-co/basic-memory PR 1641 fixed for delete_note and move_note ("A returned 'Delete Failed' string reads as success to MCP clients; raising makes the result an error (isError)"). edit_note has the same shape in its final failure branch.

Evidence

Two in-process MCP clients, 40 appends each to one note, SQLite, current main:

appends reported as written but absent from the note: ['A-29', 'B-0', 'B-22']

Each of those results had is_error=False and the text:

# Edit Failed

Error editing note '...': The note was modified concurrently. Reload the latest content and retry.

Across several runs, 3 or 4 of the 80 appends were refused this way, and none of the refusals had isError set. With this change, the refused appends (3 and 4 in two runs) all come back with isError true, and none of them is in the note.

I only saw this on SQLite. On Postgres, lock_accepted_note_content_for_entity_mutation takes a row lock (SELECT ... FOR UPDATE) that serializes the two edits, so I would not expect this race to produce refusals there. The reporting bug is the same on both backends: any edit the API refuses, for any reason, came back as a success.

What changed

  • edit_note: an edit that fails or is refused now raises ToolError, so the MCP result is an error (isError). The message is the same guidance text as before, or the structured payload in JSON mode. A small _raise_edit_failure helper mirrors _raise_delete_failure and _raise_move_failure from basicmachines-co/basic-memory PR 1641.
  • The unresolved-project-route stop (UNRESOLVED_PROJECT_ROUTE) in the same except handler raises the same way. Nothing was edited or created there either.
  • bm tool edit-note: catches the ToolError, prints the payload's error field, and exits 1, the same handling basicmachines-co/basic-memory PR 1641 added to delete-note. Without it the command printed Error during edit_note: {...raw JSON...}.
  • The edit_note docstring lists ToolError under Raises.

Testing

  • just fix, just format, just typecheck: clean. (just typecheck needs the milvus extra installed, as CI does with uv pip install -e ".[dev,milvus,pdf]"; without it, ty reports 4 unresolved pymilvus imports in files this PR does not touch.)
  • Unit modules (tests/mcp/test_tool_edit_note.py, test_tool_json_output_modes.py, test_tool_contracts.py, test_model_facing_call_examples.py, test_workspace_permalink_resolution.py, test_tool_telemetry.py, test_wiki_after_api_writes.py, test_tool_write_note.py, tests/cli/test_cli_tool_json_output.py, and the man-page tests): 283 passed. One failure and one error in this combined run, test_man_command.py::test_man_install_defaults_to_local_share_man and test_man_resources.py::test_unknown_pages_point_at_the_index ("fixture 'app' not found"). Both also occur on unchanged main with the same module set, and both pass when the man-page modules run alone.
  • Integration modules (test-int/mcp/test_edit_note_integration.py, test_concurrent_write_integration.py, test_locked_note_results.py, test_output_format_json_integration.py, test_write_note_integration.py, test_move_note_integration.py, test_param_aliases_integration.py, test_default_project_mode_integration.py, test_long_relation_type_integration.py, test_project_state_sync_integration.py, test-int/cli/test_cli_tool_edit_note_integration.py, test-int/cli/test_routing_integration.py, test-int/bughunt_fixes/test_move_note_edge_cases.py): 192 passed, SQLite.
  • Not run: the Postgres suites.

New tests, each of which fails on main:

  • test-int/mcp/test_edit_note_integration.py::test_edit_note_refused_concurrent_write_is_an_error (text and JSON): makes NoteContentRepository.accept_write raise NoteContentVersionConflict, so the real 409 path runs through the API, then calls edit_note through an MCP client with raise_on_error=False. It asserts is_error is True, that the text names the concurrent modification, and that the file is unchanged. On main: assert False is True for both formats.
  • test-int/mcp/test_concurrent_write_integration.py::test_concurrent_edit_append_reports_every_refusal: two MCP clients append 40 lines each at once. It asserts that every append reported as written is in the note and that no refused append is. On main: appends reported as written but absent from the note: ['A-29', 'B-0', 'B-22']. Where the backend refuses nothing, the test still passes, because both assertions hold trivially.
  • tests/cli/test_cli_tool_json_output.py::test_edit_note_reports_a_tool_error_payload_and_exits_nonzero: on main the CLI printed Error during edit_note: {"title": null, ...}.

Updated tests: 12 unit tests in tests/mcp/test_tool_edit_note.py expected the failure text returned as a success. They now expect a ToolError with the same message, or the same JSON payload. 5 integration tests in test-int/mcp/test_edit_note_integration.py now call with raise_on_error=False and assert is_error is True. This is the same update basicmachines-co/basic-memory PR 1641 made for delete and move.

Risks / follow-ups

  • Callers that parsed "Edit Failed" out of a successful result now receive an error with that same text instead. That's the intended change.
  • The generic except also covers a failure after the API call succeeded (for example while formatting the summary). That path reported "Edit Failed" before this change and is now an error too. I found no way to reach it in practice, but a caller that retries every error could repeat an append that did land.
  • Unchanged in this PR: the tool's own pre-mutation refusals still return their payloads as ordinary results: AMBIGUOUS_IDENTIFIER (workspace-qualified plain identifier), CROSS_PROJECT_ENTITY (identifier resolved to another project), and SECURITY_VALIDATION_ERROR (auto-create path outside the project). This matches basicmachines-co/basic-memory PR 1641 leaving the move tool's input-check failures as they were.
  • Related: the checksum-guarded edit proposed in feat(mcp): expose and document checksum-guarded edit_note basicmachines-co/basic-memory#1552 would send its stale-revision conflicts through this same branch. With this change, those conflicts reach clients as errors.
  • write_note's NOTE_REVISION_CONFLICT and NOTE_ALREADY_EXISTS remain structured non-error results, by design. This PR does not touch them.

phernandez and others added 26 commits September 29, 2026 21:33
Startup reconciliation rewrites config keys to permalinks while the
database keeps each project's display name, and config was then looked up
by that name with an exact dict get. A project named `Gamma-Mixed` (key
`gamma-mixed`) failed is_locally_syncable, so the startup index and the
watcher skipped it, and get_project_mode treated it as cloud.

BasicMemoryConfig.project_entry matches by name or permalink, and
get_project_mode, is_locally_syncable and the workspace and routing
lookups use it.

An unknown name still defaults to cloud mode so cloud-only projects stay
reachable, but with no cloud route available the local API now answers,
so a typo or a deleted project reports "not found" instead of asking for
cloud credentials.

Fixes basicmachines-co#1624
Fixes basicmachines-co#1632

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
man fell back to a project named "manual" for unbundled pages and query
mode. A local install has no such project, and routing an unknown name to
cloud turned every such call into a cloud-credentials error. When no
manual project is reachable (none named, none in config, no factory and no
cloud credentials), a page miss is "No manual entry" and query mode says
what it needs.

Fixes basicmachines-co#1628

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
build_context answered a memory:// URL that did not name a permalink with
the top fuzzy search hit. Routing makes the path project-qualified
(main/Cache Layer Design), and the title and file-path lookups saw that
prefixed text, so a title URL could only resolve through fuzzy search and
often landed on a neighbour that links to it. A miss returned whatever the
search ranked first.

The link resolver now runs its exact lookups on the own-project remainder
before any fuzzy match, with the fuzzy step extracted so it runs last.
build_context resolves with use_search=False: a memory:// URL is an
address, and a miss returns no primary result.

Fixes basicmachines-co#1626

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
…tles

The wiki projector defers until every accepted change it replays is
materialized. Cloud stamps materialized_at from its materialization job,
but the local runtime never called mark_accepted_note_change_materialized,
so one API or MCP write left bm wiki partial forever.

The local provider now settles the journal row after a write reaches a
terminal status other than conflict (a disk edit the user must resolve),
and after an inline file delete, matching the cloud rule.

Fixes basicmachines-co#1625

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
Permalink candidates drop the extension, so notes/foo.txt became notes/foo
and matched the same-stem Markdown note before any file-path lookup ran.
read_content, cat and read_note returned foo.md's bytes for foo.txt. An
identifier with a non-Markdown extension now tries its exact file path
first; a miss falls through to the usual order.

Fixes basicmachines-co#1629

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
Project routing matches names by permalink (basicmachines-co#1388), but search scoping
compared exact strings. Under BASIC_MEMORY_MCP_PROJECT=ALPHA, a project
named alpha was filtered out of the scoped project list, so
search_all_projects and projects= saw no projects; projects=["BETA"] was
refused as unknown. Both now compare permalinks.

Fixes basicmachines-co#1630

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
Error guidance, docstrings and tool descriptions still showed legacy
project-first positional calls (read_note("qa", "notes/x") reads a note
named qa from project notes/x), a retired list_projects() tool, and a
search_notes kind= parameter that does not exist. The basicmachines-co#1603 audit was a
manual pass with no test behind it.

Every example now names a registered tool and passes everything after its
first argument by keyword. A new test scans the string literals in the
MCP tools, prompts and resources and fails on a positional example, an
unknown keyword, or a retired tool name. The search-notes(3) CLI synopsis
listed --search-type, which bm tool search-notes does not accept.

Fixes basicmachines-co#1633

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
The module docstring claimed created_by/updated_by cannot be set through
note content. That holds only where the runtime supplies an identity and
stamps over them; on local runtimes the keys pass through as written,
which is the intended behavior (basicmachines-co#1634).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
… fallback

The underscore relation test used a relation-shaped URL without the
target's project prefix, which resolved only through the fuzzy search
removed for basicmachines-co#1626. Assert the real relation address resolves and the
prefix-less form is a miss.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
…lookup

file_path is project-relative, so a project-qualified
<project>/components/foo.txt missed the exact lookup and fell through to
the extension-less permalink candidates that match foo.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
…retry

Workspace-scoped routes qualify identifiers as <workspace>/<project>/Title.
The own-project retry only split the first segment and compared the
workspace slug with the project permalink, so it was skipped, and with
build_context no longer fuzzy matching, the title returned nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
Startup recovery rebuilds its materialization request from note_content,
so it carries no journal position and never settled the accepted row; a
note recovered on restart and not written again kept the wiki partial.
Recovery now settles the note's journal rows up to the recovered
db_version when the write ends in any status but conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
Resource entities (text, images, PDFs) are indexed with permalink NULL, so
a permalink candidate built by dropping .txt could only ever match the
same-stem Markdown note. Rather than special-casing an exact file-path
probe ahead of the candidates in LinkResolver, the shared candidate
builder now keeps a real non-Markdown extension, and the ordinary lookup
order reaches the file: exact file path, then the own-project remainder
retry, whose prefix match normalizes by permalink (so TEST-PROJECT/... and
workspace prefixes work too). The builder is shared, so the bulk link
resolver and OKF rendering no longer turn [[notes/foo.txt]] into foo.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
resolve_entity, the path API entity reads use, ran one exact pass on the
raw identifier and never retried the own-project remainder, so a routed
<project>/components/foo.txt (resource, permalink NULL) or
<project>/<Title> missed under strict resolution. Both entrypoints now
share _resolve_in_current_project: exact lookups, then the remainder,
then fuzzy search only for non-strict callers that ask for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
The bulk resolver declined its parsed-prefix fallback when the prefix
named the current project, so [[<project>/assets/a.txt]] (a resource with
no permalink) and [[<project>/Title]] stayed unresolved on every reindex.
It now runs the same strict lookups on the project-relative remainder,
matching LinkResolver.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
…al fallback

A caller that names a workspace asked for cloud explicitly. The unknown
project fallback to local ASGI now applies only when no workspace is
given, so a cloud request without credentials still fails instead of
reading a same-named local project.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
LinkResolver and the bulk resolver each grew their own own-project prefix
retry, and the bulk one handled only <project>/..., so a workspace route's
[[<workspace>/<project>/assets/image.png]] stayed unresolved. utils
own_project_remainder now strips <workspace>/<project>/ or <project>/ by
permalink, and both resolvers call it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
basicmachines-co#1613

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
Deletes were the only note mutation that skipped the actor resolver, so
their accepted-change journal row always had empty actor columns, even
when the same caller's creates and edits were attributed.

Add "delete" to NoteContentMutationKind, give delete_note the optional
actor inputs the other mutations take, resolve them, and carry the
result on AcceptedNoteDeleteMutation into the journal row. The DELETE
route passes user_profile_id=None and source="api" like its neighbors.
With no actor and no resolver the row is written exactly as before, and
deletes keep their "delete_note" journal source.

Closes basicmachines-co#1636

Signed-off-by: sammywachtel <subp@wachtel.us>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BF4TkgAJpVrgwzrhemkdig
Signed-off-by: phernandez <paul@basicmachines.co>
…icmachines-co#1641)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…basicmachines-co#1642)

Signed-off-by: Joe P <joe@basicmemory.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Paul Hernandez <60959+phernandez@users.noreply.github.com>
…asicmachines-co#1643)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ll pass (basicmachines-co#1645)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When the engine refuses an edit, for example with a 409 because a
concurrent edit advanced the note's db_version first, edit_note caught
the ToolError and returned the "Edit Failed" text as an ordinary result.
MCP clients that check isError read the refusal as success, so a refused
append was counted as written and never retried.

Raise ToolError instead, as delete_note and move_note already do: the
message is the same guidance text, or the structured payload in JSON
mode. The unresolved-project-route stop in the same handler raises too.
The CLI edit-note command reports the tool error's error field and
exits 1, matching delete-note.

Signed-off-by: sammywachtel <subp@wachtel.us>
@sammywachtel

Copy link
Copy Markdown
Owner Author

Opened upstream as basicmachines-co#1662; this review copy is no longer needed. The branch stays, because the upstream PR is opened from it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants