diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 385976c6c..444bbe4a8 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -24,7 +24,7 @@ jobs: pull-requests: write issues: read id-token: write - + steps: - name: Checkout repository uses: actions/checkout@v4 @@ -36,7 +36,9 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} track_progress: true # Enable visual progress tracking + allowed_bots: '*' prompt: | Review this Basic Memory PR against our team checklist: diff --git a/src/basic_memory/mcp/tools/__init__.py b/src/basic_memory/mcp/tools/__init__.py index 85618f6d8..83a2318d1 100644 --- a/src/basic_memory/mcp/tools/__init__.py +++ b/src/basic_memory/mcp/tools/__init__.py @@ -24,6 +24,8 @@ create_memory_project, delete_project, ) +# ChatGPT-compatible tools +from basic_memory.mcp.tools.chatgpt_tools import search, fetch __all__ = [ "build_context", @@ -32,12 +34,14 @@ "delete_note", "delete_project", "edit_note", + "fetch", "list_directory", "list_memory_projects", "move_note", "read_content", "read_note", "recent_activity", + "search", "search_notes", "sync_status", "view_note", diff --git a/src/basic_memory/mcp/tools/chatgpt_tools.py b/src/basic_memory/mcp/tools/chatgpt_tools.py new file mode 100644 index 000000000..cd8d5862c --- /dev/null +++ b/src/basic_memory/mcp/tools/chatgpt_tools.py @@ -0,0 +1,202 @@ +"""ChatGPT-compatible MCP tools for Basic Memory. + +These adapters expose Basic Memory's search/fetch functionality using the exact +tool names and response structure OpenAI's MCP clients expect: each call returns +a list containing a single `{"type": "text", "text": "{...json...}"}` item. +""" + +import json +from typing import Any, Dict, List, Optional +from loguru import logger +from fastmcp import Context + +from basic_memory.mcp.server import mcp +from basic_memory.mcp.tools.search import search_notes +from basic_memory.mcp.tools.read_note import read_note +from basic_memory.schemas.search import SearchResponse + + +def _format_search_results_for_chatgpt(results: SearchResponse) -> List[Dict[str, Any]]: + """Format search results according to ChatGPT's expected schema. + + Returns a list of result objects with id, title, and url fields. + """ + formatted_results = [] + + for result in results.results: + formatted_result = { + "id": result.permalink or f"doc-{len(formatted_results)}", + "title": result.title if result.title and result.title.strip() else "Untitled", + "url": result.permalink or "" + } + formatted_results.append(formatted_result) + + return formatted_results + + +def _format_document_for_chatgpt( + content: str, identifier: str, title: Optional[str] = None +) -> Dict[str, Any]: + """Format document content according to ChatGPT's expected schema. + + Returns a document object with id, title, text, url, and metadata fields. + """ + # Extract title from markdown content if not provided + if not title and isinstance(content, str): + lines = content.split('\n') + if lines and lines[0].startswith('# '): + title = lines[0][2:].strip() + else: + title = identifier.split('/')[-1].replace('-', ' ').title() + + # Ensure title is never None + if not title: + title = "Untitled Document" + + # Handle error cases + if isinstance(content, str) and content.startswith("# Note Not Found"): + return { + "id": identifier, + "title": title or "Document Not Found", + "text": content, + "url": identifier, + "metadata": {"error": "Document not found"} + } + + return { + "id": identifier, + "title": title or "Untitled Document", + "text": content, + "url": identifier, + "metadata": {"format": "markdown"} + } + + +@mcp.tool( + description="Search for content across the knowledge base" +) +async def search( + query: str, + context: Context | None = None, +) -> List[Dict[str, Any]]: + """ChatGPT/OpenAI MCP search adapter returning a single text content item. + + Args: + query: Search query (full-text syntax supported by `search_notes`) + context: Optional FastMCP context passed through for auth/session data + + Returns: + List with one dict: `{ "type": "text", "text": "{...JSON...}" }` + where the JSON body contains `results`, `total_count`, and echo of `query`. + """ + logger.info(f"ChatGPT search request: query='{query}'") + + try: + # Call underlying search_notes with sensible defaults for ChatGPT + results = await search_notes.fn( + query=query, + project=None, # Let project resolution happen automatically + page=1, + page_size=10, # Reasonable default for ChatGPT consumption + search_type="text", # Default to full-text search + context=context + ) + + # Handle string error responses from search_notes + if isinstance(results, str): + logger.warning(f"Search failed with error: {results[:100]}...") + search_results = { + "results": [], + "error": "Search failed", + "error_details": results[:500] # Truncate long error messages + } + else: + # Format successful results for ChatGPT + formatted_results = _format_search_results_for_chatgpt(results) + search_results = { + "results": formatted_results, + "total_count": len(results.results), # Use actual count from results + "query": query + } + logger.info(f"Search completed: {len(formatted_results)} results returned") + + # Return in MCP content array format as required by OpenAI + return [ + { + "type": "text", + "text": json.dumps(search_results, ensure_ascii=False) + } + ] + + except Exception as e: + logger.error(f"ChatGPT search failed for query '{query}': {e}") + error_results = { + "results": [], + "error": "Internal search error", + "error_message": str(e)[:200] + } + return [ + { + "type": "text", + "text": json.dumps(error_results, ensure_ascii=False) + } + ] + + +@mcp.tool( + description="Fetch the full contents of a search result document" +) +async def fetch( + id: str, + context: Context | None = None, +) -> List[Dict[str, Any]]: + """ChatGPT/OpenAI MCP fetch adapter returning a single text content item. + + Args: + id: Document identifier (permalink, title, or memory URL) + context: Optional FastMCP context passed through for auth/session data + + Returns: + List with one dict: `{ "type": "text", "text": "{...JSON...}" }` + where the JSON body includes `id`, `title`, `text`, `url`, and metadata. + """ + logger.info(f"ChatGPT fetch request: id='{id}'") + + try: + # Call underlying read_note function + content = await read_note.fn( + identifier=id, + project=None, # Let project resolution happen automatically + page=1, + page_size=10, # Default pagination + context=context + ) + + # Format the document for ChatGPT + document = _format_document_for_chatgpt(content, id) + + logger.info(f"Fetch completed: id='{id}', content_length={len(document.get('text', ''))}") + + # Return in MCP content array format as required by OpenAI + return [ + { + "type": "text", + "text": json.dumps(document, ensure_ascii=False) + } + ] + + except Exception as e: + logger.error(f"ChatGPT fetch failed for id '{id}': {e}") + error_document = { + "id": id, + "title": "Fetch Error", + "text": f"Failed to fetch document: {str(e)[:200]}", + "url": id, + "metadata": {"error": "Fetch failed"} + } + return [ + { + "type": "text", + "text": json.dumps(error_document, ensure_ascii=False) + } + ] diff --git a/src/basic_memory/mcp/tools/read_note.py b/src/basic_memory/mcp/tools/read_note.py index b7d90e1ac..5bf6a3957 100644 --- a/src/basic_memory/mcp/tools/read_note.py +++ b/src/basic_memory/mcp/tools/read_note.py @@ -25,7 +25,7 @@ async def read_note( page_size: int = 10, context: Context | None = None, ) -> str: - """Read a markdown note from the knowledge base. + """Return the raw markdown for a note, or guidance text if no match is found. Finds and retrieves a note by its title, permalink, or content search, returning the raw markdown content including observations, relations, and metadata. @@ -171,25 +171,25 @@ def format_not_found_message(project: str | None, identifier: str) -> str: """Format a helpful message when no note was found.""" return dedent(f""" # Note Not Found in {project}: "{identifier}" - + I couldn't find any notes matching "{identifier}". Here are some suggestions: - + ## Check Identifier Type - If you provided a title, try using the exact permalink instead - If you provided a permalink, check for typos or try a broader search - + ## Search Instead Try searching for related content: ``` search_notes(project="{project}", query="{identifier}") ``` - + ## Recent Activity Check recently modified notes: ``` recent_activity(timeframe="7d") ``` - + ## Create New Note This might be a good opportunity to create a new note on this topic: ``` @@ -198,13 +198,13 @@ def format_not_found_message(project: str | None, identifier: str) -> str: title="{identifier.capitalize()}", content=''' # {identifier.capitalize()} - + ## Overview [Your content here] - + ## Observations - [category] [Observation about {identifier}] - + ## Relations - relates_to [[Related Topic]] ''', @@ -218,9 +218,9 @@ def format_related_results(project: str | None, identifier: str, results) -> str """Format a helpful message with related results when an exact match wasn't found.""" message = dedent(f""" # Note Not Found in {project}: "{identifier}" - + I couldn't find an exact match for "{identifier}", but I found some related notes: - + """) for i, result in enumerate(results): @@ -228,24 +228,24 @@ def format_related_results(project: str | None, identifier: str, results) -> str ## {i + 1}. {result.title} - **Type**: {result.type.value} - **Permalink**: {result.permalink} - + You can read this note with: ``` read_note(project="{project}", {result.permalink}") ``` - + """) message += dedent(f""" ## Try More Specific Lookup For exact matches, try using the full permalink from one of the results above. - + ## Search For More Results To see more related content: ``` search_notes(project="{project}", query="{identifier}") ``` - + ## Create New Note If none of these match what you're looking for, consider creating a new note: ``` diff --git a/test-int/conftest.py b/test-int/conftest.py index 3fa170e0e..b96f84d71 100644 --- a/test-int/conftest.py +++ b/test-int/conftest.py @@ -122,6 +122,7 @@ def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig: env="test", projects=projects, default_project="test-project", + default_project_mode=True, update_permalinks_on_move=True, ) return app_config diff --git a/test-int/mcp/test_chatgpt_tools_integration.py b/test-int/mcp/test_chatgpt_tools_integration.py new file mode 100644 index 000000000..7d6906789 --- /dev/null +++ b/test-int/mcp/test_chatgpt_tools_integration.py @@ -0,0 +1,459 @@ +""" +Integration tests for ChatGPT-compatible MCP tools. + +Tests the complete flow of search and fetch tools designed for ChatGPT integration, +ensuring they properly wrap Basic Memory's MCP tools and return OpenAI-compatible +MCP content array format. +""" + +import json +import pytest +from fastmcp import Client + + +def extract_mcp_json_content(mcp_result): + """ + Helper to extract JSON content from MCP CallToolResult. + + FastMCP auto-serializes our List[Dict[str, Any]] return values, so we need to: + 1. Get the content list from the CallToolResult + 2. Parse the JSON string in the text field (which is our serialized list) + 3. Extract the actual JSON from the MCP content array structure + """ + content_list = mcp_result.content + mcp_content_list = json.loads(content_list[0].text) + return json.loads(mcp_content_list[0]["text"]) + + +@pytest.mark.asyncio +async def test_chatgpt_search_basic(mcp_server, app, test_project): + """Test basic ChatGPT search functionality with MCP content array format.""" + + async with Client(mcp_server) as client: + # Create test notes for searching + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Machine Learning Fundamentals", + "folder": "ai", + "content": ( + "# Machine Learning Fundamentals\n\n" + "Introduction to ML concepts and algorithms." + ), + "tags": "ml,ai,fundamentals", + }, + ) + + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Deep Learning with PyTorch", + "folder": "ai", + "content": ( + "# Deep Learning with PyTorch\n\n" + "Building neural networks using PyTorch framework." + ), + "tags": "pytorch,deep-learning,ai", + }, + ) + + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Data Visualization Guide", + "folder": "data", + "content": ( + "# Data Visualization Guide\n\n" + "Creating charts and graphs for data analysis." + ), + "tags": "visualization,data,charts", + }, + ) + + # Test ChatGPT search tool + search_result = await client.call_tool( + "search", + { + "query": "Machine Learning", + }, + ) + + # Extract JSON content from MCP result + results_json = extract_mcp_json_content(search_result) + assert "results" in results_json + assert len(results_json["results"]) > 0 + + # Check result structure + first_result = results_json["results"][0] + assert "id" in first_result + assert "title" in first_result + assert "url" in first_result + + # Verify correct content found + titles = [r["title"] for r in results_json["results"]] + assert "Machine Learning Fundamentals" in titles + assert "Data Visualization Guide" not in titles + + +@pytest.mark.asyncio +async def test_chatgpt_search_empty_results(mcp_server, app, test_project): + """Test ChatGPT search with no matching results.""" + + async with Client(mcp_server) as client: + # Search for non-existent content + search_result = await client.call_tool( + "search", + { + "query": "NonExistentTopic12345", + }, + ) + + # Extract JSON content from MCP result + results_json = extract_mcp_json_content(search_result) + assert "results" in results_json + assert len(results_json["results"]) == 0 + assert results_json["query"] == "NonExistentTopic12345" + + +@pytest.mark.asyncio +async def test_chatgpt_search_with_boolean_operators(mcp_server, app, test_project): + """Test ChatGPT search with boolean operators.""" + + async with Client(mcp_server) as client: + # Create test notes + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Python Web Frameworks", + "folder": "dev", + "content": ( + "# Python Web Frameworks\n\n" + "Comparing Django and Flask for web development." + ), + "tags": "python,web,frameworks", + }, + ) + + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "JavaScript Frameworks", + "folder": "dev", + "content": "# JavaScript Frameworks\n\nReact, Vue, and Angular comparison.", + "tags": "javascript,web,frameworks", + }, + ) + + # Test with AND operator + search_result = await client.call_tool( + "search", + { + "query": "Python AND frameworks", + }, + ) + + results_json = extract_mcp_json_content(search_result) + titles = [r["title"] for r in results_json["results"]] + assert "Python Web Frameworks" in titles + assert "JavaScript Frameworks" not in titles + + +@pytest.mark.asyncio +async def test_chatgpt_fetch_document(mcp_server, app, test_project): + """Test ChatGPT fetch tool for retrieving full document content.""" + + async with Client(mcp_server) as client: + # Create a test note + note_content = """# Advanced Python Techniques + +## Overview +This document covers advanced Python programming techniques. + +## Topics Covered +- Decorators +- Context Managers +- Metaclasses +- Async/Await patterns + +## Code Examples +```python +def my_decorator(func): + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper +``` +""" + + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Advanced Python Techniques", + "folder": "programming", + "content": note_content, + "tags": "python,advanced,programming", + }, + ) + + # Fetch the document using its title + fetch_result = await client.call_tool( + "fetch", + { + "id": "Advanced Python Techniques", + }, + ) + + # Extract JSON content from MCP result + document_json = extract_mcp_json_content(fetch_result) + assert "id" in document_json + assert "title" in document_json + assert "text" in document_json + assert "url" in document_json + assert "metadata" in document_json + + # Verify content + assert document_json["title"] == "Advanced Python Techniques" + assert "Decorators" in document_json["text"] + assert "Context Managers" in document_json["text"] + assert "def my_decorator" in document_json["text"] + + +@pytest.mark.asyncio +async def test_chatgpt_fetch_by_permalink(mcp_server, app, test_project): + """Test ChatGPT fetch using permalink identifier.""" + + async with Client(mcp_server) as client: + # Create a note with known content + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "Test Document", + "folder": "test", + "content": "# Test Document\n\nThis is test content for permalink fetching.", + "tags": "test", + }, + ) + + # First search to get the permalink + search_result = await client.call_tool( + "search", + { + "query": "Test Document", + }, + ) + + results_json = extract_mcp_json_content(search_result) + assert len(results_json["results"]) > 0 + permalink = results_json["results"][0]["id"] + + # Fetch using the permalink + fetch_result = await client.call_tool( + "fetch", + { + "id": permalink, + }, + ) + + # Verify the fetched document + document_json = extract_mcp_json_content(fetch_result) + assert document_json["id"] == permalink + assert "Test Document" in document_json["title"] + assert "test content for permalink fetching" in document_json["text"] + + +@pytest.mark.asyncio +async def test_chatgpt_fetch_nonexistent_document(mcp_server, app, test_project): + """Test ChatGPT fetch with non-existent document ID.""" + + async with Client(mcp_server) as client: + # Try to fetch a non-existent document + fetch_result = await client.call_tool( + "fetch", + { + "id": "NonExistentDocument12345", + }, + ) + + # Extract JSON content from MCP result + document_json = extract_mcp_json_content(fetch_result) + + # Should have document structure even for errors + assert "id" in document_json + assert "title" in document_json + assert "text" in document_json + + # Check for error indication + assert document_json["id"] == "NonExistentDocument12345" + assert "Not Found" in document_json["text"] or "not found" in document_json["text"] + + +@pytest.mark.asyncio +async def test_chatgpt_fetch_with_empty_title(mcp_server, app, test_project): + """Test ChatGPT fetch handles documents with empty or missing titles.""" + + async with Client(mcp_server) as client: + # Create a note without a title in the content + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": "untitled-note", + "folder": "misc", + "content": "This is content without a markdown header.\n\nJust plain text.", + "tags": "misc", + }, + ) + + # Fetch the document + fetch_result = await client.call_tool( + "fetch", + { + "id": "untitled-note", + }, + ) + + # Parse JSON response + document_json = extract_mcp_json_content(fetch_result) + + # Should have a title even if content doesn't have one + assert "title" in document_json + assert document_json["title"] != "" + assert document_json["title"] is not None + assert "content without a markdown header" in document_json["text"] + + +@pytest.mark.asyncio +async def test_chatgpt_search_pagination_default(mcp_server, app, test_project): + """Test that ChatGPT search uses reasonable pagination defaults.""" + + async with Client(mcp_server) as client: + # Create more than 10 notes to test pagination + for i in range(15): + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": f"Test Note {i}", + "folder": "bulk", + "content": f"# Test Note {i}\n\nThis is test content number {i}.", + "tags": "test,bulk", + }, + ) + + # Search should return max 10 results by default + search_result = await client.call_tool( + "search", + { + "query": "Test Note", + }, + ) + + results_json = extract_mcp_json_content(search_result) + + # Should have at most 10 results (the default page_size) + assert len(results_json["results"]) <= 10 + assert results_json["total_count"] <= 10 + + +@pytest.mark.asyncio +async def test_chatgpt_tools_error_handling(mcp_server, app, test_project): + """Test error handling in ChatGPT tools returns proper MCP format.""" + + async with Client(mcp_server) as client: + # Test search with invalid query (if validation exists) + # Using empty query to potentially trigger an error + search_result = await client.call_tool( + "search", + { + "query": "", # Empty query might cause an error + }, + ) + + # Should still return MCP content array format + assert hasattr(search_result, 'content') + content_list = search_result.content + assert isinstance(content_list, list) + assert len(content_list) == 1 + assert content_list[0].type == "text" + + # Should be valid JSON even on error + results_json = extract_mcp_json_content(search_result) + assert "results" in results_json # Should have results key even if empty + + +@pytest.mark.asyncio +async def test_chatgpt_integration_workflow(mcp_server, app, test_project): + """Test complete workflow: search then fetch, as ChatGPT would use it.""" + + async with Client(mcp_server) as client: + # Step 1: Create multiple documents + docs = [ + { + "title": "API Design Best Practices", + "content": ( + "# API Design Best Practices\n\n" + "RESTful API design principles and patterns." + ), + "tags": "api,rest,design", + }, + { + "title": "GraphQL vs REST", + "content": "# GraphQL vs REST\n\nComparing GraphQL and REST API architectures.", + "tags": "api,graphql,rest", + }, + { + "title": "Database Design Patterns", + "content": ( + "# Database Design Patterns\n\n" + "Common database design patterns and anti-patterns." + ), + "tags": "database,design,patterns", + }, + ] + + for doc in docs: + await client.call_tool( + "write_note", + { + "project": test_project.name, + "title": doc["title"], + "folder": "architecture", + "content": doc["content"], + "tags": doc["tags"], + }, + ) + + # Step 2: Search for API-related content (as ChatGPT would) + search_result = await client.call_tool( + "search", + { + "query": "API", + }, + ) + + results_json = extract_mcp_json_content(search_result) + assert len(results_json["results"]) >= 2 + + # Step 3: Fetch one of the search results (as ChatGPT would) + first_result_id = results_json["results"][0]["id"] + fetch_result = await client.call_tool( + "fetch", + { + "id": first_result_id, + }, + ) + + document_json = extract_mcp_json_content(fetch_result) + + # Verify the fetched document matches search result + assert document_json["id"] == first_result_id + assert "API" in document_json["text"] or "api" in document_json["text"].lower() + + # Verify document has expected structure + assert document_json["metadata"]["format"] == "markdown" \ No newline at end of file diff --git a/tests/mcp/tools/test_chatgpt_tools.py b/tests/mcp/tools/test_chatgpt_tools.py new file mode 100644 index 000000000..a57a7ab63 --- /dev/null +++ b/tests/mcp/tools/test_chatgpt_tools.py @@ -0,0 +1,228 @@ +"""Tests for ChatGPT-compatible MCP tools.""" + +import json +import pytest +from unittest.mock import AsyncMock, patch + +from basic_memory.schemas.search import SearchResponse, SearchResult, SearchItemType + + +@pytest.mark.asyncio +async def test_search_successful_results(): + """Test search with successful results returns proper MCP content array format.""" + # Mock successful search results + mock_results = SearchResponse( + results=[ + SearchResult( + title="Test Document 1", + permalink="docs/test-doc-1", + content="This is test content for document 1", + type=SearchItemType.ENTITY, + score=1.0, + file_path="/test/docs/test-doc-1.md" + ), + SearchResult( + title="Test Document 2", + permalink="docs/test-doc-2", + content="This is test content for document 2", + type=SearchItemType.ENTITY, + score=0.9, + file_path="/test/docs/test-doc-2.md" + ) + ], + current_page=1, + page_size=10 + ) + + with patch( + 'basic_memory.mcp.tools.chatgpt_tools.search_notes.fn', + new_callable=AsyncMock + ) as mock_search: + mock_search.return_value = mock_results + + # Import and call the actual function + from basic_memory.mcp.tools.chatgpt_tools import search + result = await search.fn("test query") + + # Verify MCP content array format + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["type"] == "text" + + # Parse the JSON content + content = json.loads(result[0]["text"]) + assert "results" in content + assert "query" in content + + # Verify result structure + assert len(content["results"]) == 2 + assert content["query"] == "test query" + + # Verify individual result format + result_item = content["results"][0] + assert result_item["id"] == "docs/test-doc-1" + assert result_item["title"] == "Test Document 1" + assert result_item["url"] == "docs/test-doc-1" + + +@pytest.mark.asyncio +async def test_search_with_error_response(): + """Test search when underlying search_notes returns error string.""" + error_message = "# Search Failed - Invalid Syntax\n\nThe search query contains errors..." + + with patch( + 'basic_memory.mcp.tools.chatgpt_tools.search_notes.fn', + new_callable=AsyncMock + ) as mock_search: + mock_search.return_value = error_message + + from basic_memory.mcp.tools.chatgpt_tools import search + result = await search.fn("invalid query") + + # Verify MCP content array format + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["type"] == "text" + + # Parse the JSON content + content = json.loads(result[0]["text"]) + assert content["results"] == [] + assert content["error"] == "Search failed" + assert "error_details" in content + + +@pytest.mark.asyncio +async def test_fetch_successful_document(): + """Test fetch with successful document retrieval.""" + document_content = """# Test Document + +This is the content of a test document. + +## Section 1 +Some content here. + +## Observations +- [observation] This is a test observation + +## Relations +- relates_to [[Another Document]] +""" + + with patch( + 'basic_memory.mcp.tools.chatgpt_tools.read_note.fn', + new_callable=AsyncMock + ) as mock_read: + mock_read.return_value = document_content + + from basic_memory.mcp.tools.chatgpt_tools import fetch + result = await fetch.fn("docs/test-document") + + # Verify MCP content array format + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["type"] == "text" + + # Parse the JSON content + content = json.loads(result[0]["text"]) + assert content["id"] == "docs/test-document" + assert content["title"] == "Test Document" # Extracted from markdown + assert content["text"] == document_content + assert content["url"] == "docs/test-document" + assert content["metadata"]["format"] == "markdown" + + +@pytest.mark.asyncio +async def test_fetch_document_not_found(): + """Test fetch when document is not found.""" + error_content = """# Note Not Found: "nonexistent-doc" + +I couldn't find any notes matching "nonexistent-doc". Here are some suggestions: + +## Check Identifier Type +- If you provided a title, try using the exact permalink instead +""" + + with patch( + 'basic_memory.mcp.tools.chatgpt_tools.read_note.fn', + new_callable=AsyncMock + ) as mock_read: + mock_read.return_value = error_content + + from basic_memory.mcp.tools.chatgpt_tools import fetch + result = await fetch.fn("nonexistent-doc") + + # Verify MCP content array format + assert isinstance(result, list) + assert len(result) == 1 + assert result[0]["type"] == "text" + + # Parse the JSON content + content = json.loads(result[0]["text"]) + assert content["id"] == "nonexistent-doc" + assert content["text"] == error_content + assert content["metadata"]["error"] == "Document not found" + + +def test_format_search_results_for_chatgpt(): + """Test search results formatting.""" + from basic_memory.mcp.tools.chatgpt_tools import _format_search_results_for_chatgpt + + mock_results = SearchResponse( + results=[ + SearchResult( + title="Document One", + permalink="docs/doc-one", + content="Content for document one", + type=SearchItemType.ENTITY, + score=1.0, + file_path="/test/docs/doc-one.md" + ), + SearchResult( + title="", # Test empty title handling + permalink="docs/untitled", + content="Content without title", + type=SearchItemType.ENTITY, + score=0.8, + file_path="/test/docs/untitled.md" + ) + ], + current_page=1, + page_size=10 + ) + + formatted = _format_search_results_for_chatgpt(mock_results) + + assert len(formatted) == 2 + assert formatted[0]["id"] == "docs/doc-one" + assert formatted[0]["title"] == "Document One" + assert formatted[0]["url"] == "docs/doc-one" + + # Test empty title handling + assert formatted[1]["title"] == "Untitled" + + +def test_format_document_for_chatgpt(): + """Test document formatting.""" + from basic_memory.mcp.tools.chatgpt_tools import _format_document_for_chatgpt + + content = "# Test Document\n\nThis is test content." + result = _format_document_for_chatgpt(content, "docs/test") + + assert result["id"] == "docs/test" + assert result["title"] == "Test Document" + assert result["text"] == content + assert result["url"] == "docs/test" + assert result["metadata"]["format"] == "markdown" + + +def test_format_document_error_handling(): + """Test document formatting with error content.""" + from basic_memory.mcp.tools.chatgpt_tools import _format_document_for_chatgpt + + error_content = "# Note Not Found: \"missing-doc\"\n\nDocument not found." + result = _format_document_for_chatgpt(error_content, "missing-doc", "Missing Doc") + + assert result["id"] == "missing-doc" + assert result["title"] == "Missing Doc" + assert result["text"] == error_content + assert result["metadata"]["error"] == "Document not found" \ No newline at end of file