├── src └── meilisearch_mcp │ ├── __init__.py │ ├── __version__.py │ ├── __main__.py │ ├── keys.py │ ├── settings.py │ ├── indexes.py │ ├── tasks.py │ ├── monitoring.py │ ├── chat.py │ ├── logging.py │ ├── client.py │ ├── documents.py │ └── server.py ├── requirements-dev.txt ├── tests ├── test_server.py ├── test_user_agent.py ├── test_docker_integration.py ├── test_chat.py └── test_mcp_client.py ├── Dockerfile ├── pyproject.toml ├── .dockerignore ├── LICENSE ├── .github └── workflows │ ├── claude.yml │ ├── test.yml │ └── publish.yml ├── .gitignore ├── CLAUDE.md └── README.md /src/meilisearch_mcp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/meilisearch_mcp/__version__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.5.0" 2 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest>=8.4.1 2 | pytest-asyncio>=1.1.0 3 | black>=25.1.0 4 | mcp>=1.12.4 -------------------------------------------------------------------------------- /src/meilisearch_mcp/__main__.py: -------------------------------------------------------------------------------- 1 | from .server import main 2 | 3 | if __name__ == "__main__": 4 | main() 5 | -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from src.meilisearch_mcp.server import create_server 3 | 4 | 5 | def test_server_creation(): 6 | """Test that we can create a server instance""" 7 | server = create_server() 8 | assert server is not None 9 | assert server.meili_client is not None 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Python 3.12 slim image for smaller size 2 | FROM python:3.13-slim 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Install system dependencies 8 | RUN apt-get update && apt-get dist-upgrade -y && \ 9 | apt-get install -y --no-install-recommends curl && \ 10 | rm -rf /var/lib/apt/lists/* 11 | 12 | # Install uv for faster Python package management 13 | RUN curl -LsSf https://astral.sh/uv/install.sh | sh 14 | ENV PATH="/root/.local/bin:${PATH}" 15 | 16 | # Copy project files 17 | COPY pyproject.toml README.md ./ 18 | COPY src/ ./src/ 19 | 20 | # Install the package 21 | RUN uv pip install --system . 22 | 23 | # Set default environment variables 24 | ENV MEILI_HTTP_ADDR=http://meilisearch:7700 25 | ENV MEILI_MASTER_KEY="" 26 | 27 | # Run the MCP server 28 | CMD ["uv", "run", "python", "-m", "src.meilisearch_mcp"] -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "meilisearch-mcp" 3 | version = "0.6.0" 4 | description = "MCP server for Meilisearch" 5 | requires-python = ">=3.10" 6 | dependencies = [ 7 | "meilisearch>=0.37.0", 8 | "mcp>=1.12.4", 9 | "httpx>=0.28.1", 10 | "pydantic>=2.11.7", 11 | ] 12 | 13 | [build-system] 14 | requires = ["setuptools>=42"] 15 | build-backend = "setuptools.build_meta" 16 | 17 | [project.scripts] 18 | meilisearch-mcp = "meilisearch_mcp.server:main" 19 | 20 | [tool.hatch.build.targets.wheel] 21 | packages = ["src/meilisearch_mcp"] 22 | 23 | [tool.pytest.ini_options] 24 | pythonpath = [ 25 | "." 26 | ] 27 | testpaths = [ 28 | "tests" 29 | ] 30 | asyncio_mode = "auto" 31 | asyncio_default_fixture_loop_scope = "function" 32 | 33 | [tool.black] 34 | line-length = 88 35 | target-version = ['py310'] 36 | include = '\.pyi?$' 37 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Python cache 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Virtual environments 24 | .venv/ 25 | venv/ 26 | ENV/ 27 | env/ 28 | 29 | # Testing 30 | .pytest_cache/ 31 | .coverage 32 | htmlcov/ 33 | .tox/ 34 | .hypothesis/ 35 | 36 | # IDE 37 | .vscode/ 38 | .idea/ 39 | *.swp 40 | *.swo 41 | *~ 42 | 43 | # Git 44 | .git/ 45 | .gitignore 46 | 47 | # Documentation 48 | docs/ 49 | *.md 50 | !README.md 51 | 52 | # CI/CD 53 | .github/ 54 | 55 | # Development files 56 | .env 57 | .env.* 58 | CLAUDE.md 59 | 60 | # Logs 61 | logs/ 62 | *.log 63 | 64 | # macOS 65 | .DS_Store 66 | 67 | # Test data 68 | tests/ 69 | data.ms/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Meilisearch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: write 24 | issues: write 25 | id-token: write 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 1 31 | 32 | - name: Run Claude Code 33 | id: claude 34 | uses: anthropics/claude-code-action@beta 35 | with: 36 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 37 | -------------------------------------------------------------------------------- /tests/test_user_agent.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import patch, MagicMock 3 | from src.meilisearch_mcp.client import MeilisearchClient 4 | from src.meilisearch_mcp.__version__ import __version__ 5 | 6 | 7 | def test_meilisearch_client_sets_custom_user_agent(): 8 | """Test that MeilisearchClient initializes with custom user agent""" 9 | with patch("src.meilisearch_mcp.client.Client") as mock_client: 10 | # Create a MeilisearchClient instance 11 | client = MeilisearchClient(url="http://localhost:7700", api_key="test_key") 12 | 13 | # Verify that Client was called with the correct parameters 14 | mock_client.assert_called_once_with( 15 | "http://localhost:7700", 16 | "test_key", 17 | client_agents=("meilisearch-mcp", f"v{__version__}"), 18 | ) 19 | 20 | 21 | def test_user_agent_includes_correct_version(): 22 | """Test that the user agent includes the correct version from __version__.py""" 23 | with patch("src.meilisearch_mcp.client.Client") as mock_client: 24 | client = MeilisearchClient() 25 | 26 | # Extract the client_agents parameter from the call 27 | call_args = mock_client.call_args 28 | client_agents = call_args[1]["client_agents"] 29 | 30 | # Verify format and version 31 | assert client_agents[0] == "meilisearch-mcp" 32 | assert client_agents[1] == "v0.5.0" 33 | assert client_agents[1] == f"v{__version__}" 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test and Lint 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | push: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | services: 14 | meilisearch: 15 | image: getmeili/meilisearch:v1.16 16 | ports: 17 | - 7700:7700 18 | env: 19 | MEILI_MASTER_KEY: test_master_key 20 | MEILI_ENV: development 21 | options: >- 22 | --health-cmd "curl -f http://localhost:7700/health || exit 1" 23 | --health-interval 30s 24 | --health-timeout 10s 25 | --health-retries 5 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Set up Python 31 | uses: actions/setup-python@v5 32 | with: 33 | python-version: '3.10' 34 | 35 | - name: Install dependencies 36 | run: | 37 | python -m pip install --upgrade pip 38 | pip install -r requirements-dev.txt 39 | pip install -e . 40 | 41 | - name: Format code with black 42 | run: | 43 | black src/ 44 | black tests/ 45 | 46 | - name: Wait for Meilisearch to be ready 47 | run: | 48 | timeout 60 bash -c 'until curl -f http://localhost:7700/health; do sleep 2; done' 49 | 50 | - name: Run tests 51 | env: 52 | MEILI_HTTP_ADDR: http://localhost:7700 53 | MEILI_MASTER_KEY: test_master_key 54 | run: | 55 | pytest tests/ -v -------------------------------------------------------------------------------- /src/meilisearch_mcp/keys.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, List, Optional 2 | from meilisearch import Client 3 | from datetime import datetime 4 | 5 | 6 | class KeyManager: 7 | """Manage Meilisearch API keys""" 8 | 9 | def __init__(self, client: Client): 10 | self.client = client 11 | 12 | def get_keys(self, parameters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: 13 | """Get list of API keys""" 14 | try: 15 | return self.client.get_keys(parameters) 16 | except Exception as e: 17 | raise Exception(f"Failed to get keys: {str(e)}") 18 | 19 | def get_key(self, key: str) -> Dict[str, Any]: 20 | """Get information about a specific key""" 21 | try: 22 | return self.client.get_key(key) 23 | except Exception as e: 24 | raise Exception(f"Failed to get key: {str(e)}") 25 | 26 | def create_key(self, options: Dict[str, Any]) -> Dict[str, Any]: 27 | """Create a new API key""" 28 | try: 29 | return self.client.create_key(options) 30 | except Exception as e: 31 | raise Exception(f"Failed to create key: {str(e)}") 32 | 33 | def update_key(self, key: str, options: Dict[str, Any]) -> Dict[str, Any]: 34 | """Update an existing API key""" 35 | try: 36 | return self.client.update_key(key, options) 37 | except Exception as e: 38 | raise Exception(f"Failed to update key: {str(e)}") 39 | 40 | def delete_key(self, key: str) -> None: 41 | """Delete an API key""" 42 | try: 43 | return self.client.delete_key(key) 44 | except Exception as e: 45 | raise Exception(f"Failed to delete key: {str(e)}") 46 | -------------------------------------------------------------------------------- /src/meilisearch_mcp/settings.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, List, Optional 2 | from meilisearch import Client 3 | from dataclasses import dataclass 4 | 5 | 6 | @dataclass 7 | class SearchSettings: 8 | displayedAttributes: Optional[List[str]] = None 9 | searchableAttributes: Optional[List[str]] = None 10 | filterableAttributes: Optional[List[str]] = None 11 | sortableAttributes: Optional[List[str]] = None 12 | rankingRules: Optional[List[str]] = None 13 | stopWords: Optional[List[str]] = None 14 | synonyms: Optional[Dict[str, List[str]]] = None 15 | distinctAttribute: Optional[str] = None 16 | typoTolerance: Optional[Dict[str, Any]] = None 17 | faceting: Optional[Dict[str, Any]] = None 18 | pagination: Optional[Dict[str, Any]] = None 19 | 20 | 21 | class SettingsManager: 22 | """Manage Meilisearch index settings""" 23 | 24 | def __init__(self, client: Client): 25 | self.client = client 26 | 27 | def get_settings(self, index_uid: str) -> Dict[str, Any]: 28 | """Get all settings for an index""" 29 | try: 30 | index = self.client.index(index_uid) 31 | return index.get_settings() 32 | except Exception as e: 33 | raise Exception(f"Failed to get settings: {str(e)}") 34 | 35 | def update_settings( 36 | self, index_uid: str, settings: Dict[str, Any] 37 | ) -> Dict[str, Any]: 38 | """Update settings for an index""" 39 | try: 40 | index = self.client.index(index_uid) 41 | return index.update_settings(settings) 42 | except Exception as e: 43 | raise Exception(f"Failed to update settings: {str(e)}") 44 | 45 | def reset_settings(self, index_uid: str) -> Dict[str, Any]: 46 | """Reset settings to default values""" 47 | try: 48 | index = self.client.index(index_uid) 49 | return index.reset_settings() 50 | except Exception as e: 51 | raise Exception(f"Failed to reset settings: {str(e)}") 52 | -------------------------------------------------------------------------------- /src/meilisearch_mcp/indexes.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, Optional, List 2 | from dataclasses import dataclass 3 | from meilisearch import Client 4 | 5 | 6 | @dataclass 7 | class IndexConfig: 8 | """Index configuration model""" 9 | 10 | uid: str 11 | primary_key: Optional[str] = None 12 | 13 | 14 | class IndexManager: 15 | """Manage Meilisearch indexes""" 16 | 17 | def __init__(self, client: Client): 18 | self.client = client 19 | 20 | def create_index( 21 | self, uid: str, primary_key: Optional[str] = None 22 | ) -> Dict[str, Any]: 23 | """Create a new index""" 24 | try: 25 | return self.client.create_index(uid, {"primaryKey": primary_key}) 26 | except Exception as e: 27 | raise Exception(f"Failed to create index: {str(e)}") 28 | 29 | def get_index(self, uid: str) -> Dict[str, Any]: 30 | """Get index information""" 31 | try: 32 | return self.client.get_index(uid) 33 | except Exception as e: 34 | raise Exception(f"Failed to get index: {str(e)}") 35 | 36 | def list_indexes(self) -> List[Dict[str, Any]]: 37 | """List all indexes""" 38 | try: 39 | return self.client.get_indexes() 40 | except Exception as e: 41 | raise Exception(f"Failed to list indexes: {str(e)}") 42 | 43 | def delete_index(self, uid: str) -> Dict[str, Any]: 44 | """Delete an index""" 45 | try: 46 | return self.client.delete_index(uid) 47 | except Exception as e: 48 | raise Exception(f"Failed to delete index: {str(e)}") 49 | 50 | def update_index(self, uid: str, primary_key: str) -> Dict[str, Any]: 51 | """Update index primary key""" 52 | try: 53 | return self.client.update_index(uid, {"primaryKey": primary_key}) 54 | except Exception as e: 55 | raise Exception(f"Failed to update index: {str(e)}") 56 | 57 | def swap_indexes(self, indexes: List[List[str]]) -> Dict[str, Any]: 58 | """Swap indexes""" 59 | try: 60 | return self.client.swap_indexes(indexes) 61 | except Exception as e: 62 | raise Exception(f"Failed to swap indexes: {str(e)}") 63 | -------------------------------------------------------------------------------- /src/meilisearch_mcp/tasks.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, List, Optional 2 | from meilisearch import Client 3 | from datetime import datetime 4 | 5 | 6 | def serialize_task_results(obj: Any) -> Any: 7 | """Serialize task results into JSON-compatible format""" 8 | if hasattr(obj, "__dict__"): 9 | return {k: serialize_task_results(v) for k, v in obj.__dict__.items()} 10 | elif isinstance(obj, (list, tuple)): 11 | return [serialize_task_results(item) for item in obj] 12 | elif isinstance(obj, datetime): 13 | return obj.isoformat() 14 | return obj 15 | 16 | 17 | class TaskManager: 18 | def __init__(self, client: Client): 19 | """Initialize TaskManager with Meilisearch client""" 20 | self.client = client 21 | 22 | def get_task(self, task_uid: int) -> Dict[str, Any]: 23 | """Get information about a specific task""" 24 | try: 25 | task = self.client.get_task(task_uid) 26 | return serialize_task_results(task) 27 | except Exception as e: 28 | raise Exception(f"Failed to get task: {str(e)}") 29 | 30 | def get_tasks(self, parameters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: 31 | """Get list of tasks with optional filters""" 32 | try: 33 | tasks = self.client.get_tasks(parameters) 34 | return serialize_task_results(tasks) 35 | except Exception as e: 36 | raise Exception(f"Failed to get tasks: {str(e)}") 37 | 38 | def cancel_tasks(self, query_parameters: Dict[str, Any]) -> Dict[str, Any]: 39 | """Cancel tasks based on query parameters""" 40 | try: 41 | result = self.client.cancel_tasks(query_parameters) 42 | return serialize_task_results(result) 43 | except Exception as e: 44 | raise Exception(f"Failed to cancel tasks: {str(e)}") 45 | 46 | def delete_tasks(self, query_parameters: Dict[str, Any]) -> Dict[str, Any]: 47 | """Delete tasks based on query parameters""" 48 | try: 49 | result = self.client.delete_tasks(query_parameters) 50 | return serialize_task_results(result) 51 | except Exception as e: 52 | raise Exception(f"Failed to delete tasks: {str(e)}") 53 | -------------------------------------------------------------------------------- /tests/test_docker_integration.py: -------------------------------------------------------------------------------- 1 | """ 2 | Integration tests for Docker image build. 3 | 4 | These tests verify that the Docker image can be built successfully. 5 | """ 6 | 7 | import subprocess 8 | import pytest 9 | import shutil 10 | 11 | 12 | # Check if Docker is available 13 | def docker_available(): 14 | """Check if Docker is available on the system.""" 15 | if not shutil.which("docker"): 16 | return False 17 | # Try to run docker version to ensure it's working 18 | try: 19 | result = subprocess.run( 20 | ["docker", "version"], capture_output=True, text=True, timeout=5 21 | ) 22 | return result.returncode == 0 23 | except (subprocess.TimeoutExpired, FileNotFoundError): 24 | return False 25 | 26 | 27 | # Skip all tests in this module if Docker is not available 28 | pytestmark = pytest.mark.skipif( 29 | not docker_available(), reason="Docker not available on this system" 30 | ) 31 | 32 | 33 | def test_docker_build(): 34 | """Test that the Docker image can be built successfully.""" 35 | result = subprocess.run( 36 | ["docker", "build", "-t", "meilisearch-mcp-test", "."], 37 | capture_output=True, 38 | text=True, 39 | ) 40 | assert result.returncode == 0, f"Docker build failed: {result.stderr}" 41 | 42 | 43 | def test_docker_image_runs(): 44 | """Test that the Docker image can run and show help.""" 45 | # First build the image 46 | build_result = subprocess.run( 47 | ["docker", "build", "-t", "meilisearch-mcp-test", "."], 48 | capture_output=True, 49 | text=True, 50 | ) 51 | if build_result.returncode != 0: 52 | pytest.skip(f"Docker build failed: {build_result.stderr}") 53 | 54 | # Try to run the container and check it starts 55 | result = subprocess.run( 56 | [ 57 | "docker", 58 | "run", 59 | "--rm", 60 | "-e", 61 | "MEILI_HTTP_ADDR=http://localhost:7700", 62 | "-e", 63 | "MEILI_MASTER_KEY=test", 64 | "meilisearch-mcp-test", 65 | "python", 66 | "-c", 67 | "import src.meilisearch_mcp; print('MCP module loaded successfully')", 68 | ], 69 | capture_output=True, 70 | text=True, 71 | timeout=30, 72 | ) 73 | 74 | assert result.returncode == 0, f"Docker run failed: {result.stderr}" 75 | assert "MCP module loaded successfully" in result.stdout 76 | 77 | 78 | if __name__ == "__main__": 79 | # Run tests 80 | pytest.main([__file__, "-v"]) 81 | -------------------------------------------------------------------------------- /src/meilisearch_mcp/monitoring.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, List, Optional 2 | from meilisearch import Client 3 | from dataclasses import dataclass 4 | from datetime import datetime 5 | import json 6 | 7 | 8 | @dataclass 9 | class HealthStatus: 10 | """Detailed health status information""" 11 | 12 | is_healthy: bool 13 | database_size: int 14 | last_update: datetime 15 | indexes_count: int 16 | indexes_info: List[Dict[str, Any]] 17 | 18 | 19 | @dataclass 20 | class IndexMetrics: 21 | """Detailed index metrics""" 22 | 23 | number_of_documents: int 24 | field_distribution: Dict[str, int] 25 | is_indexing: bool 26 | index_size: Optional[int] = None 27 | 28 | 29 | class MonitoringManager: 30 | """Enhanced monitoring and statistics for Meilisearch""" 31 | 32 | def __init__(self, client: Client): 33 | self.client = client 34 | 35 | def get_health_status(self) -> HealthStatus: 36 | """Get comprehensive health status""" 37 | try: 38 | # Get various stats to build health picture 39 | stats = self.client.get_all_stats() 40 | indexes = self.client.get_indexes() 41 | 42 | indexes_info = [] 43 | for index in indexes: 44 | index_stats = self.client.index(index.uid).get_stats() 45 | indexes_info.append( 46 | { 47 | "uid": index.uid, 48 | "documents_count": index_stats["numberOfDocuments"], 49 | "is_indexing": index_stats["isIndexing"], 50 | } 51 | ) 52 | 53 | return HealthStatus( 54 | is_healthy=True, 55 | database_size=stats["databaseSize"], 56 | last_update=datetime.fromisoformat( 57 | stats["lastUpdate"].replace("Z", "+00:00") 58 | ), 59 | indexes_count=len(indexes), 60 | indexes_info=indexes_info, 61 | ) 62 | except Exception as e: 63 | raise Exception(f"Failed to get health status: {str(e)}") 64 | 65 | def get_index_metrics(self, index_uid: str) -> IndexMetrics: 66 | """Get detailed metrics for an index""" 67 | try: 68 | index = self.client.index(index_uid) 69 | stats = index.get_stats() 70 | 71 | return IndexMetrics( 72 | number_of_documents=stats["numberOfDocuments"], 73 | field_distribution=stats["fieldDistribution"], 74 | is_indexing=stats["isIndexing"], 75 | index_size=stats.get("indexSize"), 76 | ) 77 | except Exception as e: 78 | raise Exception(f"Failed to get index metrics: {str(e)}") 79 | 80 | def get_system_information(self) -> Dict[str, Any]: 81 | """Get system-level information""" 82 | try: 83 | version = self.client.get_version() 84 | stats = self.client.get_all_stats() 85 | 86 | return { 87 | "version": version, 88 | "database_size": stats["databaseSize"], 89 | "last_update": stats["lastUpdate"], 90 | "indexes": stats["indexes"], 91 | } 92 | except Exception as e: 93 | raise Exception(f"Failed to get system information: {str(e)}") 94 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI and Docker Hub 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | check-version: 10 | runs-on: ubuntu-latest 11 | outputs: 12 | version_changed: ${{ steps.check_version.outputs.version_changed }} 13 | current_version: ${{ steps.get_version.outputs.current_version }} 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 2 18 | 19 | - name: Get current version 20 | id: get_version 21 | run: | 22 | CURRENT_VERSION=$(grep "version = " pyproject.toml | cut -d'"' -f2) 23 | echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT 24 | 25 | - name: Check if version changed 26 | id: check_version 27 | run: | 28 | if git diff HEAD^ HEAD -- pyproject.toml | grep -q "version = "; then 29 | echo "version_changed=true" >> $GITHUB_OUTPUT 30 | else 31 | echo "version_changed=false" >> $GITHUB_OUTPUT 32 | fi 33 | 34 | build-and-publish-pypi: 35 | needs: check-version 36 | if: needs.check-version.outputs.version_changed == 'true' 37 | runs-on: ubuntu-latest 38 | environment: 39 | name: pypi 40 | url: https://pypi.org/p/meilisearch-mcp 41 | permissions: 42 | id-token: write # IMPORTANT: mandatory for trusted publishing 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - name: Set up Python 48 | uses: actions/setup-python@v5 49 | with: 50 | python-version: "3.10" 51 | 52 | - name: Install build dependencies 53 | run: | 54 | python -m pip install --upgrade pip 55 | pip install build 56 | 57 | - name: Build package 58 | run: python -m build 59 | 60 | - name: Publish to PyPI 61 | uses: pypa/gh-action-pypi-publish@release/v1 62 | with: 63 | verbose: true 64 | print-hash: true 65 | 66 | build-and-publish-docker-latest: 67 | runs-on: ubuntu-latest 68 | 69 | steps: 70 | - uses: actions/checkout@v4 71 | 72 | - name: Set up QEMU 73 | uses: docker/setup-qemu-action@v3 74 | 75 | - name: Set up Docker Buildx 76 | uses: docker/setup-buildx-action@v3 77 | 78 | - name: Log in to Docker Hub 79 | uses: docker/login-action@v3 80 | with: 81 | username: ${{ secrets.DOCKERHUB_USERNAME }} 82 | password: ${{ secrets.DOCKERHUB_TOKEN }} 83 | 84 | - name: Build and push latest Docker image 85 | uses: docker/build-push-action@v6 86 | with: 87 | context: . 88 | platforms: linux/amd64,linux/arm64 89 | push: true 90 | tags: getmeili/meilisearch-mcp:latest 91 | cache-from: type=gha 92 | cache-to: type=gha,mode=max 93 | 94 | build-and-publish-docker-versioned: 95 | needs: check-version 96 | if: needs.check-version.outputs.version_changed == 'true' 97 | runs-on: ubuntu-latest 98 | 99 | steps: 100 | - uses: actions/checkout@v4 101 | 102 | - name: Set up QEMU 103 | uses: docker/setup-qemu-action@v3 104 | 105 | - name: Set up Docker Buildx 106 | uses: docker/setup-buildx-action@v3 107 | 108 | - name: Log in to Docker Hub 109 | uses: docker/login-action@v3 110 | with: 111 | username: ${{ secrets.DOCKERHUB_USERNAME }} 112 | password: ${{ secrets.DOCKERHUB_TOKEN }} 113 | 114 | - name: Build and push versioned Docker image 115 | uses: docker/build-push-action@v6 116 | with: 117 | context: . 118 | platforms: linux/amd64,linux/arm64 119 | push: true 120 | tags: getmeili/meilisearch-mcp:${{ needs.check-version.outputs.current_version }} 121 | cache-from: type=gha 122 | cache-to: type=gha,mode=max -------------------------------------------------------------------------------- /src/meilisearch_mcp/chat.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Iterator, List, Optional 2 | 3 | from meilisearch import Client 4 | from meilisearch.errors import MeilisearchApiError 5 | 6 | from .logging import MCPLogger 7 | 8 | logger = MCPLogger() 9 | 10 | 11 | class ChatManager: 12 | def __init__(self, client: Client): 13 | self.client = client 14 | 15 | async def create_chat_completion( 16 | self, 17 | workspace_uid: str, 18 | messages: List[Dict[str, str]], 19 | model: str = "gpt-3.5-turbo", 20 | stream: bool = True, 21 | ) -> str: 22 | try: 23 | logger.info(f"Creating chat completion for workspace: {workspace_uid}") 24 | 25 | # The SDK returns an iterator for streaming responses 26 | response_chunks = [] 27 | for chunk in self.client.create_chat_completion( 28 | workspace_uid=workspace_uid, 29 | messages=messages, 30 | model=model, 31 | stream=stream, 32 | ): 33 | response_chunks.append(chunk) 34 | 35 | # Combine all chunks into a complete response 36 | full_response = self._combine_chunks(response_chunks) 37 | logger.info( 38 | f"Chat completion created successfully for workspace: {workspace_uid}" 39 | ) 40 | return full_response 41 | 42 | except MeilisearchApiError as e: 43 | logger.error(f"Meilisearch API error in create_chat_completion: {e}") 44 | raise 45 | except Exception as e: 46 | logger.error(f"Error in create_chat_completion: {e}") 47 | raise 48 | 49 | def _combine_chunks(self, chunks: List[Dict[str, Any]]) -> str: 50 | """Combine streaming chunks into a single response message.""" 51 | content_parts = [] 52 | for chunk in chunks: 53 | if "choices" in chunk and chunk["choices"]: 54 | choice = chunk["choices"][0] 55 | if "delta" in choice and "content" in choice["delta"]: 56 | content_parts.append(choice["delta"]["content"]) 57 | return "".join(content_parts) 58 | 59 | async def get_chat_workspaces( 60 | self, offset: Optional[int] = None, limit: Optional[int] = None 61 | ) -> Dict[str, Any]: 62 | try: 63 | logger.info(f"Getting chat workspaces (offset={offset}, limit={limit})") 64 | workspaces = self.client.get_chat_workspaces(offset=offset, limit=limit) 65 | logger.info( 66 | f"Retrieved {len(workspaces.get('results', []))} chat workspaces" 67 | ) 68 | return workspaces 69 | except MeilisearchApiError as e: 70 | logger.error(f"Meilisearch API error in get_chat_workspaces: {e}") 71 | raise 72 | except Exception as e: 73 | logger.error(f"Error in get_chat_workspaces: {e}") 74 | raise 75 | 76 | async def get_chat_workspace_settings(self, workspace_uid: str) -> Dict[str, Any]: 77 | try: 78 | logger.info(f"Getting settings for chat workspace: {workspace_uid}") 79 | settings = self.client.get_chat_workspace_settings(workspace_uid) 80 | logger.info(f"Retrieved settings for workspace: {workspace_uid}") 81 | return settings 82 | except MeilisearchApiError as e: 83 | logger.error(f"Meilisearch API error in get_chat_workspace_settings: {e}") 84 | raise 85 | except Exception as e: 86 | logger.error(f"Error in get_chat_workspace_settings: {e}") 87 | raise 88 | 89 | async def update_chat_workspace_settings( 90 | self, workspace_uid: str, settings: Dict[str, Any] 91 | ) -> Dict[str, Any]: 92 | try: 93 | logger.info(f"Updating settings for chat workspace: {workspace_uid}") 94 | updated_settings = self.client.update_chat_workspace_settings( 95 | workspace_uid, settings 96 | ) 97 | logger.info(f"Updated settings for workspace: {workspace_uid}") 98 | return updated_settings 99 | except MeilisearchApiError as e: 100 | logger.error( 101 | f"Meilisearch API error in update_chat_workspace_settings: {e}" 102 | ) 103 | raise 104 | except Exception as e: 105 | logger.error(f"Error in update_chat_workspace_settings: {e}") 106 | raise 107 | -------------------------------------------------------------------------------- /src/meilisearch_mcp/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import json 4 | from datetime import datetime, timezone 5 | from pathlib import Path 6 | from typing import Optional, Dict, Any 7 | import threading 8 | from queue import Queue 9 | import asyncio 10 | 11 | 12 | class AsyncLogHandler: 13 | """Asynchronous log handler with buffering""" 14 | 15 | def __init__(self, max_buffer: int = 1000): 16 | self.buffer = Queue(maxsize=max_buffer) 17 | self.running = True 18 | self.worker_thread = threading.Thread(target=self._worker) 19 | self.worker_thread.daemon = True 20 | self.worker_thread.start() 21 | 22 | def _worker(self): 23 | """Background worker to process logs""" 24 | while self.running: 25 | try: 26 | record = self.buffer.get(timeout=1.0) 27 | self._write_log(record) 28 | except: 29 | continue 30 | 31 | def _write_log(self, record: Dict[str, Any]): 32 | """Write log record to storage""" 33 | raise NotImplementedError 34 | 35 | def emit(self, record: Dict[str, Any]): 36 | """Add log record to buffer""" 37 | try: 38 | self.buffer.put(record, block=False) 39 | except: 40 | pass # Buffer full, skip log 41 | 42 | def shutdown(self): 43 | """Shutdown the handler""" 44 | self.running = False 45 | self.worker_thread.join() 46 | 47 | 48 | class FileLogHandler(AsyncLogHandler): 49 | """File-based log handler""" 50 | 51 | def __init__(self, log_dir: str): 52 | super().__init__() 53 | self.log_dir = Path(log_dir) 54 | self.log_dir.mkdir(parents=True, exist_ok=True) 55 | self.current_file = None 56 | self._rotate_file() 57 | 58 | def _rotate_file(self): 59 | """Rotate log file daily""" 60 | date_str = datetime.now().strftime("%Y-%m-%d") 61 | self.current_file = self.log_dir / f"meilisearch-mcp-{date_str}.log" 62 | 63 | def _write_log(self, record: Dict[str, Any]): 64 | """Write log record to file""" 65 | current_date = datetime.now().strftime("%Y-%m-%d") 66 | if not self.current_file or current_date not in self.current_file.name: 67 | self._rotate_file() 68 | 69 | with open(self.current_file, "a") as f: 70 | f.write(json.dumps(record) + "\n") 71 | 72 | 73 | class MCPLogger: 74 | """Enhanced MCP logger with structured logging""" 75 | 76 | def __init__(self, name: str = "meilisearch-mcp", log_dir: Optional[str] = None): 77 | self.logger = logging.getLogger(name) 78 | self._setup_logger(log_dir) 79 | 80 | def _setup_logger(self, log_dir: Optional[str]): 81 | """Configure logging with multiple handlers""" 82 | if not self.logger.handlers: 83 | # Console handler 84 | console_handler = logging.StreamHandler(sys.stderr) 85 | formatter = logging.Formatter( 86 | "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 87 | ) 88 | console_handler.setFormatter(formatter) 89 | self.logger.addHandler(console_handler) 90 | 91 | # File handler for structured logging 92 | if log_dir: 93 | self.file_handler = FileLogHandler(log_dir) 94 | 95 | self.logger.setLevel(logging.INFO) 96 | 97 | def _log(self, level: str, msg: str, **kwargs): 98 | """Create structured log entry""" 99 | log_entry = { 100 | "timestamp": datetime.now(timezone.utc).isoformat(), 101 | "level": level, 102 | "message": msg, 103 | **kwargs, 104 | } 105 | 106 | # Log to console 107 | getattr(self.logger, level.lower())(msg) 108 | 109 | # Log structured data to file 110 | if hasattr(self, "file_handler"): 111 | self.file_handler.emit(log_entry) 112 | 113 | def debug(self, msg: str, **kwargs): 114 | self._log("DEBUG", msg, **kwargs) 115 | 116 | def info(self, msg: str, **kwargs): 117 | self._log("INFO", msg, **kwargs) 118 | 119 | def warning(self, msg: str, **kwargs): 120 | self._log("WARNING", msg, **kwargs) 121 | 122 | def error(self, msg: str, **kwargs): 123 | self._log("ERROR", msg, **kwargs) 124 | 125 | def shutdown(self): 126 | """Clean shutdown of logger""" 127 | if hasattr(self, "file_handler"): 128 | self.file_handler.shutdown() 129 | -------------------------------------------------------------------------------- /src/meilisearch_mcp/client.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from meilisearch import Client 3 | from typing import Optional, Dict, Any, List 4 | 5 | from .indexes import IndexManager 6 | from .documents import DocumentManager 7 | from .tasks import TaskManager 8 | from .settings import SettingsManager 9 | from .keys import KeyManager 10 | from .logging import MCPLogger 11 | from .monitoring import MonitoringManager 12 | from .__version__ import __version__ 13 | 14 | logger = MCPLogger() 15 | 16 | 17 | class MeilisearchClient: 18 | def __init__( 19 | self, url: str = "http://localhost:7700", api_key: Optional[str] = None 20 | ): 21 | """Initialize Meilisearch client""" 22 | self.url = url 23 | self.api_key = api_key 24 | # Add custom user agent to identify this as Meilisearch MCP 25 | self.client = Client( 26 | url, api_key, client_agents=("meilisearch-mcp", f"v{__version__}") 27 | ) 28 | self.indexes = IndexManager(self.client) 29 | self.documents = DocumentManager(self.client) 30 | self.settings = SettingsManager(self.client) 31 | self.tasks = TaskManager(self.client) 32 | self.keys = KeyManager(self.client) 33 | self.monitoring = MonitoringManager(self.client) 34 | 35 | def health_check(self) -> bool: 36 | """Check if Meilisearch is healthy""" 37 | try: 38 | response = self.client.health() 39 | return response.get("status") == "available" 40 | except Exception: 41 | return False 42 | 43 | def get_version(self) -> Dict[str, Any]: 44 | """Get Meilisearch version information""" 45 | return self.client.get_version() 46 | 47 | def get_stats(self) -> Dict[str, Any]: 48 | """Get database stats""" 49 | return self.client.get_all_stats() 50 | 51 | def search( 52 | self, 53 | query: str, 54 | index_uid: Optional[str] = None, 55 | limit: Optional[int] = 20, 56 | offset: Optional[int] = 0, 57 | filter: Optional[str] = None, 58 | sort: Optional[List[str]] = None, 59 | **kwargs, 60 | ) -> Dict[str, Any]: 61 | """ 62 | Search through Meilisearch indices. 63 | If index_uid is provided, search in that specific index. 64 | If not provided, search across all available indices. 65 | """ 66 | try: 67 | # Prepare search parameters, removing None values 68 | search_params = { 69 | "limit": limit if limit is not None else 20, 70 | "offset": offset if offset is not None else 0, 71 | } 72 | 73 | if filter is not None: 74 | search_params["filter"] = filter 75 | if sort is not None: 76 | search_params["sort"] = sort 77 | 78 | # Add any additional parameters 79 | search_params.update({k: v for k, v in kwargs.items() if v is not None}) 80 | 81 | if index_uid: 82 | # Search in specific index 83 | index = self.client.index(index_uid) 84 | return index.search(query, search_params) 85 | else: 86 | # Search across all indices 87 | results = {} 88 | indexes = self.client.get_indexes() 89 | 90 | for index in indexes["results"]: 91 | try: 92 | search_result = index.search(query, search_params) 93 | if search_result["hits"]: # Only include indices with matches 94 | results[index.uid] = search_result 95 | except Exception as e: 96 | logger.warning(f"Failed to search index {index.uid}: {str(e)}") 97 | continue 98 | 99 | return {"multi_index": True, "query": query, "results": results} 100 | 101 | except Exception as e: 102 | raise Exception(f"Search failed: {str(e)}") 103 | 104 | def get_indexes(self) -> Dict[str, Any]: 105 | """Get all indexes""" 106 | indexes = self.client.get_indexes() 107 | # Convert Index objects to serializable dictionaries 108 | serialized_indexes = [] 109 | for index in indexes["results"]: 110 | serialized_indexes.append( 111 | { 112 | "uid": index.uid, 113 | "primaryKey": index.primary_key, 114 | "createdAt": index.created_at, 115 | "updatedAt": index.updated_at, 116 | } 117 | ) 118 | 119 | return { 120 | "results": serialized_indexes, 121 | "offset": indexes["offset"], 122 | "limit": indexes["limit"], 123 | "total": indexes["total"], 124 | } 125 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/python,macos 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,macos 3 | 4 | ### macOS ### 5 | # General 6 | .DS_Store 7 | .AppleDouble 8 | .LSOverride 9 | 10 | # Icon must end with two \r 11 | Icon 12 | 13 | 14 | # Thumbnails 15 | ._* 16 | 17 | # Files that might appear in the root of a volume 18 | .DocumentRevisions-V100 19 | .fseventsd 20 | .Spotlight-V100 21 | .TemporaryItems 22 | .Trashes 23 | .VolumeIcon.icns 24 | .com.apple.timemachine.donotpresent 25 | 26 | # Directories potentially created on remote AFP share 27 | .AppleDB 28 | .AppleDesktop 29 | Network Trash Folder 30 | Temporary Items 31 | .apdisk 32 | 33 | ### macOS Patch ### 34 | # iCloud generated files 35 | *.icloud 36 | 37 | ### Python ### 38 | # Byte-compiled / optimized / DLL files 39 | __pycache__/ 40 | *.py[cod] 41 | *$py.class 42 | 43 | # C extensions 44 | *.so 45 | 46 | # Distribution / packaging 47 | .Python 48 | build/ 49 | develop-eggs/ 50 | dist/ 51 | downloads/ 52 | eggs/ 53 | .eggs/ 54 | lib/ 55 | lib64/ 56 | parts/ 57 | sdist/ 58 | var/ 59 | wheels/ 60 | share/python-wheels/ 61 | *.egg-info/ 62 | .installed.cfg 63 | *.egg 64 | MANIFEST 65 | 66 | # PyInstaller 67 | # Usually these files are written by a python script from a template 68 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 69 | *.manifest 70 | *.spec 71 | 72 | # Installer logs 73 | pip-log.txt 74 | pip-delete-this-directory.txt 75 | 76 | # Unit test / coverage reports 77 | htmlcov/ 78 | .tox/ 79 | .nox/ 80 | .coverage 81 | .coverage.* 82 | .cache 83 | nosetests.xml 84 | coverage.xml 85 | *.cover 86 | *.py,cover 87 | .hypothesis/ 88 | .pytest_cache/ 89 | cover/ 90 | 91 | # Translations 92 | *.mo 93 | *.pot 94 | 95 | # Django stuff: 96 | *.log 97 | local_settings.py 98 | db.sqlite3 99 | db.sqlite3-journal 100 | 101 | # Flask stuff: 102 | instance/ 103 | .webassets-cache 104 | 105 | # Scrapy stuff: 106 | .scrapy 107 | 108 | # Sphinx documentation 109 | docs/_build/ 110 | 111 | # PyBuilder 112 | .pybuilder/ 113 | target/ 114 | 115 | # Jupyter Notebook 116 | .ipynb_checkpoints 117 | 118 | # IPython 119 | profile_default/ 120 | ipython_config.py 121 | 122 | # pyenv 123 | # For a library or package, you might want to ignore these files since the code is 124 | # intended to run in multiple environments; otherwise, check them in: 125 | # .python-version 126 | 127 | # pipenv 128 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 129 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 130 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 131 | # install all needed dependencies. 132 | #Pipfile.lock 133 | 134 | # poetry 135 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 136 | # This is especially recommended for binary packages to ensure reproducibility, and is more 137 | # commonly ignored for libraries. 138 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 139 | #poetry.lock 140 | 141 | # pdm 142 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 143 | #pdm.lock 144 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 145 | # in version control. 146 | # https://pdm.fming.dev/#use-with-ide 147 | .pdm.toml 148 | 149 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 150 | __pypackages__/ 151 | 152 | # Celery stuff 153 | celerybeat-schedule 154 | celerybeat.pid 155 | 156 | # SageMath parsed files 157 | *.sage.py 158 | 159 | # Environments 160 | .env 161 | .venv 162 | env/ 163 | venv/ 164 | ENV/ 165 | env.bak/ 166 | venv.bak/ 167 | 168 | # Spyder project settings 169 | .spyderproject 170 | .spyproject 171 | 172 | # Rope project settings 173 | .ropeproject 174 | 175 | # mkdocs documentation 176 | /site 177 | 178 | # mypy 179 | .mypy_cache/ 180 | .dmypy.json 181 | dmypy.json 182 | 183 | # Pyre type checker 184 | .pyre/ 185 | 186 | # pytype static type analyzer 187 | .pytype/ 188 | 189 | # Cython debug symbols 190 | cython_debug/ 191 | 192 | # PyCharm 193 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 194 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 195 | # and can be added to the global gitignore or merged into this file. For a more nuclear 196 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 197 | #.idea/ 198 | 199 | ### Python Patch ### 200 | # Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration 201 | poetry.toml 202 | 203 | # ruff 204 | .ruff_cache/ 205 | 206 | # LSP config files 207 | pyrightconfig.json 208 | 209 | # End of https://www.toptal.com/developers/gitignore/api/python,macos 210 | .env 211 | 212 | # Meilisearch data directory 213 | data.ms/ 214 | -------------------------------------------------------------------------------- /src/meilisearch_mcp/documents.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, List, Optional, Union 2 | from meilisearch import Client 3 | 4 | 5 | class DocumentManager: 6 | """Manage documents within Meilisearch indexes""" 7 | 8 | def __init__(self, client: Client): 9 | self.client = client 10 | 11 | def get_documents( 12 | self, 13 | index_uid: str, 14 | offset: Optional[int] = None, 15 | limit: Optional[int] = None, 16 | fields: Optional[List[str]] = None, 17 | ) -> Dict[str, Any]: 18 | """Get documents from an index""" 19 | try: 20 | index = self.client.index(index_uid) 21 | # Build parameters dict, excluding None values to avoid API errors 22 | params = {} 23 | if offset is not None: 24 | params["offset"] = offset 25 | if limit is not None: 26 | params["limit"] = limit 27 | if fields is not None: 28 | params["fields"] = fields 29 | 30 | result = index.get_documents(params if params else {}) 31 | 32 | # Convert meilisearch model objects to JSON-serializable format 33 | if hasattr(result, "__dict__"): 34 | result_dict = result.__dict__.copy() 35 | # Convert individual document objects in results if they exist 36 | if "results" in result_dict and isinstance( 37 | result_dict["results"], list 38 | ): 39 | serialized_results = [] 40 | for doc in result_dict["results"]: 41 | if hasattr(doc, "__dict__"): 42 | # Extract the actual document data 43 | doc_dict = doc.__dict__.copy() 44 | # Look for private attributes that might contain the actual data 45 | for key, value in doc_dict.items(): 46 | if key.startswith("_") and isinstance(value, dict): 47 | # Use the dict content instead of the wrapper 48 | serialized_results.append(value) 49 | break 50 | else: 51 | # If no private dict found, use the object dict directly 52 | serialized_results.append(doc_dict) 53 | else: 54 | serialized_results.append(doc) 55 | result_dict["results"] = serialized_results 56 | return result_dict 57 | else: 58 | return result 59 | except Exception as e: 60 | raise Exception(f"Failed to get documents: {str(e)}") 61 | 62 | def get_document( 63 | self, index_uid: str, document_id: Union[str, int] 64 | ) -> Dict[str, Any]: 65 | """Get a single document""" 66 | try: 67 | index = self.client.index(index_uid) 68 | return index.get_document(document_id) 69 | except Exception as e: 70 | raise Exception(f"Failed to get document: {str(e)}") 71 | 72 | def add_documents( 73 | self, 74 | index_uid: str, 75 | documents: List[Dict[str, Any]], 76 | primary_key: Optional[str] = None, 77 | ) -> Dict[str, Any]: 78 | """Add documents to an index""" 79 | try: 80 | index = self.client.index(index_uid) 81 | return index.add_documents(documents, primary_key) 82 | except Exception as e: 83 | raise Exception(f"Failed to add documents: {str(e)}") 84 | 85 | def update_documents( 86 | self, index_uid: str, documents: List[Dict[str, Any]] 87 | ) -> Dict[str, Any]: 88 | """Update documents in an index""" 89 | try: 90 | index = self.client.index(index_uid) 91 | return index.update_documents(documents) 92 | except Exception as e: 93 | raise Exception(f"Failed to update documents: {str(e)}") 94 | 95 | def delete_document( 96 | self, index_uid: str, document_id: Union[str, int] 97 | ) -> Dict[str, Any]: 98 | """Delete a single document""" 99 | try: 100 | index = self.client.index(index_uid) 101 | return index.delete_document(document_id) 102 | except Exception as e: 103 | raise Exception(f"Failed to delete document: {str(e)}") 104 | 105 | def delete_documents( 106 | self, index_uid: str, document_ids: List[Union[str, int]] 107 | ) -> Dict[str, Any]: 108 | """Delete multiple documents by ID""" 109 | try: 110 | index = self.client.index(index_uid) 111 | return index.delete_documents(document_ids) 112 | except Exception as e: 113 | raise Exception(f"Failed to delete documents: {str(e)}") 114 | 115 | def delete_all_documents(self, index_uid: str) -> Dict[str, Any]: 116 | """Delete all documents in an index""" 117 | try: 118 | index = self.client.index(index_uid) 119 | return index.delete_all_documents() 120 | except Exception as e: 121 | raise Exception(f"Failed to delete all documents: {str(e)}") 122 | -------------------------------------------------------------------------------- /tests/test_chat.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from datetime import datetime 3 | from unittest.mock import MagicMock, Mock 4 | from meilisearch.errors import MeilisearchApiError 5 | 6 | from src.meilisearch_mcp.server import MeilisearchMCPServer 7 | 8 | 9 | @pytest.fixture 10 | def server(): 11 | return MeilisearchMCPServer(url="http://localhost:7700", api_key="test_key") 12 | 13 | 14 | @pytest.fixture 15 | def setup_mock_chat_client(server): 16 | """Mock the Meilisearch client for chat-related methods""" 17 | # Create a mock client with chat methods 18 | mock_client = MagicMock() 19 | 20 | # Mock create_chat_completion to return an iterator 21 | def mock_chat_completion(*args, **kwargs): 22 | # Simulate streaming response chunks 23 | chunks = [ 24 | {"choices": [{"delta": {"content": "This is "}}]}, 25 | {"choices": [{"delta": {"content": "a test "}}]}, 26 | {"choices": [{"delta": {"content": "response."}}]}, 27 | ] 28 | for chunk in chunks: 29 | yield chunk 30 | 31 | mock_client.create_chat_completion = mock_chat_completion 32 | 33 | # Mock get_chat_workspaces 34 | mock_client.get_chat_workspaces.return_value = { 35 | "results": [ 36 | {"uid": "workspace1", "name": "Customer Support"}, 37 | {"uid": "workspace2", "name": "Documentation"}, 38 | ], 39 | "limit": 10, 40 | "offset": 0, 41 | "total": 2, 42 | } 43 | 44 | # Mock get_chat_workspace_settings 45 | mock_client.get_chat_workspace_settings.return_value = { 46 | "model": "gpt-3.5-turbo", 47 | "indexUids": ["products", "docs"], 48 | "temperature": 0.7, 49 | } 50 | 51 | # Mock update_chat_workspace_settings 52 | mock_client.update_chat_workspace_settings.return_value = { 53 | "model": "gpt-4", 54 | "indexUids": ["products", "docs"], 55 | "temperature": 0.5, 56 | } 57 | 58 | # Replace the chat manager's client 59 | server.chat_manager.client = mock_client 60 | return server 61 | 62 | 63 | async def simulate_tool_call(server, tool_name, arguments=None): 64 | """Simulate a tool call directly on the server""" 65 | handlers = {} 66 | 67 | # Get the tool handler 68 | @server.server.call_tool() 69 | async def handle_call_tool(name, arguments_=None): 70 | pass 71 | 72 | # The handler is registered, now call it directly 73 | result = await server._setup_handlers.__code__.co_consts[1](tool_name, arguments) 74 | 75 | # Actually call the handler through the server's method 76 | handler_func = None 77 | for name, obj in server.__class__.__dict__.items(): 78 | if hasattr(obj, "__name__") and obj.__name__ == "handle_call_tool": 79 | handler_func = obj 80 | break 81 | 82 | # Call the actual handle_call_tool method 83 | return await server._MeilisearchMCPServer__handle_call_tool(tool_name, arguments) 84 | 85 | 86 | class TestChatTools: 87 | @pytest.mark.asyncio 88 | async def test_create_chat_completion(self, setup_mock_chat_client): 89 | """Test creating a chat completion""" 90 | server = setup_mock_chat_client 91 | 92 | # Simulate the tool call 93 | result = await server.chat_manager.create_chat_completion( 94 | workspace_uid="test-workspace", 95 | messages=[ 96 | {"role": "user", "content": "What is Meilisearch?"}, 97 | ], 98 | model="gpt-3.5-turbo", 99 | stream=True, 100 | ) 101 | 102 | # The result should be the combined response 103 | assert result == "This is a test response." 104 | 105 | @pytest.mark.asyncio 106 | async def test_get_chat_workspaces(self, setup_mock_chat_client): 107 | """Test getting chat workspaces""" 108 | server = setup_mock_chat_client 109 | 110 | result = await server.chat_manager.get_chat_workspaces(offset=0, limit=10) 111 | 112 | assert "results" in result 113 | assert len(result["results"]) == 2 114 | assert result["results"][0]["uid"] == "workspace1" 115 | assert result["total"] == 2 116 | 117 | @pytest.mark.asyncio 118 | async def test_get_chat_workspace_settings(self, setup_mock_chat_client): 119 | """Test getting chat workspace settings""" 120 | server = setup_mock_chat_client 121 | 122 | result = await server.chat_manager.get_chat_workspace_settings( 123 | workspace_uid="workspace1" 124 | ) 125 | 126 | assert result["model"] == "gpt-3.5-turbo" 127 | assert "indexUids" in result 128 | assert result["temperature"] == 0.7 129 | 130 | @pytest.mark.asyncio 131 | async def test_update_chat_workspace_settings(self, setup_mock_chat_client): 132 | """Test updating chat workspace settings""" 133 | server = setup_mock_chat_client 134 | 135 | result = await server.chat_manager.update_chat_workspace_settings( 136 | workspace_uid="workspace1", 137 | settings={"model": "gpt-4", "temperature": 0.5}, 138 | ) 139 | 140 | assert result["model"] == "gpt-4" 141 | assert result["temperature"] == 0.5 142 | 143 | @pytest.mark.asyncio 144 | async def test_chat_completion_error_handling(self, server): 145 | """Test error handling in chat completion""" 146 | # Mock the client to raise an error 147 | server.chat_manager.client = MagicMock() 148 | # Create a mock request object for the error with proper JSON text 149 | mock_request = MagicMock() 150 | mock_request.status_code = 400 151 | mock_request.text = '{"message": "Chat feature not enabled", "code": "chat_not_enabled", "type": "invalid_request", "link": "https://docs.meilisearch.com/errors#chat_not_enabled"}' 152 | server.chat_manager.client.create_chat_completion.side_effect = ( 153 | MeilisearchApiError("Chat feature not enabled", mock_request) 154 | ) 155 | 156 | with pytest.raises(MeilisearchApiError): 157 | await server.chat_manager.create_chat_completion( 158 | workspace_uid="test", 159 | messages=[{"role": "user", "content": "test"}], 160 | ) 161 | 162 | @pytest.mark.asyncio 163 | async def test_empty_chat_response(self, server): 164 | """Test handling empty chat response""" 165 | # Mock empty response 166 | server.chat_manager.client = MagicMock() 167 | 168 | def mock_empty_completion(*args, **kwargs): 169 | # Return empty chunks 170 | return iter([]) 171 | 172 | server.chat_manager.client.create_chat_completion = mock_empty_completion 173 | 174 | result = await server.chat_manager.create_chat_completion( 175 | workspace_uid="test", 176 | messages=[{"role": "user", "content": "test"}], 177 | ) 178 | 179 | assert result == "" # Empty response should return empty string 180 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## 🚨 IMPORTANT: Development Workflow Guidelines 6 | 7 | **ALL coding agents MUST follow these mandatory guidelines for every task.** 8 | 9 | ### 🔄 Fresh Start Protocol 10 | 11 | **BEFORE starting ANY new task or issue:** 12 | 13 | 1. **Always start from latest main**: 14 | ```bash 15 | git checkout main 16 | git pull origin main # Ensure you have the latest changes 17 | git checkout -b feature/your-branch-name # Create new branch 18 | ``` 19 | 20 | 2. **Verify clean state**: 21 | ```bash 22 | git status # Should show clean working directory 23 | ``` 24 | 25 | 3. **Never carry over unrelated changes** from previous work 26 | 4. **Each task gets its own focused branch** from latest main 27 | 28 | ### 🎯 Focused Development Rules 29 | 30 | **ONLY make changes directly related to the specific task/issue:** 31 | 32 | - ✅ **DO**: Add/modify code that solves the specific issue 33 | - ✅ **DO**: Add focused tests for the specific functionality 34 | - ✅ **DO**: Update documentation if specifically required 35 | - ❌ **DON'T**: Include formatting changes to unrelated files 36 | - ❌ **DON'T**: Add comprehensive test suites unless specifically requested 37 | - ❌ **DON'T**: Refactor unrelated code 38 | - ❌ **DON'T**: Include previous work from other branches 39 | 40 | ### 📋 Task Assessment Phase 41 | 42 | Before writing any code, determine scope: 43 | 44 | 1. **Read the issue/task carefully** - understand exact requirements 45 | 2. **Identify minimal changes needed** - what files need modification? 46 | 3. **Plan focused tests** - only for the specific functionality being added 47 | 4. **Avoid scope creep** - resist urge to "improve" unrelated code 48 | 49 | ### 🧪 Test-Driven Development (TDD) Approach 50 | 51 | When tests are required for the specific task: 52 | 53 | ```bash 54 | # 1. Write failing tests FIRST (focused on the issue) 55 | python -m pytest tests/test_specific_issue.py -v # Should fail 56 | 57 | # 2. Write minimal code to make tests pass 58 | # Edit ONLY files needed for the specific issue 59 | 60 | # 3. Run tests to verify they pass 61 | python -m pytest tests/test_specific_issue.py -v # Should pass 62 | 63 | # 4. Refactor if needed, but stay focused 64 | ``` 65 | 66 | ### 📝 Commit Standards 67 | 68 | **Each commit should be atomic and focused:** 69 | 70 | ```bash 71 | # Format only the files you changed 72 | black src/specific_file.py tests/test_specific_file.py 73 | 74 | # Run tests to ensure no regressions 75 | python -m pytest tests/ -v 76 | 77 | # Commit with descriptive message 78 | git add src/specific_file.py tests/test_specific_file.py 79 | git commit -m "Fix issue #X: Brief description of what was fixed" 80 | ``` 81 | 82 | ### 🚫 What NOT to Include in PRs 83 | 84 | - Formatting changes to files you didn't functionally modify 85 | - Test files not related to your specific task 86 | - Refactoring of unrelated code 87 | - Documentation updates not specifically requested 88 | - Code from previous branches or incomplete work 89 | 90 | ### ✅ PR Quality Checklist 91 | 92 | Before creating PR, verify: 93 | - [ ] Branch created from latest main 94 | - [ ] Only files related to the specific issue are modified 95 | - [ ] Tests pass and are focused on the issue 96 | - [ ] Commit messages are clear and specific 97 | - [ ] No unrelated formatting or code changes 98 | - [ ] PR description clearly links to the issue being solved 99 | 100 | **⚠️ PRs with unrelated changes will be rejected and must be redone.** 101 | 102 | ## Project Overview 103 | 104 | This is a **Model Context Protocol (MCP) server** for Meilisearch, allowing LLM interfaces like Claude to interact with Meilisearch search engines. The project implements a Python-based MCP server that provides comprehensive tools for index management, document operations, search functionality, and system monitoring. 105 | 106 | ## Development Commands 107 | 108 | ### Environment Setup 109 | ```bash 110 | # Create virtual environment and install dependencies 111 | uv venv 112 | source .venv/bin/activate # On Windows: .venv\Scripts\activate 113 | uv pip install -e . 114 | 115 | # Install development dependencies 116 | uv pip install -r requirements-dev.txt 117 | ``` 118 | 119 | ### Testing (MANDATORY for all development) 120 | ```bash 121 | # Run all tests (required before any commit) 122 | python -m pytest tests/ -v 123 | 124 | # Run specific test file 125 | python -m pytest tests/test_mcp_client.py -v 126 | 127 | # Run tests with coverage (required for new features) 128 | python -m pytest --cov=src tests/ 129 | 130 | # Watch mode for development (optional) 131 | pytest-watch tests/ 132 | ``` 133 | 134 | ### Code Quality (MANDATORY before commit) 135 | ```bash 136 | # Format code (required before commit) 137 | black src/ tests/ 138 | 139 | # Check formatting without applying 140 | black --check src/ tests/ 141 | 142 | # Run the MCP server locally for testing 143 | python -m src.meilisearch_mcp 144 | 145 | # Test with MCP Inspector 146 | npx @modelcontextprotocol/inspector python -m src.meilisearch_mcp 147 | ``` 148 | 149 | ### Prerequisites for Testing 150 | - **Meilisearch server** must be running on `http://localhost:7700` 151 | - **Docker option**: `docker run -d -p 7700:7700 getmeili/meilisearch:v1.6` 152 | - **Node.js** for MCP Inspector testing 153 | 154 | ## Architecture 155 | 156 | ### Modular Manager Design 157 | The codebase follows a modular architecture where functionality is organized into specialized managers: 158 | 159 | ``` 160 | MeilisearchClient 161 | ├── IndexManager - Index creation, listing, deletion 162 | ├── DocumentManager - Document CRUD operations 163 | ├── SettingsManager - Index configuration management 164 | ├── TaskManager - Asynchronous task monitoring 165 | ├── KeyManager - API key management 166 | └── MonitoringManager - Health checks and system metrics 167 | ``` 168 | 169 | ### MCP Server Integration 170 | - **Server Class**: `MeilisearchMCPServer` in `server.py` handles MCP protocol communication 171 | - **Tool Registration**: All tools are defined with JSON schemas for input validation 172 | - **Error Handling**: Comprehensive exception handling with logging through `MCPLogger` 173 | - **Dynamic Configuration**: Runtime connection settings updates via MCP tools 174 | 175 | ### Key Components 176 | 177 | #### Tool Handler Pattern 178 | All MCP tools follow a consistent pattern: 179 | 1. Input validation via JSON schema 180 | 2. Delegation to appropriate manager class 181 | 3. Error handling with structured logging 182 | 4. Formatted response as `TextContent` 183 | 184 | #### Search Architecture 185 | - **Single Index Search**: Direct search in specified index 186 | - **Multi-Index Search**: Parallel search across all indices when no `indexUid` provided 187 | - **Result Aggregation**: Smart filtering of results with hits 188 | 189 | #### Connection Management 190 | - **Runtime Configuration**: Dynamic URL and API key updates 191 | - **Environment Variables**: `MEILI_HTTP_ADDR` and `MEILI_MASTER_KEY` for defaults 192 | - **Connection State**: Maintained in server instance for session persistence 193 | 194 | ## Testing Strategy 195 | 196 | ### Test Structure 197 | - **Integration Tests** (`test_mcp_client.py`): End-to-end MCP tool execution with real Meilisearch 198 | - **Unit Tests** (`test_server.py`): Basic server instantiation and configuration 199 | 200 | ### Test Categories by Development Task 201 | 202 | #### When Tests are REQUIRED: 203 | - **New MCP Tools**: Add tests to `test_mcp_client.py` using `simulate_tool_call()` 204 | - **Existing Tool Changes**: Update corresponding test methods 205 | - **Manager Class Changes**: Test through MCP tool integration 206 | - **Bug Fixes**: Add regression tests to prevent reoccurrence 207 | - **API Changes**: Update tests to reflect new interfaces 208 | 209 | #### When Tests are OPTIONAL: 210 | - **Documentation Updates**: README.md, CLAUDE.md changes 211 | - **Code Formatting**: Black formatting, comment changes 212 | - **Minor Refactoring**: Internal reorganization without behavior changes 213 | 214 | ### Tool Simulation Framework 215 | Tests use `simulate_tool_call()` function that: 216 | - Directly invokes server tool handlers 217 | - Bypasses MCP protocol overhead 218 | - Returns proper `TextContent` responses 219 | - Provides comprehensive coverage of all 20+ tools 220 | - Enables fast test execution without MCP protocol complexity 221 | 222 | ### Test Isolation and Best Practices 223 | - **Unique Index Names**: Timestamped index names prevent test interference 224 | - **Cleanup Fixtures**: Automatic test environment cleanup after each test 225 | - **Service Dependencies**: Tests require running Meilisearch instance 226 | - **Test Naming**: Use descriptive test method names (e.g., `test_create_index_with_primary_key`) 227 | - **Assertions**: Test both success cases and error handling 228 | - **Coverage**: New tools must have comprehensive test coverage 229 | - **Embedder Tests**: Tests requiring Meilisearch embedder configuration should be marked with `@pytest.mark.skip` decorator 230 | 231 | ## Environment Configuration 232 | 233 | ### Required Environment Variables 234 | ```bash 235 | MEILI_HTTP_ADDR=http://localhost:7700 # Meilisearch server URL 236 | MEILI_MASTER_KEY=your_master_key # Optional: API key for authenticated instances 237 | ``` 238 | 239 | ### Claude Desktop Integration 240 | Add to `claude_desktop_config.json`: 241 | ```json 242 | { 243 | "mcpServers": { 244 | "meilisearch": { 245 | "command": "uvx", 246 | "args": ["-n", "meilisearch-mcp"] 247 | } 248 | } 249 | } 250 | ``` 251 | 252 | ### GitHub Actions Integration 253 | The repository includes Claude Code integration via GitHub Actions: 254 | - **Trigger**: Comments containing `@claude` on issues, PRs, or reviews 255 | - **Workflow**: `.github/workflows/claude.yml` handles automated Claude responses 256 | - **Permissions**: Read contents, write to pull requests and issues 257 | 258 | ## Available MCP Tools 259 | 260 | ### Core Categories 261 | - **Connection Management**: Dynamic configuration updates 262 | - **Index Operations**: CRUD operations for search indices 263 | - **Document Management**: Add, retrieve, and manage documents 264 | - **Search Capabilities**: Single and multi-index search with filtering 265 | - **Settings Control**: Index configuration and optimization 266 | - **Task Monitoring**: Asynchronous operation tracking 267 | - **API Key Management**: Authentication and authorization 268 | - **System Monitoring**: Health checks and performance metrics 269 | 270 | ### Search Tool Features 271 | - **Flexible Targeting**: Search specific index or all indices 272 | - **Rich Parameters**: Filtering, sorting, pagination support 273 | - **Hybrid Search**: Support for combining keyword and semantic search with `semanticRatio` parameter 274 | - **Vector Search**: Custom vector support for semantic similarity search 275 | - **Result Formatting**: JSON formatted responses with proper serialization 276 | - **Error Resilience**: Graceful handling of index-specific failures 277 | 278 | ## Development Notes 279 | 280 | ### Dependencies 281 | - **MCP Framework**: `mcp>=1.2.1` for protocol implementation 282 | - **Meilisearch Client**: `meilisearch>=0.34.0` for search engine integration with stable AI-powered search features 283 | - **HTTP Client**: `httpx>=0.24.0` for async HTTP operations 284 | - **Data Validation**: `pydantic>=2.0.0` for structured data handling 285 | 286 | ### Logging Infrastructure 287 | - **Structured Logging**: JSON-formatted logs with contextual information 288 | - **Log Directory**: `~/.meilisearch-mcp/logs/` for persistent logging 289 | - **Error Tracking**: Comprehensive error logging with tool context 290 | 291 | ### Hybrid Search Implementation 292 | - **Dependency**: Requires `meilisearch>=0.34.0` for stable AI-powered search features 293 | - **Parameters**: `hybrid` object with `semanticRatio` (0.0-1.0) and `embedder` (required) 294 | - **Vector Support**: Custom vectors can be provided via `vector` parameter 295 | - **Testing**: Hybrid search tests require embedder configuration in Meilisearch 296 | - **Backward Compatibility**: All hybrid search parameters are optional to maintain compatibility -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Meilisearch 3 |
4 | 5 |

Meilisearch MCP Server

6 | 7 |

8 | Meilisearch | 9 | Meilisearch Cloud | 10 | Documentation | 11 | Discord 12 |

13 | 14 |

15 | PyPI version 16 | Python Versions 17 | Tests 18 | License 19 | Downloads 20 |

21 | 22 |

⚡ Connect any LLM to Meilisearch and supercharge your AI with lightning-fast search capabilities! 🔍

23 | 24 | ## 🤔 What is this? 25 | 26 | The Meilisearch MCP Server is a Model Context Protocol server that enables any MCP-compatible client (including Claude, OpenAI agents, and other LLMs) to interact with Meilisearch. This stdio-based server allows AI assistants to manage search indices, perform searches, and handle your data through natural conversation. 27 | 28 | **Why use this?** 29 | - 🤖 **Universal Compatibility** - Works with any MCP client, not just Claude 30 | - 🗣️ **Natural Language Control** - Manage Meilisearch through conversation with any LLM 31 | - 🚀 **Zero Learning Curve** - No need to learn Meilisearch's API 32 | - 🔧 **Full Feature Access** - All Meilisearch capabilities at your fingertips 33 | - 🔄 **Dynamic Connections** - Switch between Meilisearch instances on the fly 34 | - 📡 **stdio Transport** - Currently uses stdio; native Meilisearch MCP support coming soon! 35 | 36 | ## ✨ Key Features 37 | 38 | - 📊 **Index & Document Management** - Create, update, and manage search indices 39 | - 🔍 **Smart Search** - Search across single or multiple indices with advanced filtering 40 | - ⚙️ **Settings Configuration** - Fine-tune search relevancy and performance 41 | - 📈 **Task Monitoring** - Track indexing progress and system operations 42 | - 🔐 **API Key Management** - Secure access control 43 | - 🏥 **Health Monitoring** - Keep tabs on your Meilisearch instance 44 | - 🐍 **Python Implementation** - [TypeScript version also available](https://github.com/devlimelabs/meilisearch-ts-mcp) 45 | 46 | ## 🚀 Quick Start 47 | 48 | Get up and running in just 3 steps! 49 | 50 | ### 1️⃣ Install the package 51 | 52 | ```bash 53 | # Using pip 54 | pip install meilisearch-mcp 55 | 56 | # Or using uvx (recommended) 57 | uvx -n meilisearch-mcp 58 | ``` 59 | 60 | ### 2️⃣ Configure Claude Desktop 61 | 62 | Add this to your `claude_desktop_config.json`: 63 | 64 | ```json 65 | { 66 | "mcpServers": { 67 | "meilisearch": { 68 | "command": "uvx", 69 | "args": ["-n", "meilisearch-mcp"] 70 | } 71 | } 72 | } 73 | ``` 74 | 75 | ### 3️⃣ Start Meilisearch 76 | 77 | ```bash 78 | # Using Docker (recommended) 79 | docker run -d -p 7700:7700 getmeili/meilisearch:v1.6 80 | 81 | # Or using Homebrew 82 | brew install meilisearch 83 | meilisearch 84 | ``` 85 | 86 | That's it! Now you can ask your AI assistant to search and manage your Meilisearch data! 🎉 87 | 88 | ## 📚 Examples 89 | 90 | ### 💬 Talk to your AI assistant naturally: 91 | 92 | ``` 93 | You: "Create a new index called 'products' with 'id' as the primary key" 94 | AI: I'll create that index for you... ✓ Index 'products' created successfully! 95 | 96 | You: "Add some products to the index" 97 | AI: I'll add those products... ✓ Added 5 documents to 'products' index 98 | 99 | You: "Search for products under $50 with 'electronics' in the category" 100 | AI: I'll search for those products... Found 12 matching products! 101 | ``` 102 | 103 | ### 🔍 Advanced Search Example: 104 | 105 | ``` 106 | You: "Search across all my indices for 'machine learning' and sort by date" 107 | AI: Searching across all indices... Found 47 results from 3 indices: 108 | - 'blog_posts': 23 articles about ML 109 | - 'documentation': 15 technical guides 110 | - 'tutorials': 9 hands-on tutorials 111 | ``` 112 | 113 | ## 🔧 Installation 114 | 115 | ### Prerequisites 116 | 117 | - Python ≥ 3.9 118 | - Running Meilisearch instance 119 | - MCP-compatible client (Claude Desktop, OpenAI agents, etc.) 120 | 121 | ### From PyPI 122 | 123 | ```bash 124 | pip install meilisearch-mcp 125 | ``` 126 | 127 | ### From Source (for development) 128 | 129 | ```bash 130 | # Clone repository 131 | git clone https://github.com/meilisearch/meilisearch-mcp.git 132 | cd meilisearch-mcp 133 | 134 | # Create virtual environment and install 135 | uv venv 136 | source .venv/bin/activate # On Windows: .venv\Scripts\activate 137 | uv pip install -e . 138 | ``` 139 | 140 | ### Using Docker 141 | 142 | Perfect for containerized environments like n8n workflows! 143 | 144 | #### From Docker Hub 145 | 146 | ```bash 147 | # Pull the latest image 148 | docker pull getmeili/meilisearch-mcp:latest 149 | 150 | # Or a specific version 151 | docker pull getmeili/meilisearch-mcp:0.5.0 152 | 153 | # Run the container 154 | docker run -it \ 155 | -e MEILI_HTTP_ADDR=http://your-meilisearch:7700 \ 156 | -e MEILI_MASTER_KEY=your-master-key \ 157 | getmeili/meilisearch-mcp:latest 158 | ``` 159 | 160 | #### Build from Source 161 | 162 | ```bash 163 | # Build your own image 164 | docker build -t meilisearch-mcp . 165 | docker run -it \ 166 | -e MEILI_HTTP_ADDR=http://your-meilisearch:7700 \ 167 | -e MEILI_MASTER_KEY=your-master-key \ 168 | meilisearch-mcp 169 | ``` 170 | 171 | #### Integration with n8n 172 | 173 | For n8n workflows, you can use the Docker image directly in your setup: 174 | ```yaml 175 | meilisearch-mcp: 176 | image: getmeili/meilisearch-mcp:latest 177 | environment: 178 | - MEILI_HTTP_ADDR=http://meilisearch:7700 179 | - MEILI_MASTER_KEY=masterKey 180 | ``` 181 | 182 | ## 🛠️ What Can You Do? 183 | 184 |
185 | 🔗 Connection Management 186 | 187 | - View current connection settings 188 | - Switch between Meilisearch instances dynamically 189 | - Update API keys on the fly 190 | 191 |
192 | 193 |
194 | 📁 Index Operations 195 | 196 | - Create new indices with custom primary keys 197 | - List all indices with stats 198 | - Delete indices and their data 199 | - Get detailed index metrics 200 | 201 |
202 | 203 |
204 | 📄 Document Management 205 | 206 | - Add or update documents 207 | - Retrieve documents with pagination 208 | - Bulk import data 209 | 210 |
211 | 212 |
213 | 🔍 Search Capabilities 214 | 215 | - Search with filters, sorting, and facets 216 | - Multi-index search 217 | - Semantic search with vectors 218 | - Hybrid search (keyword + semantic) 219 | 220 |
221 | 222 |
223 | ⚙️ Settings & Configuration 224 | 225 | - Configure ranking rules 226 | - Set up faceting and filtering 227 | - Manage searchable attributes 228 | - Customize typo tolerance 229 | 230 |
231 | 232 |
233 | 🔐 Security 234 | 235 | - Create and manage API keys 236 | - Set granular permissions 237 | - Monitor key usage 238 | 239 | ⚠️ Note: While you can add and update hosts and API keys directly in chat for convenience, this approach is primarily designed for development use cases (like connecting to multiple instances on the fly). It does not follow best MCP security practices and should not be used in production environments without proper safeguards. 240 | 241 |
242 | 243 |
244 | 📊 Monitoring & Health 245 | 246 | - Health checks 247 | - System statistics 248 | - Task monitoring 249 | - Version information 250 | 251 |
252 | 253 | ## 🌍 Environment Variables 254 | 255 | Configure default connection settings: 256 | 257 | ```bash 258 | MEILI_HTTP_ADDR=http://localhost:7700 # Default Meilisearch URL 259 | MEILI_MASTER_KEY=your_master_key # Optional: Default API key 260 | ``` 261 | 262 | ## 💻 Development 263 | 264 | ### Setting Up Development Environment 265 | 266 | 1. **Start Meilisearch**: 267 | ```bash 268 | docker run -d -p 7700:7700 getmeili/meilisearch:v1.6 269 | ``` 270 | 271 | 2. **Install Development Dependencies**: 272 | ```bash 273 | uv pip install -r requirements-dev.txt 274 | ``` 275 | 276 | 3. **Run Tests**: 277 | ```bash 278 | python -m pytest tests/ -v 279 | ``` 280 | 281 | 4. **Format Code**: 282 | ```bash 283 | black src/ tests/ 284 | ``` 285 | 286 | ### Testing with MCP Inspector 287 | 288 | ```bash 289 | npx @modelcontextprotocol/inspector python -m src.meilisearch_mcp 290 | ``` 291 | 292 | ## 🤝 Community & Support 293 | 294 | We'd love to hear from you! Here's how to get help and connect: 295 | 296 | - 💬 [Join our Discord](https://discord.meilisearch.com) - Chat with the community 297 | - 🐛 [Report Issues](https://github.com/meilisearch/meilisearch-mcp/issues) - Found a bug? Let us know! 298 | - 💡 [Feature Requests](https://github.com/meilisearch/meilisearch-mcp/issues) - Have an idea? We're listening! 299 | - 📖 [Meilisearch Docs](https://www.meilisearch.com/docs) - Learn more about Meilisearch 300 | 301 | ## 🤗 Contributing 302 | 303 | We welcome contributions! Here's how to get started: 304 | 305 | 1. Fork the repository 306 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 307 | 3. Write tests for your changes 308 | 4. Make your changes and run tests 309 | 5. Format your code with `black` 310 | 6. Commit your changes (`git commit -m 'Add amazing feature'`) 311 | 7. Push to your branch (`git push origin feature/amazing-feature`) 312 | 8. Open a Pull Request 313 | 314 | See our [Contributing Guidelines](#contributing-1) for more details. 315 | 316 | ## 📦 Release Process 317 | 318 | This project uses automated versioning and publishing. When the version in `pyproject.toml` changes on the `main` branch, the package is automatically published to PyPI. 319 | 320 | See the [Release Process](#release-process-1) section for detailed instructions. 321 | 322 | ## 📄 License 323 | 324 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 325 | 326 | --- 327 | 328 |

329 | Meilisearch is an open-source search engine that offers a delightful search experience.
330 | Learn more about Meilisearch at meilisearch.com 331 |

332 | 333 | --- 334 | 335 |
336 |

📖 Full Documentation

337 | 338 | ### Available Tools 339 | 340 | #### Connection Management 341 | - `get-connection-settings`: View current Meilisearch connection URL and API key status 342 | - `update-connection-settings`: Update URL and/or API key to connect to a different instance 343 | 344 | #### Index Management 345 | - `create-index`: Create a new index with optional primary key 346 | - `list-indexes`: List all available indexes 347 | - `delete-index`: Delete an existing index and all its documents 348 | - `get-index-metrics`: Get detailed metrics for a specific index 349 | 350 | #### Document Operations 351 | - `get-documents`: Retrieve documents from an index with pagination 352 | - `add-documents`: Add or update documents in an index 353 | 354 | #### Search 355 | - `search`: Flexible search across single or multiple indices with filtering and sorting options 356 | 357 | #### Settings Management 358 | - `get-settings`: View current settings for an index 359 | - `update-settings`: Update index settings (ranking, faceting, etc.) 360 | 361 | #### API Key Management 362 | - `get-keys`: List all API keys 363 | - `create-key`: Create new API key with specific permissions 364 | - `delete-key`: Delete an existing API key 365 | 366 | #### Task Management 367 | - `get-task`: Get information about a specific task 368 | - `get-tasks`: List tasks with optional filters 369 | - `cancel-tasks`: Cancel pending or enqueued tasks 370 | - `delete-tasks`: Delete completed tasks 371 | 372 | #### System Monitoring 373 | - `health-check`: Basic health check 374 | - `get-health-status`: Comprehensive health status 375 | - `get-version`: Get Meilisearch version information 376 | - `get-stats`: Get database statistics 377 | - `get-system-info`: Get system-level information 378 | 379 | ### Development Setup 380 | 381 | #### Prerequisites 382 | 383 | 1. **Start Meilisearch server**: 384 | ```bash 385 | # Using Docker (recommended for development) 386 | docker run -d -p 7700:7700 getmeili/meilisearch:v1.6 387 | 388 | # Or using brew (macOS) 389 | brew install meilisearch 390 | meilisearch 391 | 392 | # Or download from https://github.com/meilisearch/meilisearch/releases 393 | ``` 394 | 395 | 2. **Install development tools**: 396 | ```bash 397 | # Install uv for Python package management 398 | pip install uv 399 | 400 | # Install Node.js for MCP Inspector testing 401 | # Visit https://nodejs.org/ or use your package manager 402 | ``` 403 | 404 | ### Running Tests 405 | 406 | This project includes comprehensive integration tests that verify MCP tool functionality: 407 | 408 | ```bash 409 | # Run all tests 410 | python -m pytest tests/ -v 411 | 412 | # Run specific test file 413 | python -m pytest tests/test_mcp_client.py -v 414 | 415 | # Run tests with coverage report 416 | python -m pytest --cov=src tests/ 417 | 418 | # Run tests in watch mode (requires pytest-watch) 419 | pytest-watch tests/ 420 | ``` 421 | 422 | **Important**: Tests require a running Meilisearch instance on `http://localhost:7700`. 423 | 424 | ### Code Quality 425 | 426 | ```bash 427 | # Format code with Black 428 | black src/ tests/ 429 | 430 | # Run type checking (if mypy is configured) 431 | mypy src/ 432 | 433 | # Lint code (if flake8 is configured) 434 | flake8 src/ tests/ 435 | ``` 436 | 437 | ### Contributing Guidelines 438 | 439 | 1. **Fork and clone** the repository 440 | 2. **Set up development environment** following the Development Setup section above 441 | 3. **Create a feature branch** from `main` 442 | 4. **Write tests first** if adding new functionality (Test-Driven Development) 443 | 5. **Run tests locally** to ensure all tests pass before committing 444 | 6. **Format code** with Black and ensure code quality 445 | 7. **Commit changes** with descriptive commit messages 446 | 8. **Push to your fork** and create a pull request 447 | 448 | ### Development Workflow 449 | 450 | ```bash 451 | # Create feature branch 452 | git checkout -b feature/your-feature-name 453 | 454 | # Make your changes, write tests first 455 | # Edit files... 456 | 457 | # Run tests to ensure everything works 458 | python -m pytest tests/ -v 459 | 460 | # Format code 461 | black src/ tests/ 462 | 463 | # Commit and push 464 | git add . 465 | git commit -m "Add feature description" 466 | git push origin feature/your-feature-name 467 | ``` 468 | 469 | ### Testing Guidelines 470 | 471 | - All new features should include tests 472 | - Tests should pass before submitting PRs 473 | - Use descriptive test names and clear assertions 474 | - Test both success and error cases 475 | - Ensure Meilisearch is running before running tests 476 | 477 | ### Release Process 478 | 479 | This project uses automated versioning and publishing to PyPI. The release process is designed to be simple and automated. 480 | 481 | #### How Releases Work 482 | 483 | 1. **Automated Publishing**: When the version number in `pyproject.toml` changes on the `main` branch, a GitHub Action automatically: 484 | - Builds the Python package 485 | - Publishes it to PyPI using trusted publishing 486 | - Creates a new release on GitHub 487 | 488 | 2. **Version Detection**: The workflow compares the current version in `pyproject.toml` with the previous commit to detect changes 489 | 490 | 3. **PyPI Publishing**: Uses PyPA's official publish action with trusted publishing (no manual API keys needed) 491 | 492 | #### Creating a New Release 493 | 494 | To create a new release, follow these steps: 495 | 496 | ##### 1. Determine Version Number 497 | 498 | Follow [Semantic Versioning](https://semver.org/) (MAJOR.MINOR.PATCH): 499 | 500 | - **PATCH** (e.g., 0.4.0 → 0.4.1): Bug fixes, documentation updates, minor improvements 501 | - **MINOR** (e.g., 0.4.0 → 0.5.0): New features, new MCP tools, significant enhancements 502 | - **MAJOR** (e.g., 0.5.0 → 1.0.0): Breaking changes, major API changes 503 | 504 | ##### 2. Update Version and Create PR 505 | 506 | ```bash 507 | # 1. Create a branch from latest main 508 | git checkout main 509 | git pull origin main 510 | git checkout -b release/v0.5.0 511 | 512 | # 2. Update version in pyproject.toml 513 | # Edit the version = "0.4.0" line to your new version 514 | 515 | # 3. Commit and push 516 | git add pyproject.toml 517 | git commit -m "Bump version to 0.5.0" 518 | git push origin release/v0.5.0 519 | 520 | # 4. Create PR and get it reviewed/merged 521 | gh pr create --title "Release v0.5.0" --body "Bump version for release" 522 | ``` 523 | 524 | ##### 3. Merge to Main 525 | 526 | Once the PR is approved and merged to `main`, the GitHub Action will automatically: 527 | 528 | 1. Detect the version change 529 | 2. Build the package 530 | 3. Publish to PyPI at https://pypi.org/p/meilisearch-mcp 531 | 4. Make the new version available via `pip install meilisearch-mcp` 532 | 533 | ##### 4. Verify Release 534 | 535 | After merging, verify the release: 536 | 537 | ```bash 538 | # Check GitHub Action status 539 | gh run list --workflow=publish.yml 540 | 541 | # Verify on PyPI (may take a few minutes) 542 | pip index versions meilisearch-mcp 543 | 544 | # Test installation of new version 545 | pip install --upgrade meilisearch-mcp 546 | ``` 547 | 548 | ### Release Workflow File 549 | 550 | The automated release is handled by `.github/workflows/publish.yml`, which: 551 | 552 | - Triggers on pushes to `main` branch 553 | - Checks if `pyproject.toml` version changed 554 | - Uses Python 3.10 and official build tools 555 | - Publishes using trusted publishing (no API keys required) 556 | - Provides verbose output for debugging 557 | 558 | ### Troubleshooting Releases 559 | 560 | **Release didn't trigger**: Check that the version in `pyproject.toml` actually changed between commits 561 | 562 | **Build failed**: Check the GitHub Actions logs for Python package build errors 563 | 564 | **PyPI publish failed**: Verify the package name and that trusted publishing is configured properly 565 | 566 | **Version conflicts**: Ensure the new version number hasn't been used before on PyPI 567 | 568 | ### Development vs Production Versions 569 | 570 | - **Development**: Install from source using `pip install -e .` 571 | - **Production**: Install from PyPI using `pip install meilisearch-mcp` 572 | - **Specific version**: Install using `pip install meilisearch-mcp==0.5.0` 573 | 574 |
575 | -------------------------------------------------------------------------------- /tests/test_mcp_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | MCP Client Integration Tests 3 | 4 | These tests simulate an MCP client connecting to the MCP server to test: 5 | 1. Tool discovery functionality 6 | 2. Connection settings verification 7 | 8 | The tests require a running Meilisearch instance in the background. 9 | """ 10 | 11 | import asyncio 12 | import json 13 | import os 14 | import time 15 | from typing import Dict, Any, List 16 | import pytest 17 | from unittest.mock import AsyncMock, patch 18 | 19 | from mcp.types import CallToolRequest, CallToolRequestParams, ListToolsRequest 20 | from src.meilisearch_mcp.server import MeilisearchMCPServer, create_server 21 | 22 | 23 | # Test configuration constants 24 | INDEXING_WAIT_TIME = 0.5 25 | TEST_URL = "http://localhost:7700" 26 | ALT_TEST_URL = "http://localhost:7701" 27 | ALT_TEST_URL_2 = "http://localhost:7702" 28 | TEST_API_KEY = "test_api_key_123" 29 | FINAL_TEST_KEY = "final_test_key" 30 | 31 | 32 | def generate_unique_index_name(prefix: str = "test") -> str: 33 | """Generate a unique index name for testing""" 34 | return f"{prefix}_{int(time.time() * 1000)}" 35 | 36 | 37 | async def wait_for_indexing() -> None: 38 | """Wait for Meilisearch indexing to complete""" 39 | await asyncio.sleep(INDEXING_WAIT_TIME) 40 | 41 | 42 | async def simulate_mcp_call( 43 | server: MeilisearchMCPServer, tool_name: str, arguments: Dict[str, Any] = None 44 | ) -> List[Any]: 45 | """Simulate an MCP client call to the server""" 46 | handler = server.server.request_handlers.get(CallToolRequest) 47 | if not handler: 48 | raise RuntimeError("No call_tool handler found") 49 | 50 | request = CallToolRequest( 51 | method="tools/call", 52 | params=CallToolRequestParams(name=tool_name, arguments=arguments or {}), 53 | ) 54 | 55 | result = await handler(request) 56 | return result.root.content 57 | 58 | 59 | async def simulate_list_tools(server: MeilisearchMCPServer) -> List[Any]: 60 | """Simulate an MCP client request to list tools""" 61 | handler = server.server.request_handlers.get(ListToolsRequest) 62 | if not handler: 63 | raise RuntimeError("No list_tools handler found") 64 | 65 | request = ListToolsRequest(method="tools/list") 66 | result = await handler(request) 67 | return result.root.tools 68 | 69 | 70 | async def create_test_index_with_documents( 71 | server: MeilisearchMCPServer, index_name: str, documents: List[Dict[str, Any]] 72 | ) -> None: 73 | """Helper to create index and add documents for testing""" 74 | await simulate_mcp_call(server, "create-index", {"uid": index_name}) 75 | await simulate_mcp_call( 76 | server, "add-documents", {"indexUid": index_name, "documents": documents} 77 | ) 78 | await wait_for_indexing() 79 | 80 | 81 | def assert_text_content_response( 82 | result: List[Any], expected_content: str = None 83 | ) -> str: 84 | """Common assertions for text content responses""" 85 | assert isinstance(result, list) 86 | assert len(result) == 1 87 | assert result[0].type == "text" 88 | 89 | text = result[0].text 90 | if expected_content: 91 | assert expected_content in text 92 | 93 | return text 94 | 95 | 96 | @pytest.fixture 97 | async def mcp_server(): 98 | """Shared fixture for creating MCP server instances""" 99 | url = os.getenv("MEILI_HTTP_ADDR", TEST_URL) 100 | api_key = os.getenv("MEILI_MASTER_KEY") 101 | 102 | server = create_server(url, api_key) 103 | yield server 104 | server.cleanup() 105 | 106 | 107 | class TestMCPClientIntegration: 108 | """Test MCP client interaction with the server""" 109 | 110 | async def test_tool_discovery(self, mcp_server): 111 | """Test that MCP client can discover all available tools from the server""" 112 | # Simulate MCP list_tools request 113 | tools = await simulate_list_tools(mcp_server) 114 | 115 | tool_names = [tool.name for tool in tools] 116 | 117 | # Verify basic structure 118 | assert isinstance(tools, list) 119 | assert len(tools) > 0 120 | 121 | # Check for essential tools 122 | essential_tools = [ 123 | "get-connection-settings", 124 | "update-connection-settings", 125 | "health-check", 126 | "get-version", 127 | "get-stats", 128 | "create-index", 129 | "list-indexes", 130 | "get-documents", 131 | "add-documents", 132 | "search", 133 | "get-settings", 134 | "update-settings", 135 | ] 136 | 137 | for tool_name in essential_tools: 138 | assert tool_name in tool_names, f"Essential tool '{tool_name}' not found" 139 | 140 | # Verify tool structure 141 | for tool in tools: 142 | assert all( 143 | hasattr(tool, attr) for attr in ["name", "description", "inputSchema"] 144 | ) 145 | assert all( 146 | isinstance(getattr(tool, attr), expected_type) 147 | for attr, expected_type in [ 148 | ("name", str), 149 | ("description", str), 150 | ("inputSchema", dict), 151 | ] 152 | ) 153 | 154 | print(f"Discovered {len(tools)} tools: {tool_names}") 155 | 156 | async def test_connection_settings_verification(self, mcp_server): 157 | """Test connection settings tools to verify MCP client can connect to server""" 158 | # Test getting current connection settings 159 | result = await simulate_mcp_call(mcp_server, "get-connection-settings") 160 | text = assert_text_content_response(result, "Current connection settings:") 161 | assert "URL:" in text 162 | 163 | # Test updating connection settings 164 | update_result = await simulate_mcp_call( 165 | mcp_server, "update-connection-settings", {"url": ALT_TEST_URL} 166 | ) 167 | update_text = assert_text_content_response( 168 | update_result, "Successfully updated connection settings" 169 | ) 170 | assert ALT_TEST_URL in update_text 171 | 172 | # Verify the update took effect 173 | verify_result = await simulate_mcp_call(mcp_server, "get-connection-settings") 174 | verify_text = assert_text_content_response(verify_result) 175 | assert ALT_TEST_URL in verify_text 176 | 177 | async def test_health_check_tool(self, mcp_server): 178 | """Test health check tool through MCP client interface""" 179 | # Mock the health check to avoid requiring actual Meilisearch 180 | with patch.object( 181 | mcp_server.meili_client, "health_check", new_callable=AsyncMock 182 | ) as mock_health: 183 | mock_health.return_value = True 184 | result = await simulate_mcp_call(mcp_server, "health-check") 185 | 186 | assert_text_content_response(result, "available") 187 | mock_health.assert_called_once() 188 | 189 | async def test_tool_error_handling(self, mcp_server): 190 | """Test that MCP client receives proper error responses from server""" 191 | result = await simulate_mcp_call(mcp_server, "non-existent-tool") 192 | text = assert_text_content_response(result, "Error:") 193 | assert "Unknown tool" in text 194 | 195 | async def test_tool_schema_validation(self, mcp_server): 196 | """Test that tools have proper input schemas for MCP client validation""" 197 | tools = await simulate_list_tools(mcp_server) 198 | 199 | # Check specific tool schemas 200 | create_index_tool = next(tool for tool in tools if tool.name == "create-index") 201 | assert create_index_tool.inputSchema["type"] == "object" 202 | assert "uid" in create_index_tool.inputSchema["required"] 203 | assert "uid" in create_index_tool.inputSchema["properties"] 204 | assert create_index_tool.inputSchema["properties"]["uid"]["type"] == "string" 205 | 206 | search_tool = next(tool for tool in tools if tool.name == "search") 207 | assert search_tool.inputSchema["type"] == "object" 208 | assert "query" in search_tool.inputSchema["required"] 209 | assert "query" in search_tool.inputSchema["properties"] 210 | assert search_tool.inputSchema["properties"]["query"]["type"] == "string" 211 | 212 | async def test_mcp_server_initialization(self, mcp_server): 213 | """Test that MCP server initializes correctly for client connections""" 214 | # Verify server has required attributes 215 | assert hasattr(mcp_server, "server") 216 | assert hasattr(mcp_server, "meili_client") 217 | assert hasattr(mcp_server, "url") 218 | assert hasattr(mcp_server, "api_key") 219 | assert hasattr(mcp_server, "logger") 220 | 221 | # Verify server name and basic configuration 222 | assert mcp_server.server.name == "meilisearch" 223 | assert mcp_server.url is not None 224 | assert mcp_server.meili_client is not None 225 | 226 | 227 | class TestMCPToolDiscovery: 228 | """Detailed tests for MCP tool discovery functionality""" 229 | 230 | async def test_complete_tool_list(self, mcp_server): 231 | """Test that all expected tools are discoverable by MCP clients""" 232 | tools = await simulate_list_tools(mcp_server) 233 | tool_names = [tool.name for tool in tools] 234 | 235 | # Complete list of expected tools (26 total - includes 4 new chat tools) 236 | expected_tools = [ 237 | "get-connection-settings", 238 | "update-connection-settings", 239 | "health-check", 240 | "get-version", 241 | "get-stats", 242 | "create-index", 243 | "list-indexes", 244 | "delete-index", 245 | "get-documents", 246 | "add-documents", 247 | "get-settings", 248 | "update-settings", 249 | "search", 250 | "get-task", 251 | "get-tasks", 252 | "cancel-tasks", 253 | "get-keys", 254 | "create-key", 255 | "delete-key", 256 | "get-health-status", 257 | "get-index-metrics", 258 | "get-system-info", 259 | # New chat tools added in v0.6.0 260 | "create-chat-completion", 261 | "get-chat-workspaces", 262 | "get-chat-workspace-settings", 263 | "update-chat-workspace-settings", 264 | ] 265 | 266 | assert len(tools) == len(expected_tools) 267 | for tool_name in expected_tools: 268 | assert tool_name in tool_names 269 | 270 | async def test_tool_categorization(self, mcp_server): 271 | """Test that tools can be categorized for MCP client organization""" 272 | tools = await simulate_list_tools(mcp_server) 273 | 274 | # Categorize tools by functionality 275 | categories = { 276 | "connection": [t for t in tools if "connection" in t.name], 277 | "index": [ 278 | t 279 | for t in tools 280 | if any( 281 | word in t.name 282 | for word in [ 283 | "index", 284 | "create-index", 285 | "list-indexes", 286 | "delete-index", 287 | ] 288 | ) 289 | ], 290 | "document": [t for t in tools if "document" in t.name], 291 | "search": [t for t in tools if "search" in t.name], 292 | "task": [t for t in tools if "task" in t.name], 293 | "key": [t for t in tools if "key" in t.name], 294 | "monitoring": [ 295 | t 296 | for t in tools 297 | if any( 298 | word in t.name 299 | for word in ["health", "stats", "version", "system", "metrics"] 300 | ) 301 | ], 302 | "chat": [t for t in tools if "chat" in t.name], 303 | } 304 | 305 | # Verify minimum expected tools per category 306 | expected_counts = { 307 | "connection": 2, 308 | "index": 3, 309 | "document": 2, 310 | "search": 1, 311 | "task": 2, 312 | "key": 3, 313 | "monitoring": 4, 314 | "chat": 4, 315 | } 316 | 317 | for category, min_count in expected_counts.items(): 318 | assert ( 319 | len(categories[category]) >= min_count 320 | ), f"Category '{category}' has insufficient tools" 321 | 322 | 323 | class TestMCPConnectionSettings: 324 | """Detailed tests for MCP connection settings functionality""" 325 | 326 | async def test_get_connection_settings_format(self, mcp_server): 327 | """Test connection settings response format for MCP clients""" 328 | result = await simulate_mcp_call(mcp_server, "get-connection-settings") 329 | text = assert_text_content_response(result, "Current connection settings:") 330 | 331 | # Verify required fields are present 332 | required_fields = ["URL:", "API Key:"] 333 | for field in required_fields: 334 | assert field in text 335 | 336 | # Check URL is properly displayed 337 | assert mcp_server.url in text 338 | 339 | # Check API key is masked for security 340 | expected_key_display = "********" if mcp_server.api_key else "Not set" 341 | assert expected_key_display in text or "Not set" in text 342 | 343 | 344 | class TestIssue16GetDocumentsJsonSerialization: 345 | """Test for issue #16 - get-documents should return JSON, not Python object representations""" 346 | 347 | async def test_get_documents_returns_json_not_python_object(self, mcp_server): 348 | """Test that get-documents returns JSON-formatted text, not Python object string representation (issue #16)""" 349 | test_index = generate_unique_index_name("test_issue16") 350 | test_document = {"id": 1, "title": "Test Document", "content": "Test content"} 351 | 352 | # Create index and add test document 353 | await create_test_index_with_documents(mcp_server, test_index, [test_document]) 354 | 355 | # Get documents with explicit parameters 356 | result = await simulate_mcp_call( 357 | mcp_server, 358 | "get-documents", 359 | {"indexUid": test_index, "offset": 0, "limit": 10}, 360 | ) 361 | 362 | response_text = assert_text_content_response(result, "Documents:") 363 | 364 | # Issue #16 assertion: Should NOT contain Python object representation 365 | assert ( 366 | " 0 382 | except json.JSONDecodeError: 383 | pytest.fail(f"get-documents returned non-JSON data: {response_text}") 384 | 385 | async def test_update_connection_settings_persistence(self, mcp_server): 386 | """Test that connection updates persist for MCP client sessions""" 387 | # Test URL update 388 | await simulate_mcp_call( 389 | mcp_server, "update-connection-settings", {"url": ALT_TEST_URL} 390 | ) 391 | assert mcp_server.url == ALT_TEST_URL 392 | assert mcp_server.meili_client.client.config.url == ALT_TEST_URL 393 | 394 | # Test API key update 395 | await simulate_mcp_call( 396 | mcp_server, "update-connection-settings", {"api_key": TEST_API_KEY} 397 | ) 398 | assert mcp_server.api_key == TEST_API_KEY 399 | assert mcp_server.meili_client.client.config.api_key == TEST_API_KEY 400 | 401 | # Test both updates together 402 | await simulate_mcp_call( 403 | mcp_server, 404 | "update-connection-settings", 405 | {"url": ALT_TEST_URL_2, "api_key": FINAL_TEST_KEY}, 406 | ) 407 | assert mcp_server.url == ALT_TEST_URL_2 408 | assert mcp_server.api_key == FINAL_TEST_KEY 409 | 410 | async def test_connection_settings_validation(self, mcp_server): 411 | """Test that MCP client receives validation for connection settings""" 412 | # Test with empty updates 413 | result = await simulate_mcp_call(mcp_server, "update-connection-settings", {}) 414 | assert_text_content_response(result, "Successfully updated") 415 | 416 | # Test partial updates 417 | original_url = mcp_server.url 418 | await simulate_mcp_call( 419 | mcp_server, "update-connection-settings", {"api_key": "new_key_only"} 420 | ) 421 | 422 | assert mcp_server.url == original_url # URL unchanged 423 | assert mcp_server.api_key == "new_key_only" # Key updated 424 | 425 | 426 | class TestIssue17DefaultLimitOffset: 427 | """Test for issue #17 - get-documents should use default limit and offset to avoid None parameter errors""" 428 | 429 | async def test_get_documents_without_limit_offset_parameters(self, mcp_server): 430 | """Test that get-documents works without providing limit/offset parameters (issue #17)""" 431 | test_index = generate_unique_index_name("test_issue17") 432 | test_documents = [ 433 | {"id": 1, "title": "Test Document 1", "content": "Content 1"}, 434 | {"id": 2, "title": "Test Document 2", "content": "Content 2"}, 435 | {"id": 3, "title": "Test Document 3", "content": "Content 3"}, 436 | ] 437 | 438 | # Create index and add test documents 439 | await create_test_index_with_documents(mcp_server, test_index, test_documents) 440 | 441 | # Test get-documents without any limit/offset parameters (should use defaults) 442 | result = await simulate_mcp_call( 443 | mcp_server, "get-documents", {"indexUid": test_index} 444 | ) 445 | assert_text_content_response(result, "Documents:") 446 | # Should not get any errors about None parameters 447 | 448 | async def test_get_documents_with_explicit_parameters(self, mcp_server): 449 | """Test that get-documents still works with explicit limit/offset parameters""" 450 | test_index = generate_unique_index_name("test_issue17_explicit") 451 | test_documents = [ 452 | {"id": 1, "title": "Test Document 1", "content": "Content 1"}, 453 | {"id": 2, "title": "Test Document 2", "content": "Content 2"}, 454 | ] 455 | 456 | # Create index and add test documents 457 | await create_test_index_with_documents(mcp_server, test_index, test_documents) 458 | 459 | # Test get-documents with explicit parameters 460 | result = await simulate_mcp_call( 461 | mcp_server, 462 | "get-documents", 463 | {"indexUid": test_index, "offset": 0, "limit": 1}, 464 | ) 465 | assert_text_content_response(result, "Documents:") 466 | 467 | async def test_get_documents_default_values_applied(self, mcp_server): 468 | """Test that default values (offset=0, limit=20) are properly applied""" 469 | test_index = generate_unique_index_name("test_issue17_defaults") 470 | test_documents = [{"id": i, "title": f"Document {i}"} for i in range(1, 6)] 471 | 472 | # Create index and add test documents 473 | await create_test_index_with_documents(mcp_server, test_index, test_documents) 474 | 475 | # Test that both calls with and without parameters work 476 | result_no_params = await simulate_mcp_call( 477 | mcp_server, "get-documents", {"indexUid": test_index} 478 | ) 479 | result_with_defaults = await simulate_mcp_call( 480 | mcp_server, 481 | "get-documents", 482 | {"indexUid": test_index, "offset": 0, "limit": 20}, 483 | ) 484 | 485 | # Both should work and return similar results 486 | assert_text_content_response(result_no_params) 487 | assert_text_content_response(result_with_defaults) 488 | 489 | 490 | class TestIssue23DeleteIndexTool: 491 | """Test for issue #23 - Add delete-index MCP tool functionality""" 492 | 493 | async def test_delete_index_tool_discovery(self, mcp_server): 494 | """Test that delete-index tool is discoverable by MCP clients (issue #23)""" 495 | tools = await simulate_list_tools(mcp_server) 496 | tool_names = [tool.name for tool in tools] 497 | 498 | assert "delete-index" in tool_names 499 | 500 | # Find the delete-index tool and verify its schema 501 | delete_tool = next(tool for tool in tools if tool.name == "delete-index") 502 | assert delete_tool.description == "Delete a Meilisearch index" 503 | assert delete_tool.inputSchema["type"] == "object" 504 | assert "uid" in delete_tool.inputSchema["required"] 505 | assert "uid" in delete_tool.inputSchema["properties"] 506 | assert delete_tool.inputSchema["properties"]["uid"]["type"] == "string" 507 | 508 | async def test_delete_index_successful_deletion(self, mcp_server): 509 | """Test successful index deletion through MCP client (issue #23)""" 510 | test_index = generate_unique_index_name("test_delete_success") 511 | 512 | # Create index first 513 | await simulate_mcp_call(mcp_server, "create-index", {"uid": test_index}) 514 | await wait_for_indexing() 515 | 516 | # Verify index exists by listing indexes 517 | list_result = await simulate_mcp_call(mcp_server, "list-indexes") 518 | list_text = assert_text_content_response(list_result) 519 | assert test_index in list_text 520 | 521 | # Delete the index 522 | result = await simulate_mcp_call( 523 | mcp_server, "delete-index", {"uid": test_index} 524 | ) 525 | response_text = assert_text_content_response( 526 | result, "Successfully deleted index:" 527 | ) 528 | assert test_index in response_text 529 | 530 | # Verify index no longer exists by listing indexes 531 | await wait_for_indexing() 532 | list_result_after = await simulate_mcp_call(mcp_server, "list-indexes") 533 | list_text_after = assert_text_content_response(list_result_after) 534 | assert test_index not in list_text_after 535 | 536 | async def test_delete_index_with_documents(self, mcp_server): 537 | """Test deleting index that contains documents (issue #23)""" 538 | test_index = generate_unique_index_name("test_delete_with_docs") 539 | test_documents = [ 540 | {"id": 1, "title": "Test Document 1", "content": "Content 1"}, 541 | {"id": 2, "title": "Test Document 2", "content": "Content 2"}, 542 | ] 543 | 544 | # Create index and add documents 545 | await create_test_index_with_documents(mcp_server, test_index, test_documents) 546 | 547 | # Verify documents exist 548 | docs_result = await simulate_mcp_call( 549 | mcp_server, "get-documents", {"indexUid": test_index} 550 | ) 551 | docs_text = assert_text_content_response(docs_result, "Documents:") 552 | assert "Test Document 1" in docs_text 553 | 554 | # Delete the index (should also delete all documents) 555 | result = await simulate_mcp_call( 556 | mcp_server, "delete-index", {"uid": test_index} 557 | ) 558 | response_text = assert_text_content_response( 559 | result, "Successfully deleted index:" 560 | ) 561 | assert test_index in response_text 562 | 563 | # Verify index and documents are gone 564 | await wait_for_indexing() 565 | list_result = await simulate_mcp_call(mcp_server, "list-indexes") 566 | list_text = assert_text_content_response(list_result) 567 | assert test_index not in list_text 568 | 569 | async def test_delete_nonexistent_index_behavior(self, mcp_server): 570 | """Test behavior when deleting non-existent index (issue #23)""" 571 | nonexistent_index = generate_unique_index_name("nonexistent") 572 | 573 | # Try to delete non-existent index 574 | # Note: Meilisearch allows deleting non-existent indexes without error 575 | result = await simulate_mcp_call( 576 | mcp_server, "delete-index", {"uid": nonexistent_index} 577 | ) 578 | response_text = assert_text_content_response( 579 | result, "Successfully deleted index:" 580 | ) 581 | assert nonexistent_index in response_text 582 | 583 | async def test_delete_index_input_validation(self, mcp_server): 584 | """Test input validation for delete-index tool (issue #23)""" 585 | # Test missing uid parameter 586 | result = await simulate_mcp_call(mcp_server, "delete-index", {}) 587 | response_text = assert_text_content_response(result, "error:") 588 | assert "error:" in response_text 589 | 590 | async def test_delete_index_integration_workflow(self, mcp_server): 591 | """Test complete workflow: create -> add docs -> search -> delete (issue #23)""" 592 | test_index = generate_unique_index_name("test_delete_workflow") 593 | test_documents = [ 594 | {"id": 1, "title": "Workflow Document", "content": "Testing workflow"}, 595 | ] 596 | 597 | # Create index and add documents 598 | await create_test_index_with_documents(mcp_server, test_index, test_documents) 599 | 600 | # Search to verify functionality 601 | search_result = await simulate_mcp_call( 602 | mcp_server, "search", {"query": "workflow", "indexUid": test_index} 603 | ) 604 | search_text = assert_text_content_response(search_result) 605 | assert "Workflow Document" in search_text 606 | 607 | # Delete the index 608 | delete_result = await simulate_mcp_call( 609 | mcp_server, "delete-index", {"uid": test_index} 610 | ) 611 | assert_text_content_response(delete_result, "Successfully deleted index:") 612 | 613 | # Verify search no longer works on deleted index 614 | await wait_for_indexing() 615 | search_after_delete = await simulate_mcp_call( 616 | mcp_server, "search", {"query": "workflow", "indexUid": test_index} 617 | ) 618 | search_after_text = assert_text_content_response(search_after_delete, "Error:") 619 | assert "Error:" in search_after_text 620 | 621 | 622 | class TestIssue27OpenAISchemaCompatibility: 623 | """Test for issue #27 - Fix JSON schemas for OpenAI Agent SDK compatibility""" 624 | 625 | async def test_all_schemas_have_additional_properties_false(self, mcp_server): 626 | """Test that all tool schemas include additionalProperties: false for OpenAI compatibility (issue #27)""" 627 | tools = await simulate_list_tools(mcp_server) 628 | 629 | for tool in tools: 630 | schema = tool.inputSchema 631 | assert schema["type"] == "object" 632 | assert ( 633 | "additionalProperties" in schema 634 | ), f"Tool '{tool.name}' missing additionalProperties" 635 | assert ( 636 | schema["additionalProperties"] is False 637 | ), f"Tool '{tool.name}' additionalProperties should be false" 638 | 639 | async def test_array_schemas_have_items_property(self, mcp_server): 640 | """Test that all array schemas include items property for OpenAI compatibility (issue #27)""" 641 | tools = await simulate_list_tools(mcp_server) 642 | 643 | tools_with_arrays = ["add-documents", "search", "get-tasks", "create-key"] 644 | 645 | for tool in tools: 646 | if tool.name in tools_with_arrays: 647 | schema = tool.inputSchema 648 | properties = schema.get("properties", {}) 649 | 650 | for prop_name, prop_schema in properties.items(): 651 | if prop_schema.get("type") == "array": 652 | assert ( 653 | "items" in prop_schema 654 | ), f"Tool '{tool.name}' property '{prop_name}' missing items" 655 | assert isinstance( 656 | prop_schema["items"], dict 657 | ), f"Tool '{tool.name}' property '{prop_name}' items should be object" 658 | 659 | async def test_no_custom_optional_properties(self, mcp_server): 660 | """Test that schemas don't use non-standard 'optional' property (issue #27)""" 661 | tools = await simulate_list_tools(mcp_server) 662 | 663 | for tool in tools: 664 | schema = tool.inputSchema 665 | properties = schema.get("properties", {}) 666 | 667 | for prop_name, prop_schema in properties.items(): 668 | assert ( 669 | "optional" not in prop_schema 670 | ), f"Tool '{tool.name}' property '{prop_name}' uses non-standard 'optional'" 671 | 672 | async def test_specific_add_documents_schema_compliance(self, mcp_server): 673 | """Test add-documents schema specifically mentioned in issue #27""" 674 | tools = await simulate_list_tools(mcp_server) 675 | add_docs_tool = next(tool for tool in tools if tool.name == "add-documents") 676 | 677 | schema = add_docs_tool.inputSchema 678 | 679 | # Verify overall structure 680 | assert schema["type"] == "object" 681 | assert schema["additionalProperties"] is False 682 | assert "properties" in schema 683 | assert "required" in schema 684 | 685 | # Verify documents array property 686 | documents_prop = schema["properties"]["documents"] 687 | assert documents_prop["type"] == "array" 688 | assert ( 689 | "items" in documents_prop 690 | ), "add-documents documents array missing items property" 691 | assert documents_prop["items"]["type"] == "object" 692 | 693 | # Verify required fields 694 | assert "indexUid" in schema["required"] 695 | assert "documents" in schema["required"] 696 | assert "primaryKey" not in schema["required"] # Should be optional 697 | 698 | async def test_openai_compatible_tool_schema_format(self, mcp_server): 699 | """Test that tool schemas follow OpenAI function calling format (issue #27)""" 700 | tools = await simulate_list_tools(mcp_server) 701 | 702 | for tool in tools: 703 | # Verify tool has required OpenAI attributes 704 | assert hasattr(tool, "name") 705 | assert hasattr(tool, "description") 706 | assert hasattr(tool, "inputSchema") 707 | 708 | # Verify schema structure matches OpenAI expectations 709 | schema = tool.inputSchema 710 | assert isinstance(schema, dict) 711 | assert schema.get("type") == "object" 712 | assert "properties" in schema 713 | assert isinstance(schema["properties"], dict) 714 | 715 | # If tool has required parameters, they should be in required array 716 | if "required" in schema: 717 | assert isinstance(schema["required"], list) 718 | 719 | # All required fields should exist in properties 720 | for required_field in schema["required"]: 721 | assert required_field in schema["properties"] 722 | -------------------------------------------------------------------------------- /src/meilisearch_mcp/server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import os 4 | from typing import Optional, Dict, Any, List, Union 5 | from datetime import datetime 6 | import mcp.types as types 7 | from mcp.server import Server, NotificationOptions 8 | from mcp.server.models import InitializationOptions 9 | import mcp.server.stdio 10 | 11 | from .client import MeilisearchClient 12 | from .chat import ChatManager 13 | from .logging import MCPLogger 14 | 15 | logger = MCPLogger() 16 | 17 | 18 | def json_serializer(obj: Any) -> str: 19 | """Custom JSON serializer for objects not serializable by default json code""" 20 | if isinstance(obj, datetime): 21 | return obj.isoformat() 22 | # Handle Meilisearch model objects by using their __dict__ if available 23 | if hasattr(obj, "__dict__"): 24 | return obj.__dict__ 25 | return str(obj) 26 | 27 | 28 | def create_server( 29 | url: str = "http://localhost:7700", api_key: Optional[str] = None 30 | ) -> "MeilisearchMCPServer": 31 | """Create and return a configured MeilisearchMCPServer instance""" 32 | return MeilisearchMCPServer(url, api_key) 33 | 34 | 35 | class MeilisearchMCPServer: 36 | def __init__( 37 | self, 38 | url: str = "http://localhost:7700", 39 | api_key: Optional[str] = None, 40 | log_dir: Optional[str] = None, 41 | ): 42 | """Initialize MCP server for Meilisearch""" 43 | # Set up logging directory 44 | if not log_dir: 45 | log_dir = os.path.expanduser("~/.meilisearch-mcp/logs") 46 | 47 | self.logger = MCPLogger("meilisearch-mcp", log_dir) 48 | self.url = url 49 | self.api_key = api_key 50 | self.meili_client = MeilisearchClient(url, api_key) 51 | self.chat_manager = ChatManager(self.meili_client.client) 52 | self.server = Server("meilisearch") 53 | self._setup_handlers() 54 | 55 | def update_connection( 56 | self, url: Optional[str] = None, api_key: Optional[str] = None 57 | ): 58 | """Update connection settings and reinitialize client if needed""" 59 | if url: 60 | self.url = url 61 | if api_key: 62 | self.api_key = api_key 63 | 64 | self.meili_client = MeilisearchClient(self.url, self.api_key) 65 | self.chat_manager = ChatManager(self.meili_client.client) 66 | self.logger.info("Updated Meilisearch connection settings", url=self.url) 67 | 68 | def _setup_handlers(self): 69 | """Setup MCP request handlers""" 70 | 71 | @self.server.list_tools() 72 | async def handle_list_tools() -> list[types.Tool]: 73 | """List available tools""" 74 | return [ 75 | types.Tool( 76 | name="get-connection-settings", 77 | description="Get current Meilisearch connection settings", 78 | inputSchema={ 79 | "type": "object", 80 | "properties": {}, 81 | "additionalProperties": False, 82 | }, 83 | ), 84 | types.Tool( 85 | name="update-connection-settings", 86 | description="Update Meilisearch connection settings", 87 | inputSchema={ 88 | "type": "object", 89 | "properties": { 90 | "url": {"type": "string"}, 91 | "api_key": {"type": "string"}, 92 | }, 93 | "additionalProperties": False, 94 | }, 95 | ), 96 | types.Tool( 97 | name="health-check", 98 | description="Check Meilisearch server health", 99 | inputSchema={ 100 | "type": "object", 101 | "properties": {}, 102 | "additionalProperties": False, 103 | }, 104 | ), 105 | types.Tool( 106 | name="get-version", 107 | description="Get Meilisearch version information", 108 | inputSchema={ 109 | "type": "object", 110 | "properties": {}, 111 | "additionalProperties": False, 112 | }, 113 | ), 114 | types.Tool( 115 | name="get-stats", 116 | description="Get database statistics", 117 | inputSchema={ 118 | "type": "object", 119 | "properties": {}, 120 | "additionalProperties": False, 121 | }, 122 | ), 123 | types.Tool( 124 | name="create-index", 125 | description="Create a new Meilisearch index", 126 | inputSchema={ 127 | "type": "object", 128 | "properties": { 129 | "uid": {"type": "string"}, 130 | "primaryKey": {"type": "string"}, 131 | }, 132 | "required": ["uid"], 133 | "additionalProperties": False, 134 | }, 135 | ), 136 | types.Tool( 137 | name="list-indexes", 138 | description="List all Meilisearch indexes", 139 | inputSchema={ 140 | "type": "object", 141 | "properties": {}, 142 | "additionalProperties": False, 143 | }, 144 | ), 145 | types.Tool( 146 | name="delete-index", 147 | description="Delete a Meilisearch index", 148 | inputSchema={ 149 | "type": "object", 150 | "properties": {"uid": {"type": "string"}}, 151 | "required": ["uid"], 152 | "additionalProperties": False, 153 | }, 154 | ), 155 | types.Tool( 156 | name="get-documents", 157 | description="Get documents from an index", 158 | inputSchema={ 159 | "type": "object", 160 | "properties": { 161 | "indexUid": {"type": "string"}, 162 | "offset": {"type": "integer"}, 163 | "limit": {"type": "integer"}, 164 | }, 165 | "required": ["indexUid"], 166 | "additionalProperties": False, 167 | }, 168 | ), 169 | types.Tool( 170 | name="add-documents", 171 | description="Add documents to an index", 172 | inputSchema={ 173 | "type": "object", 174 | "properties": { 175 | "indexUid": {"type": "string"}, 176 | "documents": { 177 | "type": "array", 178 | "items": { 179 | "type": "object", 180 | "additionalProperties": True, 181 | }, 182 | }, 183 | "primaryKey": {"type": "string"}, 184 | }, 185 | "required": ["indexUid", "documents"], 186 | "additionalProperties": False, 187 | }, 188 | ), 189 | types.Tool( 190 | name="get-settings", 191 | description="Get current settings for an index", 192 | inputSchema={ 193 | "type": "object", 194 | "properties": {"indexUid": {"type": "string"}}, 195 | "required": ["indexUid"], 196 | "additionalProperties": False, 197 | }, 198 | ), 199 | types.Tool( 200 | name="update-settings", 201 | description="Update settings for an index", 202 | inputSchema={ 203 | "type": "object", 204 | "properties": { 205 | "indexUid": {"type": "string"}, 206 | "settings": { 207 | "type": "object", 208 | "additionalProperties": True, 209 | }, 210 | }, 211 | "required": ["indexUid", "settings"], 212 | "additionalProperties": False, 213 | }, 214 | ), 215 | types.Tool( 216 | name="search", 217 | description="Search through Meilisearch indices. If indexUid is not provided, it will search across all indices.", 218 | inputSchema={ 219 | "type": "object", 220 | "properties": { 221 | "query": {"type": "string"}, 222 | "indexUid": {"type": "string"}, 223 | "limit": {"type": "integer"}, 224 | "offset": {"type": "integer"}, 225 | "filter": {"type": "string"}, 226 | "sort": { 227 | "type": "array", 228 | "items": {"type": "string"}, 229 | }, 230 | }, 231 | "required": ["query"], 232 | "additionalProperties": False, 233 | }, 234 | ), 235 | types.Tool( 236 | name="get-task", 237 | description="Get information about a specific task", 238 | inputSchema={ 239 | "type": "object", 240 | "properties": {"taskUid": {"type": "integer"}}, 241 | "required": ["taskUid"], 242 | "additionalProperties": False, 243 | }, 244 | ), 245 | types.Tool( 246 | name="get-tasks", 247 | description="Get list of tasks with optional filters", 248 | inputSchema={ 249 | "type": "object", 250 | "properties": { 251 | "limit": {"type": "integer"}, 252 | "from": {"type": "integer"}, 253 | "reverse": {"type": "boolean"}, 254 | "batchUids": { 255 | "type": "array", 256 | "items": {"type": "string"}, 257 | }, 258 | "uids": { 259 | "type": "array", 260 | "items": {"type": "integer"}, 261 | }, 262 | "canceledBy": { 263 | "type": "array", 264 | "items": {"type": "string"}, 265 | }, 266 | "types": { 267 | "type": "array", 268 | "items": {"type": "string"}, 269 | }, 270 | "statuses": { 271 | "type": "array", 272 | "items": {"type": "string"}, 273 | }, 274 | "indexUids": { 275 | "type": "array", 276 | "items": {"type": "string"}, 277 | }, 278 | "afterEnqueuedAt": {"type": "string"}, 279 | "beforeEnqueuedAt": {"type": "string"}, 280 | "afterStartedAt": {"type": "string"}, 281 | "beforeStartedAt": {"type": "string"}, 282 | "afterFinishedAt": {"type": "string"}, 283 | "beforeFinishedAt": {"type": "string"}, 284 | }, 285 | "additionalProperties": False, 286 | }, 287 | ), 288 | types.Tool( 289 | name="cancel-tasks", 290 | description="Cancel tasks based on filters", 291 | inputSchema={ 292 | "type": "object", 293 | "properties": { 294 | "uids": {"type": "string"}, 295 | "indexUids": {"type": "string"}, 296 | "types": {"type": "string"}, 297 | "statuses": {"type": "string"}, 298 | }, 299 | "additionalProperties": False, 300 | }, 301 | ), 302 | types.Tool( 303 | name="get-keys", 304 | description="Get list of API keys", 305 | inputSchema={ 306 | "type": "object", 307 | "properties": { 308 | "offset": {"type": "integer"}, 309 | "limit": {"type": "integer"}, 310 | }, 311 | "additionalProperties": False, 312 | }, 313 | ), 314 | types.Tool( 315 | name="create-key", 316 | description="Create a new API key", 317 | inputSchema={ 318 | "type": "object", 319 | "properties": { 320 | "description": {"type": "string"}, 321 | "actions": {"type": "array", "items": {"type": "string"}}, 322 | "indexes": {"type": "array", "items": {"type": "string"}}, 323 | "expiresAt": {"type": "string"}, 324 | }, 325 | "required": ["actions", "indexes"], 326 | "additionalProperties": False, 327 | }, 328 | ), 329 | types.Tool( 330 | name="delete-key", 331 | description="Delete an API key", 332 | inputSchema={ 333 | "type": "object", 334 | "properties": {"key": {"type": "string"}}, 335 | "required": ["key"], 336 | "additionalProperties": False, 337 | }, 338 | ), 339 | types.Tool( 340 | name="get-health-status", 341 | description="Get comprehensive health status of Meilisearch", 342 | inputSchema={ 343 | "type": "object", 344 | "properties": {}, 345 | "additionalProperties": False, 346 | }, 347 | ), 348 | types.Tool( 349 | name="get-index-metrics", 350 | description="Get detailed metrics for an index", 351 | inputSchema={ 352 | "type": "object", 353 | "properties": {"indexUid": {"type": "string"}}, 354 | "required": ["indexUid"], 355 | "additionalProperties": False, 356 | }, 357 | ), 358 | types.Tool( 359 | name="get-system-info", 360 | description="Get system-level information", 361 | inputSchema={ 362 | "type": "object", 363 | "properties": {}, 364 | "additionalProperties": False, 365 | }, 366 | ), 367 | types.Tool( 368 | name="create-chat-completion", 369 | description="Create a conversational chat completion using Meilisearch's chat feature", 370 | inputSchema={ 371 | "type": "object", 372 | "properties": { 373 | "workspace_uid": { 374 | "type": "string", 375 | "description": "Unique identifier of the chat workspace", 376 | }, 377 | "messages": { 378 | "type": "array", 379 | "items": { 380 | "type": "object", 381 | "properties": { 382 | "role": { 383 | "type": "string", 384 | "enum": ["user", "assistant", "system"], 385 | }, 386 | "content": {"type": "string"}, 387 | }, 388 | "required": ["role", "content"], 389 | }, 390 | "description": "List of message objects comprising the chat history", 391 | }, 392 | "model": { 393 | "type": "string", 394 | "default": "gpt-3.5-turbo", 395 | "description": "The model to use for completion", 396 | }, 397 | "stream": { 398 | "type": "boolean", 399 | "default": True, 400 | "description": "Whether to stream the response (currently must be true)", 401 | }, 402 | }, 403 | "required": ["workspace_uid", "messages"], 404 | "additionalProperties": False, 405 | }, 406 | ), 407 | types.Tool( 408 | name="get-chat-workspaces", 409 | description="Get list of available chat workspaces", 410 | inputSchema={ 411 | "type": "object", 412 | "properties": { 413 | "offset": { 414 | "type": "integer", 415 | "description": "Number of workspaces to skip", 416 | }, 417 | "limit": { 418 | "type": "integer", 419 | "description": "Maximum number of workspaces to return", 420 | }, 421 | }, 422 | "additionalProperties": False, 423 | }, 424 | ), 425 | types.Tool( 426 | name="get-chat-workspace-settings", 427 | description="Get settings for a specific chat workspace", 428 | inputSchema={ 429 | "type": "object", 430 | "properties": { 431 | "workspace_uid": { 432 | "type": "string", 433 | "description": "Unique identifier of the chat workspace", 434 | }, 435 | }, 436 | "required": ["workspace_uid"], 437 | "additionalProperties": False, 438 | }, 439 | ), 440 | types.Tool( 441 | name="update-chat-workspace-settings", 442 | description="Update settings for a specific chat workspace", 443 | inputSchema={ 444 | "type": "object", 445 | "properties": { 446 | "workspace_uid": { 447 | "type": "string", 448 | "description": "Unique identifier of the chat workspace", 449 | }, 450 | "settings": { 451 | "type": "object", 452 | "description": "Settings to update for the workspace", 453 | "additionalProperties": True, 454 | }, 455 | }, 456 | "required": ["workspace_uid", "settings"], 457 | "additionalProperties": False, 458 | }, 459 | ), 460 | ] 461 | 462 | @self.server.call_tool() 463 | async def handle_call_tool( 464 | name: str, arguments: Optional[Dict[str, Any]] = None 465 | ) -> list[types.TextContent]: 466 | """Handle tool execution""" 467 | try: 468 | if name == "get-connection-settings": 469 | return [ 470 | types.TextContent( 471 | type="text", 472 | text=f"Current connection settings:\nURL: {self.url}\nAPI Key: {'*' * 8 if self.api_key else 'Not set'}", 473 | ) 474 | ] 475 | 476 | elif name == "update-connection-settings": 477 | self.update_connection( 478 | arguments.get("url"), arguments.get("api_key") 479 | ) 480 | return [ 481 | types.TextContent( 482 | type="text", 483 | text=f"Successfully updated connection settings to URL: {self.url}", 484 | ) 485 | ] 486 | 487 | elif name == "create-index": 488 | result = self.meili_client.indexes.create_index( 489 | arguments["uid"], arguments.get("primaryKey") 490 | ) 491 | return [ 492 | types.TextContent(type="text", text=f"Created index: {result}") 493 | ] 494 | 495 | elif name == "list-indexes": 496 | indexes = self.meili_client.get_indexes() 497 | formatted_json = json.dumps( 498 | indexes, indent=2, default=json_serializer 499 | ) 500 | return [ 501 | types.TextContent( 502 | type="text", text=f"Indexes:\n{formatted_json}" 503 | ) 504 | ] 505 | 506 | elif name == "delete-index": 507 | result = self.meili_client.indexes.delete_index(arguments["uid"]) 508 | return [ 509 | types.TextContent( 510 | type="text", 511 | text=f"Successfully deleted index: {arguments['uid']}", 512 | ) 513 | ] 514 | 515 | elif name == "get-documents": 516 | # Use default values to fix None parameter issues (related to issue #17) 517 | offset = arguments.get("offset", 0) 518 | limit = arguments.get("limit", 20) 519 | documents = self.meili_client.documents.get_documents( 520 | arguments["indexUid"], 521 | offset, 522 | limit, 523 | ) 524 | # Convert DocumentsResults object to proper JSON format (fixes issue #16) 525 | formatted_json = json.dumps( 526 | documents, indent=2, default=json_serializer 527 | ) 528 | return [ 529 | types.TextContent( 530 | type="text", text=f"Documents:\n{formatted_json}" 531 | ) 532 | ] 533 | 534 | elif name == "add-documents": 535 | result = self.meili_client.documents.add_documents( 536 | arguments["indexUid"], 537 | arguments["documents"], 538 | arguments.get("primaryKey"), 539 | ) 540 | return [ 541 | types.TextContent( 542 | type="text", text=f"Added documents: {result}" 543 | ) 544 | ] 545 | 546 | elif name == "health-check": 547 | is_healthy = self.meili_client.health_check() 548 | return [ 549 | types.TextContent( 550 | type="text", 551 | text=f"Meilisearch is {is_healthy and 'available' or 'unavailable'}", 552 | ) 553 | ] 554 | 555 | elif name == "get-version": 556 | version = self.meili_client.get_version() 557 | return [ 558 | types.TextContent(type="text", text=f"Version info: {version}") 559 | ] 560 | 561 | elif name == "get-stats": 562 | stats = self.meili_client.get_stats() 563 | return [ 564 | types.TextContent(type="text", text=f"Database stats: {stats}") 565 | ] 566 | 567 | elif name == "get-settings": 568 | settings = self.meili_client.settings.get_settings( 569 | arguments["indexUid"] 570 | ) 571 | return [ 572 | types.TextContent( 573 | type="text", text=f"Current settings: {settings}" 574 | ) 575 | ] 576 | 577 | elif name == "update-settings": 578 | result = self.meili_client.settings.update_settings( 579 | arguments["indexUid"], arguments["settings"] 580 | ) 581 | return [ 582 | types.TextContent( 583 | type="text", text=f"Settings updated: {result}" 584 | ) 585 | ] 586 | 587 | elif name == "search": 588 | search_results = self.meili_client.search( 589 | query=arguments["query"], 590 | index_uid=arguments.get("indexUid"), 591 | limit=arguments.get("limit"), 592 | offset=arguments.get("offset"), 593 | filter=arguments.get("filter"), 594 | sort=arguments.get("sort"), 595 | ) 596 | 597 | # Format the results for better readability 598 | formatted_results = json.dumps( 599 | search_results, indent=2, default=json_serializer 600 | ) 601 | return [ 602 | types.TextContent( 603 | type="text", 604 | text=f"Search results for '{arguments['query']}':\n{formatted_results}", 605 | ) 606 | ] 607 | 608 | elif name == "get-task": 609 | task = self.meili_client.tasks.get_task(arguments["taskUid"]) 610 | return [ 611 | types.TextContent(type="text", text=f"Task information: {task}") 612 | ] 613 | 614 | elif name == "get-tasks": 615 | # Filter out any invalid parameters 616 | valid_params = { 617 | "limit", 618 | "from", 619 | "reverse", 620 | "batchUids", 621 | "uids", 622 | "canceledBy", 623 | "types", 624 | "statuses", 625 | "indexUids", 626 | "afterEnqueuedAt", 627 | "beforeEnqueuedAt", 628 | "afterStartedAt", 629 | "beforeStartedAt", 630 | "afterFinishedAt", 631 | "beforeFinishedAt", 632 | } 633 | filtered_args = ( 634 | {k: v for k, v in arguments.items() if k in valid_params} 635 | if arguments 636 | else {} 637 | ) 638 | tasks = self.meili_client.tasks.get_tasks(filtered_args) 639 | return [types.TextContent(type="text", text=f"Tasks: {tasks}")] 640 | 641 | elif name == "cancel-tasks": 642 | result = self.meili_client.tasks.cancel_tasks(arguments) 643 | return [ 644 | types.TextContent( 645 | type="text", text=f"Tasks cancelled: {result}" 646 | ) 647 | ] 648 | 649 | elif name == "get-keys": 650 | keys = self.meili_client.keys.get_keys(arguments) 651 | return [types.TextContent(type="text", text=f"API keys: {keys}")] 652 | 653 | elif name == "create-key": 654 | key = self.meili_client.keys.create_key( 655 | { 656 | "description": arguments.get("description"), 657 | "actions": arguments["actions"], 658 | "indexes": arguments["indexes"], 659 | "expiresAt": arguments.get("expiresAt"), 660 | } 661 | ) 662 | return [ 663 | types.TextContent(type="text", text=f"Created API key: {key}") 664 | ] 665 | 666 | elif name == "delete-key": 667 | self.meili_client.keys.delete_key(arguments["key"]) 668 | return [ 669 | types.TextContent( 670 | type="text", 671 | text=f"Successfully deleted API key: {arguments['key']}", 672 | ) 673 | ] 674 | 675 | elif name == "get-health-status": 676 | status = self.meili_client.monitoring.get_health_status() 677 | self.logger.info("Health status checked", status=status.__dict__) 678 | return [ 679 | types.TextContent( 680 | type="text", 681 | text=f"Health status: {json.dumps(status.__dict__, default=json_serializer)}", 682 | ) 683 | ] 684 | 685 | elif name == "get-index-metrics": 686 | metrics = self.meili_client.monitoring.get_index_metrics( 687 | arguments["indexUid"] 688 | ) 689 | self.logger.info( 690 | "Index metrics retrieved", 691 | index=arguments["indexUid"], 692 | metrics=metrics.__dict__, 693 | ) 694 | return [ 695 | types.TextContent( 696 | type="text", 697 | text=f"Index metrics: {json.dumps(metrics.__dict__, default=json_serializer)}", 698 | ) 699 | ] 700 | 701 | elif name == "get-system-info": 702 | info = self.meili_client.monitoring.get_system_information() 703 | self.logger.info("System information retrieved", info=info) 704 | return [ 705 | types.TextContent( 706 | type="text", text=f"System information: {info}" 707 | ) 708 | ] 709 | 710 | elif name == "create-chat-completion": 711 | response = await self.chat_manager.create_chat_completion( 712 | workspace_uid=arguments["workspace_uid"], 713 | messages=arguments["messages"], 714 | model=arguments.get("model", "gpt-3.5-turbo"), 715 | stream=arguments.get("stream", True), 716 | ) 717 | return [ 718 | types.TextContent( 719 | type="text", 720 | text=f"Chat completion response:\n{response}", 721 | ) 722 | ] 723 | 724 | elif name == "get-chat-workspaces": 725 | workspaces = await self.chat_manager.get_chat_workspaces( 726 | offset=arguments.get("offset") if arguments else None, 727 | limit=arguments.get("limit") if arguments else None, 728 | ) 729 | formatted_json = json.dumps( 730 | workspaces, indent=2, default=json_serializer 731 | ) 732 | return [ 733 | types.TextContent( 734 | type="text", 735 | text=f"Chat workspaces:\n{formatted_json}", 736 | ) 737 | ] 738 | 739 | elif name == "get-chat-workspace-settings": 740 | settings = await self.chat_manager.get_chat_workspace_settings( 741 | workspace_uid=arguments["workspace_uid"] 742 | ) 743 | formatted_json = json.dumps( 744 | settings, indent=2, default=json_serializer 745 | ) 746 | return [ 747 | types.TextContent( 748 | type="text", 749 | text=f"Workspace settings for '{arguments['workspace_uid']}':\n{formatted_json}", 750 | ) 751 | ] 752 | 753 | elif name == "update-chat-workspace-settings": 754 | updated_settings = ( 755 | await self.chat_manager.update_chat_workspace_settings( 756 | workspace_uid=arguments["workspace_uid"], 757 | settings=arguments["settings"], 758 | ) 759 | ) 760 | formatted_json = json.dumps( 761 | updated_settings, indent=2, default=json_serializer 762 | ) 763 | return [ 764 | types.TextContent( 765 | type="text", 766 | text=f"Updated workspace settings for '{arguments['workspace_uid']}':\n{formatted_json}", 767 | ) 768 | ] 769 | 770 | raise ValueError(f"Unknown tool: {name}") 771 | 772 | except Exception as e: 773 | self.logger.error( 774 | f"Error executing tool {name}", 775 | error=str(e), 776 | tool=name, 777 | arguments=arguments, 778 | ) 779 | return [types.TextContent(type="text", text=f"Error: {str(e)}")] 780 | 781 | async def run(self): 782 | """Run the MCP server""" 783 | logger.info("Starting Meilisearch MCP server...") 784 | 785 | async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): 786 | await self.server.run( 787 | read_stream, 788 | write_stream, 789 | InitializationOptions( 790 | server_name="meilisearch", 791 | server_version="0.1.0", 792 | capabilities=self.server.get_capabilities( 793 | notification_options=NotificationOptions(), 794 | experimental_capabilities={}, 795 | ), 796 | ), 797 | ) 798 | 799 | def cleanup(self): 800 | """Clean shutdown""" 801 | self.logger.info("Shutting down MCP server") 802 | self.logger.shutdown() 803 | 804 | 805 | def main(): 806 | """Main entry point""" 807 | url = os.getenv("MEILI_HTTP_ADDR", "http://localhost:7700") 808 | api_key = os.getenv("MEILI_MASTER_KEY") 809 | 810 | server = create_server(url, api_key) 811 | asyncio.run(server.run()) 812 | 813 | 814 | if __name__ == "__main__": 815 | main() 816 | --------------------------------------------------------------------------------