diff --git a/src/basic_memory/man/man3/bm-search(3).md b/src/basic_memory/man/man3/bm-search(3).md index 1f090d3bd..493d425da 100644 --- a/src/basic_memory/man/man3/bm-search(3).md +++ b/src/basic_memory/man/man3/bm-search(3).md @@ -88,6 +88,11 @@ bm_search(query="launch", note_types=["plan"], compact=True, project="demo") # → Results for 'launch' in project 'demo': # - plans/launch-plan.md | launch-plan | score 1.212 +bm_search(query="cache layer", valid_at="2026-07-28", project="demo") +# → Results for 'cache layer' in project 'demo': +# - decisions/cache-layer.md | decision: The cache layer will use Memcached.... | score 1.000 +# effective valid time: [2026-07-27,) (date) + bm_search(query="launch", mode="text", projects=["demo", "team/research"]) bm_search(query="launch", mode="text", all_projects=True) diff --git a/src/basic_memory/mcp/note_reads.py b/src/basic_memory/mcp/note_reads.py index 6d620741d..6408f1ac8 100644 --- a/src/basic_memory/mcp/note_reads.py +++ b/src/basic_memory/mcp/note_reads.py @@ -11,7 +11,7 @@ class ReadNoteJsonPayload(TypedDict): - """Successful ``read_note(output_format="json")`` payload. + """Successful ``bm_read(output_format="json")`` note payload. The slice keys appear only on sliced reads (section/lines/max_tokens), whose ``content`` is the server-computed slice with document-absolute coordinates; @@ -23,7 +23,7 @@ class ReadNoteJsonPayload(TypedDict): file_path: str content: str frontmatter: dict[str, Any] | None - # The accepted revision's checksum: pass it as write_note's expected_checksum to + # The accepted revision's checksum: pass it as bm_write's expected_checksum to # overwrite only the revision that was read. None for entities with no accepted # Markdown (legacy or non-note entities). checksum: str | None @@ -94,7 +94,7 @@ def parse_opening_frontmatter(content: str) -> tuple[str, dict[str, Any] | None] def format_note_metadata(metadata: dict[str, Any]) -> str: """Format a metadata-only read as plain key/value lines. - The checksum is printed in full: it is the token edit_note and write_note take + The checksum is printed in full: it is the token bm_edit and bm_write take as expected_checksum, so a truncated one would be useless. """ lines = ["# Note metadata"] diff --git a/src/basic_memory/mcp/project_context.py b/src/basic_memory/mcp/project_context.py index 3ba1188b7..8a08f8aaf 100644 --- a/src/basic_memory/mcp/project_context.py +++ b/src/basic_memory/mcp/project_context.py @@ -233,7 +233,7 @@ def _workspace_identifier_discovery_available( if _cloud_workspace_discovery_available(config): return True - # Identifier detection serves read_note and search, which have no mount + # Identifier detection serves memory:// note resources, which have no mount # table and no refusal rule, so only the unmistakable three-segment form may # reach for the network here. return _local_cloud_discovery_allowed(config) and _is_workspace_route_shaped(identifier) @@ -422,9 +422,8 @@ async def invalidate_project_caches(context: Optional[Context] = None) -> None: The session listing is the one that decides which first path segment names a project (``addressable_projects``), so leaving it behind made a project created mid-session unaddressable by name and a deleted one keep routing to - its dead external_id — for ``ls "/"`` and the posix resolver, and, once - identifier detection started reading the same mount table, for ``read_note`` - and ``search_notes`` as well (#1432 review). + its dead external_id in identifier detection, which reads the same mount + table (#1432 review). Deliberately NOT cleared: ``active_workspace`` and ``available_workspaces`` hold workspace metadata — tenant id, slug, type — which no project @@ -1048,7 +1047,7 @@ async def detect_project_from_identifier_prefix( for its reason: only one reading of '/...' can win, and a name ``ls /`` advertises must address that mount or we advertise an address that resolves somewhere else. Reading it the other way here meant `ls team/docs` - and `read_note("team/docs/x")` disagreed about what 'team' named. It is also + and `bm_read("team/docs/x")` disagreed about what 'team' named. It is also the cheaper order — a mount hit never builds the account-wide workspace index at all. diff --git a/src/basic_memory/mcp/tool_params.py b/src/basic_memory/mcp/tool_params.py index 43daa5e66..0aa818772 100644 --- a/src/basic_memory/mcp/tool_params.py +++ b/src/basic_memory/mcp/tool_params.py @@ -37,7 +37,7 @@ DirectoryPath = Annotated[ str, Field( - # list_directory's `dir_name`, and the word models most often reach for. + # `dir_name` from older callers, and the word models most often reach for. validation_alias=AliasChoices("path", "dir_name", "folder"), description=( "A directory in the project, e.g. 'specs' or 'specs/active'. '/' is the " @@ -232,8 +232,8 @@ def next_call(self, tool: str, arguments: Mapping[str, object]) -> str | None: def _parse_item_kinds(value: Any) -> Any: """Split list-shaped input and read the API's word "entity" as "note". - Callers written against `search_notes(entity_types=...)` send "entity"; the alias - alone would carry that value into a field that no longer accepts it. + Callers using the `entity_types` alias often send the API's word "entity"; the + alias alone would carry that value into a field that accepts only "note". """ if value is None: return None diff --git a/src/basic_memory/mcp/tools/__init__.py b/src/basic_memory/mcp/tools/__init__.py index 2999d211c..64d740d52 100644 --- a/src/basic_memory/mcp/tools/__init__.py +++ b/src/basic_memory/mcp/tools/__init__.py @@ -5,9 +5,6 @@ all tools with the MCP server. """ -# Shared implementations used by the CLI and the bm_* tools; these do not register MCP tools. -from basic_memory.mcp.tools.search import search_notes as search_notes - # Shared POSIX implementations used by the shell commands. from basic_memory.mcp.tools.posix_tools import ( cat as cat, diff --git a/src/basic_memory/mcp/tools/find_files.py b/src/basic_memory/mcp/tools/find_files.py index b7bd0c839..35a9e8953 100644 --- a/src/basic_memory/mcp/tools/find_files.py +++ b/src/basic_memory/mcp/tools/find_files.py @@ -126,7 +126,7 @@ async def bm_find( # --- Frontmatter arm --- if filters is not None: # "note_type" is the entity column's name for the frontmatter `type`; - # search_notes and find accept both spellings, and so does bm_find. + # bm_find accepts both spellings, as bm_search's filters do. metadata_filters = { ("type" if key == "note_type" else key): value for key, value in filters.items() } diff --git a/src/basic_memory/mcp/tools/project_management.py b/src/basic_memory/mcp/tools/project_management.py index 55b3e830f..cc2bdda80 100644 --- a/src/basic_memory/mcp/tools/project_management.py +++ b/src/basic_memory/mcp/tools/project_management.py @@ -1,6 +1,6 @@ """Project listing, creation and deletion behind bm_project and bm_project_delete. -list_memory_projects also serves search across all projects and the Cloud server. +The project listing also serves bm_search across projects and the Cloud server. """ import os @@ -459,9 +459,9 @@ async def create_memory_project( Confirmation message with project details Example: - create_memory_project(project_name="my-research", project_path="~/Documents/research") - create_memory_project(project_name="work-notes", project_path="/home/user/work", set_default=True) - create_memory_project(project_name="team-notes", project_path="/team/notes", workspace="team-paul") + bm_project(command="create", name="my-research", path="~/Documents/research") + bm_project(command="create", name="work-notes", path="/home/user/work", set_default=True) + bm_project(command="create", name="team-notes", path="/team/notes", workspace="team-paul") """ # Trigger: MCP server is constrained to a single project. # Why: constrained sessions cannot create projects, and workspace selectors @@ -480,7 +480,7 @@ async def create_memory_project( workspace_id = await _resolve_workspace_routing(workspace, context, output_format) # workspace targets a non-default cloud workspace at create time. - # Trigger: caller passed workspace (e.g. a slug discovered via list_workspaces). + # Trigger: caller passed workspace (e.g. a slug from bm_project(command="list")). # Why: there is no project_id yet for per-project routing — the project doesn't exist. # Outcome: cloud factory routes the create request to the resolved workspace tenant id. async with get_client(workspace=workspace_id) as client: @@ -692,17 +692,17 @@ async def delete_project( Slug is preferred for AI callers, but tenant_id and unique name are also accepted. When omitted, the connection's default workspace is used. A workspace selector implies cloud routing: without cloud - credentials the call fails fast, matching create_memory_project - behavior. + credentials the call fails fast, as bm_project(command="create") + does. Returns: Confirmation message describing what was deleted and whether note files were removed or retained. Example: - delete_project("old-project") - delete_project("old-project", delete_notes=True) - delete_project("team-project", workspace="team-paul") + bm_project_delete(name="old-project") + bm_project_delete(name="old-project", delete_notes=True) + bm_project_delete(name="team-project", workspace="team-paul") Warning: This action cannot be undone. For a local project with diff --git a/src/basic_memory/mcp/tools/ranked_search.py b/src/basic_memory/mcp/tools/ranked_search.py index dad3284a7..8c6ed931b 100644 --- a/src/basic_memory/mcp/tools/ranked_search.py +++ b/src/basic_memory/mcp/tools/ranked_search.py @@ -1,7 +1,7 @@ """bm_search: ranked search over notes, observations and relations. -The query pipeline is search_notes's (`_build_search_query`, the tag: shorthand, -the default search mode), plus three scopes and views: +The query pipeline lives in `search.py` (`_build_search_query`, the tag: shorthand, +the default search mode, the search across projects), plus three scopes and views: - `lines`: matching-line windows, built by scanning each ranked hit's body for the query's terms, in any search mode; @@ -72,7 +72,7 @@ def _parse_mode(value: Any) -> Any: - """Read "vector", search_notes' other name for semantic search, as "semantic".""" + """Read "vector", the older name for semantic search, as "semantic".""" return "semantic" if value == "vector" else value @@ -358,7 +358,7 @@ async def bm_search( ) # --- Shared query shape --- - query, tags = split_tag_shorthand(query, None) + query, tags = split_tag_shorthand(query) normalized_note_types = [normalize_note_type(note_type) for note_type in note_types or []] entity_types = [item.value for item in search_item_types(items) or []] @@ -375,7 +375,6 @@ async def bm_search( after_date=since, metadata_filters=filters, tags=tags, - status=None, min_similarity=min_similarity, valid_at=valid_at, valid_overlaps=valid_overlaps, @@ -383,12 +382,9 @@ async def bm_search( context=context, compact=compact, projects=projects, - # bm_search re-qualifies the rows and renders text itself, so it always - # asks for the rows. - output_format="json", ) match searched: - case MultiProjectRan(rendered=dict() as rows): + case MultiProjectRan(payload=rows): # Each hit carries its project, so its path can be routed back to it. payload = { **rows, @@ -405,9 +401,6 @@ async def bm_search( scope = f"projects {', '.join(projects)}" if projects else "all projects" next_call = Pagination.of(payload).next_call("bm_search", call_arguments) return _format_results(payload, query, scope, next_call=next_call) - case MultiProjectRan(rendered=str() as text): # pragma: no cover - # Only a text request renders text, and this call asks for JSON. - return text case MultiProjectNotIndexed(guidance=guidance): return ( {"results": [], "guidance": guidance} if output_format == "json" else guidance @@ -476,7 +469,6 @@ async def bm_search( after_date=since, metadata_filters=filters, tags=tags, - status=None, min_similarity=min_similarity, valid_at=valid_at, valid_overlaps=valid_overlaps, @@ -665,6 +657,14 @@ def _format_results( if row.get("score") is not None: header += f" | score {row['score']:.3f}" lines.append(header) + # A valid-time hit says which authored time matched, and in which units: a + # bare date here would read as an edit date. + for assertion in row.get("temporal") or []: + valid_during = assertion["valid_during"] + lines.append( + f" {assertion['kind']} valid time: {valid_during['literal']} " + f"({valid_during['axis']})" + ) if "windows" in row: if row["match_count"] == 0: lines.append(" No matching lines.") diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index 90eafeafd..db1887f28 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -1,36 +1,27 @@ -"""Search tools for Basic Memory MCP server.""" +"""Shared query pipeline for bm_search. + +Maps search arguments onto one ``SearchQuery``, picks the default search mode, and +runs a search across several projects as one scoped query per database. +""" import re -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from dataclasses import dataclass -from textwrap import dedent -from typing import Annotated, List, Optional, Dict, Any, Literal, cast +from typing import Any, cast from uuid import UUID -import logfire from httpx import HTTPStatusError from loguru import logger from fastmcp import Context -from pydantic import AliasChoices, BeforeValidator, Field from basic_memory.config import ConfigManager from basic_memory.utils import ( build_canonical_permalink, - coerce_dict, generate_permalink, - parse_str_list, - parse_tags, - strict_search_tags, ) from basic_memory.mcp.async_client import get_client, routes_off_host from basic_memory.mcp.container import get_container from basic_memory.mcp.index_readiness import project_index_required -from basic_memory.mcp.project_context import ( - detect_project_from_identifier_prefix, - get_project_client, - resolve_project_and_path, -) -from basic_memory.schemas.base import normalize_note_type from basic_memory.schemas.project_info import ProjectItem from basic_memory.schemas.search import ( normalize_file_path_prefix, @@ -40,37 +31,16 @@ SearchResult, SearchRetrievalMode, ) -from basic_memory.temporal import TemporalQualifierError, parse_temporal_filter from basic_memory.repository.semantic_runtime import semantic_runtime_available -_SERVICE_UNAVAILABLE_HEADING = "# Search Failed - Service Temporarily Unavailable" -_NO_SEARCH_CRITERIA_MESSAGE = ( - "# No Search Criteria\n\n" - "Please provide at least one of: `query`, `metadata_filters`, " - "`tags`, `status`, `note_types`, `entity_types`, `categories`, " - "`after_date`, `valid_at`, `valid_overlaps`, or `time_kind`." -) # Alias common column/model names to their frontmatter key equivalents. Users often # pass "note_type" (the entity model column) when the frontmatter field is "type". - -METADATA_FILTERS_DESCRIPTION = ( - "Structured frontmatter filters, {field: condition}. Nested fields use dot notation " - '("schema.confidence"). Conditions: a value for equality ({"status": "draft"}); null ' - 'for absent-or-null ({"owner": null}); a list for array-contains-all ({"tags": ["a", "b"]}); ' - "or an operator object using $gt, $gte, $lt, $lte, $in (list), $between ([min, max]), " - '$contains (value or list), $exists (true/false), e.g. {"priority": {"$in": ["high"]}}. ' - 'Several operators on one field AND together: {"started": {"$gte": "2026-01-01", ' - '"$lt": "2026-02-01"}}. The leading $ is optional.' -) - _METADATA_KEY_ALIASES = {"note_type": "type"} _VALID_SEARCH_TYPES = ("hybrid", "permalink", "semantic", "text", "title", "vector") -def split_tag_shorthand( - query: str | None, tags: list[str] | None -) -> tuple[str | None, list[str] | None]: - """Move `tag:` tokens out of the query and into the tags filter. +def split_tag_shorthand(query: str | None) -> tuple[str | None, list[str] | None]: + """Move `tag:` tokens out of the query and into a tags filter. Handles "tag:security", "tag:coffee tag:brewing", "tag:coffee AND tag:brewing" and comma lists ("tag:coffee,brewing"). Done at the tool level so it works in every @@ -78,16 +48,16 @@ def split_tag_shorthand( parser clears the text after the mode is set. """ if not query or "tag:" not in query.lower(): - return query, tags + return query, None raw_values = re.findall(r"tag:(\S+)", query, flags=re.IGNORECASE) tag_values = [value for raw in raw_values for value in raw.split(",") if value] if not tag_values: - return query, tags - merged_tags = list(set((tags or []) + tag_values)) + return query, None + tags = list(dict.fromkeys(tag_values)) # Remove tag: tokens and Boolean connectors; whatever text remains is the query. remainder = re.sub(r"tag:\S+", "", query, flags=re.IGNORECASE) remainder = re.sub(r"\b(AND|OR|NOT)\b", "", remainder).strip() - return remainder or None, merged_tags + return remainder or None, tags def _build_search_query( @@ -100,7 +70,6 @@ def _build_search_query( after_date: str | None, metadata_filters: dict[str, Any] | None, tags: list[str] | None, - status: str | None, min_similarity: float | None, valid_at: str | None, valid_overlaps: str | None, @@ -154,8 +123,6 @@ def _build_search_query( } if tags: search_query.tags = tags - if status: - search_query.status = status if min_similarity is not None: search_query.min_similarity = min_similarity # Presence, not truthiness, for the same reason as everywhere else on this path: @@ -187,7 +154,7 @@ def _build_search_query( # filter against entity rows and return nothing, defeating the # whole query. # Outcome: scope the implicit default to observations so - # search_notes(categories=[...]) and search_notes(valid_at=...) + # bm_search(categories=[...]) and bm_search(valid_at=...) # return the matching bullets. if search_query.categories or search_query.has_temporal_filter(): search_query.entity_types = [SearchItemType("observation")] @@ -277,362 +244,6 @@ def _is_service_unavailable_error(error: BaseException) -> bool: return False -def _format_service_unavailable_response(project: str, error_message: str, query: str) -> str: - """Keep retryable outages distinct so fan-out never turns them into partial success.""" - return dedent(f""" - {_SERVICE_UNAVAILABLE_HEADING} - - Search for '{query}' in project '{project}' could not complete: {error_message} - - No partial results were returned because retrying with a changing project set - could duplicate or skip results across pages. - - ## Next step - Retry the same search after the service recovers. - """).strip() - - -def _format_search_error_response( - project: str, error_message: str, query: str, search_type: str = "text" -) -> str: - """Format helpful error responses for search failures that guide users to successful searches.""" - - # Semantic config/dependency errors - if "semantic search is unavailable" in error_message.lower(): - return dedent(f""" - # Search Failed - Semantic Search Unavailable - - You requested `{search_type}` search for query '{query}', but the vector search - runtime failed to load when Basic Memory started, so search is keyword-only. - - ## How to fix - 1. Check the Basic Memory startup log for the cause (usually sqlite-vec could - not load under this Python build) - 2. Reinstall under a Python with SQLite extension support, then restart - - ## Alternative now - - Run FTS search instead: - `bm_search(query="{query}", project="{project}", mode="text")` - """).strip() - - if "pip install" in error_message.lower() and "semantic" in error_message.lower(): - return dedent(f""" - # Search Failed - Semantic Dependencies Missing - - Semantic retrieval is enabled but required packages are not installed. - - ## Fix - 1. Install/update Basic Memory: `pip install -U basic-memory` - 2. Restart Basic Memory - 3. Retry your query: - `bm_search(query="{query}", project="{project}", mode="{search_type}")` - """).strip() - - # Corrupt/missing FastEmbed model cache (interrupted download leaves a partial - # snapshot missing model_optimized.onnx; the ONNX runtime then raises NO_SUCHFILE). - # Basic Memory self-heals by re-downloading on the next load, but if the user still - # hits this, point them at the cache dir to clear manually and offer a text fallback. - error_lower = error_message.lower() - # "load model from" is the exact ONNX phrasing ("Load model from .onnx failed"). - # The looser "load model" matched unrelated errors, so we keep only the specific phrase - # alongside the onnxruntime / no_suchfile / model_optimized.onnx fingerprints. - if ( - "onnxruntime" in error_lower - or "no_suchfile" in error_lower - or "model_optimized.onnx" in error_lower - or "load model from" in error_lower - ): - # Deferred import: keeps the repository layer out of the tool's import graph - # (matches the SearchClient deferral below) and is only needed on this error path. - from basic_memory.repository.embedding_provider_factory import _resolve_cache_dir - - try: - cache_dir = _resolve_cache_dir(get_container().config) - except RuntimeError: - cache_dir = _resolve_cache_dir(ConfigManager().config) - return dedent(f""" - # Search Failed - Embedding Model Missing or Corrupt - - The local FastEmbed model could not be loaded for query '{query}': {error_message} - - This usually means an earlier model download was interrupted and left an - incomplete file in the model cache. - - ## How to fix - 1. Delete the FastEmbed model cache so it re-downloads on the next search: - `{cache_dir}` - 2. Run your search again (the model downloads automatically on first use): - `bm_search(query="{query}", project="{project}", mode="{search_type}")` - - ## Workaround right now - - Use full-text search, which needs no embedding model: - `bm_search(query="{query}", project="{project}", mode="text")` - """).strip() - - # FTS5 syntax errors - if "syntax error" in error_message.lower() or "fts5" in error_message.lower(): - clean_query = ( - query.replace('"', "") - .replace("(", "") - .replace(")", "") - .replace("+", "") - .replace("*", "") - ) - return dedent(f""" - # Search Failed - Invalid Syntax - - The search query '{query}' contains invalid syntax that the search engine cannot process. - - ## Common syntax issues: - 1. **Special characters**: Characters like `+`, `*`, `"`, `(`, `)` have special meaning in search - 2. **Unmatched quotes**: Make sure quotes are properly paired - 3. **Invalid operators**: Check AND, OR, NOT operators are used correctly - - ## How to fix: - 1. **Simplify your search**: Try using simple words instead: `{clean_query}` - 2. **Remove special characters**: Use alphanumeric characters and spaces - 3. **Use basic boolean operators**: `word1 AND word2`, `word1 OR word2`, `word1 NOT word2` - - ## Examples of valid searches: - - Simple text: `project planning` - - Boolean AND: `project AND planning` - - Boolean OR: `meeting OR discussion` - - Boolean NOT: `project NOT archived` - - Grouped: `(project OR planning) AND notes` - - Exact phrases: `"weekly standup meeting"` - - Content-specific: `tag:example` - - ## Try again with: - ``` - bm_search(query="{clean_query}", project="{project}") - ``` - - ## Alternative search strategies: - - Break into simpler terms: `bm_search(query="{" ".join(clean_query.split()[:2])}", project="{project}")` - - Try different search types: `bm_search(query="{clean_query}", project="{project}", mode="title")` - - Use filtering: `bm_search(query="{clean_query}", project="{project}", note_types=["note"])` - """).strip() - - # Project not found errors (check before general "not found") - if "project not found" in error_message.lower(): - return dedent(f""" - # Search Failed - Project Not Found - - The current project is not accessible or doesn't exist: {error_message} - - ## How to resolve: - 1. **Check available projects**: `bm_list(path="/")` - 3. **Verify project setup**: Ensure your project is properly configured - - ## Current session info: - - See available projects: `bm_list(path="/")` - """).strip() - - # No results found - if "no results" in error_message.lower() or "not found" in error_message.lower(): - simplified_query = ( - " ".join(query.split()[:2]) - if len(query.split()) > 2 - else query.split()[0] - if query.split() - else "notes" - ) - return dedent(f""" - # Search Complete - No Results Found - - No content found matching '{query}' in the current project. - - ## Search strategy suggestions: - 1. **Broaden your search**: Try fewer or more general terms - - Instead of: `{query}` - - Try: `{simplified_query}` - - 2. **Check spelling and try variations**: - - Verify terms are spelled correctly - - Try synonyms or related terms - - 3. **Use different search approaches**: - - **Text search**: `bm_search(query="{query}", project="{project}", mode="text")` (searches full content) - - **Title search**: `bm_search(query="{query}", project="{project}", mode="title")` (searches only titles) - - **Permalink search**: `bm_search(query="{query}", project="{project}", mode="permalink")` (searches file paths) - - 4. **Try boolean operators for broader results**: - - OR search: `bm_search(query="{" OR ".join(query.split()[:3])}", project="{project}")` - - Remove restrictive terms: Focus on the most important keywords - - 5. **Use filtering to narrow scope**: - - By note type in frontmatter: `bm_search(query="{query}", project="{project}", note_types=["note"])` - - By recent content: `bm_search(query="{query}", project="{project}", since="1 week")` - - By entity type: `bm_search(query="{query}", project="{project}", items=["observation"])` - - 6. **Try advanced search patterns**: - - Tag search: `bm_search(query="tag:your-tag", project="{project}")` - - Observation category: `bm_search(query="{query}", project="{project}", items=["observation"], categories=["requirement"])` - - Pattern matching: `bm_search(query="*{query}*", project="{project}", mode="permalink")` - - ## Explore what content exists: - - **Recent activity**: `bm_search(since="7d", project="{project}")` - See what's been updated recently - - **List directories**: `bm_list(path="/", project="{project}")` - Browse all content - - **Browse by folder**: `bm_list(path="/notes", project="{project}")` or `bm_list("/docs")` - """).strip() - - # Server/API errors - if "server error" in error_message.lower() or "internal" in error_message.lower(): - return dedent(f""" - # Search Failed - Server Error - - The search service encountered an error while processing '{query}': {error_message} - - ## Immediate steps: - 1. **Try again**: The error might be temporary - 2. **Simplify the query**: Use simpler search terms - 3. **Check project status**: Ensure your project is properly synced - - ## Alternative approaches: - - Browse files directly: `bm_list(path="/", project="{project}")` - - Check recent activity: `bm_search(since="7d", project="{project}")` - - Try a different search type: `bm_search(query="{query}", project="{project}", mode="title")` - - ## If the problem persists: - The search index might need to be rebuilt. Send a message to support@basicmachines.co or check the project sync status. - """).strip() - - # Permission/access errors - if ( - "permission" in error_message.lower() - or "access" in error_message.lower() - or "forbidden" in error_message.lower() - ): - return f"""# Search Failed - Access Error - -You don't have permission to search in the current project: {error_message} - -## How to resolve: -1. **Check your project access**: Verify you have read permissions for this project -2. **Switch projects**: Try searching in a different project you have access to -3. **Check authentication**: You might need to re-authenticate - -## Alternative actions: -- List available projects: `bm_list(path="/")`""" - - # Generic fallback - return f"""# Search Failed - -Error searching for '{query}': {error_message} - -## Troubleshooting steps: -1. **Simplify your query**: Try basic words without special characters -2. **Check search syntax**: Ensure boolean operators are correctly formatted -3. **Verify project access**: Make sure you can access the current project -4. **Test with simple search**: Try `bm_search("test")` to verify search is working - -## Alternative search approaches: -- **Different search types**: - - Title only: `bm_search(query="{query}", project="{project}", mode="title")` - - Permalink patterns: `bm_search(query="{query}*", project="{project}", mode="permalink")` -- **With filters**: `bm_search(query="{query}", project="{project}", note_types=["note"])` -- **Recent content**: `bm_search(query="{query}", project="{project}", since="1 week")` -- **Boolean variations**: `bm_search(query="{" OR ".join(query.split()[:2])}", project="{project}")` - -## Explore your content: -- **Browse files**: `bm_list(path="/", project="{project}")` - See all available content -- **Recent activity**: `bm_search(since="7d", project="{project}")` - Check what's been updated -- **All projects**: `bm_list(path="/")` - -## Search syntax reference: -- **Basic**: `keyword` or `multiple words` -- **Boolean**: `term1 AND term2`, `term1 OR term2`, `term1 NOT term2` -- **Phrases**: `"exact phrase"` -- **Grouping**: `(term1 OR term2) AND term3` -- **Tags**: `tag:example` -- **Observation categories**: `items=["observation"], categories=["requirement"]`""" - - -def _format_search_markdown( - result: SearchResponse, - project: str, - query: str | None, - project_id: str | None = None, - project_names: Mapping[str, str] | None = None, -) -> str: - """Format SearchResponse as compact markdown text. - - Produces a human-readable markdown representation suitable for LLM - consumption when structured data isn't needed. ``project_names`` maps a - project's external id to the name a hit should be labelled with when the - page spans several projects. - """ - if not result.results: - # Empty search is usually "no match for this query," not "empty knowledge base," so we - # do not repeat the first-note offer here (that would nag established users). Point at - # recent_activity, which owns the getting-started guidance when the base is truly empty. - if project == "all projects": - # A bare recent_activity() is NOT force-discovery: with a configured default/cached - # project it resolves to that one project. So for an all-projects miss, point at the - # enumerator instead — suggesting recent_activity() could silently narrow to the - # default project and miss activity elsewhere. - suggestion = 'call bm_list(path="/") to see what exists across your projects' - elif project_id: - # Names collide across cloud workspaces; route the orientation call by external id. - suggestion = f'call bm_list(path="/", project="{project_id}") to browse the project' - else: - suggestion = f'call bm_list(path="/", project="{project}") to browse the project' - return ( - f"No results found for '{query or ''}' in project '{project}'. " - f"Try broader or different terms, or {suggestion}." - + (f"\n\n{result.query_hint}" if result.query_hint else "") - ) - - parts = [] - - # --- Header --- - if query: - parts.append(f"# Search Results: {query}") - else: - parts.append("# Search Results") - parts.append(f"*project: {project}*") - parts.append("") - - # --- Result blocks --- - for r in result.results: - parts.append(f"### {r.title}") - parts.append(f"- permalink: {r.permalink}") - # A page over several projects must say which one each hit lives in, or the - # reader cannot route the next call; single-project pages already know. - project_name = (project_names or {}).get(r.project_external_id or "") - if project_name is not None: - parts.append(f"- project: {project_name}") - # external_id is the note's stable identifier. Emitting it lets the hosted MCP layer - # deep-link each hit to the web app from the final (post-merge) result the caller sees, - # which matters for all-projects search where the displayed page is decided after the - # per-project API calls the gateway would otherwise record (#1423). - if r.external_id: - parts.append(f"- external_id: {r.external_id}") - parts.append(f"- score: {r.score:.4f}") - if r.matched_chunk: - parts.append(f"- match: {r.matched_chunk[:200]}") - # Name the kind and the units. A bare "2026-06-10" here would read as an edit - # date; "effective valid time ... (date)" says which time this is and that it - # is a calendar date carrying no timezone. - for assertion in r.temporal or []: - parts.append( - f"- {assertion.kind} valid time: {assertion.valid_during.literal} " - f"({assertion.valid_during.axis})" - ) - parts.append("") - - # --- Footer with pagination --- - parts.append("---") - count = len(result.results) - parts.append( - f"*{count} result{'s' if count != 1 else ''}" - f" | page {result.current_page}, page_size {result.page_size}" - f"{' | more available' if result.has_more else ''}*" - ) - - return "\n".join(parts) - - def _valid_project_id(value: object) -> str | None: """Return a UUID project id string when one is present.""" if not isinstance(value, str) or not value.strip(): @@ -765,7 +376,7 @@ def _select_project_refs( async def _load_search_project_refs(context: Context | None = None) -> list[SearchProjectRef]: - """Load accessible projects for search_all_projects without coupling the wrapper tool.""" + """Load the projects a search across projects may read.""" from basic_memory.mcp.tools.project_management import list_memory_projects return _search_project_refs(await list_memory_projects(output_format="json", context=context)) @@ -863,9 +474,9 @@ def _project_item(ref: SearchProjectRef) -> ProjectItem: @dataclass(frozen=True, slots=True) class MultiProjectRan: - """The search ran in at least one database; `rendered` is its JSON or text.""" + """The search ran in at least one database; `payload` is the merged page as JSON.""" - rendered: dict[str, Any] | str + payload: dict[str, Any] @dataclass(frozen=True, slots=True) @@ -880,8 +491,6 @@ class MultiProjectFailed: """No database answered: the search did not run.""" reason: str - # The legacy tool's rendering of the failure. - rendered: str @dataclass(frozen=True, slots=True) @@ -900,14 +509,12 @@ async def _search_all_projects( page: int, page_size: int, search_type: str | None, - output_format: Literal["text", "json"], note_types: list[str], entity_types: list[str], categories: list[str], after_date: str | None, metadata_filters: dict[str, Any] | None, tags: list[str] | None, - status: str | None, min_similarity: float | None, valid_at: str | None, valid_overlaps: str | None, @@ -931,7 +538,6 @@ async def _search_all_projects( project_refs = await _load_search_project_refs(context=context) if projects: project_refs = _select_project_refs(project_refs, projects) - scope_label = ", ".join(ref.name for ref in project_refs) if projects else "all projects" if not project_refs: response = SearchResponse( results=[], @@ -942,9 +548,7 @@ async def _search_all_projects( has_more=False, temporal_applied=True if temporal_requested else None, ) - if output_format == "json": - return MultiProjectRan(response.model_dump(mode="json", exclude_none=True)) - return MultiProjectRan(_format_search_markdown(response, scope_label, query)) + return MultiProjectRan(response.model_dump(mode="json", exclude_none=True)) effective_search_type = _search_type_for(search_type, query, [ref.name for ref in project_refs]) search_query = _build_search_query( @@ -956,7 +560,6 @@ async def _search_all_projects( after_date=after_date, metadata_filters=metadata_filters, tags=tags, - status=status, min_similarity=min_similarity, valid_at=valid_at, valid_overlaps=valid_overlaps, @@ -1005,10 +608,7 @@ async def _search_all_projects( return MultiProjectNotIndexed(guidance) except Exception as exc: if _is_service_unavailable_error(exc): - return MultiProjectFailed( - reason=f"{label}: {exc}", - rendered=_format_service_unavailable_response(label, str(exc), query or ""), - ) + return MultiProjectFailed(reason=f"{label}: {exc}") logger.warning(f"Multi-project search failed for {label}: {exc}") failures.append(f"{label}: {exc}") total_is_exact = False @@ -1046,12 +646,7 @@ async def _search_all_projects( ) if databases_answered == 0: # Every database failed. That is a failed search, not an empty one. - return MultiProjectFailed( - reason="; ".join(failures), - rendered=_format_search_error_response( - scope_label, "; ".join(failures), query or "", effective_search_type - ), - ) + return MultiProjectFailed(reason="; ".join(failures)) # Each database owns retrieval and optional reranking behind its typed API client. # The MCP process only merges returned scores; it must not instantiate repository @@ -1080,659 +675,7 @@ async def _search_all_projects( ) project_names = {ref.external_id: ref.name for ref in project_refs} - if output_format == "json": - payload = response.model_dump(mode="json", exclude_none=True) - for item in payload["results"]: - item["project"] = project_names[item["project_external_id"]] - return MultiProjectRan(payload) - return MultiProjectRan( - _format_search_markdown(response, scope_label, query, project_names=project_names) - ) - - -async def search_notes( - # Accept common search-query aliases models reach for from training data. - # `q` is the universal HTTP convention; `search`/`text` are common in NL APIs. - query: Annotated[ - Optional[str], - Field(default=None, validation_alias=AliasChoices("query", "q", "search", "text")), - ] = None, - project: Optional[str] = None, - project_id: Optional[str] = None, - search_all_projects: Annotated[ - bool, - Field( - default=False, - validation_alias=AliasChoices("search_all_projects", "all_projects"), - ), - ] = False, - projects: Optional[List[str]] = None, - # `offset` is intentionally NOT aliased to `page`: offset is item-indexed - # (skip N items) while page is 1-indexed page-number. Direct aliasing would - # silently return the wrong slice. - page: Annotated[ - int, - Field(default=1, validation_alias=AliasChoices("page", "page_number")), - ] = 1, - page_size: Annotated[ - int, - Field(default=10, validation_alias=AliasChoices("page_size", "limit", "per_page")), - ] = 10, - search_type: str | None = None, - output_format: Literal["text", "json"] = "text", - # Plural-vs-singular trips models constantly. Accept the singular too. - note_types: Annotated[ - List[str] | None, - # parse_str_list, not coerce_list: "note,task" must split into ["note", "task"] - # consistent with how tags are handled (#910/#930). coerce_list wraps the whole - # comma string as the single literal type ["note,task"], which matches nothing. - BeforeValidator(parse_str_list), - Field(default=None, validation_alias=AliasChoices("note_types", "note_type", "types")), - "Filter by the 'type' field in note frontmatter (e.g. 'note', 'chapter', 'person'). " - "Accepts a list, a comma-separated string (e.g. 'note,task'), or a JSON-array string. " - "Case-insensitive.", - ] = None, - entity_types: Annotated[ - List[str] | None, - BeforeValidator(parse_str_list), - Field(default=None, validation_alias=AliasChoices("entity_types", "entity_type")), - "Filter by knowledge graph item type: 'entity' (whole notes), 'observation', or " - "'relation'. Defaults to 'entity'. Do NOT pass schema/frontmatter types like " - "'Chapter' here — use note_types instead. " - "Accepts a list, a comma-separated string (e.g. 'entity,observation'), or a JSON-array string.", - ] = None, - categories: Annotated[ - List[str] | None, - BeforeValidator(parse_str_list), - Field(default=None, validation_alias=AliasChoices("categories", "category")), - "Filter observation results to these exact categories (e.g. ['requirement']). " - "Accepts a list, a comma-separated string (e.g. 'requirement,decision'), or a JSON-array string. " - "Pair with entity_types=['observation'] to return only observations whose " - "category matches exactly — not every row mentioning the word.", - ] = None, - # Time-filter naming varies wildly across APIs. - after_date: Annotated[ - Optional[str], - Field( - default=None, - validation_alias=AliasChoices("after_date", "since", "after", "from_date"), - ), - ] = None, - metadata_filters: Annotated[ - Dict[str, Any] | None, - BeforeValidator(coerce_dict), - # The schema description is what a client's model actually reads; the - # docstring's grammar section is often truncated or skimmed. - Field(description=METADATA_FILTERS_DESCRIPTION), - ] = None, - # strict_search_tags, not coerce_list: tags="a,b" must split into ["a", "b"] to - # match the tag: query shorthand below and write_note's documented tags convention - # (#910). coerce_list would wrap the comma string as the single literal tag - # ["a,b"], which matches nothing. Unlike bare parse_tags, the strict wrapper only - # splits str/list/None and lets Pydantic reject other types (42, {"a": 1}) with a - # clear validation error instead of stringifying them into junk tags. - tags: Annotated[ - List[str] | None, - BeforeValidator(strict_search_tags), - ] = None, - status: Optional[str] = None, - min_similarity: Annotated[ - Optional[float], - Field( - default=None, - validation_alias=AliasChoices("min_similarity", "threshold", "similarity_threshold"), - ), - ] = None, - # --- Valid-time filters (SPEC-82) --- - # A different axis from after_date: these ask what a note SAYS was true, not when - # the note was last touched. Appended at the end of the signature so no existing - # positional caller shifts. - valid_at: Annotated[ - Optional[str], - Field( - default=None, - validation_alias=AliasChoices("valid_at", "as_of", "valid_on"), - ), - "Return only sources whose authored valid range CONTAINS this date " - "('2026-07-28') or RFC 3339 instant ('2026-07-28T09:00:00Z'; a timestamp " - "with no offset is read as UTC). Sources with no temporal qualifier are " - "excluded.", - ] = None, - valid_overlaps: Annotated[ - Optional[str], - Field( - default=None, - validation_alias=AliasChoices("valid_overlaps", "overlaps", "valid_during"), - ), - "Return only sources whose authored valid range OVERLAPS this range literal, " - "written PostgreSQL-style: '[2026-06-10,2026-07-27)', '(,2026-07-27]', " - "'[2026-06-10,)'. Mutually exclusive with valid_at.", - ] = None, - time_kind: Annotated[ - Optional[str], - Field( - default=None, - validation_alias=AliasChoices("time_kind", "kind"), - ), - "Narrow valid-time matching to one authored kind of time: 'effective', " - "'valid', 'occurred', 'due', or 'mentioned'. Usable on its own to find every " - "source carrying an assertion of that kind.", - ] = None, - context: Context | None = None, - compact: Annotated[ - bool, - "Omit note bodies and matched excerpts from results. Keep identifiers, metadata, " - "relation targets, scores and pagination for discovery, then read selected notes.", - ] = False, -) -> dict[str, Any] | str: - """Search across all content in the knowledge base with comprehensive syntax support. - - This tool searches the knowledge base using full-text search, pattern matching, - or exact permalink lookup. It supports filtering by content type, entity type, - and date, with advanced boolean and phrase search capabilities. - - Project Resolution: - Server resolves projects in this order: Single Project Mode → project parameter → default project. - If project unknown, use list_memory_projects() or recent_activity() first. - Set search_all_projects=True to search every accessible project, or pass projects=[...] to - search a chosen subset. Either runs one scoped query per database the projects live in. - - ## Search Syntax Examples - - ### Basic Searches - - `search_notes("keyword", project="my-project")` - Find any content containing "keyword" - - `search_notes("'exact phrase'", project="work-docs")` - Search for exact phrase match - - ### Advanced Boolean Searches - - `search_notes("term1 term2", project="my-project")` - Strict implicit-AND first; retries with - relaxed OR terms only if strict search returns no results - - `search_notes("term1 AND term2", project="my-project")` - Explicit AND search (both terms required) - - `search_notes("term1 OR term2", project="my-project")` - Either term can be present - - `search_notes("term1 NOT term2", project="my-project")` - Include term1 but exclude term2 - - `search_notes("(project OR planning) AND notes", project="my-project")` - Grouped boolean logic - - ### Content-Specific Searches - - `search_notes("tag:example", project="research")` - Search within specific tags (if supported by content) - - `search_notes("req", project="work-project", entity_types=["observation"], categories=["requirement"])` - - Return only observations whose category is exactly "requirement" - - `search_notes("author:username", project="team-docs")` - Find content by author (if metadata available) - - **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("query", project="project", tags=["my-tag"])` - - ### Search Type Examples - - `search_notes("Meeting", project="my-project", search_type="title")` - Search only in titles - - `search_notes("docs/meeting-*", project="work-docs", search_type="permalink")` - Pattern match permalinks - Note: Permalink patterns match the full path (e.g., "project/folder/chapter-13*", not just "chapter-13*"). - - `search_notes("keyword", project="research")` - Default search (hybrid when semantic is enabled, - text when disabled) - - ### Filtering Options - - `search_notes("query", project="my-project", note_types=["note"])` - Search only notes - - `search_notes("query", project="work-docs", note_types=["note", "person"])` - Multiple note types - - `search_notes("query", project="research", entity_types=["observation"])` - Filter by entity type - - `search_notes("query", project="research", entity_types=["observation"], categories=["requirement"])` - - Filter observations to an exact category - - `search_notes("query", project="team-docs", after_date="2024-01-01")` - Recent content only - - `search_notes("query", project="my-project", after_date="1 week")` - Relative date filtering - - `search_notes("query", project="my-project", tags=["security"])` - Filter by frontmatter tags - - `search_notes("query", project="my-project", status="in-progress")` - Filter by frontmatter status - - `search_notes("query", project="my-project", metadata_filters={"priority": {"$in": ["high"]}})` - - ### Structured Metadata Filters - Filters match frontmatter metadata. Supported forms: - - Equality: `{"status": "in-progress"}` - - Array contains (all): `{"tags": ["security", "oauth"]}` - - Operators: - - `$in`: `{"priority": {"$in": ["high", "critical"]}}` - - `$gt`, `$gte`, `$lt`, `$lte`: `{"schema.confidence": {"$gt": 0.7}}` - - `$between`: `{"schema.confidence": {"$between": [0.3, 0.6]}}` - - `$contains`: `{"tags": {"$contains": "security"}}` - - `$exists`: `{"owner": {"$exists": true}}` (has a non-null value) - - Several operators on one key AND together: `{"started": {"$gte": "2026-01-01", "$lt": "2026-02-01"}}` - - The `$` is optional: `{"started": {"gte": "2026-01-01"}}` works too. - - Nested keys use dot notation (e.g., `"schema.confidence"`). - - ### Filter-only Searches - Omit `query` (or pass None) when only using structured filters: - - `search_notes(metadata_filters={"type": "spec"}, project="my-project")` - - `search_notes(tags=["security"], project="my-project")` - - `search_notes(status="draft", project="my-project")` - - ### Convenience Filters - `tags` and `status` are shorthand for metadata_filters. If the same key exists in - metadata_filters, that value wins. - - ### Valid-Time Filters (what a note says was true) - Notes can state when a fact holds, by writing a qualifier on an observation: - - - [decision] @effective[2026-06-10,2026-07-27) The cache layer will use Redis. - - [decision] @effective:2026-07-27 The cache layer will use Memcached. - - The bracket form is an explicit range; the `@kind:date` form is a point, meaning - the span its precision covers — `@2026` that year, `@2026-06` that month, and - `@2026-06-10` from that date onward. The kind may be omitted (`@2026-07-27`), - which files the assertion as `valid` time; a point with no kind has to start - with a digit and be at least as wide as a year, so `@v2` and `@may` stay prose. - - An unquoted point is **one whitespace-delimited token**, because nothing can tell - where a multi-word date ends. Slash dates (`@occurred:03/04/2026`, read by the - `date_order` setting) work as they are; anything longer goes in double quotes, - which move the token boundary to the closing quote: - - - [decision] @occurred:"June 10, 2026" The cutover ran. - - [decision] @occurred:"June 2026" The cutover ran. - - [decision] @"June 10, 2026" The cutover ran. - - Whatever is inside the quotes is read as the date, month-only forms included, and - whatever follows the closing quote is ordinary content. An unreadable token is left - as content, never half-read. - - **Relative dates are not accepted here.** `@occurred:yesterday`, `@occurred:"2 days - ago"`, `@occurred:"last week"` and a bare month name like `@occurred:March` all name - a different span depending on the day the note is indexed, so an unedited file would - assert a different valid time on every pass. They stay ordinary content, silently, - and quoting does not change that — quotes settle where a token ends, not what a date - means. Write the date the qualifier should mean: `@occurred:2026-06-10`. This is the - authored-time axis only; `recent_activity` and `build_context` still take relative - `timeframe` values, because those ask about edit time, which really is relative to now. - - These filters query that authored time, which is a different axis from `after_date` - (last-indexed time) — `after_date` is never reinterpreted as valid time. - - `search_notes("cache layer", time_kind="effective", valid_at="2026-07-28")` - - Returns the Memcached decision; the Redis decision expired at the cutover. - - `search_notes("cache layer", time_kind="effective", valid_at="2026-07-01")` - - Returns the Redis decision; Memcached is not yet effective. - - `search_notes("cache layer", time_kind="effective", valid_overlaps="[2026-06-01,2026-08-01)")` - - Returns both, since each overlaps that window. - - `search_notes("cache layer")` with no valid-time filter - - Both compete under ordinary relevance, exactly as before. - - **Sources with no temporal qualifier are excluded from any valid-time query.** - An undated note makes no claim about when it holds, so it cannot answer "what was - true on this date". Drop the valid-time filter to search dated and undated content - together. - - Because a single note can carry several assertions that disagree (as above), these - queries return observation-level results by default rather than whole notes, and - each result carries the assertion that matched so the answer can explain itself. - - Bounds follow PostgreSQL range conventions: `[` / `]` include an endpoint, `(` / `)` - exclude it, and an omitted side is unbounded. Calendar dates (`2026-07-27`) and - instants (`2026-07-27T16:42:00Z`) are separate axes that never convert into each - other: a date query matches only date ranges, an instant query only instant ranges. - An instant written without an offset is read as UTC. - - ### Advanced Pattern Examples - - `search_notes("project AND (meeting OR discussion)", project="work-project")` - Complex boolean logic - - `search_notes('"exact phrase" AND keyword', project="research")` - Combine phrase and keyword search - - `search_notes("bug NOT fixed", project="dev-notes")` - Exclude resolved issues - - `search_notes("docs/2024-*", project="archive", search_type="permalink")` - Year-based permalink search - - Args: - query: Optional search query string (supports boolean operators, phrases, patterns). - Omit or pass None for filter-only searches using metadata_filters, tags, or status. - project: Project name to search in. Optional - server will resolve using hierarchy. - If unknown, use list_memory_projects() to discover available projects. - project_id: Project external_id (UUID). Prefer this over `project` when known — - it routes to the exact project regardless of name collisions across cloud - workspaces. Takes precedence over `project`. Get from list_memory_projects(). - search_all_projects: Optional opt-in to search every accessible project. Ignored when - `project` or `project_id` is supplied. - projects: Optional list of project names or external ids to search together. Names - are matched exactly as list_memory_projects() reports them (cloud projects - by their workspace-qualified name). Ignored when `project` or `project_id` - is supplied; an unknown name is an error rather than a silent skip. - page: The page number of results to return (default 1). - Aliases: page_number. - page_size: The number of results to return per page (default 10). - Aliases: limit, per_page. - search_type: Type of search to perform, one of: - "text", "title", "permalink", "vector", "semantic", "hybrid". - Default is dynamic: "hybrid" when semantic search is enabled, otherwise "text". - output_format: "text" returns a formatted markdown result list. "json" returns a - machine-readable dictionary payload. - note_types: Filter by the frontmatter `type` field (e.g. "note", "person"). - Case-insensitive. Accepts a list, a comma-separated string, or a JSON-array string. - entity_types: Knowledge-graph item types to return: "entity" (whole notes), - "observation", "relation". Defaults to entity, or to observation when categories - or a valid-time filter is given. Not the frontmatter `type` (use note_types for - that). Accepts a list, a comma-separated string, or a JSON-array string. - categories: Observation categories to match exactly (e.g. ["requirement"]). Implies - observation results unless entity_types is set. Accepts a list, a - comma-separated string, or a JSON-array string. - after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01") - metadata_filters: Optional structured frontmatter filters (e.g., {"status": "in-progress"}). - Integer values match integer YAML fields ({"section": 3} works). - A None value is an is-null match: notes where the key is absent or explicitly - null. None inside $in/$between/a contains list/a comparison is refused — - those compare against the value, and a comparison with null is never true. - tags: Optional tag filter (frontmatter tags); shorthand for metadata_filters["tags"]. - Accepts a list (["a", "b"]) or a comma-separated string ("a,b"), matching the - write_note tags convention and the tag: query shorthand. - status: Optional status filter (frontmatter status); shorthand for metadata_filters["status"] - min_similarity: Optional float to override the global semantic_min_similarity threshold - for this query. E.g., 0.0 to see all vector results, or 0.8 for high precision. - Only applies to vector and hybrid search types. - valid_at: Optional date ("2026-07-28") or RFC 3339 instant ("2026-07-28T09:00:00Z"; - a timestamp with no offset is read as UTC). Returns sources whose authored - valid range contains it. Sources with no temporal qualifier are excluded. - Aliases: as_of, valid_on. - valid_overlaps: Optional PostgreSQL-style range literal ("[2026-06-10,2026-07-27)", - "(,2026-07-27]", "[2026-06-10,)"). Returns sources whose authored valid range - overlaps it. Mutually exclusive with valid_at; also excludes undated sources. - Aliases: overlaps, valid_during. - time_kind: Optional kind of valid time to narrow to: "effective", "valid", - "occurred", "due", or "mentioned". Valid on its own. Alias: kind. - context: Optional FastMCP context for performance caching. - compact: Omit content and matched excerpts in either output format. Defaults to False. - Use returned note identifiers with read_note for content verification. - - Returns: - Formatted markdown text (output_format="text"), dict (output_format="json"), - or helpful error guidance string if search fails - - Pagination note: use `total` as a count only when `total_is_exact` is true. - Vector and hybrid searches skip the count query (it would cost a second - semantic retrieval pass), report the results seen through this page as - `total` with `total_is_exact: false`, and use `has_more` for pagination. - - Examples: - # Basic text search - results = await search_notes("project planning") - # Plain multi-term text uses strict matching first, then relaxed OR fallback if needed - - # Boolean AND search (both terms must be present) - results = await search_notes("project AND planning") - - # Boolean OR search (either term can be present) - results = await search_notes("project OR meeting") - - # Boolean NOT search (exclude terms) - results = await search_notes("project NOT meeting") - - # Boolean search with grouping - results = await search_notes("(project OR planning) AND notes") - - # Exact phrase search - results = await search_notes("\"weekly standup meeting\"") - - # Search with note type filter - type property in frontmatter - results = await search_notes( - "meeting notes", - note_types=["note"], - ) - - # Search with entity type filter - results = await search_notes( - "meeting notes", - entity_types=["observation"], - ) - - # Search for recent content - results = await search_notes( - "bug report", - after_date="1 week" - ) - - # Pattern matching on permalinks - results = await search_notes( - "docs/meeting-*", - search_type="permalink" - ) - - # Title-only search - results = await search_notes( - "Machine Learning", - search_type="title" - ) - - # Complex search with multiple filters - results = await search_notes( - "(bug OR issue) AND NOT resolved", - note_types=["note"], - after_date="2024-01-01" - ) - - # Explicit project specification - results = await search_notes("project planning", project="my-project") - """ - # Validate pagination arguments before they reach the API/repository layer. - # Trigger: page < 1 or page_size < 1 (e.g. page_size=0 or a negative slice). - # Why: a non-positive page_size yields zero rows yet the router computes - # has_more = offset + len(results) < total, returning a misleading - # has_more=True with no reachable page; a negative page_size becomes an - # uncapped SQLite LIMIT. Mirrors recent_activity's guard so all navigation - # tools reject invalid pagination consistently. - # Outcome: caller gets an explicit ValueError instead of a silent bad payload. - if page < 1: - raise ValueError(f"page must be >= 1, got {page}") - if page_size < 1: - raise ValueError(f"page_size must be >= 1, got {page_size}") - - # Trigger: both valid-time forms supplied. - # Why: SearchQuery rejects the pair too, but the tool assigns its fields after - # construction, so that validator never runs on this path — the caller would - # otherwise learn about it as an opaque 422 from the API. - # Outcome: one clear error naming the two mutually exclusive parameters. - if valid_at and valid_overlaps: - raise ValueError("Use either valid_at (containment) or valid_overlaps (overlap), not both.") - - # Trigger: any valid-time filter string is supplied. - # Why: these strings are parsed server-side, so a typo comes back as a 400 that the - # fan-out below cannot tell from a project being unavailable -- it logs the - # project, skips it, and after every project is skipped reports an empty result - # that still claims the filter ran. A malformed filter would read as "no matches" - # instead of as an error. This is the only layer that can tell a client mistake - # from a per-project availability failure, and it shares the parser the search - # service uses so the two can never disagree about what is well formed. - # Outcome: one error naming the bad value, before any project is searched. - try: - parse_temporal_filter(valid_at=valid_at, valid_overlaps=valid_overlaps, time_kind=time_kind) - except TemporalQualifierError as exc: - raise ValueError(f"Invalid valid-time filter: {exc}") from exc - - # Trigger: list params arrived via a direct function call instead of the MCP layer. - # Why: the BeforeValidator annotations only run through MCP/Pydantic validation; direct - # callers (e.g. `bm tool search-notes --type note,task` in cli/commands/tool.py, - # which Typer collects as the one-element list ["note,task"]) would otherwise - # forward the comma string as one literal type that matches nothing (#930). - # Outcome: comma-split/list normalization applies on every path; parse_str_list is - # idempotent, so MCP-validated input passes through unchanged. - note_types = parse_str_list(note_types) if note_types is not None else [] - projects = parse_str_list(projects) if projects is not None else None - entity_types = parse_str_list(entity_types) if entity_types is not None else [] - categories = parse_str_list(categories) if categories is not None else [] - - # Avoid mutable-default-argument footguns. Treat None as "no filter". - # Note types use one snake_case identity at write and query boundaries. Lowercasing - # alone leaves multiword and camel-case inputs in a separate logical population. - note_types = [normalize_note_type(note_type) for note_type in note_types] - entity_types = entity_types or [] - # Categories are matched exactly against the indexed observation category, - # so preserve their original casing (unlike the canonicalized note_types). - categories = categories or [] - - # Trigger: tags arrived via a direct function call instead of the MCP layer. - # Why: the BeforeValidator above only runs through MCP/Pydantic validation; direct - # callers (e.g. `bm tool search-notes --tag a,b` in cli/commands/tool.py, which - # Typer collects as the one-element list ["a,b"]) would otherwise forward the - # comma string as one literal tag that matches nothing (#910). - # Outcome: comma-split/list normalization applies on every path; parse_tags is - # idempotent, so MCP-validated input passes through unchanged. - tags = parse_tags(tags) or None - - query, tags = split_tag_shorthand(query, tags) - - # Detect project from a memory URL or permalink prefix before routing. - # project_id routes by external UUID, so it bypasses URL discovery entirely. - if project is None and project_id is None and query is not None: - detected = await detect_project_from_identifier_prefix( - query, - ConfigManager().config, - context=context, - ) - if detected is not None: - # The id rides along so the name is never re-resolved against a - # different accessible workspace holding the same permalink (#1432). - project, project_id = detected.project, detected.project_id - - # Trigger: caller explicitly requests account/workspace-wide search and did not - # already provide a concrete project route. - # Why: multi-project fan-out can be slow, so default search remains project-scoped. - # Outcome: run one normal search per accessible project and merge ranked results. - if (search_all_projects or projects) and project is None and project_id is None: - all_projects_result = await _search_all_projects( - query=query, - page=page, - page_size=page_size, - search_type=search_type, - output_format=output_format, - note_types=note_types, - entity_types=entity_types, - categories=categories, - after_date=after_date, - metadata_filters=metadata_filters, - tags=tags, - status=status, - min_similarity=min_similarity, - valid_at=valid_at, - valid_overlaps=valid_overlaps, - time_kind=time_kind, - context=context, - compact=compact, - projects=projects, - ) - # search_notes reports every outcome in its result, as it always has. - match all_projects_result: - case MultiProjectRan(rendered=rendered): - return rendered - case MultiProjectNotIndexed(guidance=guidance): - return guidance - case MultiProjectFailed(rendered=rendered): - return rendered - case MultiProjectNoCriteria(): - return _NO_SEARCH_CRITERIA_MESSAGE - - with logfire.span( - "mcp.tool.search_notes", - entrypoint="mcp", - tool_name="search_notes", - requested_project=project, - requested_project_id=project_id, - search_all_projects=search_all_projects, - search_type=search_type or "default", - output_format=output_format, - page=page, - page_size=page_size, - has_query=bool(query and query.strip()), - note_type_filter_count=len(note_types), - entity_type_filter_count=len(entity_types), - category_filter_count=len(categories), - has_filters=bool( - metadata_filters - or tags - or status - or note_types - or entity_types - or categories - or after_date - or valid_at - or valid_overlaps - or time_kind - ), - has_tags_filter=bool(tags), - has_status_filter=bool(status), - has_temporal_filter=( - valid_at is not None or valid_overlaps is not None or time_kind is not None - ), - ): - async with get_project_client(project, context=context, project_id=project_id) as ( - client, - active_project, - ): - # Handle memory:// URLs by resolving to permalink search. - # Use active_project.name so resolution hits the cached active project - # when project_id was used or `project` was wrong/ambiguous. - is_memory_url = False - if query is not None: - _, resolved_query, is_memory_url = await resolve_project_and_path( - client, query, active_project.name, context - ) - if is_memory_url: - query = resolved_query - effective_search_type = _search_type_for(search_type, query, [active_project.name]) - if is_memory_url: - effective_search_type = "permalink" - - try: - search_query = _build_search_query( - query=query, - search_type=effective_search_type, - note_types=note_types, - entity_types=entity_types, - categories=categories, - after_date=after_date, - metadata_filters=metadata_filters, - tags=tags, - status=status, - min_similarity=min_similarity, - valid_at=valid_at, - valid_overlaps=valid_overlaps, - time_kind=time_kind, - ) - if search_query is None: - return _NO_SEARCH_CRITERIA_MESSAGE - effective_query = (query or "").strip() - - logger.debug( - f"Search request: project={active_project.name} " - f"search_type={effective_search_type} " - f"query={effective_query or ''} " - f"note_types={len(note_types)} entity_types={len(search_query.entity_types or [])} " - f"page={page} page_size={page_size}" - ) - # Import here to avoid circular import (tools → clients → utils → tools) - from basic_memory.mcp.clients import SearchClient - - # Use typed SearchClient for API calls - search_client = SearchClient(client, active_project.external_id) - result = await search_client.search( - search_query.model_dump(), - page=page, - page_size=page_size, - ) - logger.debug( - f"Search response: project={active_project.name} " - f"results={len(result.results)} has_more={str(result.has_more).lower()} " - f"page={result.current_page} page_size={result.page_size}" - ) - - # An empty page is a trustworthy miss only after an index pass. - if not result.results: - guidance = await project_index_required(client, active_project) - if guidance is not None: - return guidance - - if compact: - result = _compact_search_response(result) - if output_format == "json": - return result.model_dump(mode="json", exclude_none=True) - - return _format_search_markdown( - result, active_project.name, query, project_id=active_project.external_id - ) - - except Exception as e: - logger.error( - f"Search failed for query '{query or ''}': {e}, project: {active_project.name}" - ) - if _is_service_unavailable_error(e): - return _format_service_unavailable_response( - active_project.name, str(e), query or "" - ) - # Return formatted error message as string for better user experience - return _format_search_error_response( - active_project.name, str(e), query or "", effective_search_type - ) + payload = response.model_dump(mode="json", exclude_none=True) + for item in payload["results"]: + item["project"] = project_names[item["project_external_id"]] + return MultiProjectRan(payload) diff --git a/src/basic_memory/mcp/ui/__init__.py b/src/basic_memory/mcp/ui/__init__.py deleted file mode 100644 index cbf58bcf2..000000000 --- a/src/basic_memory/mcp/ui/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""UI helpers for MCP Apps resources.""" - -from basic_memory.mcp.ui.templates import get_ui_variant, load_html, load_variant_html - -__all__ = ["get_ui_variant", "load_html", "load_variant_html"] diff --git a/src/basic_memory/mcp/ui/html/note-preview-mcp-ui.html b/src/basic_memory/mcp/ui/html/note-preview-mcp-ui.html deleted file mode 100644 index b510ff827..000000000 --- a/src/basic_memory/mcp/ui/html/note-preview-mcp-ui.html +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - Basic Memory Note Preview - MCP UI - - - -
-

Note Preview

-
mcp-ui style
-
-
Waiting for note content...
- -
- -

-    
- - - - diff --git a/src/basic_memory/mcp/ui/html/note-preview-tool-ui.html b/src/basic_memory/mcp/ui/html/note-preview-tool-ui.html deleted file mode 100644 index 5c89fc35f..000000000 --- a/src/basic_memory/mcp/ui/html/note-preview-tool-ui.html +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - Basic Memory Tool UI Note Preview - - - -
- - - diff --git a/src/basic_memory/mcp/ui/html/note-preview-vanilla.html b/src/basic_memory/mcp/ui/html/note-preview-vanilla.html deleted file mode 100644 index 190bf4243..000000000 --- a/src/basic_memory/mcp/ui/html/note-preview-vanilla.html +++ /dev/null @@ -1,160 +0,0 @@ - - - - - - Basic Memory Note Preview - - - -

Note Preview

-
Waiting for note content...
- -
- -

-    
- - - - diff --git a/src/basic_memory/mcp/ui/html/search-results-mcp-ui.html b/src/basic_memory/mcp/ui/html/search-results-mcp-ui.html deleted file mode 100644 index ace9b7e6c..000000000 --- a/src/basic_memory/mcp/ui/html/search-results-mcp-ui.html +++ /dev/null @@ -1,254 +0,0 @@ - - - - - - Basic Memory MCP UI Search - - - -
-

Search Results

-
mcp-ui style
-
- -
Waiting for results...
- -
-
- -
- - - - - - diff --git a/src/basic_memory/mcp/ui/html/search-results-tool-ui.html b/src/basic_memory/mcp/ui/html/search-results-tool-ui.html deleted file mode 100644 index f231fad5e..000000000 --- a/src/basic_memory/mcp/ui/html/search-results-tool-ui.html +++ /dev/null @@ -1,132 +0,0 @@ - - - - - - Basic Memory Tool UI Search - - - -
- - - diff --git a/src/basic_memory/mcp/ui/html/search-results-vanilla.html b/src/basic_memory/mcp/ui/html/search-results-vanilla.html deleted file mode 100644 index 303cc7ff7..000000000 --- a/src/basic_memory/mcp/ui/html/search-results-vanilla.html +++ /dev/null @@ -1,280 +0,0 @@ - - - - - - Basic Memory Search Results - - - -
-

Search Results

-
Waiting for results...
-
- -
-
- -
- - - - - - diff --git a/src/basic_memory/mcp/ui/templates.py b/src/basic_memory/mcp/ui/templates.py deleted file mode 100644 index 3d8e00f0d..000000000 --- a/src/basic_memory/mcp/ui/templates.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Helpers for serving MCP UI HTML resources.""" - -from __future__ import annotations - -import os -from pathlib import Path - -DEFAULT_VARIANT = "vanilla" -SUPPORTED_VARIANTS = {"vanilla", "tool-ui", "mcp-ui"} - - -def get_ui_variant() -> str: - """Return the active UI variant from environment settings.""" - value = os.getenv("BASIC_MEMORY_MCP_UI_VARIANT", DEFAULT_VARIANT).strip().lower() - return value if value in SUPPORTED_VARIANTS else DEFAULT_VARIANT - - -def load_html(filename: str) -> str: - """Load a UI HTML template from disk.""" - path = Path(__file__).parent / "html" / filename - return path.read_text(encoding="utf-8") - - -def load_variant_html(base_name: str) -> str: - """Load a UI template for the current variant.""" - variant = get_ui_variant() - return load_html(f"{base_name}-{variant}.html") diff --git a/src/basic_memory/repository/metadata_filters.py b/src/basic_memory/repository/metadata_filters.py index 1db2950de..f75378dc6 100644 --- a/src/basic_memory/repository/metadata_filters.py +++ b/src/basic_memory/repository/metadata_filters.py @@ -125,7 +125,7 @@ def _normalize_numeric(value: object, raw_key: str) -> float: made the bound infinite, matching every note or none. Outcome: one refusal covering both, worded like this module's other filter errors, so every surface that builds filters (find's predicates, - search_notes' metadata_filters) reports it as the bad value it is + bm_search's and bm_find's filters) reports it as the bad value it is and the router answers 400. """ try: diff --git a/src/basic_memory/utils.py b/src/basic_memory/utils.py index 394312d4c..fa46a798a 100644 --- a/src/basic_memory/utils.py +++ b/src/basic_memory/utils.py @@ -685,38 +685,6 @@ def parse_tags(tags: Union[List[str], str, None]) -> List[str]: return [] -def strict_search_tags(v: Any) -> Any: - """Strictly coerce tag input at the search_notes tool boundary. - - parse_tags stringifies anything (42 -> ["42"], {"a": 1} -> junk tags), which would - turn caller type mistakes into silent no-result searches. At the tool boundary only - str, all-string lists, and None are valid tag inputs; everything else — including - lists with non-string elements like [42] — passes through unchanged so Pydantic - rejects it with a clear validation error. - - JSON array strings (the MCP clients-serialize-arrays-as-strings path) get the same - all-string check: '[42]' or '["ok", 42]' would otherwise be stringified by - parse_tags' recursive JSON handling before Pydantic ever sees the bad elements. - """ - if isinstance(v, list) and not all(isinstance(item, str) for item in v): - return v - # Trigger: a str that looks like a JSON array, mirroring parse_tags' detection. - # Why: parse_tags recursively parses JSON arrays, stringifying non-string elements - # ('[42]' -> ["42"]) and hiding the type error from Pydantic. - # Outcome: malformed arrays pass through unchanged so Pydantic rejects them; valid - # all-string arrays and plain comma strings still delegate to parse_tags. - if isinstance(v, str) and v.strip().startswith("[") and v.strip().endswith("]"): - try: - parsed = json.loads(v) - except json.JSONDecodeError: - parsed = None - if isinstance(parsed, list) and not all(isinstance(item, str) for item in parsed): - return v - if v is None or isinstance(v, (str, list)): - return parse_tags(v) - return v - - def parse_str_list(v: Any) -> List[str]: """Parse a list of plain strings from various input formats. diff --git a/tests/api/v2/test_cjk_search_guidance.py b/tests/api/v2/test_cjk_search_guidance.py index 4930bb7e2..e1eaeb08c 100644 --- a/tests/api/v2/test_cjk_search_guidance.py +++ b/tests/api/v2/test_cjk_search_guidance.py @@ -7,11 +7,12 @@ from httpx import AsyncClient from basic_memory import db +from basic_memory.mcp.tools import bm_search @pytest.mark.asyncio async def test_unspaced_compound_miss_has_actionable_hint( - client: AsyncClient, v2_project_url: str, entity_repository, search_service + client: AsyncClient, v2_project_url: str, test_project, entity_repository, search_service ): async with db.scoped_session(search_service.session_maker) as session: entity = await entity_repository.create( @@ -51,8 +52,6 @@ async def test_unspaced_compound_miss_has_actionable_hint( # The typed MCP client and formatter must preserve the API's guidance in # both output formats, including compact discovery responses. - from basic_memory.mcp.tools.search import search_notes - async with db.scoped_session(search_service.session_maker) as session: await session.execute( text("UPDATE project SET last_indexed_at = CURRENT_TIMESTAMP WHERE id = :id"), @@ -60,8 +59,12 @@ async def test_unspaced_compound_miss_has_actionable_hint( ) formats: tuple[Literal["text", "json"], ...] = ("text", "json") for output_format in formats: - result = await search_notes( - query="雾凇拼音", search_type="text", output_format=output_format, compact=True + result = await bm_search( + query="雾凇拼音", + mode="text", + output_format=output_format, + compact=True, + project=test_project.name, ) if isinstance(result, str): assert "shorter word" in result @@ -74,8 +77,6 @@ async def test_unspaced_compound_miss_has_actionable_hint( async def test_unindexed_cjk_miss_keeps_index_guidance( client, test_project, session_maker, config_home, output_format ): - from basic_memory.mcp.tools.search import search_notes - notes_dir = config_home / "notes" notes_dir.mkdir(exist_ok=True) (notes_dir / "unindexed.md").write_text("# Diary\n\n雾凇词库部署\n", encoding="utf-8") @@ -84,12 +85,12 @@ async def test_unindexed_cjk_miss_keeps_index_guidance( text("UPDATE project SET last_indexed_at = NULL WHERE id = :id"), {"id": test_project.id}, ) - result = await search_notes( - project=test_project.name, query="雾凇拼音", search_type="text", output_format=output_format + result = await bm_search( + project=test_project.name, query="雾凇拼音", mode="text", output_format=output_format ) - assert isinstance(result, str) - assert result.startswith("# Project Index Required") - assert "shorter word" not in result + guidance = result if isinstance(result, str) else result["guidance"] + assert guidance.startswith("# Project Index Required") + assert "shorter word" not in guidance @pytest.mark.asyncio diff --git a/tests/implementation_mcp.py b/tests/implementation_mcp.py index f1d47ae57..ef7f84ebe 100644 --- a/tests/implementation_mcp.py +++ b/tests/implementation_mcp.py @@ -24,7 +24,6 @@ "grep", "ls", "man", - "search_notes", "tail", ) diff --git a/tests/mcp/test_mcp_note_type_normalization.py b/tests/mcp/test_mcp_note_type_normalization.py deleted file mode 100644 index d28c62630..000000000 --- a/tests/mcp/test_mcp_note_type_normalization.py +++ /dev/null @@ -1,56 +0,0 @@ -"""MCP boundary coverage for note-type canonicalization.""" - -from contextlib import asynccontextmanager -from typing import Any - -import pytest - -from basic_memory.schemas.search import SearchResponse - - -@pytest.mark.asyncio -async def test_search_notes_canonicalizes_multiword_note_types(monkeypatch): - """MCP filters use snake_case rather than lowercase-only normalization.""" - import importlib - - search_module = importlib.import_module("basic_memory.mcp.tools.search") - clients_module = 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[str, Any] = {} - - 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_module, "get_project_client", fake_get_project_client) - monkeypatch.setattr( - search_module, - "resolve_project_and_path", - fake_resolve_project_and_path, - ) - monkeypatch.setattr(clients_module, "SearchClient", MockSearchClient) - - await search_module.search_notes( - project="test-project", - query="test", - note_types=["Task Item", "TaskItem", "task-item"], - ) - - assert captured_payload["note_types"] == ["task_item", "task_item", "task_item"] diff --git a/tests/mcp/test_search_total_exactness.py b/tests/mcp/test_search_total_exactness.py deleted file mode 100644 index cf55da003..000000000 --- a/tests/mcp/test_search_total_exactness.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Tests for the structured search total exactness contract.""" - -from contextlib import asynccontextmanager -import importlib -from types import SimpleNamespace - -import pytest - -from basic_memory.schemas.search import SearchItemType, SearchResponse, SearchResult - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("search_type", "retrieval_mode", "total", "total_is_exact"), - [ - ("text", "fts", 1, True), - ("vector", "vector", 0, False), - ("hybrid", "hybrid", 0, False), - ], -) -async def test_search_notes_json_exposes_total_exactness( - monkeypatch, - search_type: str, - retrieval_mode: str, - total: int, - total_is_exact: bool, -) -> None: - """MCP JSON preserves the API's exact-versus-unknown total distinction.""" - search_mod = importlib.import_module("basic_memory.mcp.tools.search") - clients_mod = importlib.import_module("basic_memory.mcp.clients") - captured_payload: dict[str, object] = {} - - @asynccontextmanager - async def fake_get_project_client(*args, **kwargs): - yield object(), SimpleNamespace(name="test-project", external_id="test-project-id") - - async def fake_resolve_project_and_path(client, identifier, project=None, context=None): - return None, identifier, False - - class MockSearchClient: - def __init__(self, *args, **kwargs): - pass - - async def search(self, payload, *, page, page_size): - captured_payload.update(payload) - return SearchResponse( - results=[ - SearchResult( - title="Result", - type=SearchItemType.ENTITY, - score=1.0, - permalink="notes/result", - file_path="notes/result.md", - ) - ], - current_page=page, - page_size=page_size, - total=total, - total_is_exact=total_is_exact, - ) - - 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="query", - search_type=search_type, - output_format="json", - ) - - assert isinstance(result, dict) - assert captured_payload["retrieval_mode"] == retrieval_mode - assert result["total"] == total - assert result["total_is_exact"] is total_is_exact diff --git a/tests/mcp/test_tool_bm_search.py b/tests/mcp/test_tool_bm_search.py index 7c5f7cbd0..5f6bd48a2 100644 --- a/tests/mcp/test_tool_bm_search.py +++ b/tests/mcp/test_tool_bm_search.py @@ -11,9 +11,7 @@ from basic_memory.index.local_project import LocalProjectIndexRunner from basic_memory.mcp.tools import bm_read, bm_search, bm_write -from basic_memory.mcp.tools import ranked_search as ranked_search_module from basic_memory.mcp.tools.ranked_search import _query_terms -from basic_memory.mcp.tools.search import MultiProjectFailed, MultiProjectNotIndexed async def search_text(**arguments: Any) -> str: @@ -337,26 +335,13 @@ async def test_empty_search_across_projects_is_refused(client, test_project): @pytest.mark.asyncio -async def test_a_search_that_ran_nowhere_is_an_error(client, test_project, monkeypatch): - """Every database failing is UNAVAILABLE, not an empty successful search.""" +async def test_unindexed_projects_keep_the_json_shape(client, test_project): + """A miss across never-indexed projects is guidance, not an empty success.""" + payload = await search_json(query="zxqv", mode="text", all_projects=True) - async def ran_nowhere(**_kwargs: Any) -> MultiProjectFailed: - return MultiProjectFailed(reason="local: database is locked", rendered="# Search Failed") - - monkeypatch.setattr(ranked_search_module, "_search_all_projects", ran_nowhere) - with pytest.raises(ToolError, match='"error": "UNAVAILABLE"'): - await bm_search(query="deploy", all_projects=True, output_format="json") - - -@pytest.mark.asyncio -async def test_unindexed_projects_keep_the_json_shape(client, test_project, monkeypatch): - async def not_indexed(**_kwargs: Any) -> MultiProjectNotIndexed: - return MultiProjectNotIndexed(guidance="Index the project first.") - - monkeypatch.setattr(ranked_search_module, "_search_all_projects", not_indexed) - payload = await search_json(query="deploy", all_projects=True) - - assert payload == {"results": [], "guidance": "Index the project first."} + assert payload["results"] == [] + assert payload["guidance"].startswith("# Project Index Required") + assert test_project.name in payload["guidance"] @pytest.mark.asyncio @@ -398,3 +383,53 @@ async def test_max_matches_needs_a_line_scan(client, test_project): """Ranked results have no matching lines, so a max_matches bound cannot apply.""" with pytest.raises(ToolError, match="^INVALID_ARGUMENTS: `max_matches` bounds"): await bm_search(query="deploy", max_matches=1, project=test_project.name) + + +@pytest.mark.asyncio +async def test_search_needs_an_explicit_scope(client, test_project): + with pytest.raises(ToolError, match="^INVALID_ARGUMENTS: Search needs an explicit scope"): + await bm_search(query="deploy") + + +@pytest.mark.asyncio +async def test_lines_text_points_at_further_matches(client, test_project): + """A note with more matching lines than max_matches says where the next one is.""" + await _seed(test_project.name) + + text = await search_text( + query="deploy", mode="text", lines=0, max_matches=1, path="tasks", project=test_project.name + ) + + assert "(more matches from line " in text + + +@pytest.mark.asyncio +async def test_semantic_text_results_show_the_matched_excerpt(client, test_project): + await _seed(test_project.name) + + text = await search_text( + query="rollback is manual", mode="semantic", min_similarity=0.0, project=test_project.name + ) + + assert "- tasks/Deploy Plan.md | Deploy Plan | score " in text + assert "\n " in text + + +@pytest.mark.asyncio +async def test_lines_skip_hits_without_markdown( + client, test_project, test_files, project_repository, session_maker +): + """A ranked hit that is not a Markdown note has no lines to match, so it is left out.""" + runner = LocalProjectIndexRunner( + project_repository=project_repository, session_maker=session_maker + ) + await runner.index_project(test_project.id, force_full=True) + image_name = test_files["image"].stem + + ranked = await search_json(query=image_name, mode="text", project=test_project.name) + with_lines = await search_json( + query=image_name, mode="text", lines=0, project=test_project.name + ) + + assert ranked["results"], "the image must be a ranked hit for this test to mean anything" + assert with_lines["results"] == [] diff --git a/tests/mcp/test_tool_bm_search_query.py b/tests/mcp/test_tool_bm_search_query.py new file mode 100644 index 000000000..218494a05 --- /dev/null +++ b/tests/mcp/test_tool_bm_search_query.py @@ -0,0 +1,570 @@ +"""bm_search query shape: modes, filters, tag shorthand, defaults and index readiness. + +The end-to-end cases run bm_search over +the real ASGI stack; the query-building cases call the shared, pure +`_build_search_query` / `split_tag_shorthand` / `_search_type_for` helpers that +bm_search runs every request through. +""" + +import importlib +from datetime import datetime, timedelta +from typing import Any + +import pytest +from fastmcp import Client +from fastmcp.exceptions import ToolError +from sqlalchemy import text as sa_text + +from basic_memory import db +from basic_memory.config import ProjectMode +from basic_memory.runtime.mode import RuntimeMode +from basic_memory.mcp.container import McpContainer +from basic_memory.mcp.tools import bm_search, bm_write +from basic_memory.mcp.tools.search import ( + _build_search_query, + _search_type_for, + split_tag_shorthand, +) +from basic_memory.utils import parse_str_list + +# The package exports the ChatGPT `search` adapter under the module's name. +search_module = importlib.import_module("basic_memory.mcp.tools.search") +container_module = importlib.import_module("basic_memory.mcp.container") + + +async def search_json(**arguments: Any) -> dict[str, Any]: + result = await bm_search(output_format="json", **arguments) + assert isinstance(result, dict) + return result + + +async def _write_search_note(project_name: str) -> None: + await bm_write( + project=project_name, + title="Test Search Note", + directory="test", + content="# Test\nThis is a searchable test note", + tags=["test", "search"], + ) + + +def _query(**overrides: Any) -> dict[str, Any]: + """`_build_search_query` arguments with nothing narrowing the search.""" + arguments: dict[str, Any] = { + "query": None, + "search_type": "text", + "note_types": [], + "entity_types": [], + "categories": [], + "after_date": None, + "metadata_filters": None, + "tags": None, + "min_similarity": None, + "valid_at": None, + "valid_overlaps": None, + "time_kind": None, + } + arguments.update(overrides) + return arguments + + +# --- Search modes, end to end --- + + +@pytest.mark.asyncio +async def test_title_mode_matches_the_title(client, test_project): + await _write_search_note(test_project.name) + + payload = await search_json(query="Search Note", mode="title", project=test_project.name) + + assert [row["title"] for row in payload["results"]] == ["Test Search Note"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("qualified", [False, True]) +async def test_permalink_mode_matches_an_exact_permalink(client, test_project, qualified): + """A project-relative permalink is put into index form; a qualified one is kept.""" + await _write_search_note(test_project.name) + permalink = "test/test-search-note" + query = f"{test_project.permalink}/{permalink}" if qualified else permalink + + payload = await search_json(query=query, mode="permalink", project=test_project.name) + + assert [row["permalink"] for row in payload["results"]] == [ + f"{test_project.permalink}/{permalink}" + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("mode", "total_is_exact"), + [("text", True), ("semantic", False), ("hybrid", False)], +) +async def test_json_keeps_the_total_exactness_of_the_mode( + client, test_project, mode, total_is_exact +): + """Vector and hybrid searches skip the count query, so their total is not exact.""" + await _write_search_note(test_project.name) + + payload = await search_json( + query="searchable", mode=mode, min_similarity=0.0, project=test_project.name + ) + + assert payload["results"] + assert payload["total_is_exact"] is total_is_exact + + +# --- Filters, end to end --- + + +@pytest.mark.asyncio +async def test_note_types_filter_by_frontmatter_type(client, test_project): + await bm_write( + project=test_project.name, + title="Note Type Test", + directory="test", + content="# Test\nFiltered by type", + ) + + matching = await search_json( + query="filtered", mode="text", note_types=["note"], project=test_project.name + ) + other = await search_json( + query="filtered", mode="text", note_types=["person"], project=test_project.name + ) + + assert [row["title"] for row in matching["results"]] == ["Note Type Test"] + assert other["results"] == [] + + +@pytest.mark.asyncio +async def test_note_types_are_canonicalized_to_snake_case(client, test_project): + """'Task Item', 'TaskItem' and 'task-item' all name the stored type task_item.""" + await bm_write( + project=test_project.name, + title="Canonical Task", + directory="test", + content="# Canonical Task\nCanonicalTypeToken", + note_type="task_item", + ) + + for spelling in ("Task Item", "TaskItem", "task-item"): + payload = await search_json( + query="CanonicalTypeToken", + mode="text", + note_types=[spelling], + project=test_project.name, + ) + assert [row["title"] for row in payload["results"]] == ["Canonical Task"], spelling + + +@pytest.mark.asyncio +async def test_items_note_returns_only_whole_notes(client, test_project): + await bm_write( + project=test_project.name, + title="Entity Type Test", + directory="test", + content="# Test\n- [fact] Filtered by type", + ) + + payload = await search_json( + query="filtered", mode="text", items=["note"], project=test_project.name + ) + + assert payload["results"] + assert all(row["type"] == "entity" for row in payload["results"]) + + +@pytest.mark.asyncio +async def test_categories_filter_returns_only_the_exact_category(client, test_project): + """A [decision] that mentions "requirement" is not a requirement (#430).""" + await bm_write( + 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" + ), + ) + + requirements = await search_json( + query="requirement", + mode="text", + items=["observation"], + categories=["requirement"], + project=test_project.name, + ) + decisions = await search_json( + query="requirement", + mode="text", + items=["observation"], + categories=["decision"], + project=test_project.name, + ) + + assert requirements["results"] + assert all(row["category"] == "requirement" for row in requirements["results"]) + assert decisions["results"] + assert all(row["category"] == "decision" for row in decisions["results"]) + + +@pytest.mark.asyncio +async def test_categories_alone_return_observations(client, test_project): + """Categories live on observations, so they imply observation results (#908).""" + await bm_write( + 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" + ), + ) + + payload = await search_json( + query="auth", mode="text", categories=["requirement"], project=test_project.name + ) + + assert payload["results"], "category-only search must return matching observations" + assert all(row["type"] == "observation" for row in payload["results"]) + assert all(row["category"] == "requirement" for row in payload["results"]) + + +@pytest.mark.asyncio +async def test_since_filters_by_update_time(client, test_project): + await bm_write( + project=test_project.name, + title="Recent Note", + directory="test", + content="# Test\nRecent content", + ) + + recent = await search_json( + query="recent", + mode="text", + since=(datetime.now() - timedelta(hours=1)).isoformat(), + project=test_project.name, + ) + future = await search_json( + query="recent", + mode="text", + since=(datetime.now() + timedelta(days=1)).isoformat(), + project=test_project.name, + ) + + assert [row["title"] for row in recent["results"]] == ["Recent Note"] + assert future["results"] == [] + + +@pytest.mark.asyncio +async def test_metadata_filter_note_type_key_reads_as_type(client, test_project): + """`note_type` is the model column; the frontmatter key it means is `type` (#642).""" + await bm_write( + project=test_project.name, + title="Spec Note", + directory="test", + content="# Spec Note\nAliasToken", + note_type="spec", + metadata={"priority": "high"}, + ) + + payload = await search_json( + filters={"note_type": "spec", "priority": "high"}, project=test_project.name + ) + + assert [row["title"] for row in payload["results"]] == ["Spec Note"] + + +# --- Tag shorthand, end to end --- + + +@pytest.mark.asyncio +async def test_tag_shorthand_filters_by_tag(client, test_project): + await bm_write( + project=test_project.name, + title="Coffee Brewing", + directory="test", + content="# Coffee Brewing\nBrewToken pour over", + tags=["coffee", "brewing"], + ) + await bm_write( + project=test_project.name, + title="Coffee Beans", + directory="test", + content="# Coffee Beans\nBrewToken origins", + tags=["coffee"], + ) + + by_tag = await search_json(query="tag:coffee", project=test_project.name) + both_tags = await search_json(query="tag:coffee AND tag:brewing", project=test_project.name) + tag_and_text = await search_json( + query="origins tag:coffee", mode="text", project=test_project.name + ) + + assert {row["title"] for row in by_tag["results"]} == {"Coffee Brewing", "Coffee Beans"} + assert [row["title"] for row in both_tags["results"]] == ["Coffee Brewing"] + assert [row["title"] for row in tag_and_text["results"]] == ["Coffee Beans"] + + +# --- List parameters through the MCP layer --- + + +@pytest.mark.asyncio +async def test_list_parameters_accept_comma_and_json_strings_via_mcp(mcp, client, test_project): + """'note,task' must filter like ['note', 'task'], not as one literal type (#930).""" + await bm_write( + project=test_project.name, + title="Comma Split Note", + directory="test", + content="# Comma Split Note\n- [requirement] CommaSplitToken body", + ) + + async with Client(mcp) as mcp_client: + + async def titles(**arguments: Any) -> str: + result = await mcp_client.call_tool( + "bm_search", + {"project": test_project.name, "query": "CommaSplitToken", "mode": "text"} + | arguments, + ) + return result.content[0].text + + assert "Comma Split Note" in await titles(note_types="note,task") + assert "Comma Split Note" in await titles(note_types='["note", "task"]') + assert "Comma Split Note" in await titles(note_types=["note,task"]) + assert "Comma Split Note" not in await titles(note_types="person") + assert "Comma Split Note" in await titles(categories="requirement,decision") + assert "Comma Split Note" not in await titles(categories="decision") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("field", ["note_types", "categories"]) +@pytest.mark.parametrize("value", [[42], ["note", 42]]) +async def test_list_parameters_reject_non_string_elements_via_mcp( + mcp, client, test_project, field, value +): + """[42] is a caller mistake, not the type '42' that silently matches nothing.""" + async with Client(mcp) as mcp_client: + with pytest.raises(ToolError): + await mcp_client.call_tool( + "bm_search", {"project": test_project.name, "query": "anything", field: value} + ) + + +def test_parse_str_list_returns_non_string_list_elements_for_validation(): + """parse_str_list must leave [42] as it is so Pydantic rejects it, not stringify it.""" + 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] + assert parse_str_list(["note", "task"]) == ["note", "task"] + assert parse_str_list(["note,task"]) == ["note", "task"] + + +# --- Default mode --- + + +@pytest.fixture +def search_config(app_config, monkeypatch): + """Install the MCP container the server lifespan would, holding this test's config. + + The default mode is read from the container's config. The container is + process-global and an earlier in-process MCP session leaves its own behind, so + each test installs the one it reasons about. + """ + monkeypatch.setattr( + container_module, "_container", McpContainer(config=app_config, mode=RuntimeMode.TEST) + ) + return app_config + + +@pytest.mark.asyncio +async def test_default_mode_is_hybrid_and_boolean_queries_run_as_text( + client, test_project, search_config +): + """Hybrid's vector half ignores NOT/AND, so a Boolean query defaults to full-text. + + The total's exactness tells the two apart: only full-text search counts. + """ + await _write_search_note(test_project.name) + + plain = await search_json(query="searchable", project=test_project.name) + boolean = await search_json(query="searchable NOT missing", project=test_project.name) + + assert plain["total_is_exact"] is False + assert boolean["total_is_exact"] is True + assert [row["title"] for row in boolean["results"]] == ["Test Search Note"] + + +@pytest.mark.asyncio +async def test_configured_default_search_type_wins(client, test_project, search_config): + search_config.default_search_type = "text" + await _write_search_note(test_project.name) + + payload = await search_json(query="searchable", project=test_project.name) + + assert payload["total_is_exact"] is True + + +def test_boolean_operators_are_recognized_only_as_standalone_uppercase_words(search_config): + for query in ["coffee NOT pour", "pour AND clarity", "(tea OR coffee) NOT decaf"]: + assert _search_type_for(None, query, ["test-project"]) == "text", query + for query in ["coffee not pour", "ORACLE notes", "ANDROID", None]: + assert _search_type_for(None, query, ["test-project"]) == "hybrid", query + assert _search_type_for("hybrid", "coffee NOT pour", ["test-project"]) == "hybrid" + + +# Trigger: the host's sqlite-vec loads in the test environment. +# Why: the keyword-only fallback (#711) only shows on a host that cannot load it, so +# these two tests inject that one fact; everything else runs for real. +def test_a_keyword_only_host_defaults_to_text(search_config, monkeypatch): + monkeypatch.setattr(search_module, "semantic_runtime_available", lambda config: False) + + assert _search_type_for(None, "coffee", ["test-project"]) == "text" + + +def test_cloud_projects_default_to_hybrid_on_a_keyword_only_host(search_config, monkeypatch): + """Cloud-routed projects search Cloud's Postgres, which always has vectors.""" + search_config.set_project_mode("test-project", ProjectMode.CLOUD) + monkeypatch.setattr(search_module, "semantic_runtime_available", lambda config: False) + + assert _search_type_for(None, "coffee", ["test-project"]) == "hybrid" + + +# --- Query building --- + + +@pytest.mark.parametrize( + ("search_type", "retrieval_mode"), + [("text", "fts"), ("vector", "vector"), ("semantic", "vector"), ("hybrid", "hybrid")], +) +def test_search_types_map_to_retrieval_modes(search_type, retrieval_mode): + search_query = _build_search_query(**_query(query="semantic lookup", search_type=search_type)) + + assert search_query is not None + assert search_query.text == "semantic lookup" + assert search_query.retrieval_mode == retrieval_mode + + +def test_title_and_permalink_modes_fill_their_own_fields(): + title = _build_search_query(**_query(query="Meeting", search_type="title")) + exact = _build_search_query(**_query(query="docs/meeting", search_type="permalink")) + pattern = _build_search_query(**_query(query="docs/meeting-*", search_type="permalink")) + + assert title is not None and title.title == "Meeting" + assert exact is not None and exact.permalink == "docs/meeting" + assert pattern is not None and pattern.permalink_match == "docs/meeting-*" + + +def test_an_unknown_search_type_is_an_error(): + with pytest.raises(ValueError, match="Invalid search_type 'bogus'"): + _build_search_query(**_query(query="test", search_type="bogus")) + + +def test_filters_reach_the_query(): + search_query = _build_search_query( + **_query( + query="test", + search_type="vector", + metadata_filters={"note_type": "spec", "priority": "high"}, + tags=["important"], + min_similarity=0.0, + ) + ) + + assert search_query is not None + assert search_query.metadata_filters == {"type": "spec", "priority": "high"} + assert search_query.tags == ["important"] + assert search_query.min_similarity == 0.0 + + +def test_a_filter_alone_is_enough_criteria_and_nothing_is_none(): + filter_only = _build_search_query(**_query(metadata_filters={"status": "in-progress"})) + + assert filter_only is not None + assert filter_only.text is None and filter_only.title is None + assert filter_only.permalink is None + assert _build_search_query(**_query()) is None + + +def test_results_default_to_whole_notes_unless_items_say_otherwise(): + """Observations and relations stay out of a plain search (#31).""" + default = _build_search_query(**_query(query="test")) + explicit = _build_search_query(**_query(query="test", entity_types=["observation"])) + + assert default is not None and default.entity_types == ["entity"] + assert explicit is not None and explicit.entity_types == ["observation"] + + +@pytest.mark.parametrize( + ("query", "expected_query", "expected_tags"), + [ + ("tag:security", None, ["security"]), + ("tag:coffee AND tag:brewing", None, ["coffee", "brewing"]), + ("tag:coffee,brewing tag:coffee", None, ["coffee", "brewing"]), + ("authentication tag:security", "authentication", ["security"]), + ("tag:", "tag:", None), + ("no tags here", "no tags here", None), + (None, None, None), + ], +) +def test_tag_shorthand_moves_tags_out_of_the_query(query, expected_query, expected_tags): + assert split_tag_shorthand(query) == (expected_query, expected_tags) + + +# --- Index readiness --- + + +async def _forget_indexing(session_maker, config_home, project_id: int) -> None: + """Files on disk, but no index pass has ever completed.""" + notes_dir = config_home / "notes" + notes_dir.mkdir(exist_ok=True) + (notes_dir / "unindexed-note.md").write_text("# Unindexed Note\n\nNo index covers this.\n") + async with db.scoped_session(session_maker) as session: + await session.execute( + sa_text("UPDATE project SET last_indexed_at = NULL WHERE id = :id"), + {"id": project_id}, + ) + + +@pytest.mark.asyncio +async def test_a_never_indexed_miss_says_so(client, test_project, session_maker, config_home): + """A never-indexed project must not report an ordinary empty result (#1534).""" + await _forget_indexing(session_maker, config_home, test_project.id) + + text = await bm_search(query="unindexed-note-xyzzy", mode="text", project=test_project.name) + payload = await search_json( + query="unindexed-note-xyzzy", mode="text", project=test_project.name + ) + + assert isinstance(text, str) + assert "never been indexed" in text + assert "bm project index" in text + assert payload["results"] == [] + assert payload["guidance"].startswith("# Project Index Required") + + +@pytest.mark.asyncio +async def test_an_indexed_miss_is_an_ordinary_empty_result(client, test_project, session_maker): + async with db.scoped_session(session_maker) as session: + await session.execute( + sa_text("UPDATE project SET last_indexed_at = :now WHERE id = :id"), + {"now": datetime.now(), "id": test_project.id}, + ) + await bm_write( + project=test_project.name, + title="Indexed Note", + directory="notes", + content="# Indexed Note\n\nSearchable content about telescopes.\n", + ) + + miss = await bm_search( + query="nothing-matches-this-xyzzy", mode="text", project=test_project.name + ) + hit = await search_json(query="telescopes", mode="text", project=test_project.name) + + assert miss == f"No results for 'nothing-matches-this-xyzzy' in project '{test_project.name}'." + assert hit["results"] + assert "guidance" not in hit diff --git a/tests/mcp/test_tool_search_temporal.py b/tests/mcp/test_tool_bm_search_temporal.py similarity index 59% rename from tests/mcp/test_tool_search_temporal.py rename to tests/mcp/test_tool_bm_search_temporal.py index 8ecebb4b8..6c8baafeb 100644 --- a/tests/mcp/test_tool_search_temporal.py +++ b/tests/mcp/test_tool_bm_search_temporal.py @@ -1,8 +1,8 @@ -"""End-to-end valid-time search through the MCP `search_notes` tool (SPEC-82). +"""End-to-end valid-time search through the MCP `bm_search` tool (SPEC-82). These tests exercise the whole chain the spec's acceptance cases describe: markdown -carrying temporal qualifiers is written through `write_note`, indexed, projected, and -then queried by authored valid time through `search_notes`. +carrying temporal qualifiers is written through `bm_write`, indexed, projected, and +then queried by authored valid time through `bm_search`. The scenario is the spec's own: one note holding two `[decision]` observations that disagree about the cache layer, each qualified with the window it was effective over. @@ -10,15 +10,13 @@ insufficient and forces the projection to address individual observations. """ -import inspect -import sys +import importlib from typing import Any -from unittest.mock import AsyncMock import pytest +from fastmcp.exceptions import ToolError -from basic_memory.mcp.tools import bm_write -from basic_memory.mcp.tools.search import SearchProjectRef, search_notes +from basic_memory.mcp.tools import bm_search, bm_write # The spec's worked example, verbatim: one note, two decisions, adjacent half-open # effective windows meeting at the July 27 cutover. @@ -57,6 +55,12 @@ async def _write_cache_layer_note(project_name: str) -> None: ) +async def search_json(**arguments: Any) -> dict[str, Any]: + result = await bm_search(output_format="json", **arguments) + assert isinstance(result, dict) + return result + + def _contents(response: dict[str, Any]) -> list[str]: """The matched observation text of every result, for readable assertions.""" return [result["content"] or "" for result in response["results"]] @@ -71,15 +75,13 @@ async def test_valid_at_after_cutover_returns_memcached_excludes_redis(client, t """ await _write_cache_layer_note(test_project.name) - response = await search_notes( + response = await search_json( project=test_project.name, query="cache layer", time_kind="effective", valid_at="2026-07-28", - output_format="json", ) - assert isinstance(response, dict), response contents = _contents(response) assert any("Memcached" in content for content in contents), contents assert not any("Redis" in content for content in contents), contents @@ -90,15 +92,13 @@ async def test_valid_at_before_cutover_returns_redis_excludes_memcached(client, """Acceptance 6: `valid_at=2026-07-01` returns Redis and not Memcached.""" await _write_cache_layer_note(test_project.name) - response = await search_notes( + response = await search_json( project=test_project.name, query="cache layer", time_kind="effective", valid_at="2026-07-01", - output_format="json", ) - assert isinstance(response, dict), response contents = _contents(response) assert any("Redis" in content for content in contents), contents assert not any("Memcached" in content for content in contents), contents @@ -119,22 +119,19 @@ async def test_point_qualifier_answers_the_cutover_like_a_range(client, test_pro content=CACHE_LAYER_POINT_NOTE, ) - after = await search_notes( + after = await search_json( project=test_project.name, query="cache layer", time_kind="effective", valid_at="2026-07-28", - output_format="json", ) - before = await search_notes( + before = await search_json( project=test_project.name, query="cache layer", time_kind="effective", valid_at="2026-07-01", - output_format="json", ) - assert isinstance(after, dict) and isinstance(before, dict) after_contents = _contents(after) assert any("Memcached" in content for content in after_contents), after_contents assert not any("Redis" in content for content in after_contents), after_contents @@ -149,14 +146,12 @@ async def test_no_temporal_filter_lets_both_decisions_compete(client, test_proje """Acceptance 7: with no valid-time filter both decisions are candidates again.""" await _write_cache_layer_note(test_project.name) - response = await search_notes( + response = await search_json( project=test_project.name, query="cache layer", - entity_types=["observation"], - output_format="json", + items=["observation"], ) - assert isinstance(response, dict), response contents = _contents(response) assert any("Redis" in content for content in contents), contents assert any("Memcached" in content for content in contents), contents @@ -169,15 +164,13 @@ async def test_valid_overlaps_returns_both_decisions(client, test_project): """A window spanning the cutover overlaps both effective ranges.""" await _write_cache_layer_note(test_project.name) - response = await search_notes( + response = await search_json( project=test_project.name, query="cache layer", time_kind="effective", valid_overlaps="[2026-06-01,2026-08-01)", - output_format="json", ) - assert isinstance(response, dict), response contents = _contents(response) assert any("Redis" in content for content in contents), contents assert any("Memcached" in content for content in contents), contents @@ -193,13 +186,11 @@ async def test_undated_note_search_is_unchanged(client, test_project): content=UNDATED_NOTE, ) - response = await search_notes( + response = await search_json( project=test_project.name, query="RabbitMQ", - output_format="json", ) - assert isinstance(response, dict), response assert response["results"], response assert response.get("temporal_applied") is None @@ -215,22 +206,18 @@ async def test_valid_at_excludes_undated_observations(client, test_project): content=UNDATED_NOTE, ) - unfiltered = await search_notes( + unfiltered = await search_json( project=test_project.name, query="layer", - entity_types=["observation"], - output_format="json", + items=["observation"], ) - assert isinstance(unfiltered, dict), unfiltered assert any("RabbitMQ" in content for content in _contents(unfiltered)) - filtered = await search_notes( + filtered = await search_json( project=test_project.name, query="layer", valid_at="2026-07-28", - output_format="json", ) - assert isinstance(filtered, dict), filtered assert not any("RabbitMQ" in content for content in _contents(filtered)) assert filtered["temporal_applied"] is True @@ -240,14 +227,12 @@ async def test_results_carry_the_assertion_that_matched(client, test_project): """A valid-time hit explains itself: kind, canonical range, and authored text.""" await _write_cache_layer_note(test_project.name) - response = await search_notes( + response = await search_json( project=test_project.name, query="cache layer", valid_at="2026-07-28", - output_format="json", ) - assert isinstance(response, dict), response [result] = [r for r in response["results"] if "Memcached" in (r["content"] or "")] [assertion] = result["temporal"] assert assertion["kind"] == "effective" @@ -261,18 +246,17 @@ async def test_results_carry_the_assertion_that_matched(client, test_project): @pytest.mark.asyncio -async def test_markdown_output_labels_the_time_kind(client, test_project): - """Human-readable output names the kind instead of printing a bare date.""" +async def test_text_output_labels_the_time_kind(client, test_project): + """Text output names the kind and units of the matched time, not a bare date.""" await _write_cache_layer_note(test_project.name) - rendered = await search_notes( - project=test_project.name, - query="cache layer", - valid_at="2026-07-28", + rendered = await bm_search( + project=test_project.name, query="cache layer", valid_at="2026-07-28" ) - assert isinstance(rendered, str), rendered - assert "effective valid time: [2026-07-27,) (date)" in rendered + assert isinstance(rendered, str) + assert " effective valid time: [2026-07-27,) (date)" in rendered + assert "[2026-06-10,2026-07-27)" not in rendered @pytest.mark.asyncio @@ -286,14 +270,12 @@ async def test_kind_only_filter_finds_every_source_of_that_kind(client, test_pro content=UNDATED_NOTE, ) - response = await search_notes( + response = await search_json( project=test_project.name, query="layer", time_kind="effective", - output_format="json", ) - assert isinstance(response, dict), response contents = _contents(response) assert any("Redis" in content for content in contents), contents assert any("Memcached" in content for content in contents), contents @@ -303,8 +285,8 @@ async def test_kind_only_filter_finds_every_source_of_that_kind(client, test_pro @pytest.mark.asyncio async def test_valid_at_and_valid_overlaps_together_are_refused(client, test_project): """The two forms ask different questions; supplying both is an authoring error.""" - with pytest.raises(ValueError, match="not both"): - await search_notes( + with pytest.raises(ToolError, match="^INVALID_ARGUMENTS: .*not both"): + await bm_search( project=test_project.name, query="cache layer", valid_at="2026-07-28", @@ -313,89 +295,44 @@ async def test_valid_at_and_valid_overlaps_together_are_refused(client, test_pro @pytest.mark.asyncio -async def test_a_malformed_valid_time_filter_is_refused_rather_than_searched(client, test_project): - """A typo in a valid-time filter is an error, not a search that finds nothing.""" - await _write_cache_layer_note(test_project.name) - - with pytest.raises(ValueError, match="2026-13-01"): - await search_notes( - project=test_project.name, - query="cache layer", - valid_at="2026-13-01", - output_format="json", - ) - - -@pytest.mark.asyncio -async def test_all_projects_search_refuses_a_malformed_filter_instead_of_reporting_nothing( - client, test_project +@pytest.mark.parametrize("scope", ["project", "all_projects"]) +async def test_a_malformed_valid_time_filter_is_refused_rather_than_searched( + client, test_project, scope ): - """The same typo across every project must not come back as "no matches found". + """A typo in a valid-time filter is an error, not a search that finds nothing. - Through the real API each per-project leg 400s on the bad bound and returns a - `# Search Failed` string, which the fan-out logs and skips as an unavailable project. - Skipping every project leaves an empty response that still claims the filter ran -- - an invalid query wearing the shape of a successful one. + Across projects it matters most: each database would 400 on the bad bound, the + fan-out would skip every one, and the empty merge would still claim the filter ran. """ await _write_cache_layer_note(test_project.name) + where: dict[str, Any] = ( + {"project": test_project.name} if scope == "project" else {"all_projects": True} + ) - with pytest.raises(ValueError, match="2026-13-01"): - await search_notes( - query="cache layer", - search_all_projects=True, - valid_at="2026-13-01", - output_format="json", - ) + with pytest.raises(ToolError, match="^INVALID_ARGUMENTS: .*2026-13-01"): + await bm_search(query="cache layer", valid_at="2026-13-01", **where) @pytest.mark.asyncio @pytest.mark.parametrize( - ("field", "search"), - [ - ( - "valid_at", - lambda: search_notes( - query="cache layer", - search_all_projects=True, - output_format="json", - valid_at="", - ), - ), - ( - "valid_overlaps", - lambda: search_notes( - query="cache layer", - search_all_projects=True, - output_format="json", - valid_overlaps=" ", - ), - ), - ( - "time_kind", - lambda: search_notes( - query="cache layer", - search_all_projects=True, - output_format="json", - time_kind="", - ), - ), - ], + ("field", "value"), + [("valid_at", ""), ("valid_overlaps", " "), ("time_kind", "")], ) async def test_all_projects_search_refuses_an_empty_filter_instead_of_running_unfiltered( - client, test_project, field: str, search + client, test_project, field: str, value: str ): """An empty value must not resurrect the silent-unfiltered fan-out. - This is the shape the fan-out is least able to survive. A blank field read as absence - meant every per-project leg ran an ordinary unfiltered search, so the merged answer - came back full of undated rows with nothing marking it as unfiltered -- the caller - asked a valid-time question and got a plain search wearing the same shape. Refused - once, before any project is searched, by the parser the per-project legs share. + A blank field read as absence meant every per-project leg ran an ordinary + unfiltered search, so the merged answer came back full of undated rows with + nothing marking it as unfiltered. Refused once, before any project is searched, + by the parser the per-project legs share. """ await _write_cache_layer_note(test_project.name) + blank_filter: dict[str, Any] = {field: value} - with pytest.raises(ValueError, match=f"{field} was given as an empty value"): - await search() + with pytest.raises(ToolError, match=f"{field} was given as an empty value"): + await bm_search(query="cache layer", all_projects=True, **blank_filter) @pytest.mark.asyncio @@ -403,57 +340,39 @@ async def test_time_kind_alone_is_enough_search_criteria(client, test_project): """A valid-time filter is real criteria, so it must not trip the empty-query guard.""" await _write_cache_layer_note(test_project.name) - response = await search_notes( + response = await search_json( project=test_project.name, time_kind="effective", - output_format="json", ) - assert isinstance(response, dict), response assert len(response["results"]) == 2 -def test_tool_help_documents_undated_exclusion(): - """Acceptance 8: the exclusion is documented where a caller will read it.""" - doc = inspect.getdoc(search_notes) or "" - assert "Sources with no temporal qualifier are excluded" in doc - assert "valid_at" in doc and "valid_overlaps" in doc and "time_kind" in doc - +# --- The manual must not promise what the reader refuses --- -# --- The instructions must not promise what the reader refuses --- - -_RELATIVE_QUALIFIER_SPELLINGS = ("yesterday", "2 days ago", "last week") +def _search_manual() -> str: + from basic_memory.man import MAN_DIR -def test_the_tool_instructions_do_not_advertise_relative_qualifiers(): - """Docs are the interface an agent actually programs against. + return (MAN_DIR / "man3" / "bm-search(3).md").read_text(encoding="utf-8") - `search_notes`' docstring is what an LLM reads before writing a qualifier, so a stale - promise here is worse than a stale comment: the agent writes `@occurred:yesterday`, - the reader silently declines it, the line stays ordinary content, and every valid-time - search quietly omits it. The behaviour is pinned in tests/test_temporal.py; this pins - that the instructions agree with it. - """ - from basic_memory.mcp.tools.search import search_notes - docs = ( - (search_notes.fn.__doc__ or "") - if hasattr(search_notes, "fn") - else (search_notes.__doc__ or "") - ) +def test_the_man_page_documents_undated_exclusion(): + """Acceptance 8: the exclusion is documented where a caller will read it.""" + page = _search_manual() - assert "Relative dates are not accepted" in docs - for spelling in _RELATIVE_QUALIFIER_SPELLINGS: - # Named only to refuse them; never shown as a working example. - if spelling in docs: - assert "not accepted" in docs + assert "A valid-time filter excludes every source without a temporal qualifier" in page + assert "valid_at" in page and "valid_overlaps" in page and "time_kind" in page def test_the_man_page_does_not_advertise_relative_qualifiers(): - """The same promise, in the page a human reads.""" - from basic_memory.man import MAN_DIR + """The manual is what an agent reads before writing a qualifier. - page = (MAN_DIR / "man3" / "bm-search(3).md").read_text(encoding="utf-8") + A stale promise here is worse than a stale comment: the agent writes + `@occurred:yesterday`, the reader silently declines it, and every valid-time + search quietly omits the line. The behaviour is pinned in tests/test_temporal.py. + """ + page = _search_manual() assert "A relative date is never filed as a qualifier" in page # The quoted-form example list must not offer one as a spelling that files. @@ -476,28 +395,11 @@ async def test_all_projects_search_propagates_a_filter_no_project_could_apply( """ await _write_cache_layer_note(test_project.name) - # `basic_memory.mcp.tools.search` is shadowed by a `search` function exported - # from the package, so reach the module itself rather than that name. - search_module = sys.modules["basic_memory.mcp.tools.search"] - - monkeypatch.setattr( - search_module, - "_load_search_project_refs", - AsyncMock( - return_value=[ - SearchProjectRef( - name=test_project.name, - external_id=test_project.external_id, - id=test_project.id, - workspace_tenant_id=None, - path=test_project.path, - ) - ] - ), - ) - + # Trigger: a server predating SPEC-82 answers a valid-time query unfiltered. + # Why: no such server exists in-process; the client's refusal of its response is + # the one fact injected here. Project discovery and the fan-out run for real. + # Outcome: the one database is skipped, exactly as an old server would be. class RefusingScopedSearchClient: - # What a database looks like once the client rejects its response. def __init__(self, client: Any) -> None: pass @@ -505,26 +407,10 @@ async def search(self, *args: Any, **kwargs: Any) -> Any: raise ValueError("The search API did not apply the requested valid-time filter") monkeypatch.setattr( - sys.modules["basic_memory.mcp.clients"], "ScopedSearchClient", RefusingScopedSearchClient + importlib.import_module("basic_memory.mcp.clients"), + "ScopedSearchClient", + RefusingScopedSearchClient, ) with pytest.raises(ValueError, match="No project applied the requested valid-time filter"): - await search_module._search_all_projects( - query="cache layer", - page=1, - page_size=10, - search_type="text", - output_format="json", - note_types=None, - entity_types=None, - categories=None, - after_date=None, - metadata_filters=None, - tags=None, - status=None, - min_similarity=None, - valid_at="2026-07-01", - valid_overlaps=None, - time_kind=None, - context=None, - ) + await bm_search(query="cache layer", mode="text", all_projects=True, valid_at="2026-07-01") diff --git a/tests/mcp/test_tool_contracts.py b/tests/mcp/test_tool_contracts.py index 9681b96bc..4e0a0f4d8 100644 --- a/tests/mcp/test_tool_contracts.py +++ b/tests/mcp/test_tool_contracts.py @@ -169,29 +169,6 @@ ], "ls": ["path", "page", "page_size", "project", "project_id"], "man": ["page", "query", "project", "project_id"], - "search_notes": [ - "query", - "project", - "project_id", - "search_all_projects", - "projects", - "page", - "page_size", - "search_type", - "output_format", - "note_types", - "entity_types", - "categories", - "after_date", - "metadata_filters", - "tags", - "status", - "min_similarity", - "valid_at", - "valid_overlaps", - "time_kind", - "compact", - ], "tail": ["timeframe", "lines", "project", "project_id"], } @@ -212,18 +189,12 @@ "bm_project": {"readOnlyHint": False, "destructiveHint": False}, "bm_schema": {"readOnlyHint": True, "destructiveHint": False}, "bm_man": {"readOnlyHint": True, "destructiveHint": False}, - "search_notes": {"readOnlyHint": True, "destructiveHint": False}, } -# The MCP-UI tools are disabled in tools/__init__.py but register onto the shared -# server whenever tests import their module directly, so tolerate their presence -# without requiring it — keeps this contract independent of test execution order. # The POSIX tools are enabled by default during lifespan startup, but remain # hidden before config is read. Optional status keeps this direct-list contract # independent of whether another test has started the shared server. OPTIONAL_TOOL_ANNOTATIONS: dict[str, dict[str, bool]] = { - "read_note_ui": {"readOnlyHint": True, "destructiveHint": False}, - "search_notes_ui": {"readOnlyHint": True, "destructiveHint": False}, "cat": {"readOnlyHint": True, "destructiveHint": False}, "find": {"readOnlyHint": True, "destructiveHint": False}, "grep": {"readOnlyHint": True, "destructiveHint": False}, @@ -264,7 +235,6 @@ "grep": tools.grep, "ls": tools.ls, "man": tools.man, - "search_notes": tools.search_notes, "tail": tools.tail, } diff --git a/tests/mcp/test_tool_posix.py b/tests/mcp/test_tool_posix.py index 62838ba8c..1b43dcf23 100644 --- a/tests/mcp/test_tool_posix.py +++ b/tests/mcp/test_tool_posix.py @@ -25,7 +25,7 @@ ProjectPrefixConflictError, UnqualifiedPathRefusedError, ) -from basic_memory.mcp.tools import bm_write, cat, find, grep, ls, man, search_notes, tail +from basic_memory.mcp.tools import bm_search, bm_write, cat, find, grep, ls, man, tail from basic_memory.models import Entity from basic_memory.repository.metadata_filters import ( MetadataPath, @@ -1580,19 +1580,13 @@ async def test_find_without_meta_stays_the_directory_listing(client, test_graph, (["review.approved=true"], {"review.approved": True}), ], ) -async def test_find_meta_matches_search_notes_parity( - client, test_project, meta_notes, meta, filters -): +async def test_find_meta_matches_bm_search_parity(client, test_project, meta_notes, meta, filters): """PARITY: the POSIX surface and the rich surface answer the same question identically — same filters dict, same hits — through the same real stack.""" assert posix_tools._parse_meta_predicates(meta) == filters found = await find(meta=meta, project=test_project.name) - searched = await search_notes( - project=test_project.name, - metadata_filters=filters, - output_format="json", - ) + searched = await bm_search(project=test_project.name, filters=filters, output_format="json") assert isinstance(searched, dict) assert found["results"] diff --git a/tests/mcp/test_tool_search.py b/tests/mcp/test_tool_search.py deleted file mode 100644 index 3cd229e86..000000000 --- a/tests/mcp/test_tool_search.py +++ /dev/null @@ -1,2336 +0,0 @@ -"""Tests for search MCP tools.""" - -import inspect - -import pytest - -from basic_memory.config import DatabaseBackend, ProjectMode -from contextlib import asynccontextmanager -from datetime import datetime, timedelta -from types import SimpleNamespace -from typing import Any, cast - -from pydantic import TypeAdapter - -from basic_memory.mcp.tools import bm_write -from basic_memory.mcp.tools.search import ( - search_notes, - _format_search_error_response, - _format_search_markdown, -) -from basic_memory.schemas.search import SearchResponse - -import implementation_mcp - -implementation_server = implementation_mcp.implementation_server - - -@pytest.mark.asyncio -async def test_search_text(client, test_project): - """Test basic search functionality.""" - # Create a test note - result = await bm_write( - 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 bm_write( - 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 bm_write( - 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 bm_write( - 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 bm_write( - 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" - # The workspace-qualified route hands on the entry's id too, so the name is - # never re-resolved against a workspace holding a project literally named - # 'personal/main' (#1432). - assert captured["project_id"] == "11111111-1111-1111-1111-111111111111" - 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 bm_write( - 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 bm_write( - 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 bm_write( - 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 bm_write( - 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 bm_write( - 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 bm_write( - 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_unavailable(self): - """Test formatting for errors when the vector runtime failed to load.""" - result = _format_search_error_response( - "test-project", - "Semantic search is unavailable: the vector runtime failed to load at startup, " - "so search is keyword-only. See the startup log for the cause.", - "semantic query", - "vector", - ) - - assert "# Search Failed - Semantic Search Unavailable" in result - assert "startup log" in result - assert 'mode="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 'mode="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[str, Any] = {} - - 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[str, Any] = {} - - 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[str, Any] = {} - - 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[str, Any] = {} - - 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[str, Any] = {} - - 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[str, Any] = {} - - 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: - default_search_type: str | None = None - database_backend: DatabaseBackend = DatabaseBackend.SQLITE - - def get_project_mode(self, project_name: str) -> ProjectMode: - return ProjectMode.LOCAL - - @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 is hybrid - assert captured_payload["retrieval_mode"] == "hybrid" - 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[str, Any] = {} - - 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: - default_search_type: str | None = None - database_backend: DatabaseBackend = DatabaseBackend.SQLITE - - def get_project_mode(self, project_name: str) -> ProjectMode: - return ProjectMode.LOCAL - - @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[str, Any] = {} - - 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", - (), - { - "default_search_type": None, - "database_backend": DatabaseBackend.SQLITE, - "get_project_mode": lambda self, name: ProjectMode.LOCAL, - }, - )() - }, - )(), - ) - - 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" - - -# --- 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[str, Any] = {} - - 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[str, Any] = {} - - 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[str, Any] = {} - - 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[str, Any] = {} - - 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[str, Any] = {} - - 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[str, Any] = {} - - 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[str, Any] = {} - - 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( - implementation_server, client, test_project -): - """tags="alpha,beta" through the real MCP layer must match like a real list (#910).""" - from fastmcp import Client - - await bm_write( - project=test_project.name, - title="Tag Split Note", - directory="test", - content="# Tag Split Note\nTagSplitToken body", - tags=["alpha", "beta"], - ) - async with Client(implementation_server) as mcp_client: - - 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( - implementation_server, 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(implementation_server) 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, indexed_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 bm_write( - 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", - external_id="46adce12-adfc-42f5-a0f9-83aa65f22619", - 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 - # external_id is emitted for hits that carry one, so hosted MCP can deep-link them (#1423). - assert "external_id: 46adce12-adfc-42f5-a0f9-83aa65f22619" in text - assert "0.8500" in text - assert "match: This is a matching snippet" in text - assert "### Other Note" in text - # A hit without an external_id must not render an empty external_id line. - assert text.count("external_id:") == 1 - 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[str, Any] = {} - - 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[str, Any] = {} - - 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_container = MagicMock() - mock_container.config = mock_config - - with patch.object(search_module, "get_container", return_value=mock_container): - assert search_module._default_search_type(["main"]) == "vector" - - -def test_default_search_type_falls_back_to_hybrid(): - """When default_search_type is None, 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_container = MagicMock() - mock_container.config = mock_config - - with ( - patch.object(search_module, "get_container", return_value=mock_container), - patch.object(search_module, "semantic_runtime_available", return_value=True), - ): - assert search_module._default_search_type(["main"]) == "hybrid" - - -def test_default_search_type_is_text_when_the_vector_runtime_cannot_load(): - """A host that cannot load sqlite-vec runs keyword-only (#711), so plain searches - default to text instead of failing as semantic-unavailable.""" - 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_container = MagicMock() - mock_container.config = mock_config - - with ( - patch.object(search_module, "get_container", return_value=mock_container), - # Injected: this host can load sqlite-vec, so the fallback is simulated. - patch.object(search_module, "semantic_runtime_available", return_value=False), - ): - assert search_module._default_search_type(["main"]) == "text" - - -def test_default_search_type_is_hybrid_for_cloud_projects_on_a_keyword_only_host(): - """Cloud-routed projects search on Cloud's Postgres, so a local host that cannot - load sqlite-vec must not downgrade them 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.get_project_mode.return_value = ProjectMode.CLOUD - mock_container = MagicMock() - mock_container.config = mock_config - - with ( - patch.object(search_module, "get_container", return_value=mock_container), - # Injected: the local host cannot load sqlite-vec. - patch.object(search_module, "semantic_runtime_available", return_value=False), - ): - assert search_module._default_search_type(["cloud-notes"]) == "hybrid" - - -def test_boolean_queries_default_to_text_search(): - """Hybrid's vector half ignores NOT/AND, so default Boolean queries run as full-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_container = MagicMock() - mock_container.config = mock_config - - with patch.object(search_module, "get_container", return_value=mock_container): - for query in ["coffee NOT pour", "pour AND clarity", "(tea OR coffee) NOT decaf"]: - assert search_module._search_type_for(None, query, ["main"]) == "text", query - # Lowercase words and operator-like substrings are ordinary text. - for query in ["coffee not pour", "ORACLE notes", "ANDROID", None]: - assert search_module._search_type_for(None, query, ["main"]) == "hybrid", query - # An explicit choice always wins. - assert search_module._search_type_for("hybrid", "coffee NOT pour", ["main"]) == "hybrid" - - -# --- 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, indexed_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 bm_write( - 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"] - - -@pytest.mark.asyncio -async def test_search_never_indexed_text_says_so(client, test_project, session_maker, config_home): - """A never-indexed project must not report an ordinary empty result (#1534).""" - from sqlalchemy import text as sa_text - - from basic_memory import db - - # Trigger: files exist on disk but no index pass has ever completed. - notes_dir = config_home / "notes" - notes_dir.mkdir(exist_ok=True) - (notes_dir / "unindexed-note.md").write_text( - "# Unindexed Note\n\nNothing about this file can be found by search.\n" - ) - async with db.scoped_session(session_maker) as session: - await session.execute( - sa_text("UPDATE project SET last_indexed_at = NULL WHERE id = :id"), - {"id": test_project.id}, - ) - - response = await search_notes( - project=test_project.name, - query="unindexed-note-xyzzy", - search_type="text", - output_format="text", - ) - - assert isinstance(response, str) - assert "never been indexed" in response - assert "bm project index" in response - assert "Try broader or different terms" not in response - - -@pytest.mark.asyncio -async def test_search_never_indexed_json_returns_index_required( - client, test_project, session_maker, config_home -): - """JSON mode uses error guidance, never a success-shaped empty result (#1534).""" - from sqlalchemy import text as sa_text - - from basic_memory import db - - notes_dir = config_home / "notes" - notes_dir.mkdir(exist_ok=True) - (notes_dir / "unindexed-note.md").write_text("# Unindexed Note\n\nNo index covers this.\n") - async with db.scoped_session(session_maker) as session: - await session.execute( - sa_text("UPDATE project SET last_indexed_at = NULL WHERE id = :id"), - {"id": test_project.id}, - ) - - response = await search_notes( - project=test_project.name, - query="unindexed-note-xyzzy", - search_type="text", - output_format="json", - ) - - assert isinstance(response, str) - assert response.startswith("# Project Index Required") - assert "never been indexed" in response - assert "bm project index" in response - - -@pytest.mark.asyncio -async def test_search_indexed_miss_keeps_original_copy( - client, test_project, session_maker, config_home -): - """An indexed project with genuinely no match keeps the plain-miss copy (#1534).""" - from datetime import datetime - - from sqlalchemy import text as sa_text - - from basic_memory import db - - async with db.scoped_session(session_maker) as session: - await session.execute( - sa_text("UPDATE project SET last_indexed_at = :now WHERE id = :id"), - {"now": datetime.now(), "id": test_project.id}, - ) - - created = await bm_write( - project=test_project.name, - title="Indexed Note", - directory="notes", - content="# Indexed Note\n\nSearchable content about telescopes.\n", - ) - assert created - - miss = await search_notes( - project=test_project.name, - query="nothing-matches-this-xyzzy", - search_type="text", - output_format="text", - ) - assert isinstance(miss, str) - assert "Try broader or different terms" in miss - assert "never been indexed" not in miss - - hit = await search_notes( - project=test_project.name, - query="telescopes", - search_type="text", - output_format="json", - ) - assert isinstance(hit, dict) - assert len(hit["results"]) > 0 - assert "index_phase" not in hit diff --git a/tests/mcp/test_ui_templates.py b/tests/mcp/test_ui_templates.py deleted file mode 100644 index 894790f6e..000000000 --- a/tests/mcp/test_ui_templates.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Tests for MCP UI template helpers (templates.py). - -Covers get_ui_variant(), load_html(), and load_variant_html(). -""" - -import pytest - -from basic_memory.mcp.ui.templates import ( - get_ui_variant, - load_html, - load_variant_html, - DEFAULT_VARIANT, -) - - -class TestGetUIVariant: - """Tests for get_ui_variant().""" - - def test_default_variant(self, monkeypatch): - """Returns 'vanilla' when env var is not set.""" - monkeypatch.delenv("BASIC_MEMORY_MCP_UI_VARIANT", raising=False) - assert get_ui_variant() == "vanilla" - - def test_vanilla_variant(self, monkeypatch): - monkeypatch.setenv("BASIC_MEMORY_MCP_UI_VARIANT", "vanilla") - assert get_ui_variant() == "vanilla" - - def test_tool_ui_variant(self, monkeypatch): - monkeypatch.setenv("BASIC_MEMORY_MCP_UI_VARIANT", "tool-ui") - assert get_ui_variant() == "tool-ui" - - def test_mcp_ui_variant(self, monkeypatch): - monkeypatch.setenv("BASIC_MEMORY_MCP_UI_VARIANT", "mcp-ui") - assert get_ui_variant() == "mcp-ui" - - def test_unsupported_variant_falls_back(self, monkeypatch): - """Unsupported values fall back to DEFAULT_VARIANT.""" - monkeypatch.setenv("BASIC_MEMORY_MCP_UI_VARIANT", "nonexistent") - assert get_ui_variant() == DEFAULT_VARIANT - - def test_whitespace_trimmed(self, monkeypatch): - monkeypatch.setenv("BASIC_MEMORY_MCP_UI_VARIANT", " tool-ui ") - assert get_ui_variant() == "tool-ui" - - def test_case_insensitive(self, monkeypatch): - monkeypatch.setenv("BASIC_MEMORY_MCP_UI_VARIANT", "VANILLA") - assert get_ui_variant() == "vanilla" - - -class TestLoadHtml: - """Tests for load_html().""" - - def test_load_search_results_vanilla(self): - html = load_html("search-results-vanilla.html") - assert isinstance(html, str) - assert len(html) > 0 - # HTML files should contain standard HTML markers - assert "<" in html - - def test_load_note_preview_vanilla(self): - html = load_html("note-preview-vanilla.html") - assert isinstance(html, str) - assert len(html) > 0 - - def test_load_nonexistent_raises(self): - with pytest.raises(FileNotFoundError): - load_html("does-not-exist.html") - - -class TestLoadVariantHtml: - """Tests for load_variant_html().""" - - def test_loads_vanilla_variant(self, monkeypatch): - monkeypatch.setenv("BASIC_MEMORY_MCP_UI_VARIANT", "vanilla") - html = load_variant_html("search-results") - assert isinstance(html, str) - assert len(html) > 0 - - def test_loads_tool_ui_variant(self, monkeypatch): - monkeypatch.setenv("BASIC_MEMORY_MCP_UI_VARIANT", "tool-ui") - html = load_variant_html("search-results") - assert isinstance(html, str) - assert len(html) > 0 - - def test_loads_mcp_ui_variant(self, monkeypatch): - monkeypatch.setenv("BASIC_MEMORY_MCP_UI_VARIANT", "mcp-ui") - html = load_variant_html("note-preview") - assert isinstance(html, str) - assert len(html) > 0 diff --git a/tests/mcp/tools/test_search_notes_multi_project.py b/tests/mcp/tools/test_bm_search_multi_project.py similarity index 55% rename from tests/mcp/tools/test_search_notes_multi_project.py rename to tests/mcp/tools/test_bm_search_multi_project.py index ecab258aa..d878afbb1 100644 --- a/tests/mcp/tools/test_search_notes_multi_project.py +++ b/tests/mcp/tools/test_bm_search_multi_project.py @@ -1,17 +1,31 @@ -"""Optional multi-project search_notes behavior: one scoped query per database.""" +"""bm_search across projects: one scoped query per database, merged by score. + +The local projects share one database; every cloud workspace is its own. Most tests +here need several databases, which only Cloud can provide, so they replace the +per-database boundary -- project discovery, the per-workspace HTTP client, the scoped +search client and the index-readiness probe -- with a stub that records each call. +The merge, paging, attribution and failure handling between them run for real. +`test_hits_from_two_local_projects_name_their_project` runs the whole path. +""" import importlib from contextlib import asynccontextmanager -from typing import Any +from typing import Any, Literal from unittest.mock import AsyncMock import pytest from fastmcp.exceptions import ToolError from httpx import HTTPStatusError, Request, Response +from loguru import logger +from basic_memory.mcp.tools import bm_search, bm_write from basic_memory.mcp.tools.search import SearchProjectRef from basic_memory.schemas.search import SearchItemType, SearchResponse, SearchResult +# The package exports the ChatGPT `search` adapter under the module's name. +search_module = importlib.import_module("basic_memory.mcp.tools.search") +clients_module = importlib.import_module("basic_memory.mcp.clients") + PERSONAL = SearchProjectRef( name="personal/main", external_id="11111111-1111-1111-1111-111111111111", @@ -49,7 +63,7 @@ def _result( score: float, observation: bool = False, permalink: str = "notes/example", - file_path: str = "/notes/example.md", + file_path: str = "notes/example.md", ) -> SearchResult: return SearchResult( title=title, @@ -69,21 +83,19 @@ def _install_scoped_search( refs: list[SearchProjectRef], answer, ) -> tuple[list[str | None], list[dict[str, Any]]]: - """Route the all-projects search into a stub that records each database's call. + """Route each database's scoped search into a stub that records the call. ``answer(workspace, project_ids, page, page_size)`` returns the SearchResponse for one database, or raises to model a failed database. """ - clients_mod = importlib.import_module("basic_memory.mcp.clients") - search_mod = importlib.import_module("basic_memory.mcp.tools.search") workspaces: list[str | None] = [] calls: list[dict[str, Any]] = [] - async def fake_load_search_project_refs(context=None): + async def load_search_project_refs(context=None): return refs @asynccontextmanager - async def fake_get_client(project_name=None, workspace=None): + async def get_client(project_name=None, workspace=None): workspaces.append(workspace) yield {"workspace": workspace} @@ -103,10 +115,10 @@ async def search(self, payload, *, project_ids, page, page_size): ) return answer(self.workspace, list(project_ids), page, page_size) - monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs) - monkeypatch.setattr(search_mod, "get_client", fake_get_client) - monkeypatch.setattr(clients_mod, "ScopedSearchClient", StubScopedSearchClient) - monkeypatch.setattr(search_mod, "project_index_required", AsyncMock(return_value=None)) + monkeypatch.setattr(search_module, "_load_search_project_refs", load_search_project_refs) + monkeypatch.setattr(search_module, "get_client", get_client) + monkeypatch.setattr(clients_module, "ScopedSearchClient", StubScopedSearchClient) + monkeypatch.setattr(search_module, "project_index_required", AsyncMock(return_value=None)) return workspaces, calls @@ -120,6 +132,15 @@ def _page(results: list[SearchResult], page: int, page_size: int, **fields: Any) ) +async def search_json(**arguments: Any) -> dict[str, Any]: + result = await bm_search(output_format="json", **arguments) + assert isinstance(result, dict) + return result + + +# --- One query per database --- + + @pytest.mark.asyncio @pytest.mark.parametrize("compact", [False, True]) @pytest.mark.parametrize("observations", [False, True]) @@ -127,7 +148,6 @@ async def test_each_workspace_is_one_query_and_results_stay_routable( monkeypatch, compact, observations ): """Two cloud workspaces are two databases: one scoped call each, merged by score.""" - search_mod = importlib.import_module("basic_memory.mcp.tools.search") def answer(workspace, project_ids, page, page_size): ref, title, score = ( @@ -143,9 +163,7 @@ def answer(workspace, project_ids, page, page_size): score=score, observation=observations, permalink="main/tests/mcp-test-note", - file_path="tests/Exact Note.md" - if observations - else "/main/tests/mcp-test-note.md", + file_path="tests/Exact Note.md", ) ], page, @@ -154,14 +172,8 @@ def answer(workspace, project_ids, page, page_size): workspaces, calls = _install_scoped_search(monkeypatch, [PERSONAL, TEAM], answer) - result = await search_mod.search_notes( - query="MCP Test Note", - search_all_projects=True, - output_format="json", - compact=compact, - ) + result = await search_json(query="MCP Test Note", all_projects=True, compact=compact) - assert isinstance(result, dict) assert workspaces == [PERSONAL.workspace_tenant_id, TEAM.workspace_tenant_id] assert [(call["workspace"], call["project_ids"]) for call in calls] == [ (PERSONAL.workspace_tenant_id, [PERSONAL.id]), @@ -172,6 +184,11 @@ def answer(workspace, project_ids, page, page_size): f"team-paul/main/{path}", f"personal/main/{path}", ] + # Each hit's path names the project it lives in, so bm_read can follow it. + assert [item["file_path"] for item in result["results"]] == [ + "team-paul/main/tests/Exact Note.md", + "personal/main/tests/Exact Note.md", + ] if compact: assert all("content" not in item for item in result["results"]) assert result["total"] == 2 @@ -182,7 +199,6 @@ def answer(workspace, project_ids, page, page_size): @pytest.mark.parametrize("compact_observations", [False, True]) async def test_local_projects_share_one_scoped_query(monkeypatch, compact_observations): """Projects in the local database are one query, and every hit names its project.""" - search_mod = importlib.import_module("basic_memory.mcp.tools.search") def answer(workspace, project_ids, page, page_size): return _page( @@ -198,14 +214,8 @@ def answer(workspace, project_ids, page, page_size): workspaces, calls = _install_scoped_search(monkeypatch, [ALPHA, BETA], answer) - result = await search_mod.search_notes( - query="anything", - search_all_projects=True, - output_format="json", - compact=compact_observations, - ) + result = await search_json(query="anything", all_projects=True, compact=compact_observations) - assert isinstance(result, dict) assert workspaces == [None] assert [(call["workspace"], call["project_ids"]) for call in calls] == [ (None, [ALPHA.id, BETA.id]) @@ -222,16 +232,44 @@ def answer(workspace, project_ids, page, page_size): "notes/example", "notes/example", ] - assert [item["project_external_id"] for item in result["results"]] == [ - ALPHA.external_id, - BETA.external_id, - ] + assert [item["project"] for item in result["results"]] == ["alpha", "beta"] + + +@pytest.mark.asyncio +async def test_hits_from_two_local_projects_name_their_project( + client, test_project, second_project +): + """The whole path, no stubs: two local projects, one database, routable hits.""" + for project in (test_project, second_project): + await bm_write( + project=project.name, + title=f"Fan Out {project.name}", + directory="notes", + content=f"# Fan Out {project.name}\nFanOutToken in {project.name}", + ) + + as_json = await search_json(query="FanOutToken", mode="text", all_projects=True) + as_text = await bm_search( + query="FanOutToken", mode="text", projects=[test_project.name, second_project.name] + ) + + assert {(item["title"], item["project"]) for item in as_json["results"]} == { + (f"Fan Out {test_project.name}", test_project.name), + (f"Fan Out {second_project.name}", second_project.name), + } + assert isinstance(as_text, str) + assert as_text.startswith( + f"Results for 'FanOutToken' in projects {test_project.name}, {second_project.name}:" + ) + assert f"- {second_project.permalink}/notes/Fan Out {second_project.name}.md" in as_text + + +# --- Paging the merge --- @pytest.mark.asyncio async def test_each_database_answers_the_whole_prefix_the_page_needs(monkeypatch): """Page two of five asks every database for its top ten, then slices the merge.""" - search_mod = importlib.import_module("basic_memory.mcp.tools.search") def answer(workspace, project_ids, page, page_size): ref = PERSONAL if workspace == PERSONAL.workspace_tenant_id else TEAM @@ -249,11 +287,8 @@ def answer(workspace, project_ids, page, page_size): _workspaces, calls = _install_scoped_search(monkeypatch, [PERSONAL, TEAM], answer) - result = await search_mod.search_notes( - query="notes", search_all_projects=True, output_format="json", page=2, page_size=5 - ) + result = await search_json(query="notes", all_projects=True, page=2, page_size=5) - assert isinstance(result, dict) assert [(call["page"], call["page_size"]) for call in calls] == [(1, 10), (1, 10)] assert [item["title"] for item in result["results"]] == [ "personal/main 2", @@ -274,7 +309,6 @@ async def test_full_text_hits_keep_the_server_order_and_page_from_the_strongest( Sorting the raw values put the weakest hit first and made page two repeat page one, because every page was cut from the wrong end of the same ranking. """ - search_mod = importlib.import_module("basic_memory.mcp.tools.search") def answer(workspace, project_ids, page, page_size): # The server's own order: strongest (most negative) first. @@ -288,117 +322,22 @@ def answer(workspace, project_ids, page, page_size): _install_scoped_search(monkeypatch, [ALPHA, BETA], answer) - first = await search_mod.search_notes( - query="notes", search_all_projects=True, output_format="json", page=1, page_size=2 - ) - second = await search_mod.search_notes( - query="notes", search_all_projects=True, output_format="json", page=2, page_size=2 - ) + first = await search_json(query="notes", all_projects=True, page=1, page_size=2) + second = await search_json(query="notes", all_projects=True, page=2, page_size=2) - assert isinstance(first, dict) and isinstance(second, dict) assert [item["title"] for item in first["results"]] == ["strongest", "middle"] assert [item["title"] for item in second["results"]] == ["weakest"] -@pytest.mark.asyncio -async def test_every_hit_names_the_project_it_lives_in(monkeypatch): - """A page over several projects labels each hit, so the next call can be routed.""" - search_mod = importlib.import_module("basic_memory.mcp.tools.search") - - def answer(workspace, project_ids, page, page_size): - return _page( - [ - _result(BETA, title="In beta", score=0.9), - _result(ALPHA, title="In alpha", score=0.5), - ], - page, - page_size, - ) - - _install_scoped_search(monkeypatch, [ALPHA, BETA], answer) - - as_json = await search_mod.search_notes( - query="notes", search_all_projects=True, output_format="json" - ) - as_text = await search_mod.search_notes( - query="notes", search_all_projects=True, output_format="text" - ) - - assert isinstance(as_json, dict) - assert [(item["title"], item["project"]) for item in as_json["results"]] == [ - ("In beta", "beta"), - ("In alpha", "alpha"), - ] - assert isinstance(as_text, str) - assert "### In beta\n- permalink: notes/example\n- project: beta\n" in as_text - assert "### In alpha\n- permalink: notes/example\n- project: alpha\n" in as_text - - -@pytest.mark.asyncio -async def test_multi_project_search_is_opt_in(monkeypatch): - """Default search_notes calls stay scoped to the resolved project.""" - clients_mod = importlib.import_module("basic_memory.mcp.clients") - search_mod = importlib.import_module("basic_memory.mcp.tools.search") - - searched_projects: list[tuple[str | None, str | None]] = [] - - async def fake_load_search_project_refs(context=None): - raise AssertionError("project discovery should only run when search_all_projects=True") - - class StubProject: - name = "main" - external_id = "local-main" - - @asynccontextmanager - async def fake_get_project_client(project=None, context=None, project_id=None): - searched_projects.append((project, project_id)) - yield object(), StubProject() - - async def fake_resolve_project_and_path(client, identifier, project=None, context=None): - return StubProject(), identifier, False - - class MockSearchClient: - def __init__(self, *args, **kwargs): - pass - - async def search(self, payload, page, page_size): - return SearchResponse(results=[], current_page=page, page_size=page_size) - - monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs) - 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) - monkeypatch.setattr(search_mod, "project_index_required", AsyncMock(return_value=None)) - - result = await search_mod.search_notes(query="MCP Test Note", output_format="json") - - assert isinstance(result, dict) - assert result["results"] == [] - assert searched_projects == [(None, None)] - - @pytest.mark.asyncio async def test_no_accessible_projects_is_an_empty_all_projects_answer(monkeypatch): """Explicit all-project search must not silently fall back to one project.""" - search_mod = importlib.import_module("basic_memory.mcp.tools.search") - - async def fake_load_search_project_refs(context=None): - return [] - - @asynccontextmanager - async def fail_get_client(*args, **kwargs): - raise AssertionError("search_all_projects=True should not query without projects") - yield - - monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs) - monkeypatch.setattr(search_mod, "get_client", fail_get_client) - - result = await search_mod.search_notes( - query="MCP Test Note", - search_all_projects=True, - output_format="json", + _workspaces, calls = _install_scoped_search( + monkeypatch, [], lambda *args: pytest.fail("must not query without projects") ) + result = await search_json(query="MCP Test Note", all_projects=True) + assert result == { "results": [], "current_page": 1, @@ -407,23 +346,15 @@ async def fail_get_client(*args, **kwargs): "total_is_exact": True, "has_more": False, } + assert calls == [] + + +# --- Failures --- @pytest.mark.asyncio async def test_a_failing_database_is_skipped_and_the_total_becomes_inexact(monkeypatch): """One failing workspace should not discard the other's results.""" - search_mod = importlib.import_module("basic_memory.mcp.tools.search") - warnings: list[str] = [] - - class FakeLogger: - def debug(self, *args, **kwargs): - pass - - def error(self, *args, **kwargs): - pass - - def warning(self, message, *args, **kwargs): - warnings.append(str(message)) def answer(workspace, project_ids, page, page_size): if workspace == TEAM.workspace_tenant_id: @@ -433,45 +364,38 @@ def answer(workspace, project_ids, page, page_size): ) _install_scoped_search(monkeypatch, [PERSONAL, TEAM], answer) - monkeypatch.setattr(search_mod, "logger", FakeLogger()) - - result = await search_mod.search_notes( - query="MCP Test Note", - search_all_projects=True, - output_format="json", - ) + warnings: list[str] = [] + sink = logger.add(lambda message: warnings.append(str(message)), level="WARNING") + try: + result = await search_json(query="MCP Test Note", all_projects=True) + finally: + logger.remove(sink) - assert isinstance(result, dict) assert [item["permalink"] for item in result["results"]] == ["personal/main/notes/example"] assert result["total"] == 1 assert result["total_is_exact"] is False - assert any("workspace team-paul" in warning for warning in warnings) - assert any("team index unavailable" in warning for warning in warnings) + assert any( + "workspace team-paul" in warning and "team index unavailable" in warning + for warning in warnings + ) @pytest.mark.asyncio async def test_every_database_failing_is_a_failed_search_not_an_empty_one(monkeypatch): """With one database, its failure is the whole answer; do not dress it as no matches.""" - search_mod = importlib.import_module("basic_memory.mcp.tools.search") def answer(workspace, project_ids, page, page_size): raise RuntimeError("local index unavailable") _install_scoped_search(monkeypatch, [ALPHA, BETA], answer) - result = await search_mod.search_notes( - query="MCP Test Note", search_all_projects=True, output_format="json" - ) - - assert isinstance(result, str) - assert result.startswith("# Search Failed") - assert "local index unavailable" in result + with pytest.raises(ToolError, match="^UNAVAILABLE: .*local index unavailable"): + await bm_search(query="MCP Test Note", all_projects=True) @pytest.mark.asyncio async def test_a_retryable_service_outage_fails_the_merged_page(monkeypatch): """A retryable outage must fail the merged page instead of returning a partial one.""" - search_mod = importlib.import_module("basic_memory.mcp.tools.search") def answer(workspace, project_ids, page, page_size): if workspace == TEAM.workspace_tenant_id: @@ -487,20 +411,16 @@ def answer(workspace, project_ids, page, page_size): _install_scoped_search(monkeypatch, [PERSONAL, TEAM], answer) - result = await search_mod.search_notes( - query="MCP Test Note", - search_all_projects=True, - output_format="json", - ) - - assert isinstance(result, str) - assert result.startswith("# Search Failed - Service Temporarily Unavailable") + with pytest.raises(ToolError, match='"error": "UNAVAILABLE"'): + await bm_search(query="MCP Test Note", all_projects=True, output_format="json") @pytest.mark.asyncio -async def test_an_empty_answer_checks_every_project_in_the_database_for_an_index(monkeypatch): +@pytest.mark.parametrize("output_format", ["text", "json"]) +async def test_an_empty_answer_checks_every_project_in_the_database_for_an_index( + monkeypatch, output_format: Literal["text", "json"] +): """An empty scoped page is only a miss once each project has been indexed.""" - search_mod = importlib.import_module("basic_memory.mcp.tools.search") def answer(workspace, project_ids, page, page_size): return _page([], page, page_size, total=0) @@ -512,21 +432,40 @@ async def readiness(client, project): checked.append(project.name) return "# Project Index Required\n\nProject 'beta'" if project.name == "beta" else None - monkeypatch.setattr(search_mod, "project_index_required", readiness) + monkeypatch.setattr(search_module, "project_index_required", readiness) - result = await search_mod.search_notes( - query="missing", search_all_projects=True, output_format="text" - ) + result = await bm_search(query="missing", all_projects=True, output_format=output_format) - assert isinstance(result, str) - assert result.startswith("# Project Index Required") + guidance = result if isinstance(result, str) else result["guidance"] + assert guidance.startswith("# Project Index Required") + if isinstance(result, dict): + assert result["results"] == [] assert checked == ["alpha", "beta"] +@pytest.mark.asyncio +async def test_a_hit_the_server_does_not_attribute_is_a_version_skew_error(monkeypatch): + """A result without project identity cannot be qualified; say so instead of guessing.""" + + def answer(workspace, project_ids, page, page_size): + stray = _result(ALPHA, title="stray", score=0.5).model_copy( + update={"project_external_id": None} + ) + return _page([stray], page, page_size) + + _install_scoped_search(monkeypatch, [ALPHA, BETA], answer) + + # A version mismatch, not a search miss: an error rather than an empty or partial page. + with pytest.raises(ValueError, match="did not attribute"): + await bm_search(query="notes", all_projects=True) + + +# --- Choosing projects --- + + @pytest.mark.asyncio async def test_projects_selects_a_subset_across_databases(monkeypatch): """Naming projects searches only those, grouped by the database each lives in.""" - search_mod = importlib.import_module("basic_memory.mcp.tools.search") def answer(workspace, project_ids, page, page_size): ref = TEAM if workspace == TEAM.workspace_tenant_id else BETA @@ -534,11 +473,8 @@ def answer(workspace, project_ids, page, page_size): _workspaces, calls = _install_scoped_search(monkeypatch, [PERSONAL, TEAM, ALPHA, BETA], answer) - result = await search_mod.search_notes( - query="notes", projects=["beta", TEAM.external_id], output_format="json" - ) + result = await search_json(query="notes", projects=["beta", TEAM.external_id]) - assert isinstance(result, dict) assert [(call["workspace"], call["project_ids"]) for call in calls] == [ (None, [BETA.id]), (TEAM.workspace_tenant_id, [TEAM.id]), @@ -548,45 +484,26 @@ def answer(workspace, project_ids, page, page_size): @pytest.mark.asyncio async def test_an_unknown_project_name_is_refused_before_any_query(monkeypatch): - search_mod = importlib.import_module("basic_memory.mcp.tools.search") _workspaces, calls = _install_scoped_search( monkeypatch, [ALPHA, BETA], lambda *args: pytest.fail("must not query") ) with pytest.raises(ValueError, match="Unknown project\\(s\\): gamma"): - await search_mod.search_notes(query="notes", projects=["gamma"], output_format="json") + await bm_search(query="notes", projects=["gamma"]) assert calls == [] -@pytest.mark.asyncio -async def test_a_hit_the_server_does_not_attribute_is_a_version_skew_error(monkeypatch): - """A result without project identity cannot be qualified; say so instead of guessing.""" - search_mod = importlib.import_module("basic_memory.mcp.tools.search") - - def answer(workspace, project_ids, page, page_size): - stray = _result(ALPHA, title="stray", score=0.5).model_copy( - update={"project_external_id": None} - ) - return _page([stray], page, page_size) - - _install_scoped_search(monkeypatch, [ALPHA, BETA], answer) - - # Like the valid-time skew above, this is a version mismatch, not a search miss: - # it is raised as an error rather than returned as an empty or partial page. - with pytest.raises(ValueError, match="did not attribute"): - await search_mod.search_notes(query="notes", search_all_projects=True, output_format="json") - - def test_project_refs_need_an_id_an_external_id_and_a_name(): """List rows a scoped search cannot address or attribute are left out.""" - search_mod = importlib.import_module("basic_memory.mcp.tools.search") payload = { "projects": [ {"name": "alpha", "external_id": ALPHA.external_id, "id": 3, "path": "/alpha"}, {"name": "no-id", "external_id": BETA.external_id}, {"name": "no-external-id", "id": 5}, {"name": "bool-id", "external_id": PERSONAL.external_id, "id": True}, + {"name": "bad-uuid", "external_id": "not-a-uuid", "id": 6}, + {"name": "alpha-again", "external_id": ALPHA.external_id, "id": 3}, { "name": "main", "qualified_name": "team-paul/main", @@ -599,7 +516,7 @@ def test_project_refs_need_an_id_an_external_id_and_a_name(): "constrained_project": None, } - refs = search_mod._search_project_refs(payload) + refs = search_module._search_project_refs(payload) assert refs == [ SearchProjectRef( @@ -611,13 +528,21 @@ def test_project_refs_need_an_id_an_external_id_and_a_name(): ), TEAM, ] - assert search_mod._search_project_refs({"projects": "nope"}) == [] - assert search_mod._search_project_refs(None) == [] + assert search_module._search_project_refs({"projects": "nope"}) == [] + assert search_module._search_project_refs(None) == [] + + +def test_permalinks_are_qualified_with_the_workspace_once(): + qualify = search_module._qualify_permalink_for_project + + assert qualify(None, "team-paul/main") is None + assert qualify("notes/a", "alpha") == "notes/a" + assert qualify("/team-paul/main/notes/a", "team-paul/main") == "team-paul/main/notes/a" + assert qualify("notes/a", "team-paul/main") == "team-paul/main/notes/a" def test_project_names_match_by_permalink_like_routing(): """`ALPHA` names project `alpha` in search scoping, as it does in routing (#1630).""" - search_mod = importlib.import_module("basic_memory.mcp.tools.search") payload = { "projects": [ {"name": "alpha", "external_id": ALPHA.external_id, "id": 3, "path": "/alpha"}, @@ -626,9 +551,180 @@ def test_project_names_match_by_permalink_like_routing(): "constrained_project": "ALPHA", } - refs = search_mod._search_project_refs(payload) + refs = search_module._search_project_refs(payload) assert [ref.name for ref in refs] == ["alpha"] - assert search_mod._select_project_refs([ALPHA, BETA], ["BETA"]) == [BETA] + assert search_module._select_project_refs([ALPHA, BETA], ["BETA"]) == [BETA] with pytest.raises(ValueError, match="Unknown project\\(s\\): gamma"): - search_mod._select_project_refs([ALPHA, BETA], ["gamma"]) + search_module._select_project_refs([ALPHA, BETA], ["gamma"]) + + +# --- Valid-time filters reach every database (SPEC-82) --- + + +def _temporal_answer(workspace, project_ids, page, page_size): + """Each database confirms it applied the valid-time filter.""" + ref = PERSONAL if workspace == PERSONAL.workspace_tenant_id else TEAM + return _page( + [ + _result( + ref, + title="Cache Layer", + score=0.5, + observation=True, + permalink="main/decisions/cache-layer", + file_path="main/decisions/cache-layer.md", + ) + ], + page, + page_size, + temporal_applied=True, + ) + + +@pytest.mark.asyncio +async def test_every_database_is_asked_the_same_valid_time_question(monkeypatch): + """A filter dropped on the way to one database would mix filtered and unfiltered rows.""" + _workspaces, calls = _install_scoped_search(monkeypatch, [PERSONAL, TEAM], _temporal_answer) + + result = await search_json( + query="cache layer", all_projects=True, time_kind="effective", valid_at="2026-07-28" + ) + + payloads = [call["payload"] for call in calls] + assert len(payloads) == 2 + for payload in payloads: + assert payload["valid_at"] == "2026-07-28" + assert payload["time_kind"] == "effective" + assert payload["valid_overlaps"] is None + # Every database confirmed it ran the filter, so the merged answer confirms it too. + assert result["temporal_applied"] is True + + +@pytest.mark.asyncio +async def test_every_database_is_asked_the_same_overlap_question(monkeypatch): + _workspaces, calls = _install_scoped_search(monkeypatch, [PERSONAL, TEAM], _temporal_answer) + + await search_json( + query="cache layer", all_projects=True, valid_overlaps="[2026-06-01,2026-08-01)" + ) + + assert [call["payload"]["valid_overlaps"] for call in calls] == [ + "[2026-06-01,2026-08-01)", + "[2026-06-01,2026-08-01)", + ] + + +@pytest.mark.asyncio +async def test_a_search_without_a_valid_time_filter_claims_none(monkeypatch): + _install_scoped_search(monkeypatch, [PERSONAL, TEAM], _temporal_answer) + + result = await search_json(query="cache layer", all_projects=True) + + assert "temporal_applied" not in result + + +@pytest.mark.parametrize( + ("valid_at", "valid_overlaps", "time_kind", "bad_value"), + [ + ("2026-13-01", None, None, "2026-13-01"), + (None, "2026-06-10..2026-07-27", None, "2026-06-10..2026-07-27"), + (None, None, "asserted", "asserted"), + ], +) +@pytest.mark.asyncio +async def test_a_malformed_filter_is_refused_before_any_database_is_searched( + monkeypatch, + valid_at: str | None, + valid_overlaps: str | None, + time_kind: str | None, + bad_value: str, +): + """A typo must read as an error, never as an all-projects search with no matches. + + A database that refuses the filter is logged and skipped, so with every database + skipped the merged answer would be an empty success that still reports + `temporal_applied`. Client-side validation is the only layer that can tell a typo + from an unavailable database, so it runs once, before any query. + """ + _workspaces, calls = _install_scoped_search(monkeypatch, [PERSONAL, TEAM], _temporal_answer) + + with pytest.raises(ToolError, match=f"^INVALID_ARGUMENTS: .*{bad_value}"): + await bm_search( + query="cache layer", + all_projects=True, + valid_at=valid_at, + valid_overlaps=valid_overlaps, + time_kind=time_kind, + ) + + assert calls == [] + + +@pytest.mark.asyncio +async def test_no_projects_is_still_an_answer_to_the_valid_time_question(monkeypatch): + """Zero projects is an empty answer to the valid-time question, not an unfiltered one.""" + _install_scoped_search(monkeypatch, [], _temporal_answer) + + result = await search_json(query="cache layer", all_projects=True, valid_at="2026-07-28") + + assert result["results"] == [] + assert result["temporal_applied"] is True + + +# --- Query guidance (#1533) --- + + +@pytest.mark.asyncio +@pytest.mark.parametrize("case", ["empty", "hit", "failed", "later"]) +@pytest.mark.parametrize("output_format", ["text", "json"]) +async def test_query_hint_only_for_a_complete_empty_first_page( + monkeypatch, case: str, output_format: Literal["text", "json"] +): + """The local database is always empty here; the cloud workspace varies by case.""" + one = SearchProjectRef( + name="one", + external_id="55555555-5555-5555-5555-555555555555", + id=1, + workspace_tenant_id=None, + path="/one", + ) + two = SearchProjectRef( + name="two", + external_id="66666666-6666-6666-6666-666666666666", + id=2, + workspace_tenant_id="tenant-two", + path="/two", + ) + + def empty(page: int, page_size: int) -> SearchResponse: + return _page( + [], + page, + page_size, + total=0, + total_is_exact=True, + query_hint="Try a shorter word from your query.", + ) + + def answer(workspace, project_ids, page, page_size): + if workspace is None or case in ("empty", "later"): + return empty(page, page_size) + if case == "failed": + raise RuntimeError("access denied") + return _page([_result(two, title="Match", score=1.0)], page, page_size) + + _install_scoped_search(monkeypatch, [one, two], answer) + + result = await bm_search( + query="雾凇拼音", + mode="text", + all_projects=True, + page=2 if case == "later" else 1, + output_format=output_format, + ) + + if isinstance(result, str): + assert ("shorter word" in result) == (case == "empty") + else: + assert (result.get("query_hint") is not None) == (case == "empty") diff --git a/tests/mcp/tools/test_cjk_search_guidance.py b/tests/mcp/tools/test_cjk_search_guidance.py deleted file mode 100644 index f02f2f21a..000000000 --- a/tests/mcp/tools/test_cjk_search_guidance.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Account search carries query guidance only for a complete, empty first page.""" - -import importlib -from contextlib import asynccontextmanager -from typing import Literal -from unittest.mock import AsyncMock - -import pytest - -from basic_memory.mcp.tools.search import SearchProjectRef -from basic_memory.schemas.search import SearchItemType, SearchResponse, SearchResult - -ONE = SearchProjectRef( - name="one", - external_id="11111111-1111-1111-1111-111111111111", - id=1, - workspace_tenant_id=None, - path="/one", -) -TWO = SearchProjectRef( - name="two", - external_id="22222222-2222-2222-2222-222222222222", - id=2, - workspace_tenant_id="tenant-two", - path="/two", -) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("case", ["empty", "hit", "failed", "later"]) -@pytest.mark.parametrize("output_format", ["text", "json"]) -async def test_fanout_query_hint(monkeypatch, case: str, output_format: Literal["text", "json"]): - search = importlib.import_module("basic_memory.mcp.tools.search") - clients = importlib.import_module("basic_memory.mcp.clients") - monkeypatch.setattr(search, "_load_search_project_refs", AsyncMock(return_value=[ONE, TWO])) - monkeypatch.setattr(search, "project_index_required", AsyncMock(return_value=None)) - - @asynccontextmanager - async def fake_get_client(project_name=None, workspace=None): - yield {"workspace": workspace} - - def empty(page: int, page_size: int) -> SearchResponse: - return SearchResponse( - results=[], - current_page=page, - page_size=page_size, - total=0, - total_is_exact=True, - query_hint="Try a shorter word from your query.", - ) - - class StubScopedSearchClient: - def __init__(self, client): - self.workspace = client["workspace"] - - async def search(self, payload, *, project_ids, page, page_size): - # The first database (local) is always empty; the second database varies. - if self.workspace is None: - return empty(page, page_size) - if case == "hit": - return SearchResponse( - results=[ - SearchResult( - title="Match", - type=SearchItemType.ENTITY, - score=1.0, - file_path="note.md", - permalink="note", - project_id=TWO.id, - project_external_id=TWO.external_id, - ) - ], - current_page=page, - page_size=page_size, - total=1, - total_is_exact=True, - ) - if case == "failed": - raise RuntimeError("access denied") - return empty(page, page_size) - - monkeypatch.setattr(search, "get_client", fake_get_client) - monkeypatch.setattr(clients, "ScopedSearchClient", StubScopedSearchClient) - - result = await search._search_all_projects( - query="雾凇拼音", - page=2 if case == "later" else 1, - page_size=10, - search_type="text", - output_format=output_format, - note_types=[], - entity_types=[], - categories=[], - after_date=None, - metadata_filters=None, - tags=None, - status=None, - min_similarity=None, - valid_at=None, - valid_overlaps=None, - time_kind=None, - context=None, - ) - assert isinstance(result, search.MultiProjectRan | search.MultiProjectFailed) - rendered = result.rendered - if isinstance(rendered, str): - assert ("shorter word" in rendered) == (case == "empty") - else: - assert (rendered.get("query_hint") is not None) == (case == "empty") diff --git a/tests/mcp/tools/test_search_notes_multi_project_temporal.py b/tests/mcp/tools/test_search_notes_multi_project_temporal.py deleted file mode 100644 index b0431d2b6..000000000 --- a/tests/mcp/tools/test_search_notes_multi_project_temporal.py +++ /dev/null @@ -1,199 +0,0 @@ -"""All-projects search must carry the valid-time filter into every database (SPEC-82). - -`_search_all_projects` re-declares the whole filter surface in its own signature and then -runs one scoped query per database. A filter that is not repeated there is dropped for -every project at once, and the merged answer would quietly mix filtered and unfiltered -rows -- the worst shape this failure can take, because the result still looks like an -answer. -""" - -import importlib -from contextlib import asynccontextmanager -from typing import Any -from unittest.mock import AsyncMock - -import pytest - -from basic_memory.mcp.tools.search import SearchProjectRef -from basic_memory.schemas.search import SearchItemType, SearchResponse, SearchResult - -PROJECT_REFS = [ - SearchProjectRef( - name="personal/main", - external_id="11111111-1111-1111-1111-111111111111", - id=11, - workspace_tenant_id="tenant-personal", - path="/personal/main", - ), - SearchProjectRef( - name="team-paul/main", - external_id="22222222-2222-2222-2222-222222222222", - id=22, - workspace_tenant_id="tenant-team", - path="/team/main", - ), -] - - -def _install_stub_client(monkeypatch, payloads: list[dict[str, Any]], refs) -> None: - """Route every database's scoped search into a stub that records its query payload.""" - clients_mod = importlib.import_module("basic_memory.mcp.clients") - search_mod = importlib.import_module("basic_memory.mcp.tools.search") - refs_by_tenant = {ref.workspace_tenant_id: ref for ref in refs} - - @asynccontextmanager - async def fake_get_client(project_name=None, workspace=None): - yield {"workspace": workspace} - - async def fake_load_search_project_refs(context=None): - return refs - - class StubScopedSearchClient: - def __init__(self, client): - self.ref = refs_by_tenant[client["workspace"]] - - async def search(self, payload, *, project_ids, page, page_size): - payloads.append(payload) - return SearchResponse( - results=[ - SearchResult( - title="Cache Layer", - permalink="main/decisions/cache-layer", - content="The cache layer will use Memcached.", - type=SearchItemType.OBSERVATION, - score=0.5, - file_path="/main/decisions/cache-layer.md", - project_id=self.ref.id, - project_external_id=self.ref.external_id, - ) - ], - current_page=page, - page_size=page_size, - total=1, - temporal_applied=True, - ) - - monkeypatch.setattr(search_mod, "_load_search_project_refs", fake_load_search_project_refs) - monkeypatch.setattr(search_mod, "get_client", fake_get_client) - monkeypatch.setattr(clients_mod, "ScopedSearchClient", StubScopedSearchClient) - monkeypatch.setattr(search_mod, "project_index_required", AsyncMock(return_value=None)) - - -@pytest.mark.asyncio -async def test_all_projects_search_forwards_the_valid_time_filter(monkeypatch): - """Every database is asked the same valid-time question, not just the first.""" - search_mod = importlib.import_module("basic_memory.mcp.tools.search") - payloads: list[dict[str, Any]] = [] - _install_stub_client(monkeypatch, payloads, PROJECT_REFS) - - result = await search_mod.search_notes( - query="cache layer", - search_all_projects=True, - time_kind="effective", - valid_at="2026-07-28", - output_format="json", - ) - - assert isinstance(result, dict) - assert len(payloads) == len(PROJECT_REFS) - for payload in payloads: - assert payload["valid_at"] == "2026-07-28" - assert payload["time_kind"] == "effective" - assert payload["valid_overlaps"] is None - # Every database confirmed it ran the filter, so the merged answer confirms it too. - assert result["temporal_applied"] is True - - -@pytest.mark.asyncio -async def test_all_projects_search_forwards_an_overlap_filter(monkeypatch): - payloads: list[dict[str, Any]] = [] - _install_stub_client(monkeypatch, payloads, PROJECT_REFS) - search_mod = importlib.import_module("basic_memory.mcp.tools.search") - - await search_mod.search_notes( - query="cache layer", - search_all_projects=True, - valid_overlaps="[2026-06-01,2026-08-01)", - output_format="json", - ) - - assert [payload["valid_overlaps"] for payload in payloads] == [ - "[2026-06-01,2026-08-01)", - "[2026-06-01,2026-08-01)", - ] - - -@pytest.mark.asyncio -async def test_all_projects_search_without_a_filter_claims_nothing(monkeypatch): - """An ordinary all-projects search stays exactly the payload it always was.""" - payloads: list[dict[str, Any]] = [] - _install_stub_client(monkeypatch, payloads, PROJECT_REFS) - search_mod = importlib.import_module("basic_memory.mcp.tools.search") - - result = await search_mod.search_notes( - query="cache layer", - search_all_projects=True, - output_format="json", - ) - - assert isinstance(result, dict) - assert "temporal_applied" not in result - - -@pytest.mark.parametrize( - ("valid_at", "valid_overlaps", "time_kind", "bad_value"), - [ - ("2026-13-01", None, None, "2026-13-01"), - (None, "2026-06-10..2026-07-27", None, "2026-06-10..2026-07-27"), - (None, None, "asserted", "asserted"), - ], -) -@pytest.mark.asyncio -async def test_a_malformed_filter_is_refused_before_any_database_is_searched( - monkeypatch, - valid_at: str | None, - valid_overlaps: str | None, - time_kind: str | None, - bad_value: str, -): - """A typo must read as an error, never as an all-projects search with no matches. - - A database that refuses the filter is logged and skipped, so with every database - skipped the merged answer would be an empty success that still reports - `temporal_applied`. Client-side validation is the only layer that can tell a typo - from an unavailable database, so it runs once, before any query. - """ - payloads: list[dict[str, Any]] = [] - _install_stub_client(monkeypatch, payloads, PROJECT_REFS) - search_mod = importlib.import_module("basic_memory.mcp.tools.search") - - with pytest.raises(ValueError, match=bad_value): - await search_mod.search_notes( - query="cache layer", - search_all_projects=True, - output_format="json", - valid_at=valid_at, - valid_overlaps=valid_overlaps, - time_kind=time_kind, - ) - - assert payloads == [] - - -@pytest.mark.asyncio -async def test_all_projects_search_with_no_projects_still_confirms_the_filter(monkeypatch): - """Zero projects is an empty answer to the valid-time question, not an unfiltered one.""" - payloads: list[dict[str, Any]] = [] - _install_stub_client(monkeypatch, payloads, []) - search_mod = importlib.import_module("basic_memory.mcp.tools.search") - - result = await search_mod.search_notes( - query="cache layer", - search_all_projects=True, - valid_at="2026-07-28", - output_format="json", - ) - - assert isinstance(result, dict) - assert result["results"] == [] - assert result["temporal_applied"] is True diff --git a/tests/test_coerce.py b/tests/test_coerce.py index 91cd42cc9..49098af62 100644 --- a/tests/test_coerce.py +++ b/tests/test_coerce.py @@ -1,9 +1,9 @@ -"""Tests for coerce_list, coerce_dict, strict_search_tags, and parse_str_list utility functions. +"""Tests for coerce_list, coerce_dict, and parse_str_list utility functions. These must fail until the helpers are implemented in utils.py. """ -from basic_memory.utils import coerce_dict, coerce_list, parse_str_list, strict_search_tags +from basic_memory.utils import coerce_dict, coerce_list, parse_str_list class TestCoerceList: @@ -33,64 +33,6 @@ def test_int_passthrough(self): assert coerce_list(42) == 42 -class TestStrictSearchTags: - """Tests for strict_search_tags (the search_notes tags boundary coercer).""" - - def test_none_parses_to_empty_list(self): - assert strict_search_tags(None) == [] - - def test_comma_string_splits(self): - assert strict_search_tags("a,b") == ["a", "b"] - - def test_list_with_comma_element_splits(self): - assert strict_search_tags(["alpha,beta"]) == ["alpha", "beta"] - - def test_plain_list_passthrough(self): - assert strict_search_tags(["a", "b"]) == ["a", "b"] - - def test_json_array_string(self): - assert strict_search_tags('["a", "b"]') == ["a", "b"] - - def test_int_passthrough_for_pydantic_rejection(self): - """Unsupported types pass through unchanged so Pydantic rejects them.""" - assert strict_search_tags(42) == 42 - - def test_dict_passthrough_for_pydantic_rejection(self): - value = {"a": 1} - assert strict_search_tags(value) is value - - def test_int_list_passthrough_for_pydantic_rejection(self): - """Lists with non-string elements pass through unchanged so Pydantic rejects them.""" - value = [42] - assert strict_search_tags(value) is value - - def test_dict_list_passthrough_for_pydantic_rejection(self): - value = [{"a": 1}] - assert strict_search_tags(value) is value - - def test_mixed_list_passthrough_for_pydantic_rejection(self): - """One bad element poisons the whole list — no partial stringification.""" - value = ["ok", 42] - assert strict_search_tags(value) is value - - def test_json_array_string_with_int_passthrough_for_pydantic_rejection(self): - """A JSON-array string with non-string elements must not be stringified.""" - value = "[42]" - assert strict_search_tags(value) is value - - def test_json_array_string_with_dict_passthrough_for_pydantic_rejection(self): - value = '[{"a": 1}]' - assert strict_search_tags(value) is value - - def test_json_array_string_mixed_passthrough_for_pydantic_rejection(self): - """One bad element poisons the whole JSON-array string — no partial parse.""" - value = '["ok", 42]' - assert strict_search_tags(value) is value - - def test_json_array_string_all_strings_still_parses(self): - assert strict_search_tags('["a","b"]') == ["a", "b"] - - class TestCoerceDict: """Tests for coerce_dict."""