Skip to content

Commit 4ed20f6

Browse files
phernandezclaude
andcommitted
fix(cli): align bm tool commands with MCP (error exits, overwrite, category, default)
Fixes confirmed CLI/MCP parity bugs found by the integration-test bug hunt: - #1 / #5: `bm tool write-note` exited 0 on an error/conflict JSON result (e.g. NOTE_ALREADY_EXISTS). Added the same error-field guard the sibling commands (delete-note/edit-note/search-notes) use so a blocked/failed write exits non-zero. The conflict JSON is still printed to stdout for tooling. - #6: `bm tool read-note` exited 0 on a SECURITY_VALIDATION_ERROR (path traversal). Added the same error-field guard; genuine not-found (null fields, no error key) still exits 0. - #2: `bm tool write-note` had no --overwrite flag though MCP write_note supports overwrite=True. Added --overwrite and forward it to the tool. - #4: `bm tool search-notes` lacked a --category filter though MCP search_notes has a categories param. Added repeatable --category and forward categories= to the tool. - #3: `bm tool recent-activity` default --page-size was 50; changed to 10 to match the MCP recent_activity default and the other CLI tool commands. All five fixes live in src/basic_memory/cli/commands/tool.py. Regression tests added under test-int/bughunt_fixes/ come from the integration bug hunt (real CliRunner -> CLI -> MCP tool -> ASGI API -> DB/files, no mocks). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 07cb7a6 commit 4ed20f6

5 files changed

Lines changed: 439 additions & 1 deletion

File tree

src/basic_memory/cli/commands/tool.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ def write_note(
100100
help="Project external_id (UUID). Takes precedence over --project; use to disambiguate same-named projects across cloud workspaces.",
101101
),
102102
] = None,
103+
overwrite: bool = typer.Option(
104+
False,
105+
"--overwrite",
106+
help="Replace an existing note on conflict (matches MCP write_note overwrite=True)",
107+
),
103108
local: bool = typer.Option(
104109
False, "--local", help="Force local API routing (ignore cloud mode)"
105110
),
@@ -112,6 +117,7 @@ def write_note(
112117
bm tool write-note --title "My Note" --folder "notes" --content "Note content"
113118
bm tool write-note --title "My Guide" --folder "notes" --content "..." --type guide
114119
echo "content" | bm tool write-note --title "My Note" --folder "notes"
120+
bm tool write-note --title "My Note" --folder "notes" --overwrite
115121
bm tool write-note --title "My Note" --folder "notes" --local
116122
"""
117123
try:
@@ -144,9 +150,22 @@ def write_note(
144150
project_id=project_id,
145151
tags=tags,
146152
note_type=note_type,
153+
overwrite=overwrite,
147154
output_format="json",
148155
)
149156
)
157+
158+
# MCP tool returns an error field on failure in JSON mode (e.g.
159+
# NOTE_ALREADY_EXISTS on a blocked overwrite, SECURITY_VALIDATION_ERROR).
160+
# Trigger: result carries a non-empty `error`.
161+
# Why: parity with delete-note/edit-note/search-notes so exit-code-driven
162+
# scripts detect a failed/blocked write instead of seeing exit 0.
163+
# Outcome: print the error to stderr and exit non-zero.
164+
if isinstance(result, dict) and result.get("error"):
165+
typer.echo(f"Error: {result['error']}", err=True)
166+
_print_json(result)
167+
raise typer.Exit(1)
168+
150169
_print_json(result)
151170
except ValueError as e:
152171
typer.echo(f"Error: {e}", err=True)
@@ -200,6 +219,19 @@ def read_note(
200219
output_format="json",
201220
)
202221
)
222+
223+
# MCP tool returns an error field on failure in JSON mode (e.g.
224+
# SECURITY_VALIDATION_ERROR on a path-traversal identifier). A genuine
225+
# not-found returns null fields with no `error` key, so it still exits 0.
226+
# Trigger: result carries a non-empty `error`.
227+
# Why: parity with edit-note/delete-note/search-notes so a blocked read
228+
# surfaces a non-zero exit instead of looking like success.
229+
# Outcome: print the error to stderr and exit non-zero.
230+
if isinstance(result, dict) and result.get("error"):
231+
typer.echo(f"Error: {result['error']}", err=True)
232+
_print_json(result)
233+
raise typer.Exit(1)
234+
203235
_print_json(result)
204236
except ValueError as e:
205237
typer.echo(f"Error: {e}", err=True)
@@ -417,7 +449,9 @@ def recent_activity(
417449
"7d", "--timeframe", help="Timeframe filter (e.g., '7d', '1 week')"
418450
),
419451
page: int = typer.Option(1, "--page", help="Page number for pagination"),
420-
page_size: int = typer.Option(50, "--page-size", help="Number of results per page"),
452+
# Match the MCP recent_activity default (page_size=10) so identical default
453+
# invocations return the same number of rows from CLI and MCP.
454+
page_size: int = typer.Option(10, "--page-size", help="Number of results per page"),
421455
project: Annotated[
422456
Optional[str],
423457
typer.Option(help="The project to use. If not provided, the default project will be used."),
@@ -502,6 +536,16 @@ def search_notes(
502536
help="Filter by search item type: entity, observation, relation (repeatable)",
503537
),
504538
] = None,
539+
categories: Annotated[
540+
Optional[List[str]],
541+
typer.Option(
542+
"--category",
543+
help=(
544+
"Filter observation results to exact categories (repeatable); "
545+
"pair with --entity-type observation"
546+
),
547+
),
548+
] = None,
505549
meta: Annotated[
506550
Optional[List[str]],
507551
typer.Option("--meta", help="Filter by frontmatter key=value (repeatable)"),
@@ -536,6 +580,7 @@ def search_notes(
536580
bm tool search-notes --permalink "specs/*"
537581
bm tool search-notes --tag python --tag async
538582
bm tool search-notes --meta status=draft
583+
bm tool search-notes "auth" --entity-type observation --category requirement
539584
"""
540585
try:
541586
validate_routing_flags(local, cloud)
@@ -601,6 +646,7 @@ def search_notes(
601646
page_size=page_size,
602647
note_types=note_types,
603648
entity_types=entity_types,
649+
categories=categories,
604650
metadata_filters=metadata_filters,
605651
tags=tags,
606652
status=status,
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Bug hunt regression test (#6): `bm tool read-note` exit code on a
2+
path-traversal SECURITY_VALIDATION_ERROR.
3+
4+
The MCP read_note tool detects path-traversal identifiers and returns
5+
{"error": "SECURITY_VALIDATION_ERROR", ...}. Every other wrapped tool command
6+
exits non-zero on an error payload; read-note used to print the payload and
7+
exit 0. These integration tests assert read-note now matches its siblings.
8+
"""
9+
10+
import pytest
11+
from typer.testing import CliRunner
12+
13+
from basic_memory.cli.main import app as cli_app
14+
from basic_memory.mcp.tools import read_note as mcp_read_note
15+
16+
runner = CliRunner()
17+
18+
TRAVERSAL_IDENTIFIER = "../../../../etc/passwd"
19+
20+
21+
@pytest.mark.asyncio
22+
async def test_read_note_security_error_mcp_emits_error_field(
23+
app, app_config, test_project, config_manager
24+
):
25+
"""MCP read_note JSON flags the path traversal with a SECURITY_VALIDATION_ERROR."""
26+
result = await mcp_read_note(
27+
identifier=TRAVERSAL_IDENTIFIER,
28+
project=test_project.name,
29+
output_format="json",
30+
)
31+
assert isinstance(result, dict)
32+
assert result.get("error") == "SECURITY_VALIDATION_ERROR"
33+
34+
35+
def test_read_note_security_error_cli_exit_code_matches_other_tools(
36+
app, app_config, test_project, config_manager
37+
):
38+
"""CLI read-note must not exit 0 when the MCP payload carries an error."""
39+
result = runner.invoke(
40+
cli_app,
41+
["tool", "read-note", TRAVERSAL_IDENTIFIER, "--project", test_project.name],
42+
)
43+
44+
combined = result.stdout
45+
assert "SECURITY_VALIDATION_ERROR" in combined, combined
46+
47+
assert result.exit_code != 0, (
48+
f"read-note exited {result.exit_code} on a SECURITY_VALIDATION_ERROR; "
49+
"other tool commands exit non-zero on error payloads"
50+
)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Bug hunt regression test (#3): `bm tool recent-activity` page_size default.
2+
3+
The MCP recent_activity tool defaults page_size=10; the CLI wrapper used to
4+
default to 50. Because page_size becomes the SQL LIMIT for the query, identical
5+
default invocations returned a different number of rows from CLI vs MCP. This
6+
integration test proves the CLI default now matches the MCP default of 10.
7+
"""
8+
9+
import json
10+
11+
from typer.testing import CliRunner
12+
13+
from basic_memory.cli.main import app as cli_app
14+
15+
runner = CliRunner()
16+
17+
MCP_DEFAULT_PAGE_SIZE = 10
18+
19+
20+
def _write_note(title: str, folder: str, content: str) -> None:
21+
result = runner.invoke(
22+
cli_app,
23+
[
24+
"tool",
25+
"write-note",
26+
"--title",
27+
title,
28+
"--folder",
29+
folder,
30+
"--content",
31+
content,
32+
],
33+
)
34+
assert result.exit_code == 0, result.output
35+
36+
37+
def test_recent_activity_default_page_size_matches_mcp(
38+
app, app_config, test_project, config_manager, monkeypatch
39+
):
40+
"""CLI recent-activity default page_size must match the MCP tool default (10)."""
41+
monkeypatch.setenv("BASIC_MEMORY_MCP_PROJECT", test_project.name)
42+
43+
for i in range(15):
44+
_write_note(
45+
f"Parity Note {i:02d}",
46+
"parity-recent",
47+
f"# Parity Note {i:02d}\n\nUnique body token PARITY{i:02d}.",
48+
)
49+
50+
mcp_default_result = runner.invoke(
51+
cli_app,
52+
[
53+
"tool",
54+
"recent-activity",
55+
"--project",
56+
test_project.name,
57+
"--page-size",
58+
str(MCP_DEFAULT_PAGE_SIZE),
59+
],
60+
)
61+
assert mcp_default_result.exit_code == 0, mcp_default_result.output
62+
mcp_default_rows = json.loads(mcp_default_result.stdout)
63+
assert len(mcp_default_rows) == MCP_DEFAULT_PAGE_SIZE
64+
65+
cli_default_result = runner.invoke(
66+
cli_app,
67+
["tool", "recent-activity", "--project", test_project.name],
68+
)
69+
assert cli_default_result.exit_code == 0, cli_default_result.output
70+
cli_default_rows = json.loads(cli_default_result.stdout)
71+
72+
assert len(cli_default_rows) == MCP_DEFAULT_PAGE_SIZE, (
73+
f"CLI recent-activity default returned {len(cli_default_rows)} rows but "
74+
f"the MCP tool default (page_size={MCP_DEFAULT_PAGE_SIZE}) returns "
75+
f"{len(mcp_default_rows)}. Default page_size diverges (CLI=50 vs MCP=10)."
76+
)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Bug hunt regression test (#4): `bm tool search-notes` --category filter.
2+
3+
The MCP search_notes tool exposes a `categories` parameter for exact-match
4+
observation-category filtering. The CLI wrapper had no equivalent flag. This
5+
integration test asserts the CLI now exposes `--category` and that it filters
6+
observation results to the requested category exactly.
7+
"""
8+
9+
import json
10+
11+
from typer.testing import CliRunner
12+
13+
from basic_memory.cli.main import app as cli_app
14+
15+
runner = CliRunner()
16+
17+
18+
def _write_note(title: str, folder: str, content: str) -> dict:
19+
result = runner.invoke(
20+
cli_app,
21+
[
22+
"tool",
23+
"write-note",
24+
"--title",
25+
title,
26+
"--folder",
27+
folder,
28+
"--content",
29+
content,
30+
],
31+
)
32+
assert result.exit_code == 0, result.output
33+
return json.loads(result.stdout)
34+
35+
36+
def test_search_notes_exposes_category_filter(app, app_config, test_project, config_manager):
37+
"""CLI search-notes should expose --category like the MCP `categories` param."""
38+
_write_note(
39+
"Category Filter Note",
40+
"parity-category",
41+
"# Category Filter Note\n\n"
42+
"## Observations\n"
43+
"- [requirement] system must authenticate users CATTOKEN\n"
44+
"- [decision] use OAuth for auth CATTOKEN\n",
45+
)
46+
47+
result = runner.invoke(
48+
cli_app,
49+
[
50+
"tool",
51+
"search-notes",
52+
"CATTOKEN",
53+
"--project",
54+
test_project.name,
55+
"--entity-type",
56+
"observation",
57+
"--category",
58+
"requirement",
59+
],
60+
)
61+
62+
assert result.exit_code == 0, (
63+
"`--category` filter is not supported by the CLI search-notes command "
64+
"even though the MCP search_notes tool documents a `categories` param. "
65+
f"exit_code={result.exit_code} output={result.output}"
66+
)
67+
68+
payload = json.loads(result.stdout)
69+
categories = {r.get("category") for r in payload.get("results", []) if r.get("category")}
70+
assert categories == {"requirement"}, (
71+
"--category requirement should return only requirement observations, "
72+
f"got categories={categories}"
73+
)

0 commit comments

Comments
 (0)