|
1 | 1 | """Tests for search MCP tools.""" |
2 | 2 |
|
| 3 | +import inspect |
| 4 | + |
3 | 5 | import pytest |
4 | 6 | from contextlib import asynccontextmanager |
5 | 7 | from datetime import datetime, timedelta |
6 | 8 | from types import SimpleNamespace |
7 | 9 | from typing import cast |
8 | 10 |
|
| 11 | +from pydantic import TypeAdapter |
| 12 | + |
9 | 13 | from basic_memory.mcp.tools import write_note |
10 | 14 | from basic_memory.mcp.tools.search import ( |
11 | 15 | search_notes, |
@@ -1622,6 +1626,74 @@ async def fake_resolve(client, query, project, context): |
1622 | 1626 | assert captured_payload["text"] == "authentication" |
1623 | 1627 |
|
1624 | 1628 |
|
| 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 | + |
1625 | 1697 | # --- Tests for text output format (#641) ----------------------------------- |
1626 | 1698 |
|
1627 | 1699 |
|
|
0 commit comments