mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user