Skip to content

Commit 4f12182

Browse files
phernandezclaude
andcommitted
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>
1 parent 2c5b63c commit 4f12182

2 files changed

Lines changed: 97 additions & 3 deletions

File tree

src/basic_memory/mcp/tools/search.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Search tools for Basic Memory MCP server."""
22

3+
import re
34
from textwrap import dedent
45
from typing import List, Optional, Dict, Any, Literal
56

@@ -302,9 +303,9 @@ async def search_notes(
302303
- `search_notes("work-project", "category:observation")` - Filter by observation categories
303304
- `search_notes("team-docs", "author:username")` - Find content by author (if metadata available)
304305
305-
**Note:** `tag:` shorthand requires `search_type="text"` when semantic search is enabled
306-
(the default is hybrid). Alternatively, use the `tags` parameter for tag filtering with
307-
any search type: `search_notes("project", "query", tags=["my-tag"])`
306+
**Note:** `tag:` shorthand is automatically converted to a `tags` filter, so it works
307+
with any search type (text, hybrid, vector). You can also use the `tags` parameter
308+
directly: `search_notes("project", "query", tags=["my-tag"])`
308309
309310
### Search Type Examples
310311
- `search_notes("my-project", "Meeting", search_type="title")` - Search only in titles
@@ -438,6 +439,16 @@ async def search_notes(
438439
note_types = note_types or []
439440
entity_types = entity_types or []
440441

442+
# Parse tag:<value> shorthand at tool level so it works with all search modes.
443+
# Without this, hybrid/vector modes fail because they require non-empty text,
444+
# but the service-layer tag: parser clears the text after the mode is set.
445+
if query and query.strip().lower().startswith("tag:"):
446+
tag_values = [t for t in re.split(r"[,\s]+", query.strip()[4:].strip()) if t]
447+
if tag_values:
448+
# Merge with any explicitly provided tags
449+
tags = list(set((tags or []) + tag_values))
450+
query = None
451+
441452
# Detect project from memory URL prefix before routing
442453
if project is None and query is not None:
443454
detected = detect_project_from_url_prefix(query, ConfigManager().config)

tests/mcp/test_tool_search.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,3 +1119,86 @@ async def search(self, payload, page, page_size):
11191119

11201120
# Explicit entity_types should be used, not the default
11211121
assert captured_payload["entity_types"] == ["observation"]
1122+
1123+
1124+
# --- Tests for tag: prefix parsing (issue #30) ---------------------------------
1125+
1126+
1127+
@pytest.mark.asyncio
1128+
async def test_search_notes_tag_prefix_converts_to_tags_filter(monkeypatch):
1129+
"""query='tag:security' should be converted to a tags filter with no text query."""
1130+
import importlib
1131+
1132+
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
1133+
clients_mod = importlib.import_module("basic_memory.mcp.clients")
1134+
1135+
class StubProject:
1136+
name = "test-project"
1137+
external_id = "test-external-id"
1138+
1139+
@asynccontextmanager
1140+
async def fake_get_project_client(*args, **kwargs):
1141+
yield (object(), StubProject())
1142+
1143+
captured_payload: dict = {}
1144+
1145+
class MockSearchClient:
1146+
def __init__(self, *args, **kwargs):
1147+
pass
1148+
1149+
async def search(self, payload, page, page_size):
1150+
captured_payload.update(payload)
1151+
return SearchResponse(results=[], current_page=page, page_size=page_size)
1152+
1153+
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
1154+
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
1155+
1156+
result = await search_mod.search_notes(
1157+
project="test-project",
1158+
query="tag:security",
1159+
)
1160+
1161+
assert isinstance(result, SearchResponse)
1162+
assert captured_payload["tags"] == ["security"]
1163+
# No text query should be set — tag: prefix was consumed
1164+
assert captured_payload.get("text") is None
1165+
1166+
1167+
@pytest.mark.asyncio
1168+
async def test_search_notes_tag_prefix_merges_with_explicit_tags(monkeypatch):
1169+
"""query='tag:security' with tags=['oauth'] should merge both tag values."""
1170+
import importlib
1171+
1172+
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
1173+
clients_mod = importlib.import_module("basic_memory.mcp.clients")
1174+
1175+
class StubProject:
1176+
name = "test-project"
1177+
external_id = "test-external-id"
1178+
1179+
@asynccontextmanager
1180+
async def fake_get_project_client(*args, **kwargs):
1181+
yield (object(), StubProject())
1182+
1183+
captured_payload: dict = {}
1184+
1185+
class MockSearchClient:
1186+
def __init__(self, *args, **kwargs):
1187+
pass
1188+
1189+
async def search(self, payload, page, page_size):
1190+
captured_payload.update(payload)
1191+
return SearchResponse(results=[], current_page=page, page_size=page_size)
1192+
1193+
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
1194+
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
1195+
1196+
result = await search_mod.search_notes(
1197+
project="test-project",
1198+
query="tag:security",
1199+
tags=["oauth"],
1200+
)
1201+
1202+
assert isinstance(result, SearchResponse)
1203+
assert set(captured_payload["tags"]) == {"security", "oauth"}
1204+
assert captured_payload.get("text") is None

0 commit comments

Comments
 (0)