Files
Drew Cain 0811c48252 fix(mcp): normalize note_types/entity_types/categories on direct call path and reject non-string list elements
Codex review of PR #962 identified two real issues:

1. CLI bypass: the BeforeValidator(parse_str_list) on note_types, entity_types, and
   categories only fires through MCP/Pydantic validation. The CLI path in
   cli/commands/tool.py calls search_notes() directly, so `bm tool search-notes
   --type note,task` arrived as note_types=["note,task"] and matched nothing.
   Fix: add in-body parse_str_list() normalization for all three params (mirroring
   the existing parse_tags() call for tags on the same code path).

2. Silent stringify: parse_str_list used str(raw) in the list branch, so [42] became
   ["42"] before Pydantic saw it, accepting invalid input as a no-result search
   instead of rejecting it. Fix: guard against non-string list elements and return
   the original value unchanged so Pydantic rejects it with a clear error.

Tests added: annotation-level split tests for note_types/entity_types/categories,
non-string-element rejection tests, async direct-call regression for note_types,
and unit-level parse_str_list non-string list tests in test_coerce.py.

Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: Drew Cain <groksrc@gmail.com>
2026-06-11 14:38:24 -05:00

2276 lines
81 KiB
Python

"""Tests for search MCP tools."""
import inspect
import pytest
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from types import SimpleNamespace
from typing import cast
from pydantic import TypeAdapter
from basic_memory.mcp.tools import write_note
from basic_memory.mcp.tools.search import (
search_notes,
_format_search_error_response,
_format_search_markdown,
)
from basic_memory.schemas.search import SearchResponse
@pytest.mark.asyncio
async def test_search_text(client, test_project):
"""Test basic search functionality."""
# Create a test note
result = await write_note(
project=test_project.name,
title="Test Search Note",
directory="test",
content="# Test\nThis is a searchable test note",
tags=["test", "search"],
)
assert result
# Search for it (use json format to inspect structured results)
response = await search_notes(
project=test_project.name,
query="searchable",
search_type="text",
output_format="json",
)
# Verify results - handle both success and error cases
if isinstance(response, dict):
# Success case - verify dict response
assert len(response["results"]) > 0
assert any(
r["permalink"] == f"{test_project.name}/test/test-search-note"
for r in response["results"]
)
else:
# If search failed and returned error message, test should fail with informative message
pytest.fail(f"Search failed with error: {response}")
@pytest.mark.asyncio
async def test_search_title(client, test_project):
"""Test basic search functionality."""
# Create a test note
result = await write_note(
project=test_project.name,
title="Test Search Note",
directory="test",
content="# Test\nThis is a searchable test note",
tags=["test", "search"],
)
assert result
# Search for it (use json format to inspect structured results)
response = await search_notes(
project=test_project.name, query="Search Note", search_type="title", output_format="json"
)
# Verify results - handle both success and error cases
if isinstance(response, dict):
# Success case - verify dict response
assert len(response["results"]) > 0
assert any(
r["permalink"] == f"{test_project.name}/test/test-search-note"
for r in response["results"]
)
else:
# If search failed and returned error message, test should fail with informative message
pytest.fail(f"Search failed with error: {response}")
@pytest.mark.asyncio
async def test_search_permalink(client, test_project):
"""Test basic search functionality."""
# Create a test note
result = await write_note(
project=test_project.name,
title="Test Search Note",
directory="test",
content="# Test\nThis is a searchable test note",
tags=["test", "search"],
)
assert result
# Search for it (use json format to inspect structured results)
response = await search_notes(
project=test_project.name,
query=f"{test_project.name}/test/test-search-note",
search_type="permalink",
output_format="json",
)
# Verify results - handle both success and error cases
if isinstance(response, dict):
# Success case - verify dict response
assert len(response["results"]) > 0
assert any(
r["permalink"] == f"{test_project.name}/test/test-search-note"
for r in response["results"]
)
else:
# If search failed and returned error message, test should fail with informative message
pytest.fail(f"Search failed with error: {response}")
@pytest.mark.asyncio
async def test_search_permalink_match(client, test_project):
"""Test basic search functionality."""
# Create a test note
result = await write_note(
project=test_project.name,
title="Test Search Note",
directory="test",
content="# Test\nThis is a searchable test note",
tags=["test", "search"],
)
assert result
# Search for it (use json format to inspect structured results)
response = await search_notes(
project=test_project.name,
query=f"{test_project.name}/test/test-search-*",
search_type="permalink",
output_format="json",
)
# Verify results - handle both success and error cases
if isinstance(response, dict):
# Success case - verify dict response
assert len(response["results"]) > 0
assert any(
r["permalink"] == f"{test_project.name}/test/test-search-note"
for r in response["results"]
)
else:
# If search failed and returned error message, test should fail with informative message
pytest.fail(f"Search failed with error: {response}")
@pytest.mark.asyncio
async def test_search_memory_url_with_project_prefix(client, test_project):
"""Test searching with a memory:// URL that includes the project prefix."""
result = await write_note(
project=test_project.name,
title="Memory URL Search Note",
directory="test",
content="# Memory URL Search\nThis note should be found via memory URL search",
)
assert result
response = await search_notes(
query=f"memory://{test_project.name}/test/memory-url-search-note", output_format="json"
)
if isinstance(response, dict):
assert len(response["results"]) > 0
assert any(
r["permalink"] == f"{test_project.name}/test/memory-url-search-note"
for r in response["results"]
)
else:
pytest.fail(f"Search failed with error: {response}")
@pytest.mark.asyncio
async def test_search_workspace_memory_url_routes_with_local_config(monkeypatch, config_manager):
"""Workspace-qualified memory URL searches should self-route in mixed mode."""
import importlib
import basic_memory.mcp.project_context as project_context
from basic_memory.config import ProjectEntry
from basic_memory.mcp.project_context import (
WorkspaceProjectEntry,
_build_workspace_project_index,
)
from basic_memory.schemas.cloud import WorkspaceInfo
from basic_memory.schemas.project_info import ProjectItem
from basic_memory.schemas.search import SearchItemType, SearchResult
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
clients_mod = importlib.import_module("basic_memory.mcp.clients")
config = config_manager.load_config()
config.projects["hermes-memory"] = ProjectEntry(
path=str(config_manager.config_dir.parent / "hermes-memory")
)
config.cloud_api_key = "bmc_test123"
config_manager.save_config(config)
personal = WorkspaceInfo(
tenant_id="personal-tenant",
workspace_type="personal",
slug="personal",
name="Personal",
role="owner",
is_default=True,
)
project_item = ProjectItem(
id=1,
external_id="11111111-1111-1111-1111-111111111111",
name="main",
path="/tmp/main",
is_default=False,
)
index = _build_workspace_project_index(
(personal,),
(WorkspaceProjectEntry(workspace=personal, project=project_item),),
)
async def fake_index(context=None):
return index
captured: dict[str, object] = {}
@asynccontextmanager
async def fake_get_project_client(project=None, context=None, project_id=None):
captured["project"] = project
captured["project_id"] = project_id
yield object(), SimpleNamespace(name="main", external_id=project_item.external_id)
async def fake_resolve_project_and_path(client, identifier, project=None, context=None):
assert identifier == "memory://personal/main/tests/search-note"
assert project == "main"
return None, "personal/main/tests/search-note", True
class FakeSearchClient:
def __init__(self, client, project_id):
captured["search_project_id"] = project_id
async def search(self, payload, *, page, page_size):
captured["payload"] = payload
return SearchResponse(
results=[
SearchResult(
title="Search Note",
type=SearchItemType.ENTITY,
score=1.0,
permalink="personal/main/tests/search-note",
file_path="tests/Search Note.md",
)
],
current_page=page,
page_size=page_size,
total=1,
)
monkeypatch.setattr(project_context, "_ensure_workspace_project_index", fake_index)
monkeypatch.setattr("basic_memory.mcp.async_client.is_factory_mode", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._explicit_routing", lambda: False)
monkeypatch.setattr("basic_memory.mcp.async_client._force_local_mode", lambda: False)
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", FakeSearchClient)
response = await search_notes(
query="memory://personal/main/tests/search-note",
output_format="json",
)
assert captured["project"] == "personal/main"
assert captured["project_id"] is None
assert captured["search_project_id"] == "11111111-1111-1111-1111-111111111111"
payload = cast(dict[str, object], captured["payload"])
assert payload["permalink"] == "personal/main/tests/search-note"
assert isinstance(response, dict)
assert response["results"][0]["permalink"] == "personal/main/tests/search-note"
@pytest.mark.asyncio
async def test_search_pagination(client, test_project):
"""Test basic search functionality."""
# Create a test note
result = await write_note(
project=test_project.name,
title="Test Search Note",
directory="test",
content="# Test\nThis is a searchable test note",
tags=["test", "search"],
)
assert result
# Search for it (use json format to inspect structured results)
response = await search_notes(
project=test_project.name,
query="searchable",
search_type="text",
page=1,
page_size=1,
output_format="json",
)
# Verify results - handle both success and error cases
if isinstance(response, dict):
# Success case - verify dict response
assert len(response["results"]) == 1
assert any(
r["permalink"] == f"{test_project.name}/test/test-search-note"
for r in response["results"]
)
else:
# If search failed and returned error message, test should fail with informative message
pytest.fail(f"Search failed with error: {response}")
@pytest.mark.asyncio
async def test_search_with_type_filter(client, test_project):
"""Test search with note type filter."""
# Create test content
await write_note(
project=test_project.name,
title="Note Type Test",
directory="test",
content="# Test\nFiltered by type",
)
# Search with note type filter (use json format to inspect structured results)
response = await search_notes(
project=test_project.name,
query="type",
search_type="text",
note_types=["note"],
output_format="json",
)
# Verify results - handle both success and error cases
if isinstance(response, dict):
# Success case - verify all results are entities
assert all(r["type"] == "entity" for r in response["results"])
else:
# If search failed and returned error message, test should fail with informative message
pytest.fail(f"Search failed with error: {response}")
@pytest.mark.asyncio
async def test_search_with_entity_type_filter(client, test_project):
"""Test search with entity_types (SearchItemType) filter."""
# Create test content
await write_note(
project=test_project.name,
title="Entity Type Test",
directory="test",
content="# Test\nFiltered by type",
)
# Search with entity_types (SearchItemType) filter (use json format)
response = await search_notes(
project=test_project.name,
query="type",
search_type="text",
entity_types=["entity"],
output_format="json",
)
# Verify results - handle both success and error cases
if isinstance(response, dict):
# Success case - verify all results are entities
assert all(r["type"] == "entity" for r in response["results"])
else:
# If search failed and returned error message, test should fail with informative message
pytest.fail(f"Search failed with error: {response}")
@pytest.mark.asyncio
async def test_search_with_categories_filter(client, test_project):
"""Observation category filter returns only the exact category (#430).
Writes a note whose body has a [requirement] observation and a [decision]
observation that also mentions the word "requirement". The categories filter
must return only the requirement observation.
"""
await write_note(
project=test_project.name,
title="Category Filter Note",
directory="test",
content=(
"# Category Filter Note\n"
"- [requirement] The system must enforce auth on every request\n"
"- [decision] We deferred the auth requirement to next sprint\n"
),
)
response = await search_notes(
project=test_project.name,
query="requirement",
search_type="text",
entity_types=["observation"],
categories=["requirement"],
output_format="json",
)
assert isinstance(response, dict), f"Search failed with error: {response}"
results = response["results"]
assert len(results) > 0
# Every result is a requirement observation; the [decision] row is excluded
# even though its text contains the word "requirement".
assert all(r["type"] == "observation" for r in results)
assert all(r["category"] == "requirement" for r in results)
# A non-matching category yields no results for the same text query.
decision = await search_notes(
project=test_project.name,
query="requirement",
search_type="text",
entity_types=["observation"],
categories=["decision"],
output_format="json",
)
assert isinstance(decision, dict), f"Search failed with error: {decision}"
assert all(r["category"] == "decision" for r in decision["results"])
# The requirement observation must not leak into a decision-scoped search.
assert all("requirement" != r.get("category") for r in decision["results"])
@pytest.mark.asyncio
async def test_search_categories_without_entity_types_returns_observations(client, test_project):
"""categories=[...] WITHOUT entity_types must return the matching observations (#908).
search_notes defaults entity_types to "entity" when unset, but categories only exist on
observations — so a category filter without an explicit entity_types would AND the
category against entity rows (which have NULL category) and return nothing. The implicit
default must scope to observations when categories is supplied.
"""
await write_note(
project=test_project.name,
title="Category Default Note",
directory="test",
content=(
"# Category Default Note\n"
"- [requirement] Auth tokens must rotate every 24 hours\n"
"- [decision] We chose JWT for the auth token format\n"
),
)
# Note: no entity_types passed — exercises the implicit default.
response = await search_notes(
project=test_project.name,
query="auth",
search_type="text",
categories=["requirement"],
output_format="json",
)
assert isinstance(response, dict), f"Search failed with error: {response}"
results = response["results"]
assert len(results) > 0, "category-only search must return matching observations"
assert all(r["type"] == "observation" for r in results)
assert all(r["category"] == "requirement" for r in results)
@pytest.mark.asyncio
async def test_search_with_date_filter(client, test_project):
"""Test search with date filter."""
# Create test content
await write_note(
project=test_project.name,
title="Recent Note",
directory="test",
content="# Test\nRecent content",
)
# Search with date filter (use json format to inspect structured results)
one_hour_ago = datetime.now() - timedelta(hours=1)
response = await search_notes(
project=test_project.name,
query="recent",
search_type="text",
after_date=one_hour_ago.isoformat(),
output_format="json",
)
# Verify results - handle both success and error cases
if isinstance(response, dict):
# Success case - verify we get results within timeframe
assert len(response["results"]) > 0
else:
# If search failed and returned error message, test should fail with informative message
pytest.fail(f"Search failed with error: {response}")
class TestSearchErrorFormatting:
"""Test search error formatting for better user experience."""
def test_format_search_error_fts5_syntax(self):
"""Test formatting for FTS5 syntax errors."""
result = _format_search_error_response(
"test-project", "syntax error in FTS5", "test query("
)
assert "# Search Failed - Invalid Syntax" in result
assert "The search query 'test query(' contains invalid syntax" in result
assert "Special characters" in result
assert "test query" in result # Clean query without special chars
def test_format_search_error_no_results(self):
"""Test formatting for no results found."""
result = _format_search_error_response(
"test-project", "no results found", "very specific query"
)
assert "# Search Complete - No Results Found" in result
assert "No content found matching 'very specific query'" in result
assert "Broaden your search" in result
assert "very" in result # Simplified query
def test_format_search_error_server_error(self):
"""Test formatting for server errors."""
result = _format_search_error_response(
"test-project", "internal server error", "test query"
)
assert "# Search Failed - Server Error" in result
assert "The search service encountered an error while processing 'test query'" in result
assert "Try again" in result
assert "Check project status" in result
def test_format_search_error_permission_denied(self):
"""Test formatting for permission errors."""
result = _format_search_error_response("test-project", "permission denied", "test query")
assert "# Search Failed - Access Error" in result
assert "You don't have permission to search" in result
assert "Check your project access" in result
def test_format_search_error_project_not_found(self):
"""Test formatting for project not found errors."""
result = _format_search_error_response(
"test-project", "current project not found", "test query"
)
assert "# Search Failed - Project Not Found" in result
assert "The current project is not accessible" in result
assert "Check available projects" in result
def test_format_search_error_semantic_disabled(self):
"""Test formatting for semantic-search-disabled errors."""
result = _format_search_error_response(
"test-project",
"Semantic search is disabled. Set BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true.",
"semantic query",
"vector",
)
assert "# Search Failed - Semantic Search Disabled" in result
assert "BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=true" in result
assert 'search_type="text"' in result
def test_format_search_error_semantic_dependencies_missing(self):
"""Test formatting for missing semantic dependencies."""
result = _format_search_error_response(
"test-project",
"fastembed package is missing. Install/update basic-memory to include semantic dependencies: pip install -U basic-memory",
"semantic query",
"hybrid",
)
assert "# Search Failed - Semantic Dependencies Missing" in result
assert "pip install -U basic-memory" in result
def test_format_search_error_corrupt_embedding_model(self):
"""Test formatting for a corrupt/missing FastEmbed model (ONNX NO_SUCHFILE)."""
from basic_memory.config import ConfigManager
from basic_memory.repository.embedding_provider_factory import _resolve_cache_dir
result = _format_search_error_response(
"test-project",
"[ONNXRuntimeError] : 3 : NO_SUCHFILE : Load model from "
"/home/u/.basic-memory/fastembed_cache/models--qdrant--bge-small-en-v1.5-onnx-q/"
"snapshots/abc/model_optimized.onnx failed. File doesn't exist",
"semantic query",
"hybrid",
)
expected_cache_dir = _resolve_cache_dir(ConfigManager().config)
assert "# Search Failed - Embedding Model Missing or Corrupt" in result
# Names the actual resolved cache dir so the user knows what to delete.
assert expected_cache_dir in result
# Offers full-text search as an immediate workaround.
assert 'search_type="text"' in result
def test_format_search_error_load_model_phrase_does_not_overmatch(self):
"""A generic error mentioning 'load model' (no 'from') must not hit the embedding branch.
The marker was tightened from the broad 'load model' to the exact ONNX phrasing
'load model from' so unrelated failures fall through to the generic handler.
"""
result = _format_search_error_response(
"test-project",
"Failed to load model configuration for this project",
"test query",
)
assert "# Search Failed - Embedding Model Missing or Corrupt" not in result
assert "# Search Failed" in result
def test_format_search_error_generic(self):
"""Test formatting for generic errors."""
result = _format_search_error_response("test-project", "unknown error", "test query")
assert "# Search Failed" in result
assert "Error searching for 'test query': unknown error" in result
assert "## Troubleshooting steps:" in result
class TestSearchToolErrorHandling:
"""Test search tool exception handling."""
@pytest.mark.asyncio
async def test_search_notes_exception_handling(self, monkeypatch):
"""Test exception handling in search_notes."""
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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
# Mock SearchClient to raise an exception
class MockSearchClient:
def __init__(self, *args, **kwargs):
pass
async def search(self, *args, **kwargs):
raise Exception("syntax error")
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
# Patch at the clients module level where the import happens
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_mod.search_notes(project="test-project", query="test query")
assert isinstance(result, str)
assert "# Search Failed - Invalid Syntax" in result
@pytest.mark.asyncio
async def test_search_notes_permission_error(self, monkeypatch):
"""Test search_notes with permission error."""
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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
# Mock SearchClient to raise a permission error
class MockSearchClient:
def __init__(self, *args, **kwargs):
pass
async def search(self, *args, **kwargs):
raise Exception("permission denied")
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
# Patch at the clients module level where the import happens
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_mod.search_notes(project="test-project", query="test query")
assert isinstance(result, str)
assert "# Search Failed - Access Error" in result
@pytest.mark.asyncio
@pytest.mark.parametrize("search_type", ["vector", "semantic", "hybrid"])
async def test_search_notes_sets_retrieval_mode_for_semantic_types(monkeypatch, search_type):
"""Vector/hybrid search types should populate retrieval_mode in API payload."""
import importlib
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
clients_mod = importlib.import_module("basic_memory.mcp.clients")
class StubProject:
project_url = "http://test"
name = "test-project"
id = 1
external_id = "test-external-id"
@asynccontextmanager
async def fake_get_project_client(*args, **kwargs):
yield (object(), StubProject())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
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(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_mod.search_notes(
project="test-project",
query="semantic lookup",
search_type=search_type,
)
# Default text format returns a formatted string for empty results
assert isinstance(result, str)
assert captured_payload["text"] == "semantic lookup"
# "semantic" is an alias for "vector" retrieval mode
expected_mode = "vector" if search_type in ("vector", "semantic") else search_type
assert captured_payload["retrieval_mode"] == expected_mode
# --- Tests for metadata_filters / tags / status params (lines 440-444) ------
@pytest.mark.asyncio
async def test_search_notes_passes_metadata_filters(monkeypatch):
"""metadata_filters param propagates to the search 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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
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(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
await search_mod.search_notes(
project="test-project",
query="test",
metadata_filters={"status": "active"},
tags=["important"],
status="published",
)
assert captured_payload["metadata_filters"] == {"status": "active"}
assert captured_payload["tags"] == ["important"]
assert captured_payload["status"] == "published"
# --- Tests for filter-only search (query=None) --------------------------------
@pytest.mark.asyncio
async def test_search_notes_filter_only_metadata(monkeypatch):
"""search_notes with metadata_filters only (no query) sends correct payload."""
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",
metadata_filters={"status": "in-progress"},
)
# Default text format returns a formatted string for empty results
assert isinstance(result, str)
assert captured_payload["metadata_filters"] == {"status": "in-progress"}
# No text/title/permalink should be set
assert captured_payload.get("text") is None
assert captured_payload.get("title") is None
assert captured_payload.get("permalink") is None
@pytest.mark.asyncio
async def test_search_notes_filter_only_tags(monkeypatch):
"""search_notes with tags only (no query) sends correct payload."""
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",
tags=["security", "oauth"],
)
# Default text format returns a formatted string for empty results
assert isinstance(result, str)
assert captured_payload["tags"] == ["security", "oauth"]
assert captured_payload.get("text") is None
@pytest.mark.asyncio
async def test_search_notes_no_criteria_returns_error(monkeypatch):
"""search_notes with no args at all returns a helpful error string."""
import importlib
search_mod = importlib.import_module("basic_memory.mcp.tools.search")
class StubProject:
name = "test-project"
external_id = "test-external-id"
@asynccontextmanager
async def fake_get_project_client(*args, **kwargs):
yield (object(), StubProject())
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
result = await search_mod.search_notes(project="test-project")
assert isinstance(result, str)
assert "No Search Criteria" in result
@pytest.mark.asyncio
async def test_search_notes_invalid_search_type_returns_error(monkeypatch):
"""Invalid search_type values should return an error message listing valid options."""
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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
class MockSearchClient:
def __init__(self, *args, **kwargs):
pass
async def search(self, *args, **kwargs):
pytest.fail("SearchClient.search should not be called for invalid search_type")
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_mod.search_notes(
project="test-project",
query="test query",
search_type="bogus",
)
# The ValueError is caught by the generic exception handler and formatted
assert isinstance(result, str)
assert "Invalid search_type" in result
assert "bogus" in result
@pytest.mark.asyncio
async def test_search_notes_passes_min_similarity(monkeypatch):
"""min_similarity param propagates to the SearchQuery payload."""
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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
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(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
await search_mod.search_notes(
project="test-project",
query="test",
search_type="vector",
min_similarity=0.0,
)
assert captured_payload["min_similarity"] == 0.0
assert captured_payload["retrieval_mode"] == "vector"
@pytest.mark.asyncio
async def test_search_notes_defaults_to_hybrid_when_semantic_enabled(monkeypatch):
"""When search_type is omitted, semantic-enabled configs should default to hybrid."""
import importlib
from dataclasses import dataclass
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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
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(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
# Stub get_container to return a config with semantic_search_enabled=True
@dataclass
class StubConfig:
semantic_search_enabled: bool = True
default_search_type: str | None = None
@dataclass
class StubContainer:
config: StubConfig | None = None
def __post_init__(self):
if self.config is None:
self.config = StubConfig()
monkeypatch.setattr(search_mod, "get_container", lambda: StubContainer())
await search_mod.search_notes(
project="test-project",
query="test query",
)
# Default mode should be hybrid when semantic search is enabled
assert captured_payload["retrieval_mode"] == "hybrid"
assert captured_payload["text"] == "test query"
@pytest.mark.asyncio
async def test_search_notes_defaults_to_fts_when_semantic_disabled(monkeypatch):
"""When search_type is omitted, semantic-disabled configs should default to FTS."""
import importlib
from dataclasses import dataclass
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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
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(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
# Stub get_container to return a config with semantic_search_enabled=False
@dataclass
class StubConfig:
semantic_search_enabled: bool = False
default_search_type: str | None = None
@dataclass
class StubContainer:
config: StubConfig | None = None
def __post_init__(self):
if self.config is None:
self.config = StubConfig()
monkeypatch.setattr(search_mod, "get_container", lambda: StubContainer())
await search_mod.search_notes(
project="test-project",
query="test query",
)
# Default mode should be FTS when semantic search is disabled
assert captured_payload["retrieval_mode"] == "fts"
assert captured_payload["text"] == "test query"
@pytest.mark.asyncio
async def test_search_notes_explicit_text_stays_fts_when_semantic_enabled(monkeypatch):
"""Explicit text mode should preserve FTS behavior even when semantic is enabled."""
import importlib
from dataclasses import dataclass
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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
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(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
@dataclass
class StubConfig:
semantic_search_enabled: bool = True
default_search_type: str | None = None
@dataclass
class StubContainer:
config: StubConfig | None = None
def __post_init__(self):
if self.config is None:
self.config = StubConfig()
monkeypatch.setattr(search_mod, "get_container", lambda: StubContainer())
await search_mod.search_notes(
project="test-project",
query="test query",
search_type="text",
)
assert captured_payload["retrieval_mode"] == "fts"
assert captured_payload["text"] == "test query"
@pytest.mark.asyncio
async def test_search_notes_defaults_to_hybrid_when_container_not_initialized(monkeypatch):
"""CLI fallback config should still default omitted search_type to hybrid."""
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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
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(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
# Stub get_container to raise RuntimeError (container not initialized)
def raise_runtime_error():
raise RuntimeError("MCP container not initialized")
monkeypatch.setattr(search_mod, "get_container", raise_runtime_error)
monkeypatch.setattr(
search_mod,
"ConfigManager",
lambda: type(
"StubConfigManager",
(),
{
"config": type(
"Cfg", (), {"semantic_search_enabled": True, "default_search_type": None}
)()
},
)(),
)
await search_mod.search_notes(
project="test-project",
query="test query",
)
# Should upgrade using ConfigManager fallback
assert captured_payload["retrieval_mode"] == "hybrid"
assert captured_payload["text"] == "test query"
@pytest.mark.asyncio
async def test_search_notes_defaults_to_fts_when_container_not_initialized_and_semantic_disabled(
monkeypatch,
):
"""CLI fallback config should default omitted search_type to FTS when semantic is disabled."""
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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
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(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
def raise_runtime_error():
raise RuntimeError("MCP container not initialized")
monkeypatch.setattr(search_mod, "get_container", raise_runtime_error)
monkeypatch.setattr(
search_mod,
"ConfigManager",
lambda: type(
"StubConfigManager",
(),
{
"config": type(
"Cfg", (), {"semantic_search_enabled": False, "default_search_type": None}
)()
},
)(),
)
await search_mod.search_notes(
project="test-project",
query="test query",
)
assert captured_payload["retrieval_mode"] == "fts"
assert captured_payload["text"] == "test query"
# --- Tests for default entity_types (issue #31) --------------------------------
@pytest.mark.asyncio
async def test_search_notes_defaults_entity_types_to_entity(monkeypatch):
"""search_notes defaults entity_types to ['entity'] when not explicitly provided.
This prevents individual observations/relations from appearing as separate
search results, since the entity row already indexes full file content.
"""
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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
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(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
await search_mod.search_notes(
project="test-project",
query="test",
)
# entity_types should default to ["entity"]
assert captured_payload["entity_types"] == ["entity"]
@pytest.mark.asyncio
async def test_search_notes_explicit_entity_types_overrides_default(monkeypatch):
"""Explicit entity_types parameter overrides the default ['entity'] filter."""
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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
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(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
await search_mod.search_notes(
project="test-project",
query="test",
entity_types=["observation"],
)
# Explicit entity_types should be used, not the default
assert captured_payload["entity_types"] == ["observation"]
# --- Tests for note_types case-insensitivity ------------------------------------
@pytest.mark.asyncio
async def test_search_notes_note_types_lowercased(monkeypatch):
"""note_types values are lowercased so 'Chapter' matches stored 'chapter'."""
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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
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(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
await search_mod.search_notes(
project="test-project",
query="test",
note_types=["Chapter", "Person"],
)
# note_types should be lowercased
assert captured_payload["note_types"] == ["chapter", "person"]
# --- 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",
)
# Default text format returns a formatted string for empty results
assert isinstance(result, str)
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"],
)
# Default text format returns a formatted string for empty results
assert isinstance(result, str)
assert set(captured_payload["tags"]) == {"security", "oauth"}
assert captured_payload.get("text") is None
@pytest.mark.asyncio
async def test_search_notes_multiple_tag_prefixes(monkeypatch):
"""query='tag:coffee AND tag:brewing' should extract both tags."""
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:coffee AND tag:brewing",
)
# Default text format returns a formatted string for empty results
assert isinstance(result, str)
assert set(captured_payload["tags"]) == {"coffee", "brewing"}
# Boolean connector AND should be stripped, leaving no text query
assert captured_payload.get("text") is None
@pytest.mark.asyncio
async def test_search_notes_tag_prefix_with_remaining_text(monkeypatch):
"""query='authentication tag:security' should keep text and extract tag."""
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)
# Remaining text query triggers resolve_project_and_path, so stub it too
async def fake_resolve(client, query, project, context):
return project, query, False
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_mod.search_notes(
project="test-project",
query="authentication tag:security",
)
# Default text format returns a formatted string for empty results
assert isinstance(result, str)
assert captured_payload["tags"] == ["security"]
# Remaining text should be preserved as the query
assert captured_payload["text"] == "authentication"
# --- Tests for comma-separated tags parameter (#910) ----------------------------
def test_search_notes_tags_annotation_splits_comma_strings():
"""The tags parameter annotation must parse every documented input form (#910).
Direct function calls bypass the BeforeValidator, so validate through the same
Annotated metadata pydantic applies on the MCP path. coerce_list wrapped a bare
comma string as the single literal tag ["a,b"]; parse_tags splits it like the
tag: query shorthand and write_note's tags convention.
"""
annotation = inspect.signature(search_notes).parameters["tags"].annotation
adapter = TypeAdapter(annotation)
real_list = adapter.validate_python(["a", "b"])
comma_string = adapter.validate_python("a,b")
json_string = adapter.validate_python('["a", "b"]')
single_string = adapter.validate_python("a")
assert real_list == ["a", "b"]
# The comma string and the real list must behave identically (the #910 bug).
assert comma_string == real_list
assert json_string == real_list
assert single_string == ["a"]
@pytest.mark.asyncio
async def test_search_notes_tags_comma_string_filters_via_mcp(mcp, client, test_project):
"""tags="alpha,beta" through the real MCP layer must match like a real list (#910)."""
from fastmcp import Client
async with Client(mcp) as mcp_client:
await mcp_client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "Tag Split Note",
"directory": "test",
"content": "# Tag Split Note\nTagSplitToken body",
"tags": ["alpha", "beta"],
},
)
async def found(tags_value: object) -> bool:
result = await mcp_client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "TagSplitToken",
"search_type": "text",
"tags": tags_value,
},
)
return "Tag Split Note" in result.content[0].text
as_list = await found(["alpha", "beta"])
as_comma_string = await found("alpha,beta")
as_json_string = await found('["alpha", "beta"]')
as_single_string = await found("alpha")
assert as_list, "real-list tags must match (sanity)"
assert as_comma_string == as_list, "comma string must behave like the real list"
assert as_json_string == as_list
assert as_single_string == as_list
# Negative control: the filter is actually applied, not silently dropped.
assert not await found("gamma")
def test_search_notes_tags_annotation_rejects_non_string_types():
"""Unsupported tag types must fail validation, not be stringified (#932 follow-up).
Bare parse_tags coerces anything to strings (42 -> ["42"], {"a": 1} -> junk tags),
silently turning caller mistakes into no-result searches. The strict_search_tags
wrapper only normalizes str/list/None and lets Pydantic reject everything else.
"""
from pydantic import ValidationError
annotation = inspect.signature(search_notes).parameters["tags"].annotation
adapter = TypeAdapter(annotation)
with pytest.raises(ValidationError):
adapter.validate_python(42)
with pytest.raises(ValidationError):
adapter.validate_python({"a": 1})
# Lists with non-string elements must also fail, not be stringified ([42] -> ["42"]).
with pytest.raises(ValidationError):
adapter.validate_python([42])
with pytest.raises(ValidationError):
adapter.validate_python([{"a": 1}])
with pytest.raises(ValidationError):
adapter.validate_python(["ok", 42])
# JSON-array strings with non-string elements must fail the same way — parse_tags
# would otherwise recursively stringify them before Pydantic validates List[str].
with pytest.raises(ValidationError):
adapter.validate_python("[42]")
with pytest.raises(ValidationError):
adapter.validate_python('[{"a": 1}]')
with pytest.raises(ValidationError):
adapter.validate_python('["ok", 42]')
# All-string lists and all-string JSON-array strings remain valid.
assert adapter.validate_python(["a", "b"]) == ["a", "b"]
assert adapter.validate_python('["a","b"]') == ["a", "b"]
# None stays a valid "no filter" input.
assert adapter.validate_python(None) in (None, [])
@pytest.mark.asyncio
async def test_search_notes_tags_invalid_type_rejected_via_mcp(mcp, client, test_project):
"""tags=42 through the real MCP layer must raise a validation error (#932 follow-up)."""
from fastmcp import Client
from fastmcp.exceptions import ToolError
async with Client(mcp) as mcp_client:
with pytest.raises(ToolError):
await mcp_client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "anything",
"tags": 42,
},
)
with pytest.raises(ToolError):
await mcp_client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "anything",
"tags": {"a": 1},
},
)
# Lists with non-string elements must be rejected too, not stringified.
with pytest.raises(ToolError):
await mcp_client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "anything",
"tags": [42],
},
)
with pytest.raises(ToolError):
await mcp_client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "anything",
"tags": [{"a": 1}],
},
)
with pytest.raises(ToolError):
await mcp_client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "anything",
"tags": ["ok", 42],
},
)
# JSON-array strings with non-string elements (clients that serialize arrays as
# strings) must be rejected too, not recursively stringified by parse_tags.
with pytest.raises(ToolError):
await mcp_client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "anything",
"tags": "[42]",
},
)
with pytest.raises(ToolError):
await mcp_client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "anything",
"tags": '[{"a": 1}]',
},
)
with pytest.raises(ToolError):
await mcp_client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "anything",
"tags": '["ok", 42]',
},
)
# Sanity: a valid all-string JSON-array string is still accepted.
await mcp_client.call_tool(
"search_notes",
{
"project": test_project.name,
"query": "anything",
"tags": '["a","b"]',
},
)
@pytest.mark.asyncio
async def test_search_notes_direct_call_splits_comma_tags(client, test_project):
"""Direct callers bypass the BeforeValidator, so the body must normalize tags.
Regression for the CLI path: `bm tool search-notes --tag alpha,beta` calls this
function directly with Typer's collected list ["alpha,beta"], which must split
into ["alpha", "beta"] instead of matching nothing (#910, #932 follow-up).
"""
await write_note(
project=test_project.name,
title="Direct Tag Split Note",
directory="test",
content="# Direct Tag Split Note\nDirectTagToken body",
tags=["alpha", "beta"],
)
async def found(tags_value: list[str] | None) -> bool:
result = await search_notes(
project=test_project.name,
query="DirectTagToken",
search_type="text",
output_format="json",
tags=tags_value,
)
assert isinstance(result, dict), f"search failed: {result}"
return any(r["title"] == "Direct Tag Split Note" for r in result["results"])
assert await found(["alpha"]), "plain tag list must match (sanity)"
# The CLI regression: Typer collects --tag alpha,beta as the single element "alpha,beta".
assert await found(["alpha,beta"])
# Negative control: the filter is still applied.
assert not await found(["gamma"])
# --- Tests for text output format (#641) -----------------------------------
def test_format_search_markdown_with_results():
"""_format_search_markdown returns readable markdown for non-empty results."""
from basic_memory.schemas.search import SearchResult, SearchItemType
result = SearchResponse(
results=[
SearchResult(
title="My Note",
type=SearchItemType.ENTITY,
score=0.85,
permalink="docs/my-note",
file_path="docs/My Note.md",
matched_chunk="This is a matching snippet",
),
SearchResult(
title="Other Note",
type=SearchItemType.ENTITY,
score=0.42,
permalink="docs/other-note",
file_path="docs/Other Note.md",
),
],
current_page=1,
page_size=10,
)
text = _format_search_markdown(result, "test-project", "my query")
assert isinstance(text, str)
assert "# Search Results: my query" in text
assert "test-project" in text
assert "### My Note" in text
assert "permalink: docs/my-note" in text
assert "0.8500" in text
assert "match: This is a matching snippet" in text
assert "### Other Note" in text
assert "2 results" in text
assert "page 1" in text
def test_format_search_markdown_empty_results():
"""_format_search_markdown returns a no-results message when results are empty."""
result = SearchResponse(results=[], current_page=1, page_size=10)
text = _format_search_markdown(result, "test-project", "missing")
assert isinstance(text, str)
assert "No results found" in text
assert "missing" in text
@pytest.mark.asyncio
async def test_search_notes_text_format_returns_string(monkeypatch):
"""search_notes with output_format='text' returns a formatted markdown string."""
import importlib
from basic_memory.schemas.search import SearchResult, SearchItemType
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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
class MockSearchClient:
def __init__(self, *args, **kwargs):
pass
async def search(self, payload, page, page_size):
return SearchResponse(
results=[
SearchResult(
title="Found Note",
type=SearchItemType.ENTITY,
score=0.9,
permalink="docs/found-note",
file_path="docs/Found Note.md",
matched_chunk="snippet",
),
],
current_page=page,
page_size=page_size,
)
monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client)
monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
result = await search_mod.search_notes(
project="test-project",
query="test",
output_format="text",
)
assert isinstance(result, str)
assert "# Search Results: test" in result
assert "### Found Note" in result
assert "permalink: docs/found-note" in result
# --- Tests for metadata_filters key aliasing (#642) ----------------------------
@pytest.mark.asyncio
async def test_search_notes_metadata_filters_aliases_note_type(monkeypatch):
"""metadata_filters={'note_type': 'note'} is aliased to {'type': 'note'}."""
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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
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(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
await search_mod.search_notes(
project="test-project",
query="test",
metadata_filters={"note_type": "note"},
)
# "note_type" should be aliased to "type" in the payload
assert captured_payload["metadata_filters"] == {"type": "note"}
@pytest.mark.asyncio
async def test_search_notes_metadata_filters_preserves_non_aliased_keys(monkeypatch):
"""metadata_filters with non-aliased keys pass through unchanged."""
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())
async def fake_resolve_project_and_path(
client, identifier, project=None, context=None, headers=None
):
return StubProject(), identifier, False
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(search_mod, "resolve_project_and_path", fake_resolve_project_and_path)
monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient)
await search_mod.search_notes(
project="test-project",
query="test",
metadata_filters={"note_type": "spec", "priority": "high"},
)
# "note_type" aliased to "type", "priority" passes through unchanged
assert captured_payload["metadata_filters"] == {"type": "spec", "priority": "high"}
def test_default_search_type_uses_config_value():
"""_default_search_type should return config.default_search_type when set."""
import sys
from unittest.mock import MagicMock, patch
search_module = sys.modules["basic_memory.mcp.tools.search"]
mock_config = MagicMock()
mock_config.default_search_type = "vector"
mock_config.semantic_search_enabled = True
mock_container = MagicMock()
mock_container.config = mock_config
with patch.object(search_module, "get_container", return_value=mock_container):
assert search_module._default_search_type() == "vector"
def test_default_search_type_falls_back_to_hybrid_when_semantic_enabled():
"""When default_search_type is None and semantic is enabled, default to hybrid."""
import sys
from unittest.mock import MagicMock, patch
search_module = sys.modules["basic_memory.mcp.tools.search"]
mock_config = MagicMock()
mock_config.default_search_type = None
mock_config.semantic_search_enabled = True
mock_container = MagicMock()
mock_container.config = mock_config
with patch.object(search_module, "get_container", return_value=mock_container):
assert search_module._default_search_type() == "hybrid"
def test_default_search_type_falls_back_to_text_when_semantic_disabled():
"""When default_search_type is None and semantic is disabled, default to text."""
import sys
from unittest.mock import MagicMock, patch
search_module = sys.modules["basic_memory.mcp.tools.search"]
mock_config = MagicMock()
mock_config.default_search_type = None
mock_config.semantic_search_enabled = False
mock_container = MagicMock()
mock_container.config = mock_config
with patch.object(search_module, "get_container", return_value=mock_container):
assert search_module._default_search_type() == "text"
# --- Tests for note_types/entity_types/categories comma-split fix (#930, Codex review) ---
def test_search_notes_note_types_annotation_splits_comma_strings():
"""The note_types parameter annotation must parse every documented input form (#930).
Direct function calls bypass the BeforeValidator; validate through the same
Annotated metadata pydantic applies on the MCP path. The old coerce_list wrapped a
bare comma string as the single literal type ["note,task"]; parse_str_list splits it.
"""
annotation = inspect.signature(search_notes).parameters["note_types"].annotation
adapter = TypeAdapter(annotation)
real_list = adapter.validate_python(["note", "task"])
comma_string = adapter.validate_python("note,task")
json_string = adapter.validate_python('["note", "task"]')
single_string = adapter.validate_python("note")
comma_in_list = adapter.validate_python(["note,task"])
assert real_list == ["note", "task"]
assert comma_string == real_list, "comma string must behave like the real list"
assert json_string == real_list
assert single_string == ["note"]
assert comma_in_list == real_list, "list with comma element must be flattened"
def test_search_notes_entity_types_annotation_splits_comma_strings():
"""The entity_types parameter annotation must parse every documented input form (#930)."""
annotation = inspect.signature(search_notes).parameters["entity_types"].annotation
adapter = TypeAdapter(annotation)
real_list = adapter.validate_python(["entity", "observation"])
comma_string = adapter.validate_python("entity,observation")
comma_in_list = adapter.validate_python(["entity,observation"])
assert real_list == ["entity", "observation"]
assert comma_string == real_list
assert comma_in_list == real_list
def test_search_notes_categories_annotation_splits_comma_strings():
"""The categories parameter annotation must parse every documented input form (#930)."""
annotation = inspect.signature(search_notes).parameters["categories"].annotation
adapter = TypeAdapter(annotation)
real_list = adapter.validate_python(["requirement", "decision"])
comma_string = adapter.validate_python("requirement,decision")
comma_in_list = adapter.validate_python(["requirement,decision"])
assert real_list == ["requirement", "decision"]
assert comma_string == real_list
assert comma_in_list == real_list
def test_search_notes_note_types_annotation_rejects_non_string_list_elements():
"""note_types=[42] must fail Pydantic validation, not be stringified to ['42'].
parse_str_list used str(raw) to coerce list elements, silently accepting [42] as
["42"]. The fix guards against non-string list elements and returns the original
value so Pydantic rejects it with a clear error.
"""
from pydantic import ValidationError
annotation = inspect.signature(search_notes).parameters["note_types"].annotation
adapter = TypeAdapter(annotation)
with pytest.raises(ValidationError):
adapter.validate_python([42])
with pytest.raises(ValidationError):
adapter.validate_python(["note", 42])
# All-string lists remain valid.
assert adapter.validate_python(["note", "task"]) == ["note", "task"]
def test_search_notes_entity_types_annotation_rejects_non_string_list_elements():
"""entity_types=[42] must fail Pydantic validation, not be stringified."""
from pydantic import ValidationError
annotation = inspect.signature(search_notes).parameters["entity_types"].annotation
adapter = TypeAdapter(annotation)
with pytest.raises(ValidationError):
adapter.validate_python([42])
with pytest.raises(ValidationError):
adapter.validate_python(["entity", 42])
assert adapter.validate_python(["entity", "observation"]) == ["entity", "observation"]
def test_search_notes_categories_annotation_rejects_non_string_list_elements():
"""categories=[42] must fail Pydantic validation, not be stringified."""
from pydantic import ValidationError
annotation = inspect.signature(search_notes).parameters["categories"].annotation
adapter = TypeAdapter(annotation)
with pytest.raises(ValidationError):
adapter.validate_python([42])
with pytest.raises(ValidationError):
adapter.validate_python(["requirement", 42])
assert adapter.validate_python(["requirement", "decision"]) == ["requirement", "decision"]
@pytest.mark.asyncio
async def test_search_notes_direct_call_splits_comma_note_types(client, test_project):
"""Direct callers bypass the BeforeValidator, so the body must normalize note_types.
Regression for the CLI path: `bm tool search-notes --type note,task` calls this
function directly with Typer's collected list ["note,task"], which must split into
["note", "task"] and match the note correctly (#930, Codex review follow-up).
"""
await write_note(
project=test_project.name,
title="Direct NoteType Split Note",
directory="test",
content="# Direct NoteType Split Note\nNoteTypeSplitToken body",
)
async def found(note_types_value: list[str] | None) -> bool:
result = await search_notes(
project=test_project.name,
query="NoteTypeSplitToken",
search_type="text",
output_format="json",
note_types=note_types_value,
)
assert isinstance(result, dict), f"search failed: {result}"
return any(r["title"] == "Direct NoteType Split Note" for r in result["results"])
assert await found(None), "no filter must match (sanity)"
assert await found(["note"]), "plain single-type list must match (sanity)"
# The CLI regression: Typer collects --type note,task as the single element "note,task".
assert await found(["note,task"]), "comma list element must be flattened and match 'note'"
# Negative control: a specific nonexistent type must not match.
assert not await found(["nonexistent_type"])
def test_search_notes_parse_str_list_rejects_non_string_list_elements_in_place():
"""parse_str_list must return non-str list elements unchanged for Pydantic rejection.
The old implementation used str(raw) which silently coerced [42] -> ['42'],
causing bad caller data to become silent no-result searches instead of a
clear Pydantic validation error.
"""
from basic_memory.utils import parse_str_list
# Non-string list elements pass through unchanged.
assert parse_str_list([42]) == [42] # type: ignore[arg-type]
assert parse_str_list(["ok", 42]) == ["ok", 42] # type: ignore[arg-type]
assert parse_str_list([{"a": 1}]) == [{"a": 1}] # type: ignore[arg-type]
# All-string lists still work correctly.
assert parse_str_list(["note", "task"]) == ["note", "task"]
assert parse_str_list(["note,task"]) == ["note", "task"]