From 4f12182e281d259697eabe98be32cd082dcf8c54 Mon Sep 17 00:00:00 2001 From: phernandez Date: Sat, 28 Feb 2026 11:53:05 -0600 Subject: [PATCH] fix: parse tag: prefix at MCP tool level to avoid hybrid search failure (#30) When semantic search is enabled (default), `search_notes(query="tag:security")` failed because the HYBRID retrieval mode requires non-empty text, but the service-layer tag: parser clears the text after the mode is already set. Parse tag: prefix at the tool level before search mode selection, converting it to a tags filter. This works with all search modes (text, hybrid, vector). Co-Authored-By: Claude Opus 4.6 Signed-off-by: phernandez --- src/basic_memory/mcp/tools/search.py | 17 +++++- tests/mcp/test_tool_search.py | 83 ++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index 58a2152e..8796e625 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -1,5 +1,6 @@ """Search tools for Basic Memory MCP server.""" +import re from textwrap import dedent from typing import List, Optional, Dict, Any, Literal @@ -302,9 +303,9 @@ async def search_notes( - `search_notes("work-project", "category:observation")` - Filter by observation categories - `search_notes("team-docs", "author:username")` - Find content by author (if metadata available) - **Note:** `tag:` shorthand requires `search_type="text"` when semantic search is enabled - (the default is hybrid). Alternatively, use the `tags` parameter for tag filtering with - any search type: `search_notes("project", "query", tags=["my-tag"])` + **Note:** `tag:` shorthand is automatically converted to a `tags` filter, so it works + with any search type (text, hybrid, vector). You can also use the `tags` parameter + directly: `search_notes("project", "query", tags=["my-tag"])` ### Search Type Examples - `search_notes("my-project", "Meeting", search_type="title")` - Search only in titles @@ -438,6 +439,16 @@ async def search_notes( note_types = note_types or [] entity_types = entity_types or [] + # Parse tag: shorthand at tool level so it works with all search modes. + # Without this, hybrid/vector modes fail because they require non-empty text, + # but the service-layer tag: parser clears the text after the mode is set. + if query and query.strip().lower().startswith("tag:"): + tag_values = [t for t in re.split(r"[,\s]+", query.strip()[4:].strip()) if t] + if tag_values: + # Merge with any explicitly provided tags + tags = list(set((tags or []) + tag_values)) + query = None + # Detect project from memory URL prefix before routing if project is None and query is not None: detected = detect_project_from_url_prefix(query, ConfigManager().config) diff --git a/tests/mcp/test_tool_search.py b/tests/mcp/test_tool_search.py index 1d8d3d56..6b1e8cca 100644 --- a/tests/mcp/test_tool_search.py +++ b/tests/mcp/test_tool_search.py @@ -1119,3 +1119,86 @@ async def test_search_notes_explicit_entity_types_overrides_default(monkeypatch) # Explicit entity_types should be used, not the default assert captured_payload["entity_types"] == ["observation"] + + +# --- Tests for tag: prefix parsing (issue #30) --------------------------------- + + +@pytest.mark.asyncio +async def test_search_notes_tag_prefix_converts_to_tags_filter(monkeypatch): + """query='tag:security' should be converted to a tags filter with no text query.""" + import importlib + + search_mod = importlib.import_module("basic_memory.mcp.tools.search") + clients_mod = importlib.import_module("basic_memory.mcp.clients") + + class StubProject: + name = "test-project" + external_id = "test-external-id" + + @asynccontextmanager + async def fake_get_project_client(*args, **kwargs): + yield (object(), StubProject()) + + captured_payload: dict = {} + + class MockSearchClient: + def __init__(self, *args, **kwargs): + pass + + async def search(self, payload, page, page_size): + captured_payload.update(payload) + return SearchResponse(results=[], current_page=page, page_size=page_size) + + monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client) + monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient) + + result = await search_mod.search_notes( + project="test-project", + query="tag:security", + ) + + assert isinstance(result, SearchResponse) + assert captured_payload["tags"] == ["security"] + # No text query should be set — tag: prefix was consumed + assert captured_payload.get("text") is None + + +@pytest.mark.asyncio +async def test_search_notes_tag_prefix_merges_with_explicit_tags(monkeypatch): + """query='tag:security' with tags=['oauth'] should merge both tag values.""" + import importlib + + search_mod = importlib.import_module("basic_memory.mcp.tools.search") + clients_mod = importlib.import_module("basic_memory.mcp.clients") + + class StubProject: + name = "test-project" + external_id = "test-external-id" + + @asynccontextmanager + async def fake_get_project_client(*args, **kwargs): + yield (object(), StubProject()) + + captured_payload: dict = {} + + class MockSearchClient: + def __init__(self, *args, **kwargs): + pass + + async def search(self, payload, page, page_size): + captured_payload.update(payload) + return SearchResponse(results=[], current_page=page, page_size=page_size) + + monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client) + monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient) + + result = await search_mod.search_notes( + project="test-project", + query="tag:security", + tags=["oauth"], + ) + + assert isinstance(result, SearchResponse) + assert set(captured_payload["tags"]) == {"security", "oauth"} + assert captured_payload.get("text") is None