Skip to content

Commit ade8334

Browse files
committed
fix(cli): report missing notes with a failing exit status
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 368e607 commit ade8334

4 files changed

Lines changed: 77 additions & 20 deletions

File tree

src/basic_memory/cli/commands/tool.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -770,14 +770,11 @@ def read_note(
770770
)
771771
)
772772

773-
# MCP tool returns an error field on failure in JSON mode (e.g.
774-
# SECURITY_VALIDATION_ERROR on a path-traversal identifier). A genuine
775-
# not-found returns null fields with no `error` key, so it still exits 0.
776-
# Trigger: result carries a non-empty `error`.
777-
# Why: parity with edit-note/delete-note/search-notes so a blocked read
778-
# surfaces a non-zero exit instead of looking like success.
779-
# Outcome: print the error to stderr and exit non-zero.
780-
if isinstance(result, dict) and result.get("error"):
773+
# A missing note may carry useful suggestions. Render those in the
774+
# requested mode before exiting non-zero; other failures keep the
775+
# existing JSON diagnostic path.
776+
not_found = isinstance(result, dict) and result.get("error") == "NOTE_NOT_FOUND"
777+
if isinstance(result, dict) and result.get("error") and not not_found:
781778
typer.echo(f"Error: {result['error']}", err=True)
782779
_print_json(result)
783780
raise typer.Exit(1)
@@ -801,9 +798,12 @@ def read_note(
801798
else:
802799
console.print(Text(text))
803800
elif mode == "plain":
804-
_plain_read_note(result, include_frontmatter=include_frontmatter)
801+
_plain_read_note(result, include_frontmatter=include_frontmatter and not not_found)
805802
else:
806803
_display_read_note(result, include_frontmatter=include_frontmatter)
804+
if not_found:
805+
typer.echo(f"Error: Note not found: {identifier}", err=True)
806+
raise typer.Exit(1)
807807
except ValueError as e:
808808
typer.echo(f"Error: {e}", err=True)
809809
raise typer.Exit(1)

src/basic_memory/man/man3/read-note(3).md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,13 @@ bm tool read-note IDENTIFIER [--project NAME | --project-id UUID]
3737

3838
Returns the raw markdown of a note. The identifier is resolved through a
3939
cascade: direct permalink lookup, then exact title match, then full-text
40-
search. If nothing matches exactly, read-note returns guidance text instead
41-
of an error: a ranked list of related notes, each with a copy-pasteable
40+
search. If nothing matches exactly, MCP text mode returns guidance:
41+
a ranked list of related notes, each with a copy-pasteable
4242
`read_note()` call, plus suggested `search_notes()` and `write_note()` next
43-
steps. A miss is a navigable dead end, not an exception.
43+
steps. JSON mode returns null note fields plus `error: "NOTE_NOT_FOUND"`,
44+
a message naming the identifier, and `related_results` when available.
45+
The CLI exits with status 1 for a missing note in every output mode, retaining
46+
suggestions in JSON, plain, and Rich output. An existing empty note still succeeds.
4447

4548
Accepted identifier forms (all verified):
4649

@@ -56,7 +59,7 @@ Accepted identifier forms (all verified):
5659
- **project_id** (string | null, optional, default: None) — Project external_id (UUID). Prefer this over `project` when known — it routes to the exact project regardless of name collisions across cloud workspaces. Takes precedence over `project`. Get from list_memory_projects().
5760
- **page** (integer, optional, default: 1) — Page of fallback-search results to use when the identifier does not resolve to a note directly (default: 1). A direct or exact-title match returns the note content — page/page_size never chunk the note itself, and the title-match lookup pages through fixed-size pages of title results until an exact match is found or results are exhausted, regardless of page or page_size. Aliases: page_number.
5861
- **page_size** (integer, optional, default: 10) — Number of fallback-search results per page (default: 10). When no match is found, this caps how many related-note suggestions are listed. Aliases: limit, per_page.
59-
- **output_format** (string, optional, default: "text") — "text" returns markdown content or guidance text. "json" returns a structured object with title/permalink/file_path/content/frontmatter.
62+
- **output_format** (string, optional, default: "text") — "text" returns markdown content or guidance text. "json" returns a structured object with title/permalink/file_path/content/frontmatter. Unresolved notes carry error="NOTE_NOT_FOUND" and a message, with related_results when suggestions are available.
6063
- **include_frontmatter** (boolean, optional, default: False) — For unsliced JSON reads, include opening YAML in content; parsed frontmatter is returned either way. Explicit line ranges are never stripped. CLI: --frontmatter (--include-frontmatter is a deprecated alias).
6164
- **start_line** (integer | null, optional, default: None) — First document line to read (1-based, inclusive). Defaults to 1 when only end_line is given. Line scans count the full Markdown, including frontmatter, matching cat's default line coordinates.
6265
- **end_line** (integer | null, optional, default: None) — Last document line to read (inclusive); omitted means EOF. Out-of-file ranges return empty content; invalid/reversed ranges fail. With either bound, text output is numbered and JSON carries coordinates, has_more, and next_start_line/next_end_line. include_frontmatter does not strip an explicitly addressed range. Edits between calls may shift lines.
@@ -102,7 +105,7 @@ bm tool read-note runbook --start-line 120 --end-line 180 --plain
102105

103106
## EXAMPLES
104107

105-
A miss returns suggestions, not an error (run against the dev project):
108+
A miss in MCP text mode returns suggestions (run against the dev project):
106109

107110
```
108111
read_note("xyzzy definitely missing note", project="dev")

src/basic_memory/mcp/tools/read_note.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ async def read_note(
130130
Aliases: limit, per_page.
131131
output_format: "text" returns markdown content or guidance text.
132132
"json" returns a structured object with title/permalink/file_path/content/frontmatter.
133+
Unresolved notes carry error="NOTE_NOT_FOUND" and a message, with
134+
related_results when suggestions are available.
133135
include_frontmatter: For unsliced JSON reads, include opening YAML in content;
134136
parsed frontmatter is returned either way. Explicit line ranges are never
135137
stripped. CLI: --frontmatter (--include-frontmatter is a deprecated alias).
@@ -290,13 +292,15 @@ async def _read_resolved_note(entity_id: str) -> str | dict[str, Any]:
290292
"next_end_line": min(total, last + width) if last < total else None,
291293
}
292294

293-
def _empty_json_payload() -> dict[str, Any]:
295+
def _not_found_json_payload() -> dict[str, Any]:
294296
return {
295297
"title": None,
296298
"permalink": None,
297299
"file_path": None,
298300
"content": None,
299301
"frontmatter": None,
302+
"error": "NOTE_NOT_FOUND",
303+
"message": f"Note not found: {identifier}",
300304
}
301305

302306
def _search_results(payload: object) -> list[dict[str, object]]:
@@ -472,13 +476,13 @@ def _result_external_id(item: dict[str, object]) -> str | None:
472476
text_candidates = _search_results(text_results)
473477
if not text_candidates:
474478
if output_format == "json":
475-
return _empty_json_payload()
479+
return _not_found_json_payload()
476480
return format_not_found_message(active_project.name, identifier)
477481
# The fallback search is paginated server-side to page_size, so list
478482
# the whole returned page instead of a hardcoded cap — otherwise the
479483
# caller's page_size would be silently ignored past the cap.
480484
if output_format == "json":
481-
payload = _empty_json_payload()
485+
payload = _not_found_json_payload()
482486
payload["related_results"] = [
483487
{
484488
"title": _result_title(result),

test-int/cli/test_cli_tool_json_failure_integration.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import json
88

9+
import pytest
910
from typer.testing import CliRunner
1011

1112
from basic_memory.cli.main import app as cli_app
@@ -14,20 +15,69 @@
1415

1516

1617
def test_read_note_not_found(app, app_config, test_project, config_manager):
17-
"""read-note with non-existent identifier returns JSON with null fields."""
18+
"""A missing note remains machine-readable but must not report success."""
1819
result = runner.invoke(
1920
cli_app,
2021
["tool", "read-note", "nonexistent-note-that-does-not-exist"],
2122
)
2223

23-
assert result.exit_code == 0
24+
assert result.exit_code == 1
2425
data = json.loads(result.stdout)
25-
# MCP tool returns a valid JSON payload with null fields for not-found
26+
assert data["error"] == "NOTE_NOT_FOUND"
27+
assert data["message"] == "Note not found: nonexistent-note-that-does-not-exist"
28+
assert "Note not found" in result.stderr
2629
assert data["title"] is None
2730
assert data["permalink"] is None
2831
assert data["content"] is None
2932

3033

34+
@pytest.mark.parametrize("mode", ["piped", "json", "plain", "rich"])
35+
@pytest.mark.parametrize("related", [False, True])
36+
def test_read_after_delete_fails_in_every_mode(
37+
app, app_config, test_project, config_manager, monkeypatch, mode, related
38+
):
39+
"""The actual write/delete/read flow must fail even when search offers alternatives."""
40+
monkeypatch.setattr("basic_memory.cli.commands.tool._use_rich", lambda: mode == "rich")
41+
title = "Missingneedle"
42+
created = runner.invoke(
43+
cli_app,
44+
["tool", "write-note", "--title", title, "--folder", "test", "--content", "original"],
45+
)
46+
assert created.exit_code == 0, created.output
47+
deleted = runner.invoke(cli_app, ["tool", "delete-note", title])
48+
assert deleted.exit_code == 0, deleted.output
49+
if related:
50+
alternative = runner.invoke(
51+
cli_app,
52+
[
53+
"tool",
54+
"write-note",
55+
"--title",
56+
"Alternative",
57+
"--folder",
58+
"test",
59+
"--content",
60+
f"This mentions {title} but is a different note.",
61+
],
62+
)
63+
assert alternative.exit_code == 0, alternative.output
64+
65+
flags = [f"--{mode}"] if mode in {"json", "plain"} else []
66+
result = runner.invoke(cli_app, ["tool", "read-note", title, "--frontmatter", *flags])
67+
assert result.exit_code == 1, result.output
68+
assert f"Note not found: {title}" in result.stderr
69+
if mode in {"piped", "json"}:
70+
payload = json.loads(result.stdout)
71+
assert payload["error"] == "NOTE_NOT_FOUND"
72+
assert payload["content"] is None
73+
if related:
74+
assert payload["related_results"][0]["title"] == "Alternative"
75+
else:
76+
assert "Note not found" in result.stdout
77+
if related:
78+
assert "Alternative" in result.stdout
79+
80+
3181
def test_write_note_missing_content(app, app_config, test_project, config_manager):
3282
"""write-note without content or stdin returns error exit code."""
3383
result = runner.invoke(

0 commit comments

Comments
 (0)