Skip to content

Commit df485aa

Browse files
authored
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>
1 parent 44ecec2 commit df485aa

4 files changed

Lines changed: 284 additions & 8 deletions

File tree

src/basic_memory/mcp/tools/search.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,13 @@
1111
from pydantic import AliasChoices, BeforeValidator, Field
1212

1313
from basic_memory.config import ConfigManager, has_cloud_credentials
14-
from basic_memory.utils import build_canonical_permalink, coerce_dict, coerce_list, parse_tags
14+
from basic_memory.utils import (
15+
build_canonical_permalink,
16+
coerce_dict,
17+
coerce_list,
18+
parse_tags,
19+
strict_search_tags,
20+
)
1521
from basic_memory.mcp.async_client import (
1622
_explicit_routing,
1723
_force_local_mode,
@@ -676,13 +682,15 @@ async def search_notes(
676682
Dict[str, Any] | None,
677683
BeforeValidator(coerce_dict),
678684
] = None,
679-
# parse_tags, not coerce_list: tags="a,b" must split into ["a", "b"] to match the
680-
# tag: query shorthand below and write_note's documented tags convention (#910).
681-
# coerce_list would wrap the comma string as the single literal tag ["a,b"],
682-
# which matches nothing.
685+
# strict_search_tags, not coerce_list: tags="a,b" must split into ["a", "b"] to
686+
# match the tag: query shorthand below and write_note's documented tags convention
687+
# (#910). coerce_list would wrap the comma string as the single literal tag
688+
# ["a,b"], which matches nothing. Unlike bare parse_tags, the strict wrapper only
689+
# splits str/list/None and lets Pydantic reject other types (42, {"a": 1}) with a
690+
# clear validation error instead of stringifying them into junk tags.
683691
tags: Annotated[
684692
List[str] | None,
685-
BeforeValidator(parse_tags),
693+
BeforeValidator(strict_search_tags),
686694
] = None,
687695
status: Optional[str] = None,
688696
min_similarity: Annotated[
@@ -893,6 +901,15 @@ async def search_notes(
893901
# so preserve their original casing (unlike the lowercased note_types).
894902
categories = categories or []
895903

904+
# Trigger: tags arrived via a direct function call instead of the MCP layer.
905+
# Why: the BeforeValidator above only runs through MCP/Pydantic validation; direct
906+
# callers (e.g. `bm tool search-notes --tag a,b` in cli/commands/tool.py, which
907+
# Typer collects as the one-element list ["a,b"]) would otherwise forward the
908+
# comma string as one literal tag that matches nothing (#910).
909+
# Outcome: comma-split/list normalization applies on every path; parse_tags is
910+
# idempotent, so MCP-validated input passes through unchanged.
911+
tags = parse_tags(tags) or None
912+
896913
# Parse tag:<value> shorthand at tool level so it works with all search modes.
897914
# Handles "tag:security", "tag:coffee tag:brewing", "tag:coffee AND tag:brewing".
898915
# Without this, hybrid/vector modes fail because they require non-empty text,

src/basic_memory/utils.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,38 @@ def parse_tags(tags: Union[List[str], str, None]) -> List[str]:
568568
return []
569569

570570

571+
def strict_search_tags(v: Any) -> Any:
572+
"""Strictly coerce tag input at the search_notes tool boundary.
573+
574+
parse_tags stringifies anything (42 -> ["42"], {"a": 1} -> junk tags), which would
575+
turn caller type mistakes into silent no-result searches. At the tool boundary only
576+
str, all-string lists, and None are valid tag inputs; everything else — including
577+
lists with non-string elements like [42] — passes through unchanged so Pydantic
578+
rejects it with a clear validation error.
579+
580+
JSON array strings (the MCP clients-serialize-arrays-as-strings path) get the same
581+
all-string check: '[42]' or '["ok", 42]' would otherwise be stringified by
582+
parse_tags' recursive JSON handling before Pydantic ever sees the bad elements.
583+
"""
584+
if isinstance(v, list) and not all(isinstance(item, str) for item in v):
585+
return v
586+
# Trigger: a str that looks like a JSON array, mirroring parse_tags' detection.
587+
# Why: parse_tags recursively parses JSON arrays, stringifying non-string elements
588+
# ('[42]' -> ["42"]) and hiding the type error from Pydantic.
589+
# Outcome: malformed arrays pass through unchanged so Pydantic rejects them; valid
590+
# all-string arrays and plain comma strings still delegate to parse_tags.
591+
if isinstance(v, str) and v.strip().startswith("[") and v.strip().endswith("]"):
592+
try:
593+
parsed = json.loads(v)
594+
except json.JSONDecodeError:
595+
parsed = None
596+
if isinstance(parsed, list) and not all(isinstance(item, str) for item in parsed):
597+
return v
598+
if v is None or isinstance(v, (str, list)):
599+
return parse_tags(v)
600+
return v
601+
602+
571603
def coerce_list(v: Any) -> Any:
572604
"""Coerce string input to list for MCP clients that serialize lists as strings."""
573605
if v is None:

tests/mcp/test_tool_search.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1694,6 +1694,175 @@ async def found(tags_value: object) -> bool:
16941694
assert not await found("gamma")
16951695

16961696

1697+
def test_search_notes_tags_annotation_rejects_non_string_types():
1698+
"""Unsupported tag types must fail validation, not be stringified (#932 follow-up).
1699+
1700+
Bare parse_tags coerces anything to strings (42 -> ["42"], {"a": 1} -> junk tags),
1701+
silently turning caller mistakes into no-result searches. The strict_search_tags
1702+
wrapper only normalizes str/list/None and lets Pydantic reject everything else.
1703+
"""
1704+
from pydantic import ValidationError
1705+
1706+
annotation = inspect.signature(search_notes).parameters["tags"].annotation
1707+
adapter = TypeAdapter(annotation)
1708+
1709+
with pytest.raises(ValidationError):
1710+
adapter.validate_python(42)
1711+
with pytest.raises(ValidationError):
1712+
adapter.validate_python({"a": 1})
1713+
1714+
# Lists with non-string elements must also fail, not be stringified ([42] -> ["42"]).
1715+
with pytest.raises(ValidationError):
1716+
adapter.validate_python([42])
1717+
with pytest.raises(ValidationError):
1718+
adapter.validate_python([{"a": 1}])
1719+
with pytest.raises(ValidationError):
1720+
adapter.validate_python(["ok", 42])
1721+
1722+
# JSON-array strings with non-string elements must fail the same way — parse_tags
1723+
# would otherwise recursively stringify them before Pydantic validates List[str].
1724+
with pytest.raises(ValidationError):
1725+
adapter.validate_python("[42]")
1726+
with pytest.raises(ValidationError):
1727+
adapter.validate_python('[{"a": 1}]')
1728+
with pytest.raises(ValidationError):
1729+
adapter.validate_python('["ok", 42]')
1730+
1731+
# All-string lists and all-string JSON-array strings remain valid.
1732+
assert adapter.validate_python(["a", "b"]) == ["a", "b"]
1733+
assert adapter.validate_python('["a","b"]') == ["a", "b"]
1734+
1735+
# None stays a valid "no filter" input.
1736+
assert adapter.validate_python(None) in (None, [])
1737+
1738+
1739+
@pytest.mark.asyncio
1740+
async def test_search_notes_tags_invalid_type_rejected_via_mcp(mcp, client, test_project):
1741+
"""tags=42 through the real MCP layer must raise a validation error (#932 follow-up)."""
1742+
from fastmcp import Client
1743+
from fastmcp.exceptions import ToolError
1744+
1745+
async with Client(mcp) as mcp_client:
1746+
with pytest.raises(ToolError):
1747+
await mcp_client.call_tool(
1748+
"search_notes",
1749+
{
1750+
"project": test_project.name,
1751+
"query": "anything",
1752+
"tags": 42,
1753+
},
1754+
)
1755+
with pytest.raises(ToolError):
1756+
await mcp_client.call_tool(
1757+
"search_notes",
1758+
{
1759+
"project": test_project.name,
1760+
"query": "anything",
1761+
"tags": {"a": 1},
1762+
},
1763+
)
1764+
# Lists with non-string elements must be rejected too, not stringified.
1765+
with pytest.raises(ToolError):
1766+
await mcp_client.call_tool(
1767+
"search_notes",
1768+
{
1769+
"project": test_project.name,
1770+
"query": "anything",
1771+
"tags": [42],
1772+
},
1773+
)
1774+
with pytest.raises(ToolError):
1775+
await mcp_client.call_tool(
1776+
"search_notes",
1777+
{
1778+
"project": test_project.name,
1779+
"query": "anything",
1780+
"tags": [{"a": 1}],
1781+
},
1782+
)
1783+
with pytest.raises(ToolError):
1784+
await mcp_client.call_tool(
1785+
"search_notes",
1786+
{
1787+
"project": test_project.name,
1788+
"query": "anything",
1789+
"tags": ["ok", 42],
1790+
},
1791+
)
1792+
# JSON-array strings with non-string elements (clients that serialize arrays as
1793+
# strings) must be rejected too, not recursively stringified by parse_tags.
1794+
with pytest.raises(ToolError):
1795+
await mcp_client.call_tool(
1796+
"search_notes",
1797+
{
1798+
"project": test_project.name,
1799+
"query": "anything",
1800+
"tags": "[42]",
1801+
},
1802+
)
1803+
with pytest.raises(ToolError):
1804+
await mcp_client.call_tool(
1805+
"search_notes",
1806+
{
1807+
"project": test_project.name,
1808+
"query": "anything",
1809+
"tags": '[{"a": 1}]',
1810+
},
1811+
)
1812+
with pytest.raises(ToolError):
1813+
await mcp_client.call_tool(
1814+
"search_notes",
1815+
{
1816+
"project": test_project.name,
1817+
"query": "anything",
1818+
"tags": '["ok", 42]',
1819+
},
1820+
)
1821+
# Sanity: a valid all-string JSON-array string is still accepted.
1822+
await mcp_client.call_tool(
1823+
"search_notes",
1824+
{
1825+
"project": test_project.name,
1826+
"query": "anything",
1827+
"tags": '["a","b"]',
1828+
},
1829+
)
1830+
1831+
1832+
@pytest.mark.asyncio
1833+
async def test_search_notes_direct_call_splits_comma_tags(client, test_project):
1834+
"""Direct callers bypass the BeforeValidator, so the body must normalize tags.
1835+
1836+
Regression for the CLI path: `bm tool search-notes --tag alpha,beta` calls this
1837+
function directly with Typer's collected list ["alpha,beta"], which must split
1838+
into ["alpha", "beta"] instead of matching nothing (#910, #932 follow-up).
1839+
"""
1840+
await write_note(
1841+
project=test_project.name,
1842+
title="Direct Tag Split Note",
1843+
directory="test",
1844+
content="# Direct Tag Split Note\nDirectTagToken body",
1845+
tags=["alpha", "beta"],
1846+
)
1847+
1848+
async def found(tags_value: list[str] | None) -> bool:
1849+
result = await search_notes(
1850+
project=test_project.name,
1851+
query="DirectTagToken",
1852+
search_type="text",
1853+
output_format="json",
1854+
tags=tags_value,
1855+
)
1856+
assert isinstance(result, dict), f"search failed: {result}"
1857+
return any(r["title"] == "Direct Tag Split Note" for r in result["results"])
1858+
1859+
assert await found(["alpha"]), "plain tag list must match (sanity)"
1860+
# The CLI regression: Typer collects --tag alpha,beta as the single element "alpha,beta".
1861+
assert await found(["alpha,beta"])
1862+
# Negative control: the filter is still applied.
1863+
assert not await found(["gamma"])
1864+
1865+
16971866
# --- Tests for text output format (#641) -----------------------------------
16981867

16991868

tests/test_coerce.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
"""Tests for coerce_list and coerce_dict utility functions.
1+
"""Tests for coerce_list, coerce_dict, and strict_search_tags utility functions.
22
33
These must fail until the helpers are implemented in utils.py.
44
"""
55

6-
from basic_memory.utils import coerce_list, coerce_dict
6+
from basic_memory.utils import coerce_list, coerce_dict, strict_search_tags
77

88

99
class TestCoerceList:
@@ -33,6 +33,64 @@ def test_int_passthrough(self):
3333
assert coerce_list(42) == 42
3434

3535

36+
class TestStrictSearchTags:
37+
"""Tests for strict_search_tags (the search_notes tags boundary coercer)."""
38+
39+
def test_none_parses_to_empty_list(self):
40+
assert strict_search_tags(None) == []
41+
42+
def test_comma_string_splits(self):
43+
assert strict_search_tags("a,b") == ["a", "b"]
44+
45+
def test_list_with_comma_element_splits(self):
46+
assert strict_search_tags(["alpha,beta"]) == ["alpha", "beta"]
47+
48+
def test_plain_list_passthrough(self):
49+
assert strict_search_tags(["a", "b"]) == ["a", "b"]
50+
51+
def test_json_array_string(self):
52+
assert strict_search_tags('["a", "b"]') == ["a", "b"]
53+
54+
def test_int_passthrough_for_pydantic_rejection(self):
55+
"""Unsupported types pass through unchanged so Pydantic rejects them."""
56+
assert strict_search_tags(42) == 42
57+
58+
def test_dict_passthrough_for_pydantic_rejection(self):
59+
value = {"a": 1}
60+
assert strict_search_tags(value) is value
61+
62+
def test_int_list_passthrough_for_pydantic_rejection(self):
63+
"""Lists with non-string elements pass through unchanged so Pydantic rejects them."""
64+
value = [42]
65+
assert strict_search_tags(value) is value
66+
67+
def test_dict_list_passthrough_for_pydantic_rejection(self):
68+
value = [{"a": 1}]
69+
assert strict_search_tags(value) is value
70+
71+
def test_mixed_list_passthrough_for_pydantic_rejection(self):
72+
"""One bad element poisons the whole list — no partial stringification."""
73+
value = ["ok", 42]
74+
assert strict_search_tags(value) is value
75+
76+
def test_json_array_string_with_int_passthrough_for_pydantic_rejection(self):
77+
"""A JSON-array string with non-string elements must not be stringified."""
78+
value = "[42]"
79+
assert strict_search_tags(value) is value
80+
81+
def test_json_array_string_with_dict_passthrough_for_pydantic_rejection(self):
82+
value = '[{"a": 1}]'
83+
assert strict_search_tags(value) is value
84+
85+
def test_json_array_string_mixed_passthrough_for_pydantic_rejection(self):
86+
"""One bad element poisons the whole JSON-array string — no partial parse."""
87+
value = '["ok", 42]'
88+
assert strict_search_tags(value) is value
89+
90+
def test_json_array_string_all_strings_still_parses(self):
91+
assert strict_search_tags('["a","b"]') == ["a", "b"]
92+
93+
3694
class TestCoerceDict:
3795
"""Tests for coerce_dict."""
3896

0 commit comments

Comments
 (0)