Skip to content

Commit 0811c48

Browse files
groksrcclaude
andcommitted
fix(mcp): normalize note_types/entity_types/categories on direct call path and reject non-string list elements
Codex review of PR #962 identified two real issues: 1. CLI bypass: the BeforeValidator(parse_str_list) on note_types, entity_types, and categories only fires through MCP/Pydantic validation. The CLI path in cli/commands/tool.py calls search_notes() directly, so `bm tool search-notes --type note,task` arrived as note_types=["note,task"] and matched nothing. Fix: add in-body parse_str_list() normalization for all three params (mirroring the existing parse_tags() call for tags on the same code path). 2. Silent stringify: parse_str_list used str(raw) in the list branch, so [42] became ["42"] before Pydantic saw it, accepting invalid input as a no-result search instead of rejecting it. Fix: guard against non-string list elements and return the original value unchanged so Pydantic rejects it with a clear error. Tests added: annotation-level split tests for note_types/entity_types/categories, non-string-element rejection tests, async direct-call regression for note_types, and unit-level parse_str_list non-string list tests in test_coerce.py. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Drew Cain <groksrc@gmail.com>
1 parent 747e64e commit 0811c48

4 files changed

Lines changed: 193 additions & 1 deletion

File tree

src/basic_memory/mcp/tools/search.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,6 +899,17 @@ async def search_notes(
899899
if page_size < 1:
900900
raise ValueError(f"page_size must be >= 1, got {page_size}")
901901

902+
# Trigger: list params arrived via a direct function call instead of the MCP layer.
903+
# Why: the BeforeValidator annotations only run through MCP/Pydantic validation; direct
904+
# callers (e.g. `bm tool search-notes --type note,task` in cli/commands/tool.py,
905+
# which Typer collects as the one-element list ["note,task"]) would otherwise
906+
# forward the comma string as one literal type that matches nothing (#930).
907+
# Outcome: comma-split/list normalization applies on every path; parse_str_list is
908+
# idempotent, so MCP-validated input passes through unchanged.
909+
note_types = parse_str_list(note_types) if note_types is not None else []
910+
entity_types = parse_str_list(entity_types) if entity_types is not None else []
911+
categories = parse_str_list(categories) if categories is not None else []
912+
902913
# Avoid mutable-default-argument footguns. Treat None as "no filter".
903914
# Lowercase note_types so "Chapter" matches the stored "chapter".
904915
note_types = [t.lower() for t in note_types] if note_types else []

src/basic_memory/utils.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,14 +619,21 @@ def parse_str_list(v: Any) -> List[str]:
619619
return []
620620

621621
if isinstance(v, list):
622+
# Trigger: a list element is not a string (e.g. [42] or ["note", 42]).
623+
# Why: str(raw) would silently convert 42 → "42" and let invalid caller data pass
624+
# Pydantic validation as a junk filter, producing a silent no-result search.
625+
# Outcome: return the list unchanged so Pydantic rejects it with a clear error.
626+
if not all(isinstance(raw, str) for raw in v if raw is not None):
627+
return v # type: ignore[return-value]
628+
622629
# Trigger: a list element may itself be a comma-separated string (e.g. some MCP clients
623630
# serialise `["note,task"]` when the caller passed `note_types="note,task"`).
624631
# Outcome: flatten each element by splitting on commas and stripping whitespace.
625632
return [
626633
item.strip()
627634
for raw in v
628635
if raw is not None
629-
for item in str(raw).split(",")
636+
for item in raw.split(",")
630637
if item and item.strip()
631638
]
632639

tests/mcp/test_tool_search.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2115,3 +2115,161 @@ def test_default_search_type_falls_back_to_text_when_semantic_disabled():
21152115

21162116
with patch.object(search_module, "get_container", return_value=mock_container):
21172117
assert search_module._default_search_type() == "text"
2118+
2119+
2120+
# --- Tests for note_types/entity_types/categories comma-split fix (#930, Codex review) ---
2121+
2122+
2123+
def test_search_notes_note_types_annotation_splits_comma_strings():
2124+
"""The note_types parameter annotation must parse every documented input form (#930).
2125+
2126+
Direct function calls bypass the BeforeValidator; validate through the same
2127+
Annotated metadata pydantic applies on the MCP path. The old coerce_list wrapped a
2128+
bare comma string as the single literal type ["note,task"]; parse_str_list splits it.
2129+
"""
2130+
annotation = inspect.signature(search_notes).parameters["note_types"].annotation
2131+
adapter = TypeAdapter(annotation)
2132+
2133+
real_list = adapter.validate_python(["note", "task"])
2134+
comma_string = adapter.validate_python("note,task")
2135+
json_string = adapter.validate_python('["note", "task"]')
2136+
single_string = adapter.validate_python("note")
2137+
comma_in_list = adapter.validate_python(["note,task"])
2138+
2139+
assert real_list == ["note", "task"]
2140+
assert comma_string == real_list, "comma string must behave like the real list"
2141+
assert json_string == real_list
2142+
assert single_string == ["note"]
2143+
assert comma_in_list == real_list, "list with comma element must be flattened"
2144+
2145+
2146+
def test_search_notes_entity_types_annotation_splits_comma_strings():
2147+
"""The entity_types parameter annotation must parse every documented input form (#930)."""
2148+
annotation = inspect.signature(search_notes).parameters["entity_types"].annotation
2149+
adapter = TypeAdapter(annotation)
2150+
2151+
real_list = adapter.validate_python(["entity", "observation"])
2152+
comma_string = adapter.validate_python("entity,observation")
2153+
comma_in_list = adapter.validate_python(["entity,observation"])
2154+
2155+
assert real_list == ["entity", "observation"]
2156+
assert comma_string == real_list
2157+
assert comma_in_list == real_list
2158+
2159+
2160+
def test_search_notes_categories_annotation_splits_comma_strings():
2161+
"""The categories parameter annotation must parse every documented input form (#930)."""
2162+
annotation = inspect.signature(search_notes).parameters["categories"].annotation
2163+
adapter = TypeAdapter(annotation)
2164+
2165+
real_list = adapter.validate_python(["requirement", "decision"])
2166+
comma_string = adapter.validate_python("requirement,decision")
2167+
comma_in_list = adapter.validate_python(["requirement,decision"])
2168+
2169+
assert real_list == ["requirement", "decision"]
2170+
assert comma_string == real_list
2171+
assert comma_in_list == real_list
2172+
2173+
2174+
def test_search_notes_note_types_annotation_rejects_non_string_list_elements():
2175+
"""note_types=[42] must fail Pydantic validation, not be stringified to ['42'].
2176+
2177+
parse_str_list used str(raw) to coerce list elements, silently accepting [42] as
2178+
["42"]. The fix guards against non-string list elements and returns the original
2179+
value so Pydantic rejects it with a clear error.
2180+
"""
2181+
from pydantic import ValidationError
2182+
2183+
annotation = inspect.signature(search_notes).parameters["note_types"].annotation
2184+
adapter = TypeAdapter(annotation)
2185+
2186+
with pytest.raises(ValidationError):
2187+
adapter.validate_python([42])
2188+
with pytest.raises(ValidationError):
2189+
adapter.validate_python(["note", 42])
2190+
2191+
# All-string lists remain valid.
2192+
assert adapter.validate_python(["note", "task"]) == ["note", "task"]
2193+
2194+
2195+
def test_search_notes_entity_types_annotation_rejects_non_string_list_elements():
2196+
"""entity_types=[42] must fail Pydantic validation, not be stringified."""
2197+
from pydantic import ValidationError
2198+
2199+
annotation = inspect.signature(search_notes).parameters["entity_types"].annotation
2200+
adapter = TypeAdapter(annotation)
2201+
2202+
with pytest.raises(ValidationError):
2203+
adapter.validate_python([42])
2204+
with pytest.raises(ValidationError):
2205+
adapter.validate_python(["entity", 42])
2206+
2207+
assert adapter.validate_python(["entity", "observation"]) == ["entity", "observation"]
2208+
2209+
2210+
def test_search_notes_categories_annotation_rejects_non_string_list_elements():
2211+
"""categories=[42] must fail Pydantic validation, not be stringified."""
2212+
from pydantic import ValidationError
2213+
2214+
annotation = inspect.signature(search_notes).parameters["categories"].annotation
2215+
adapter = TypeAdapter(annotation)
2216+
2217+
with pytest.raises(ValidationError):
2218+
adapter.validate_python([42])
2219+
with pytest.raises(ValidationError):
2220+
adapter.validate_python(["requirement", 42])
2221+
2222+
assert adapter.validate_python(["requirement", "decision"]) == ["requirement", "decision"]
2223+
2224+
2225+
@pytest.mark.asyncio
2226+
async def test_search_notes_direct_call_splits_comma_note_types(client, test_project):
2227+
"""Direct callers bypass the BeforeValidator, so the body must normalize note_types.
2228+
2229+
Regression for the CLI path: `bm tool search-notes --type note,task` calls this
2230+
function directly with Typer's collected list ["note,task"], which must split into
2231+
["note", "task"] and match the note correctly (#930, Codex review follow-up).
2232+
"""
2233+
await write_note(
2234+
project=test_project.name,
2235+
title="Direct NoteType Split Note",
2236+
directory="test",
2237+
content="# Direct NoteType Split Note\nNoteTypeSplitToken body",
2238+
)
2239+
2240+
async def found(note_types_value: list[str] | None) -> bool:
2241+
result = await search_notes(
2242+
project=test_project.name,
2243+
query="NoteTypeSplitToken",
2244+
search_type="text",
2245+
output_format="json",
2246+
note_types=note_types_value,
2247+
)
2248+
assert isinstance(result, dict), f"search failed: {result}"
2249+
return any(r["title"] == "Direct NoteType Split Note" for r in result["results"])
2250+
2251+
assert await found(None), "no filter must match (sanity)"
2252+
assert await found(["note"]), "plain single-type list must match (sanity)"
2253+
# The CLI regression: Typer collects --type note,task as the single element "note,task".
2254+
assert await found(["note,task"]), "comma list element must be flattened and match 'note'"
2255+
# Negative control: a specific nonexistent type must not match.
2256+
assert not await found(["nonexistent_type"])
2257+
2258+
2259+
def test_search_notes_parse_str_list_rejects_non_string_list_elements_in_place():
2260+
"""parse_str_list must return non-str list elements unchanged for Pydantic rejection.
2261+
2262+
The old implementation used str(raw) which silently coerced [42] -> ['42'],
2263+
causing bad caller data to become silent no-result searches instead of a
2264+
clear Pydantic validation error.
2265+
"""
2266+
from basic_memory.utils import parse_str_list
2267+
2268+
# Non-string list elements pass through unchanged.
2269+
assert parse_str_list([42]) == [42] # type: ignore[arg-type]
2270+
assert parse_str_list(["ok", 42]) == ["ok", 42] # type: ignore[arg-type]
2271+
assert parse_str_list([{"a": 1}]) == [{"a": 1}] # type: ignore[arg-type]
2272+
2273+
# All-string lists still work correctly.
2274+
assert parse_str_list(["note", "task"]) == ["note", "task"]
2275+
assert parse_str_list(["note,task"]) == ["note", "task"]

tests/test_coerce.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,19 @@ def test_int_passthrough_for_pydantic_rejection(self):
176176
def test_dict_passthrough_for_pydantic_rejection(self):
177177
value = {"a": 1}
178178
assert parse_str_list(value) is value # type: ignore[arg-type]
179+
180+
# --- Non-string list elements pass through unchanged (Codex review fix) ---
181+
182+
def test_int_list_passthrough_for_pydantic_rejection(self):
183+
"""Lists with non-string elements must not be stringified ([42] → ['42'])."""
184+
value = [42]
185+
assert parse_str_list(value) is value # type: ignore[arg-type]
186+
187+
def test_mixed_list_passthrough_for_pydantic_rejection(self):
188+
"""One non-string element poisons the whole list — no partial coercion."""
189+
value = ["note", 42]
190+
assert parse_str_list(value) is value # type: ignore[arg-type]
191+
192+
def test_dict_list_passthrough_for_pydantic_rejection(self):
193+
value = [{"a": 1}]
194+
assert parse_str_list(value) is value # type: ignore[arg-type]

0 commit comments

Comments
 (0)