@@ -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\n DirectTagToken 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
0 commit comments