diff --git a/src/meilisearch_mcp/documents.py b/src/meilisearch_mcp/documents.py index 4b86b18..4498b95 100644 --- a/src/meilisearch_mcp/documents.py +++ b/src/meilisearch_mcp/documents.py @@ -18,9 +18,43 @@ async def get_documents( """Get documents from an index""" try: index = self.client.index(index_uid) - return index.get_documents( - {"offset": offset, "limit": limit, "fields": fields} - ) + # Build parameters dict, excluding None values to avoid API errors + params = {} + if offset is not None: + params["offset"] = offset + if limit is not None: + params["limit"] = limit + if fields is not None: + params["fields"] = fields + + result = index.get_documents(params if params else {}) + + # Convert meilisearch model objects to JSON-serializable format + if hasattr(result, '__dict__'): + result_dict = result.__dict__.copy() + # Convert individual document objects in results if they exist + if 'results' in result_dict and isinstance(result_dict['results'], list): + serialized_results = [] + for doc in result_dict['results']: + if hasattr(doc, '__dict__'): + # Extract the actual document data + doc_dict = doc.__dict__.copy() + # Look for private attributes that might contain the actual data + for key, value in doc_dict.items(): + if key.startswith('_') and isinstance(value, dict): + # Use the dict content instead of the wrapper + serialized_results.append(value) + break + else: + # If no private dict found, use the object dict directly + serialized_results.append(doc_dict) + else: + serialized_results.append(doc) + result_dict['results'] = serialized_results + return result_dict + else: + return result + except Exception as e: raise Exception(f"Failed to get documents: {str(e)}") diff --git a/src/meilisearch_mcp/server.py b/src/meilisearch_mcp/server.py index 3755605..68ee540 100644 --- a/src/meilisearch_mcp/server.py +++ b/src/meilisearch_mcp/server.py @@ -18,6 +18,9 @@ def json_serializer(obj: Any) -> str: """Custom JSON serializer for objects not serializable by default json code""" if isinstance(obj, datetime): return obj.isoformat() + # Handle Meilisearch model objects by using their __dict__ if available + if hasattr(obj, '__dict__'): + return obj.__dict__ return str(obj) @@ -318,13 +321,19 @@ async def handle_call_tool( ] elif name == "get-documents": + # Use default values to fix issue #17 (None offset/limit causes API errors) + offset = arguments.get("offset", 0) + limit = arguments.get("limit", 20) documents = await self.meili_client.documents.get_documents( arguments["indexUid"], - arguments.get("offset"), - arguments.get("limit"), + offset, + limit, ) + # Convert DocumentsResults object to proper JSON format + # The documents object should be directly JSON serializable from Meilisearch client + formatted_json = json.dumps(documents, indent=2, default=json_serializer) return [ - types.TextContent(type="text", text=f"Documents: {documents}") + types.TextContent(type="text", text=f"Documents:\n{formatted_json}") ] elif name == "add-documents": diff --git a/tests/test_mcp_client.py b/tests/test_mcp_client.py index f994e77..fb7a847 100644 --- a/tests/test_mcp_client.py +++ b/tests/test_mcp_client.py @@ -303,6 +303,464 @@ async def test_get_connection_settings_format(self, server): assert "********" in text or "Not set" in text else: assert "Not set" in text + + +class TestMCPToolExecution: + """Comprehensive tests for all MCP tools execution""" + + @pytest.fixture + async def server(self): + """Create server instance for tool execution tests""" + url = os.getenv("MEILI_HTTP_ADDR", "http://localhost:7700") + api_key = os.getenv("MEILI_MASTER_KEY") + server = create_server(url, api_key) + yield server + await server.cleanup() + + async def simulate_tool_call(self, server, tool_name: str, arguments: dict = None): + """Simulate MCP tool call using the existing pattern""" + return await simulate_mcp_call(server, tool_name, arguments) + + # Index Operations Tests + async def test_create_index_tool(self, server): + """Test create-index tool""" + import time + test_index = f"test_create_{int(time.time() * 1000)}" + + result = await self.simulate_tool_call(server, "create-index", { + "uid": test_index, + "primaryKey": "id" + }) + + assert len(result) == 1 + assert result[0].type == "text" + assert "Created index:" in result[0].text + assert test_index in result[0].text + + async def test_list_indexes_tool(self, server): + """Test list-indexes tool""" + result = await self.simulate_tool_call(server, "list-indexes") + + assert len(result) == 1 + assert result[0].type == "text" + assert "Indexes:" in result[0].text + # Should return valid JSON format + assert "[" in result[0].text or "[]" in result[0].text + + async def test_get_index_metrics_tool(self, server): + """Test get-index-metrics tool""" + # First create a test index + import time + test_index = f"test_metrics_{int(time.time() * 1000)}" + await self.simulate_tool_call(server, "create-index", {"uid": test_index}) + + result = await self.simulate_tool_call(server, "get-index-metrics", { + "indexUid": test_index + }) + + assert len(result) == 1 + assert result[0].type == "text" + assert "Index metrics:" in result[0].text + + # Document Management Tests + async def test_get_documents_tool(self, server): + """Test get-documents tool""" + # First create a test index + import time + test_index = f"test_docs_{int(time.time() * 1000)}" + await self.simulate_tool_call(server, "create-index", {"uid": test_index}) + + # Wait for index creation + import asyncio + await asyncio.sleep(0.5) + + result = await self.simulate_tool_call(server, "get-documents", { + "indexUid": test_index, + "limit": 10 + }) + + assert len(result) == 1 + assert result[0].type == "text" + assert "Documents:" in result[0].text + + async def test_add_documents_tool(self, server): + """Test add-documents tool""" + # First create a test index + import time + test_index = f"test_add_docs_{int(time.time() * 1000)}" + await self.simulate_tool_call(server, "create-index", {"uid": test_index}) + + test_documents = [ + {"id": 1, "title": "Test Document 1", "content": "Test content 1"}, + {"id": 2, "title": "Test Document 2", "content": "Test content 2"} + ] + + result = await self.simulate_tool_call(server, "add-documents", { + "indexUid": test_index, + "documents": test_documents + }) + + assert len(result) == 1 + assert result[0].type == "text" + assert "Added documents:" in result[0].text + + # Search Tests + async def test_search_tool_with_index(self, server): + """Test search tool with specific index""" + # First create a test index and add documents + import time + test_index = f"test_search_{int(time.time() * 1000)}" + await self.simulate_tool_call(server, "create-index", {"uid": test_index}) + + test_documents = [{"id": 1, "title": "Searchable Document", "content": "This is searchable"}] + await self.simulate_tool_call(server, "add-documents", { + "indexUid": test_index, + "documents": test_documents + }) + + result = await self.simulate_tool_call(server, "search", { + "query": "searchable", + "indexUid": test_index, + "limit": 5 + }) + + assert len(result) == 1 + assert result[0].type == "text" + assert "Search results for 'searchable':" in result[0].text + + async def test_search_tool_all_indexes(self, server): + """Test search tool across all indexes""" + result = await self.simulate_tool_call(server, "search", { + "query": "test", + "limit": 5 + }) + + assert len(result) == 1 + assert result[0].type == "text" + assert "Search results for 'test':" in result[0].text + + # Settings Management Tests + async def test_get_settings_tool(self, server): + """Test get-settings tool""" + # First create a test index + import time + test_index = f"test_settings_{int(time.time() * 1000)}" + await self.simulate_tool_call(server, "create-index", {"uid": test_index}) + + result = await self.simulate_tool_call(server, "get-settings", { + "indexUid": test_index + }) + + assert len(result) == 1 + assert result[0].type == "text" + assert "Current settings:" in result[0].text + + async def test_update_settings_tool(self, server): + """Test update-settings tool""" + # First create a test index + import time + test_index = f"test_update_settings_{int(time.time() * 1000)}" + await self.simulate_tool_call(server, "create-index", {"uid": test_index}) + + settings = { + "searchableAttributes": ["title", "content"], + "filterableAttributes": ["category"] + } + + result = await self.simulate_tool_call(server, "update-settings", { + "indexUid": test_index, + "settings": settings + }) + + assert len(result) == 1 + assert result[0].type == "text" + assert "Settings updated:" in result[0].text + + # Task Management Tests + async def test_get_tasks_tool(self, server): + """Test get-tasks tool""" + result = await self.simulate_tool_call(server, "get-tasks", { + "limit": 5 + }) + + assert len(result) == 1 + assert result[0].type == "text" + assert "Tasks:" in result[0].text + + async def test_get_tasks_tool_no_params(self, server): + """Test get-tasks tool without parameters""" + result = await self.simulate_tool_call(server, "get-tasks") + + assert len(result) == 1 + assert result[0].type == "text" + assert "Tasks:" in result[0].text + + async def test_get_task_tool(self, server): + """Test get-task tool""" + # First get tasks to find a valid task UID + tasks_result = await self.simulate_tool_call(server, "get-tasks", {"limit": 1}) + + # Try with a sample task UID (this might fail if no tasks exist) + result = await self.simulate_tool_call(server, "get-task", { + "taskUid": 0 + }) + + assert len(result) == 1 + assert result[0].type == "text" + # Could be successful task info or error message + assert "Task information:" in result[0].text or "Error:" in result[0].text + + async def test_cancel_tasks_tool(self, server): + """Test cancel-tasks tool""" + result = await self.simulate_tool_call(server, "cancel-tasks", { + "statuses": "enqueued,processing" + }) + + assert len(result) == 1 + assert result[0].type == "text" + assert "Tasks cancelled:" in result[0].text or "Error:" in result[0].text + + # API Key Management Tests + async def test_get_keys_tool(self, server): + """Test get-keys tool""" + result = await self.simulate_tool_call(server, "get-keys", { + "limit": 10 + }) + + assert len(result) == 1 + assert result[0].type == "text" + assert "API keys:" in result[0].text or "Error:" in result[0].text + + async def test_create_key_tool(self, server): + """Test create-key tool""" + result = await self.simulate_tool_call(server, "create-key", { + "description": "Test API key", + "actions": ["search"], + "indexes": ["*"], + "expiresAt": "2025-12-31T23:59:59Z" + }) + + assert len(result) == 1 + assert result[0].type == "text" + assert "Created API key:" in result[0].text or "Error:" in result[0].text + + async def test_delete_key_tool(self, server): + """Test delete-key tool""" + # Use a dummy key (this will likely fail but test the tool execution) + result = await self.simulate_tool_call(server, "delete-key", { + "key": "dummy_test_key_12345" + }) + + assert len(result) == 1 + assert result[0].type == "text" + assert "Successfully deleted API key:" in result[0].text or "Error:" in result[0].text + + # System Monitoring Tests + async def test_get_version_tool(self, server): + """Test get-version tool""" + result = await self.simulate_tool_call(server, "get-version") + + assert len(result) == 1 + assert result[0].type == "text" + assert "Version info:" in result[0].text or "Error:" in result[0].text + + async def test_get_stats_tool(self, server): + """Test get-stats tool""" + result = await self.simulate_tool_call(server, "get-stats") + + assert len(result) == 1 + assert result[0].type == "text" + assert "Database stats:" in result[0].text or "Error:" in result[0].text + + async def test_get_health_status_tool(self, server): + """Test get-health-status tool""" + result = await self.simulate_tool_call(server, "get-health-status") + + assert len(result) == 1 + assert result[0].type == "text" + assert "Health status:" in result[0].text or "Error:" in result[0].text + + async def test_get_system_info_tool(self, server): + """Test get-system-info tool""" + result = await self.simulate_tool_call(server, "get-system-info") + + assert len(result) == 1 + assert result[0].type == "text" + assert "System information:" in result[0].text or "Error:" in result[0].text + + # Error Handling Tests + async def test_tool_with_invalid_index(self, server): + """Test tool behavior with invalid index UID""" + result = await self.simulate_tool_call(server, "get-documents", { + "indexUid": "nonexistent_index_12345" + }) + + assert len(result) == 1 + assert result[0].type == "text" + assert "Error:" in result[0].text + + async def test_tool_with_missing_required_param(self, server): + """Test tool behavior with missing required parameters""" + result = await self.simulate_tool_call(server, "create-index", {}) + + assert len(result) == 1 + assert result[0].type == "text" + assert "Error:" in result[0].text + + async def test_tool_with_invalid_task_uid(self, server): + """Test get-task with invalid task UID""" + result = await self.simulate_tool_call(server, "get-task", { + "taskUid": 999999 + }) + + assert len(result) == 1 + assert result[0].type == "text" + assert "Task information:" in result[0].text or "Error:" in result[0].text + + +class TestGetDocumentsJsonSerialization: + """Tests for issue #16 - Get documents should return JSON, not Python objects""" + + @pytest.fixture + async def server(self): + """Create server instance for JSON serialization tests""" + url = os.getenv("MEILI_HTTP_ADDR", "http://localhost:7700") + api_key = os.getenv("MEILI_MASTER_KEY") + server = create_server(url, api_key) + yield server + await server.cleanup() + + async def simulate_tool_call(self, server, tool_name: str, arguments: dict = None): + """Simulate MCP tool call using the existing pattern""" + return await simulate_mcp_call(server, tool_name, arguments) + + async def test_get_documents_returns_json_not_object(self, server): + """Test that get-documents returns JSON-formatted text, not Python object string representation""" + import time + test_index = f"test_json_docs_{int(time.time() * 1000)}" + + # Create index and add documents + await self.simulate_tool_call(server, "create-index", {"uid": test_index}) + + test_documents = [ + {"id": 1, "title": "Test Document 1", "content": "Test content 1"}, + {"id": 2, "title": "Test Document 2", "content": "Test content 2"} + ] + + await self.simulate_tool_call(server, "add-documents", { + "indexUid": test_index, + "documents": test_documents + }) + + # Wait a moment for documents to be indexed + import asyncio + await asyncio.sleep(0.5) + + # Get documents with explicit offset and limit to avoid the None issue + result = await self.simulate_tool_call(server, "get-documents", { + "indexUid": test_index, + "offset": 0, + "limit": 10 + }) + + assert len(result) == 1 + assert result[0].type == "text" + + response_text = result[0].text + + # Issue #16 test: Should NOT contain Python object representation + assert "