mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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:
@@ -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 []
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user