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>
This commit is contained in:
Drew Cain
2026-06-11 09:38:36 -05:00
committed by Drew Cain
parent 747e64e5e9
commit 0811c48252
4 changed files with 193 additions and 1 deletions
+11
View File
@@ -899,6 +899,17 @@ async def search_notes(
if page_size < 1:
raise ValueError(f"page_size must be >= 1, got {page_size}")
# Trigger: list params arrived via a direct function call instead of the MCP layer.
# Why: the BeforeValidator annotations only run through MCP/Pydantic validation; direct
# callers (e.g. `bm tool search-notes --type note,task` in cli/commands/tool.py,
# which Typer collects as the one-element list ["note,task"]) would otherwise
# forward the comma string as one literal type that matches nothing (#930).
# Outcome: comma-split/list normalization applies on every path; parse_str_list is
# idempotent, so MCP-validated input passes through unchanged.
note_types = parse_str_list(note_types) if note_types is not None else []
entity_types = parse_str_list(entity_types) if entity_types is not None else []
categories = parse_str_list(categories) if categories is not None else []
# Avoid mutable-default-argument footguns. Treat None as "no filter".
# Lowercase note_types so "Chapter" matches the stored "chapter".
note_types = [t.lower() for t in note_types] if note_types else []
+8 -1
View File
@@ -619,6 +619,13 @@ def parse_str_list(v: Any) -> List[str]:
return []
if isinstance(v, list):
# Trigger: a list element is not a string (e.g. [42] or ["note", 42]).
# Why: str(raw) would silently convert 42 → "42" and let invalid caller data pass
# Pydantic validation as a junk filter, producing a silent no-result search.
# Outcome: return the list unchanged so Pydantic rejects it with a clear error.
if not all(isinstance(raw, str) for raw in v if raw is not None):
return v # type: ignore[return-value]
# Trigger: a list element may itself be a comma-separated string (e.g. some MCP clients
# serialise `["note,task"]` when the caller passed `note_types="note,task"`).
# Outcome: flatten each element by splitting on commas and stripping whitespace.
@@ -626,7 +633,7 @@ def parse_str_list(v: Any) -> List[str]:
item.strip()
for raw in v
if raw is not None
for item in str(raw).split(",")
for item in raw.split(",")
if item and item.strip()
]