Skip to content

Commit 2da78a7

Browse files
authored
fix(cli): report missing notes with a failing exit status (#1508)
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 8b09d06 commit 2da78a7

9 files changed

Lines changed: 372 additions & 60 deletions

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: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
from loguru import logger
1010
from fastmcp import Context
11+
from fastmcp.exceptions import ToolError
12+
from httpx import HTTPStatusError
1113
from pydantic import AliasChoices, Field
1214

1315
from basic_memory.config import ConfigManager
@@ -130,6 +132,8 @@ async def read_note(
130132
Aliases: limit, per_page.
131133
output_format: "text" returns markdown content or guidance text.
132134
"json" returns a structured object with title/permalink/file_path/content/frontmatter.
135+
Unresolved notes carry error="NOTE_NOT_FOUND" and a message, with
136+
related_results when suggestions are available.
133137
include_frontmatter: For unsliced JSON reads, include opening YAML in content;
134138
parsed frontmatter is returned either way. Explicit line ranges are never
135139
stripped. CLI: --frontmatter (--include-frontmatter is a deprecated alias).
@@ -290,13 +294,15 @@ async def _read_resolved_note(entity_id: str) -> str | dict[str, Any]:
290294
"next_end_line": min(total, last + width) if last < total else None,
291295
}
292296

293-
def _empty_json_payload() -> dict[str, Any]:
297+
def _not_found_json_payload() -> dict[str, Any]:
294298
return {
295299
"title": None,
296300
"permalink": None,
297301
"file_path": None,
298302
"content": None,
299303
"frontmatter": None,
304+
"error": "NOTE_NOT_FOUND",
305+
"message": f"Note not found: {identifier}",
300306
}
301307

302308
def _search_results(payload: object) -> list[dict[str, object]]:
@@ -345,7 +351,13 @@ async def _search_candidates(
345351
output_format="json",
346352
context=context,
347353
)
348-
return cast(dict[str, object], response) if isinstance(response, dict) else {}
354+
# JSON searches return a dict even when empty. Text here is a
355+
# formatted search failure, not evidence that the note is absent.
356+
if not isinstance(response, dict):
357+
if output_format == "json" or line_scan:
358+
raise RuntimeError(f"Fallback search failed: {response}")
359+
return {}
360+
return cast(dict[str, object], response)
349361

350362
def _result_title(item: dict[str, object]) -> str:
351363
return str(item.get("title") or "")
@@ -365,11 +377,47 @@ def _result_external_id(item: dict[str, object]) -> str | None:
365377
if output_format == "json" or line_scan:
366378
exact_external_id = _exact_external_id(entity_path)
367379
if exact_external_id is not None:
368-
return await _read_resolved_note(exact_external_id)
380+
try:
381+
return await _read_resolved_note(exact_external_id)
382+
except ToolError as error:
383+
cause = error.__cause__
384+
# Only the entity GET proves this UUID is absent. A 404
385+
# from its resource fallback is a failed content read.
386+
if (
387+
isinstance(cause, HTTPStatusError)
388+
and cause.response.status_code == 404
389+
and cause.request.url.path.endswith(
390+
f"/knowledge/entities/{exact_external_id}"
391+
)
392+
):
393+
if line_scan:
394+
# The slice endpoint also uses 404 for an existing
395+
# non-Markdown entity. Confirm absence without slice
396+
# parameters before classifying this error as missing.
397+
try:
398+
await knowledge_client.get_entity(exact_external_id)
399+
except ToolError as lookup_error:
400+
lookup_cause = lookup_error.__cause__
401+
if (
402+
not isinstance(lookup_cause, HTTPStatusError)
403+
or lookup_cause.response.status_code != 404
404+
):
405+
raise
406+
else:
407+
raise error
408+
if output_format == "json":
409+
return _not_found_json_payload()
410+
return format_not_found_message(active_project.name, identifier)
411+
raise
369412

370413
try:
371414
entity_id = await knowledge_client.resolve_entity(entity_path, strict=True)
372-
except Exception as error: # pragma: no cover
415+
except ToolError as error:
416+
cause = error.__cause__
417+
# Search is a recovery for a confirmed lookup miss, not for
418+
# unavailable or unauthorized resolution services.
419+
if not isinstance(cause, HTTPStatusError) or cause.response.status_code != 404:
420+
raise
373421
logger.info(f"Direct lookup failed for '{entity_path}': {error}")
374422
else:
375423
logger.info(
@@ -431,22 +479,16 @@ def _result_external_id(item: dict[str, object]) -> str | None:
431479
break
432480

433481
if result is not None and (output_format == "json" or line_scan):
434-
try:
435-
entity_id = _result_external_id(result)
436-
if entity_id is None and _result_permalink(result) is not None:
437-
entity_id = await knowledge_client.resolve_entity(
438-
_result_permalink(result) or "", strict=True
439-
)
440-
if entity_id is not None:
441-
logger.info(
442-
f"Found note by exact title search: {_result_permalink(result)}"
443-
)
444-
return await _read_resolved_note(entity_id)
445-
except Exception as error: # pragma: no cover
446-
logger.info(
447-
"Failed to fetch content for found title match "
448-
f"{_result_permalink(result)}: {error}"
482+
# An exact candidate identifies a note; retrieval failures must surface
483+
# as operational errors instead of suggesting that it is missing.
484+
entity_id = _result_external_id(result)
485+
if entity_id is None and _result_permalink(result) is not None:
486+
entity_id = await knowledge_client.resolve_entity(
487+
_result_permalink(result) or "", strict=True
449488
)
489+
if entity_id is not None:
490+
logger.info(f"Found note by exact title search: {_result_permalink(result)}")
491+
return await _read_resolved_note(entity_id)
450492
elif result is not None and _result_permalink(result):
451493
try:
452494
entity_id = await knowledge_client.resolve_entity(
@@ -472,13 +514,13 @@ def _result_external_id(item: dict[str, object]) -> str | None:
472514
text_candidates = _search_results(text_results)
473515
if not text_candidates:
474516
if output_format == "json":
475-
return _empty_json_payload()
517+
return _not_found_json_payload()
476518
return format_not_found_message(active_project.name, identifier)
477519
# The fallback search is paginated server-side to page_size, so list
478520
# the whole returned page instead of a hardcoded cap — otherwise the
479521
# caller's page_size would be silently ignored past the cap.
480522
if output_format == "json":
481-
payload = _empty_json_payload()
523+
payload = _not_found_json_payload()
482524
payload["related_results"] = [
483525
{
484526
"title": _result_title(result),

test-int/cli/test_cli_tool_delete_note_integration.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,19 @@ def _write_note(
3636
return json.loads(result.stdout)
3737

3838

39-
def _read_note(identifier: str, *, project: str | None = None) -> dict[str, Any]:
39+
def _read_note(
40+
identifier: str, *, project: str | None = None, missing: bool = False
41+
) -> dict[str, Any]:
4042
args = ["tool", "read-note", identifier]
4143
if project is not None:
4244
args.extend(["--project", project])
4345

4446
result = runner.invoke(cli_app, args)
45-
assert result.exit_code == 0, result.output
46-
return json.loads(result.stdout)
47+
assert result.exit_code == (1 if missing else 0), result.output
48+
payload = json.loads(result.stdout)
49+
if missing:
50+
assert payload["error"] == "NOTE_NOT_FOUND"
51+
return payload
4752

4853

4954
def _delete_note(
@@ -114,7 +119,7 @@ def test_delete_note_removes_file_database_record_and_search_result(
114119
}
115120
assert not note_path.exists()
116121

117-
missing = _read_note(note["permalink"])
122+
missing = _read_note(note["permalink"], missing=True)
118123
assert missing["title"] is None
119124
assert missing["permalink"] is None
120125
assert missing["content"] is None
@@ -177,7 +182,7 @@ def test_delete_note_project_id_takes_precedence_over_wrong_project_name(
177182
assert exit_code == 0, output
178183
assert payload["deleted"] is True
179184
assert payload["title"] == "CLI Delete By Project ID"
180-
assert _read_note(note["permalink"])["title"] is None
185+
assert _read_note(note["permalink"], missing=True)["title"] is None
181186

182187

183188
def test_delete_note_memory_url_detects_project_from_identifier(
@@ -197,7 +202,7 @@ def test_delete_note_memory_url_detects_project_from_identifier(
197202
assert exit_code == 0, output
198203
assert payload["deleted"] is True
199204
assert payload["permalink"] == note["permalink"]
200-
assert _read_note(note["permalink"], project=test_project.name)["title"] is None
205+
assert _read_note(note["permalink"], project=test_project.name, missing=True)["title"] is None
201206

202207

203208
def test_delete_directory_removes_nested_files_database_records_and_search_results(
@@ -238,7 +243,7 @@ def test_delete_directory_removes_nested_files_database_records_and_search_resul
238243
assert not any(path.exists() for path in note_paths)
239244

240245
for note in notes:
241-
assert _read_note(note["permalink"])["title"] is None
246+
assert _read_note(note["permalink"], missing=True)["title"] is None
242247

243248
search = _search_notes("CLI Delete Directory", mode_flag="--title")
244249
assert search["total"] == 0

test-int/cli/test_cli_tool_json_failure_integration.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,81 @@
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
1213

1314
runner = CliRunner()
1415

1516

16-
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."""
17+
@pytest.mark.parametrize(
18+
"identifier", ["nonexistent-note-that-does-not-exist", "22222222-2222-4222-8222-222222222222"]
19+
)
20+
def test_read_note_not_found(app, app_config, test_project, config_manager, identifier):
21+
"""A missing note remains machine-readable but must not report success."""
1822
result = runner.invoke(
1923
cli_app,
20-
["tool", "read-note", "nonexistent-note-that-does-not-exist"],
24+
["tool", "read-note", identifier],
2125
)
2226

23-
assert result.exit_code == 0
27+
assert result.exit_code == 1
2428
data = json.loads(result.stdout)
25-
# MCP tool returns a valid JSON payload with null fields for not-found
29+
assert data["error"] == "NOTE_NOT_FOUND"
30+
assert data["message"] == f"Note not found: {identifier}"
31+
assert "Note not found" in result.stderr
2632
assert data["title"] is None
2733
assert data["permalink"] is None
2834
assert data["content"] is None
2935

3036

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

0 commit comments

Comments
 (0)