From ca3a20a7109be2c2a724c945fca099a35d6cc7c9 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 02:34:09 +0000 Subject: [PATCH] fix: Accept JSON strings for entity_types and types in search_notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #392 - ChatGPT and other clients serialize array parameters as JSON strings (e.g., '["observation"]'), which were rejected by the schema validation that expected Optional[List[str]]. Changes: - Added _normalize_list_param() helper to parse JSON strings or pass through lists - Updated search_notes signature to accept Union[str, List[str]] for types/entity_types - Added 4 integration tests for JSON string parameter formats - Updated docstring to document both accepted formats This allows clients that serialize arrays as strings to successfully filter search results by entity_types and types. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Co-authored-by: Paul Hernandez --- src/basic_memory/mcp/tools/search.py | 52 +++++-- test-int/mcp/test_search_integration.py | 182 ++++++++++++++++++++++++ 2 files changed, 225 insertions(+), 9 deletions(-) diff --git a/src/basic_memory/mcp/tools/search.py b/src/basic_memory/mcp/tools/search.py index b1cbd3c8..497bfe07 100644 --- a/src/basic_memory/mcp/tools/search.py +++ b/src/basic_memory/mcp/tools/search.py @@ -1,7 +1,8 @@ """Search tools for Basic Memory MCP server.""" +import json from textwrap import dedent -from typing import List, Optional +from typing import List, Optional, Union from loguru import logger from fastmcp import Context @@ -13,6 +14,33 @@ from basic_memory.mcp.tools.utils import call_post from basic_memory.schemas.search import SearchItemType, SearchQuery, SearchResponse +def _normalize_list_param(param: Optional[Union[str, List[str]]]) -> Optional[List[str]]: + """Normalize a parameter that can be either a JSON string or a list. + + Args: + param: Either a JSON string like '["item1", "item2"]' or a list ["item1", "item2"] + + Returns: + A list of strings, or None if param is None + + Raises: + ValueError: If the string cannot be parsed as JSON + """ + if param is None: + return None + + if isinstance(param, str): + try: + parsed = json.loads(param) + if not isinstance(parsed, list): + raise ValueError(f"Expected JSON array, got {type(parsed).__name__}") + return parsed + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON string: {e}") + + return param + + def _format_search_error_response( project: str, error_message: str, query: str, search_type: str = "text" ) -> str: @@ -205,8 +233,8 @@ async def search_notes( page: int = 1, page_size: int = 10, search_type: str = "text", - types: Optional[List[str]] = None, - entity_types: Optional[List[str]] = None, + types: Optional[Union[str, List[str]]] = None, + entity_types: Optional[Union[str, List[str]]] = None, after_date: Optional[str] = None, context: Context | None = None, ) -> SearchResponse | str: @@ -263,8 +291,10 @@ async def search_notes( page: The page number of results to return (default 1) page_size: The number of results to return per page (default 10) search_type: Type of search to perform, one of: "text", "title", "permalink" (default: "text") - types: Optional list of note types to search (e.g., ["note", "person"]) - entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"]) + types: Optional list of note types to search. Accepts either a list ["note", "person"] + or a JSON string '["note", "person"]' for clients that serialize arrays as strings. + entity_types: Optional list of entity types to filter by. Accepts either a list + ["entity", "observation"] or a JSON string '["entity", "observation"]'. after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01") context: Optional FastMCP context for performance caching. @@ -330,6 +360,10 @@ async def search_notes( # Explicit project specification results = await search_notes("project planning", project="my-project") """ + # Normalize list parameters (accept both JSON strings and lists) + normalized_types = _normalize_list_param(types) + normalized_entity_types = _normalize_list_param(entity_types) + # Create a SearchQuery object based on the parameters search_query = SearchQuery() @@ -346,10 +380,10 @@ async def search_notes( search_query.text = query # Default to text search # Add optional filters if provided - if entity_types: - search_query.entity_types = [SearchItemType(t) for t in entity_types] - if types: - search_query.types = types + if normalized_entity_types: + search_query.entity_types = [SearchItemType(t) for t in normalized_entity_types] + if normalized_types: + search_query.types = normalized_types if after_date: search_query.after_date = after_date diff --git a/test-int/mcp/test_search_integration.py b/test-int/mcp/test_search_integration.py index 9fac85cf..0f6eb48b 100644 --- a/test-int/mcp/test_search_integration.py +++ b/test-int/mcp/test_search_integration.py @@ -499,3 +499,185 @@ async def test_search_case_insensitive(mcp_server, app, test_project): result_text = search_result.content[0].text assert "Machine Learning Guide" in result_text, f"Failed for search term: {search_term}" + + +@pytest.mark.asyncio +async def test_search_with_json_string_entity_types(mcp_server, app, test_project): + """Test search with entity_types as JSON string (for ChatGPT compatibility).""" + + async with Client(mcp_server) as client: + # Create a note with observations and relations + content_with_observations = """# Development Process + +This describes our development workflow. + +## Observations +- [process] We use Git for version control +- [tool] We use VS Code as our editor + +## Relations +- uses [[Git]] +- part_of [[Development Workflow]] + +Regular content about development practices.""" + + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Development Process", + "folder": "processes", + "content": content_with_observations, + "tags": "development,process", + }, + ) + + # Search with entity_types as JSON string (simulating ChatGPT behavior) + search_result = await client.call_tool( + "search_notes", + { + "project": test_project.name, + "query": "development", + "entity_types": '["entity"]', # JSON string format + }, + ) + + result_text = search_result.content[0].text + # Should find the main entity + assert "Development Process" in result_text + + +@pytest.mark.asyncio +async def test_search_with_json_string_types(mcp_server, app, test_project): + """Test search with types as JSON string (for ChatGPT compatibility).""" + + async with Client(mcp_server) as client: + # Create test notes with different types + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Python Programming", + "folder": "docs", + "content": """# Python Programming +--- +type: entity +--- + +Python programming language guide.""", + "tags": "python,programming", + }, + ) + + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "JavaScript Basics", + "folder": "docs", + "content": """# JavaScript Basics +--- +type: note +--- + +JavaScript programming guide.""", + "tags": "javascript,programming", + }, + ) + + # Search with types as JSON string + search_result = await client.call_tool( + "search_notes", + { + "project": test_project.name, + "query": "programming", + "types": '["entity"]', # JSON string format + }, + ) + + result_text = search_result.content[0].text + assert "Python Programming" in result_text + # Note: Can't definitively assert "JavaScript Basics" not in result_text + # without checking the type filtering logic, but the test verifies + # that JSON string format is accepted + + +@pytest.mark.asyncio +async def test_search_with_json_string_observation_filter(mcp_server, app, test_project): + """Test search with entity_types filtering for observations using JSON string.""" + + async with Client(mcp_server) as client: + # Create a note with observations + content = """# Project Planning + +## Observations +- [requirement] Must support authentication +- [decision] Using OAuth2 for auth +- [problem] Legacy systems don't support OAuth + +## Content +Planning our project architecture.""" + + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Project Planning", + "folder": "planning", + "content": content, + "tags": "planning,project", + }, + ) + + # Search for observations using JSON string format + search_result = await client.call_tool( + "search_notes", + { + "project": test_project.name, + "query": "auth", + "entity_types": '["observation"]', # JSON string format + }, + ) + + result_text = search_result.content[0].text + # Should find observations containing "auth" + assert "authentication" in result_text or "OAuth" in result_text + + +@pytest.mark.asyncio +async def test_search_with_multiple_entity_types_json_string(mcp_server, app, test_project): + """Test search with multiple entity_types in JSON string format.""" + + async with Client(mcp_server) as client: + # Create a note with both entities and observations + content = """# Development Tools + +## Observations +- [tool] VS Code is our primary editor +- [process] We use Git for version control + +Regular content about development.""" + + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Development Tools", + "folder": "tools", + "content": content, + "tags": "development,tools", + }, + ) + + # Search with multiple entity_types as JSON string + search_result = await client.call_tool( + "search_notes", + { + "project": test_project.name, + "query": "development", + "entity_types": '["entity", "observation"]', # JSON string with multiple values + }, + ) + + result_text = search_result.content[0].text + assert "Development Tools" in result_text