mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
feat(mcp): comma-split note_types/entity_types/categories in search_notes (#930)
Add `parse_str_list` to utils.py — like `parse_tags` but without stripping '#' — and wire it as the BeforeValidator for note_types, entity_types, and categories in search_notes. This makes passing "note,task" or '["note","task"]' work correctly instead of being wrapped as a single literal value by coerce_list. coerce_list is left unchanged; canvas and other callers that depend on its wrap-single-string behaviour are unaffected. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Drew Cain <groksrc@gmail.com>
This commit is contained in:
@@ -14,7 +14,7 @@ from basic_memory.config import ConfigManager, has_cloud_credentials
|
||||
from basic_memory.utils import (
|
||||
build_canonical_permalink,
|
||||
coerce_dict,
|
||||
coerce_list,
|
||||
parse_str_list,
|
||||
parse_tags,
|
||||
strict_search_tags,
|
||||
)
|
||||
@@ -649,24 +649,30 @@ async def search_notes(
|
||||
# Plural-vs-singular trips models constantly. Accept the singular too.
|
||||
note_types: Annotated[
|
||||
List[str] | None,
|
||||
BeforeValidator(coerce_list),
|
||||
# parse_str_list, not coerce_list: "note,task" must split into ["note", "task"]
|
||||
# consistent with how tags are handled (#910/#930). coerce_list wraps the whole
|
||||
# comma string as the single literal type ["note,task"], which matches nothing.
|
||||
BeforeValidator(parse_str_list),
|
||||
Field(default=None, validation_alias=AliasChoices("note_types", "note_type", "types")),
|
||||
"Filter by the 'type' field in note frontmatter (e.g. 'note', 'chapter', 'person'). "
|
||||
"Accepts a list, a comma-separated string (e.g. 'note,task'), or a JSON-array string. "
|
||||
"Case-insensitive.",
|
||||
] = None,
|
||||
entity_types: Annotated[
|
||||
List[str] | None,
|
||||
BeforeValidator(coerce_list),
|
||||
BeforeValidator(parse_str_list),
|
||||
Field(default=None, validation_alias=AliasChoices("entity_types", "entity_type")),
|
||||
"Filter by knowledge graph item type: 'entity' (whole notes), 'observation', or "
|
||||
"'relation'. Defaults to 'entity'. Do NOT pass schema/frontmatter types like "
|
||||
"'Chapter' here — use note_types instead.",
|
||||
"'Chapter' here — use note_types instead. "
|
||||
"Accepts a list, a comma-separated string (e.g. 'entity,observation'), or a JSON-array string.",
|
||||
] = None,
|
||||
categories: Annotated[
|
||||
List[str] | None,
|
||||
BeforeValidator(coerce_list),
|
||||
BeforeValidator(parse_str_list),
|
||||
Field(default=None, validation_alias=AliasChoices("categories", "category")),
|
||||
"Filter observation results to these exact categories (e.g. ['requirement']). "
|
||||
"Accepts a list, a comma-separated string (e.g. 'requirement,decision'), or a JSON-array string. "
|
||||
"Pair with entity_types=['observation'] to return only observations whose "
|
||||
"category matches exactly — not every row mentioning the word.",
|
||||
] = None,
|
||||
|
||||
@@ -600,6 +600,55 @@ def strict_search_tags(v: Any) -> Any:
|
||||
return v
|
||||
|
||||
|
||||
def parse_str_list(v: Any) -> List[str]:
|
||||
"""Parse a list of plain strings from various input formats.
|
||||
|
||||
Like parse_tags but without stripping '#' — correct for type/category params
|
||||
where the value is a literal identifier, not a hashtag.
|
||||
|
||||
Handles the four input shapes that MCP clients commonly produce:
|
||||
- None → []
|
||||
- "note,task" → ["note", "task"] (comma-split string)
|
||||
- '["note","task"]' → ["note", "task"] (JSON array string)
|
||||
- ["note,task"] → ["note", "task"] (list with comma-string element)
|
||||
|
||||
Non-str/list/None values are returned unchanged so Pydantic can reject them
|
||||
with a clear validation error instead of silently coercing.
|
||||
"""
|
||||
if v is None:
|
||||
return []
|
||||
|
||||
if isinstance(v, list):
|
||||
# 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.
|
||||
return [
|
||||
item.strip()
|
||||
for raw in v
|
||||
if raw is not None
|
||||
for item in str(raw).split(",")
|
||||
if item and item.strip()
|
||||
]
|
||||
|
||||
if isinstance(v, str):
|
||||
# Trigger: MCP clients sometimes send a JSON array string like '["note","task"]'.
|
||||
# Outcome: parse it as JSON first, then recurse to handle the resulting list.
|
||||
stripped = v.strip()
|
||||
if stripped.startswith("[") and stripped.endswith("]"):
|
||||
try:
|
||||
parsed_json = json.loads(stripped)
|
||||
if isinstance(parsed_json, list):
|
||||
return parse_str_list(parsed_json)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Plain comma-separated string: "note,task" → ["note", "task"]
|
||||
return [item.strip() for item in v.split(",") if item and item.strip()]
|
||||
|
||||
# Non-str/list/None — return unchanged so Pydantic rejects with a clear error.
|
||||
return v # type: ignore[return-value]
|
||||
|
||||
|
||||
def coerce_list(v: Any) -> Any:
|
||||
"""Coerce string input to list for MCP clients that serialize lists as strings."""
|
||||
if v is None:
|
||||
|
||||
Reference in New Issue
Block a user