From 63e4bcdf1d244cefe855d3dd45ed3a9a23458bdd Mon Sep 17 00:00:00 2001 From: phernandez Date: Thu, 5 Mar 2026 15:08:52 -0600 Subject: [PATCH] fix: clarify search_notes parameter naming and fix note_types case sensitivity - Add Annotated descriptions to note_types and entity_types parameters so LLMs can distinguish frontmatter type filtering from knowledge graph item type filtering (search.py, ui_sdk.py) - Lowercase note_types values at filter time so "Chapter" matches stored "chapter" - Fix misleading entity_types references in schema.py guidance strings (should be note_types) - Add permalink pattern documentation note about full path matching - Add test for note_types case-insensitive lowercasing Co-Authored-By: Claude Opus 4.6 Signed-off-by: phernandez --- src/basic_memory/cli/promo.py | 2 +- src/basic_memory/mcp/tools/schema.py | 4 +-- src/basic_memory/mcp/tools/search.py | 23 +++++++++---- src/basic_memory/mcp/tools/ui_sdk.py | 15 +++++++-- tests/mcp/test_tool_search.py | 48 ++++++++++++++++++++++++++++ 5 files changed, 80 insertions(+), 12 deletions(-) diff --git a/src/basic_memory/cli/promo.py b/src/basic_memory/cli/promo.py index b5965fc8..b3dfdcdc 100644 --- a/src/basic_memory/cli/promo.py +++ b/src/basic_memory/cli/promo.py @@ -12,7 +12,7 @@ from basic_memory.config import ConfigManager OSS_DISCOUNT_CODE = "BMFOSS" CLOUD_LEARN_MORE_URL = ( - "https://basicmemory.com?utm_source=bm-cli&utm_medium=promo&utm_campaign=cloud-upsell" + "https://basicmemory.com?utm_source=bm-foss&utm_medium=promo&utm_campaign=cloud-upsell" ) diff --git a/src/basic_memory/mcp/tools/schema.py b/src/basic_memory/mcp/tools/schema.py index 1e6ca807..b24663d1 100644 --- a/src/basic_memory/mcp/tools/schema.py +++ b/src/basic_memory/mcp/tools/schema.py @@ -160,7 +160,7 @@ def _no_notes_guidance(note_type: str, tool_name: str) -> str: f"## Next Steps\n\n" f"1. **Create notes of this type** — use `write_note` with " f'`note_type="{note_type}"` to create notes\n' - f"2. **Check existing types** — use `search_notes` with `entity_types` " + f"2. **Check existing types** — use `search_notes` with `note_types` " f"filter to see what types exist\n" f"3. **Browse content** — use `list_directory` or `recent_activity` to " f"see what's in the project\n" @@ -397,7 +397,7 @@ async def schema_infer( f"share a consistent structure.\n\n" f"## Suggestions\n" f"1. **Use a more specific type** — try `search_notes` with " - f"`entity_types` filter to see what types exist\n" + f"`note_types` filter to see what types exist\n" f"2. **Lower the threshold** — " f'`schema_infer("{note_type}", threshold=0.1)` to include ' f"rarer fields\n" diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index 3694a7b0..9ea90276 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -2,7 +2,7 @@ import re from textwrap import dedent -from typing import List, Optional, Dict, Any, Literal +from typing import Annotated, List, Optional, Dict, Any, Literal from loguru import logger from fastmcp import Context @@ -165,7 +165,7 @@ def _format_search_error_response( - Remove restrictive terms: Focus on the most important keywords 5. **Use filtering to narrow scope**: - - By content type: `search_notes("{project}","{query}", note_types=["note"])` + - By note type in frontmatter: `search_notes("{project}","{query}", note_types=["note"])` - By recent content: `search_notes("{project}","{query}", after_date="1 week")` - By entity type: `search_notes("{project}","{query}", entity_types=["observation"])` @@ -305,8 +305,17 @@ async def search_notes( page_size: int = 10, search_type: str | None = None, output_format: Literal["text", "json"] = "text", - note_types: List[str] | None = None, - entity_types: List[str] | None = None, + note_types: Annotated[ + List[str] | None, + "Filter by the 'type' field in note frontmatter (e.g. 'note', 'chapter', 'person'). " + "Case-insensitive.", + ] = None, + entity_types: Annotated[ + List[str] | None, + "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.", + ] = None, after_date: Optional[str] = None, metadata_filters: Optional[Dict[str, Any]] = None, tags: Optional[List[str]] = None, @@ -350,6 +359,7 @@ async def search_notes( ### Search Type Examples - `search_notes("my-project", "Meeting", search_type="title")` - Search only in titles - `search_notes("work-docs", "docs/meeting-*", 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("research", "keyword")` - Default search (hybrid when semantic is enabled, text when disabled) @@ -436,7 +446,7 @@ async def search_notes( # Exact phrase search results = await search_notes("\"weekly standup meeting\"") - # Search with note type filter + # Search with note type filter - type property in frontmatter results = await search_notes( "meeting notes", note_types=["note"], @@ -477,7 +487,8 @@ async def search_notes( results = await search_notes("project planning", project="my-project") """ # Avoid mutable-default-argument footguns. Treat None as "no filter". - note_types = note_types or [] + # Lowercase note_types so "Chapter" matches the stored "chapter". + note_types = [t.lower() for t in note_types] if note_types else [] entity_types = entity_types or [] # Parse tag: shorthand at tool level so it works with all search modes. diff --git a/src/basic_memory/mcp/tools/ui_sdk.py b/src/basic_memory/mcp/tools/ui_sdk.py index 50fdb1c5..fc709a0b 100644 --- a/src/basic_memory/mcp/tools/ui_sdk.py +++ b/src/basic_memory/mcp/tools/ui_sdk.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Annotated, Any, Dict, List, Optional from fastmcp import Context from mcp.types import ContentBlock, TextContent @@ -28,8 +28,17 @@ async def search_notes_ui( page: int = 1, page_size: int = 10, search_type: Optional[str] = None, - note_types: List[str] | None = None, - entity_types: List[str] | None = None, + note_types: Annotated[ + List[str] | None, + "Filter by the 'type' field in note frontmatter (e.g. 'note', 'chapter', 'person'). " + "Case-insensitive.", + ] = None, + entity_types: Annotated[ + List[str] | None, + "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.", + ] = None, after_date: Optional[str] = None, metadata_filters: Optional[Dict[str, Any]] = None, tags: Optional[List[str]] = None, diff --git a/tests/mcp/test_tool_search.py b/tests/mcp/test_tool_search.py index cae457ab..c5385a7c 100644 --- a/tests/mcp/test_tool_search.py +++ b/tests/mcp/test_tool_search.py @@ -1146,6 +1146,54 @@ async def test_search_notes_explicit_entity_types_overrides_default(monkeypatch) assert captured_payload["entity_types"] == ["observation"] +# --- Tests for note_types case-insensitivity ------------------------------------ + + +@pytest.mark.asyncio +async def test_search_notes_note_types_lowercased(monkeypatch): + """note_types values are lowercased so 'Chapter' matches stored 'chapter'.""" + import importlib + + search_mod = importlib.import_module("basic_memory.mcp.tools.search") + clients_mod = importlib.import_module("basic_memory.mcp.clients") + + class StubProject: + name = "test-project" + external_id = "test-external-id" + + @asynccontextmanager + async def fake_get_project_client(*args, **kwargs): + yield (object(), StubProject()) + + async def fake_resolve_project_and_path( + client, identifier, project=None, context=None, headers=None + ): + return StubProject(), identifier, False + + captured_payload: dict = {} + + class MockSearchClient: + def __init__(self, *args, **kwargs): + pass + + async def search(self, payload, page, page_size): + captured_payload.update(payload) + return SearchResponse(results=[], current_page=page, page_size=page_size) + + monkeypatch.setattr(search_mod, "get_project_client", fake_get_project_client) + monkeypatch.setattr(search_mod, "resolve_project_and_path", fake_resolve_project_and_path) + monkeypatch.setattr(clients_mod, "SearchClient", MockSearchClient) + + await search_mod.search_notes( + project="test-project", + query="test", + note_types=["Chapter", "Person"], + ) + + # note_types should be lowercased + assert captured_payload["note_types"] == ["chapter", "person"] + + # --- Tests for tag: prefix parsing (issue #30) ---------------------------------