fix(mcp): tighten search_notes tags input and normalize for direct callers (#941)

Refs #910. Follow-up to #932.

Signed-off-by: phernandez <paul@basicmemory.com>
This commit is contained in:
Paul Hernandez
2026-06-10 14:04:56 -05:00
committed by GitHub
parent 44ecec2917
commit df485aa5a4
4 changed files with 284 additions and 8 deletions
+23 -6
View File
@@ -11,7 +11,13 @@ from fastmcp import Context
from pydantic import AliasChoices, BeforeValidator, Field
from basic_memory.config import ConfigManager, has_cloud_credentials
from basic_memory.utils import build_canonical_permalink, coerce_dict, coerce_list, parse_tags
from basic_memory.utils import (
build_canonical_permalink,
coerce_dict,
coerce_list,
parse_tags,
strict_search_tags,
)
from basic_memory.mcp.async_client import (
_explicit_routing,
_force_local_mode,
@@ -676,13 +682,15 @@ async def search_notes(
Dict[str, Any] | None,
BeforeValidator(coerce_dict),
] = None,
# parse_tags, not coerce_list: tags="a,b" must split into ["a", "b"] to match the
# tag: query shorthand below and write_note's documented tags convention (#910).
# coerce_list would wrap the comma string as the single literal tag ["a,b"],
# which matches nothing.
# strict_search_tags, not coerce_list: tags="a,b" must split into ["a", "b"] to
# match the tag: query shorthand below and write_note's documented tags convention
# (#910). coerce_list would wrap the comma string as the single literal tag
# ["a,b"], which matches nothing. Unlike bare parse_tags, the strict wrapper only
# splits str/list/None and lets Pydantic reject other types (42, {"a": 1}) with a
# clear validation error instead of stringifying them into junk tags.
tags: Annotated[
List[str] | None,
BeforeValidator(parse_tags),
BeforeValidator(strict_search_tags),
] = None,
status: Optional[str] = None,
min_similarity: Annotated[
@@ -893,6 +901,15 @@ async def search_notes(
# so preserve their original casing (unlike the lowercased note_types).
categories = categories or []
# Trigger: tags arrived via a direct function call instead of the MCP layer.
# Why: the BeforeValidator above only runs through MCP/Pydantic validation; direct
# callers (e.g. `bm tool search-notes --tag a,b` in cli/commands/tool.py, which
# Typer collects as the one-element list ["a,b"]) would otherwise forward the
# comma string as one literal tag that matches nothing (#910).
# Outcome: comma-split/list normalization applies on every path; parse_tags is
# idempotent, so MCP-validated input passes through unchanged.
tags = parse_tags(tags) or None
# Parse tag:<value> shorthand at tool level so it works with all search modes.
# Handles "tag:security", "tag:coffee tag:brewing", "tag:coffee AND tag:brewing".
# Without this, hybrid/vector modes fail because they require non-empty text,
+32
View File
@@ -568,6 +568,38 @@ def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
return []
def strict_search_tags(v: Any) -> Any:
"""Strictly coerce tag input at the search_notes tool boundary.
parse_tags stringifies anything (42 -> ["42"], {"a": 1} -> junk tags), which would
turn caller type mistakes into silent no-result searches. At the tool boundary only
str, all-string lists, and None are valid tag inputs; everything else — including
lists with non-string elements like [42] — passes through unchanged so Pydantic
rejects it with a clear validation error.
JSON array strings (the MCP clients-serialize-arrays-as-strings path) get the same
all-string check: '[42]' or '["ok", 42]' would otherwise be stringified by
parse_tags' recursive JSON handling before Pydantic ever sees the bad elements.
"""
if isinstance(v, list) and not all(isinstance(item, str) for item in v):
return v
# Trigger: a str that looks like a JSON array, mirroring parse_tags' detection.
# Why: parse_tags recursively parses JSON arrays, stringifying non-string elements
# ('[42]' -> ["42"]) and hiding the type error from Pydantic.
# Outcome: malformed arrays pass through unchanged so Pydantic rejects them; valid
# all-string arrays and plain comma strings still delegate to parse_tags.
if isinstance(v, str) and v.strip().startswith("[") and v.strip().endswith("]"):
try:
parsed = json.loads(v)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, list) and not all(isinstance(item, str) for item in parsed):
return v
if v is None or isinstance(v, (str, list)):
return parse_tags(v)
return v
def coerce_list(v: Any) -> Any:
"""Coerce string input to list for MCP clients that serialize lists as strings."""
if v is None: