Skip to content

Commit 08f9063

Browse files
Merge branch 'main' into global-settings-fallback
2 parents 9be1824 + ca9a4d9 commit 08f9063

2 files changed

Lines changed: 81 additions & 3 deletions

File tree

src/basic_memory/mcp/tools/search.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
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
14+
from basic_memory.utils import build_canonical_permalink, coerce_dict, coerce_list, parse_tags
1515
from basic_memory.mcp.async_client import (
1616
_explicit_routing,
1717
_force_local_mode,
@@ -676,9 +676,13 @@ async def search_notes(
676676
Dict[str, Any] | None,
677677
BeforeValidator(coerce_dict),
678678
] = 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.
679683
tags: Annotated[
680684
List[str] | None,
681-
BeforeValidator(coerce_list),
685+
BeforeValidator(parse_tags),
682686
] = None,
683687
status: Optional[str] = None,
684688
min_similarity: Annotated[
@@ -795,7 +799,9 @@ async def search_notes(
795799
observations whose category matches exactly.
796800
after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01")
797801
metadata_filters: Optional structured frontmatter filters (e.g., {"status": "in-progress"})
798-
tags: Optional tag filter (frontmatter tags); shorthand for metadata_filters["tags"]
802+
tags: Optional tag filter (frontmatter tags); shorthand for metadata_filters["tags"].
803+
Accepts a list (["a", "b"]) or a comma-separated string ("a,b"), matching the
804+
write_note tags convention and the tag: query shorthand.
799805
status: Optional status filter (frontmatter status); shorthand for metadata_filters["status"]
800806
min_similarity: Optional float to override the global semantic_min_similarity threshold
801807
for this query. E.g., 0.0 to see all vector results, or 0.8 for high precision.

tests/mcp/test_tool_search.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""Tests for search MCP tools."""
22

3+
import inspect
4+
35
import pytest
46
from contextlib import asynccontextmanager
57
from datetime import datetime, timedelta
68
from types import SimpleNamespace
79
from typing import cast
810

11+
from pydantic import TypeAdapter
12+
913
from basic_memory.mcp.tools import write_note
1014
from basic_memory.mcp.tools.search import (
1115
search_notes,
@@ -1622,6 +1626,74 @@ async def fake_resolve(client, query, project, context):
16221626
assert captured_payload["text"] == "authentication"
16231627

16241628

1629+
# --- Tests for comma-separated tags parameter (#910) ----------------------------
1630+
1631+
1632+
def test_search_notes_tags_annotation_splits_comma_strings():
1633+
"""The tags parameter annotation must parse every documented input form (#910).
1634+
1635+
Direct function calls bypass the BeforeValidator, so validate through the same
1636+
Annotated metadata pydantic applies on the MCP path. coerce_list wrapped a bare
1637+
comma string as the single literal tag ["a,b"]; parse_tags splits it like the
1638+
tag: query shorthand and write_note's tags convention.
1639+
"""
1640+
annotation = inspect.signature(search_notes).parameters["tags"].annotation
1641+
adapter = TypeAdapter(annotation)
1642+
1643+
real_list = adapter.validate_python(["a", "b"])
1644+
comma_string = adapter.validate_python("a,b")
1645+
json_string = adapter.validate_python('["a", "b"]')
1646+
single_string = adapter.validate_python("a")
1647+
1648+
assert real_list == ["a", "b"]
1649+
# The comma string and the real list must behave identically (the #910 bug).
1650+
assert comma_string == real_list
1651+
assert json_string == real_list
1652+
assert single_string == ["a"]
1653+
1654+
1655+
@pytest.mark.asyncio
1656+
async def test_search_notes_tags_comma_string_filters_via_mcp(mcp, client, test_project):
1657+
"""tags="alpha,beta" through the real MCP layer must match like a real list (#910)."""
1658+
from fastmcp import Client
1659+
1660+
async with Client(mcp) as mcp_client:
1661+
await mcp_client.call_tool(
1662+
"write_note",
1663+
{
1664+
"project": test_project.name,
1665+
"title": "Tag Split Note",
1666+
"directory": "test",
1667+
"content": "# Tag Split Note\nTagSplitToken body",
1668+
"tags": ["alpha", "beta"],
1669+
},
1670+
)
1671+
1672+
async def found(tags_value: object) -> bool:
1673+
result = await mcp_client.call_tool(
1674+
"search_notes",
1675+
{
1676+
"project": test_project.name,
1677+
"query": "TagSplitToken",
1678+
"search_type": "text",
1679+
"tags": tags_value,
1680+
},
1681+
)
1682+
return "Tag Split Note" in result.content[0].text
1683+
1684+
as_list = await found(["alpha", "beta"])
1685+
as_comma_string = await found("alpha,beta")
1686+
as_json_string = await found('["alpha", "beta"]')
1687+
as_single_string = await found("alpha")
1688+
1689+
assert as_list, "real-list tags must match (sanity)"
1690+
assert as_comma_string == as_list, "comma string must behave like the real list"
1691+
assert as_json_string == as_list
1692+
assert as_single_string == as_list
1693+
# Negative control: the filter is actually applied, not silently dropped.
1694+
assert not await found("gamma")
1695+
1696+
16251697
# --- Tests for text output format (#641) -----------------------------------
16261698

16271699

0 commit comments

Comments
 (0)