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 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
This commit is contained in:
phernandez
2026-02-28 11:53:05 -06:00
parent 2c5b63c4a2
commit 4f12182e28
2 changed files with 97 additions and 3 deletions
+14 -3
View File
@@ -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:<value> 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)
+83
View File
@@ -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