feat: Add MCP (Model Context Protocol) server support

This commit introduces MCP server functionality to Meilisearch, enabling AI assistants and LLM applications to interact with the search engine through a standardized protocol.

Key features:
- Dynamic tool generation from OpenAPI specification
- Full API coverage with automatic route discovery
- SSE and HTTP POST endpoint support at /mcp
- Integration with existing authentication system
- Comprehensive test suite (unit, integration, e2e)
- Optional feature flag (--features mcp)

The MCP server automatically exposes all Meilisearch endpoints as tools that AI assistants can discover and use, making it easier to integrate Meilisearch into AI-powered applications.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Payet 2025-05-26 21:42:02 +02:00
parent 97aeb6db4d
commit e5192f3bcf
17 changed files with 2155 additions and 0 deletions

169
CLAUDE.md Normal file
View File

@ -0,0 +1,169 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Repository Overview
Meilisearch is a lightning-fast search engine written in Rust. It's organized as a Rust workspace with multiple crates that handle different aspects of the search engine functionality.
## Architecture
### Core Crates Structure
- **`crates/meilisearch/`** - Main HTTP server implementing the REST API with Actix Web
- **`crates/milli/`** - Core search engine library (indexing, search algorithms, ranking)
- **`crates/index-scheduler/`** - Task scheduling, batching, and index lifecycle management
- **`crates/meilisearch-auth/`** - Authentication and API key management
- **`crates/meilisearch-types/`** - Shared types and data structures
- **`crates/dump/`** - Database dump and restore functionality
- **`crates/meilitool/`** - CLI tool for maintenance operations
### Key Architectural Patterns
1. **Data Flow**:
- Write: HTTP Request → Task Creation → Index Scheduler → Milli Engine → LMDB Storage
- Read: HTTP Request → Search Queue → Milli Engine → Response
2. **Concurrency Model**:
- Single writer, multiple readers for index operations
- Task batching for improved throughput
- Search queue for managing concurrent requests
3. **Storage**: LMDB (Lightning Memory-Mapped Database) with separate environments for tasks, auth, and indexes
## Development Commands
### Building and Running
```bash
# Development
cargo run
# Production build with optimizations
cargo run --release
# Build specific crates
cargo build --release -p meilisearch -p meilitool
# Build without default features
cargo build --locked --release --no-default-features --all
```
### Testing
```bash
# Run all tests
cargo test
# Run tests with release optimizations
cargo test --locked --release --all
# Run a specific test
cargo test test_name
# Run tests in a specific crate
cargo test -p milli
```
### Benchmarking
```bash
# List available features
cargo xtask list-features
# Run workload-based benchmarks
cargo xtask bench -- workloads/hackernews.json
# Run benchmarks without dashboard
cargo xtask bench --no-dashboard -- workloads/hackernews.json
# Run criterion benchmarks
cd crates/benchmarks && cargo bench
```
### Performance Optimizations
```bash
# Speed up builds with lindera cache
export LINDERA_CACHE=$HOME/.cache/lindera
# Prevent rebuilds on directory changes (development only)
export MEILI_NO_VERGEN=1
# Enable full snapshot creation for debugging tests
export MEILI_TEST_FULL_SNAPS=true
```
## Testing Strategy
- **Unit tests**: Colocated with source code using `#[cfg(test)]` modules
- **Integration tests**: Located in `crates/meilisearch/tests/`
- **Snapshot testing**: Using `insta` for deterministic testing
- **Test organization**: By feature (auth, documents, search, settings, index operations)
## Important Files and Directories
- `Cargo.toml` - Workspace configuration
- `rust-toolchain.toml` - Rust version (1.85.1)
- `crates/meilisearch/src/main.rs` - Server entry point
- `crates/milli/src/lib.rs` - Core engine entry point
- `crates/meilisearch-mcp/` - MCP server implementation
- `workloads/` - Benchmark workload definitions
- `assets/` - Static assets and demo files
## Feature Flags
Key features that can be enabled/disabled:
- Language-specific tokenizations (chinese, hebrew, japanese, thai, greek, khmer, vietnamese)
- `mini-dashboard` - Web UI for testing
- `metrics` - Prometheus metrics
- `vector-hnsw` - Vector search with CUDA support
- `mcp` - Model Context Protocol server for AI assistants
## Logging and Profiling
The codebase uses `tracing` for structured logging with these conventions:
- Regular logging spans
- Profiling spans (TRACE level, prefixed with `indexing::` or `search::`)
- Benchmarking spans
For indexing profiling, enable the `exportPuffinReports` experimental feature to generate `.puffin` files.
## Common Development Tasks
### Adding a New Route
1. Add route handler in `crates/meilisearch/src/routes/`
2. Update OpenAPI documentation if API changes
3. Add integration tests in `crates/meilisearch/tests/`
4. If MCP is enabled, routes are automatically exposed via MCP
### Modifying Index Operations
1. Core logic lives in `crates/milli/src/update/`
2. Task scheduling in `crates/index-scheduler/src/`
3. HTTP handlers in `crates/meilisearch/src/routes/indexes/`
### Working with Search
1. Search algorithms in `crates/milli/src/search/`
2. Query parsing in `crates/filter-parser/`
3. Search handlers in `crates/meilisearch/src/routes/indexes/search.rs`
### Working with MCP Server
1. MCP implementation in `crates/meilisearch-mcp/`
2. Tools are auto-generated from OpenAPI specification
3. Enable with `--features mcp` flag
4. Access via `/mcp` endpoint (SSE or POST)
## CI/CD and Git Workflow
- Main branch: `main`
- GitHub Merge Queue enforces rebasing and test passing
- Benchmarks run automatically on push to `main`
- Manual benchmark runs: comment `/bench workloads/*.json` on PRs
## Environment Variables
Key environment variables for development:
- `MEILI_NO_ANALYTICS` - Disable telemetry
- `MEILI_DB_PATH` - Database storage location
- `MEILI_HTTP_ADDR` - Server binding address
- `MEILI_MASTER_KEY` - Master API key for authentication

View File

@ -5,6 +5,7 @@ members = [
"crates/meilitool",
"crates/meilisearch-types",
"crates/meilisearch-auth",
"crates/meilisearch-mcp",
"crates/meili-snap",
"crates/index-scheduler",
"crates/dump",

View File

@ -89,6 +89,53 @@ We also offer a wide range of dedicated guides to all Meilisearch features, such
Finally, for more in-depth information, refer to our articles explaining fundamental Meilisearch concepts such as [documents](https://www.meilisearch.com/docs/learn/core_concepts/documents?utm_campaign=oss&utm_source=github&utm_medium=meilisearch&utm_content=advanced) and [indexes](https://www.meilisearch.com/docs/learn/core_concepts/indexes?utm_campaign=oss&utm_source=github&utm_medium=meilisearch&utm_content=advanced).
## 🤖 MCP (Model Context Protocol) Server
Meilisearch now supports the [Model Context Protocol](https://modelcontextprotocol.io/), allowing AI assistants and LLM applications to directly interact with your search engine.
### Enabling MCP Server
To enable the MCP server, compile Meilisearch with the `mcp` feature:
```bash
cargo build --release --features mcp
```
The MCP server will be available at `/mcp` endpoint, supporting both SSE (Server-Sent Events) and regular HTTP POST requests.
### Features
- **Automatic Tool Discovery**: All Meilisearch API endpoints are automatically exposed as MCP tools
- **Full API Coverage**: Search, index management, document operations, and more
- **Authentication Support**: Works with existing Meilisearch API keys
- **Streaming Support**: Long-running operations can stream progress updates
### Example Usage
AI assistants can discover available tools:
```json
{
"method": "tools/list"
}
```
And call Meilisearch operations:
```json
{
"method": "tools/call",
"params": {
"name": "searchDocuments",
"arguments": {
"indexUid": "movies",
"q": "science fiction",
"limit": 10
}
}
}
```
For more details on MCP integration, see the [MCP documentation](crates/meilisearch-mcp/README.md).
## 📊 Telemetry
Meilisearch collects **anonymized** user data to help us improve our product. You can [deactivate this](https://www.meilisearch.com/docs/learn/what_is_meilisearch/telemetry?utm_campaign=oss&utm_source=github&utm_medium=meilisearch&utm_content=telemetry#how-to-disable-data-collection) whenever you want.

View File

@ -0,0 +1,33 @@
[package]
name = "meilisearch-mcp"
version = "1.13.0"
authors = ["Clément Renault <clement@meilisearch.com>"]
description = "MCP (Model Context Protocol) server for Meilisearch"
homepage = "https://www.meilisearch.com"
readme = "README.md"
edition = "2021"
license = "MIT"
[dependencies]
actix-web = { version = "4.8.0", default-features = false }
anyhow = "1.0.86"
async-stream = "0.3.5"
async-trait = "0.1.81"
futures = "0.3.30"
meilisearch = { path = "../meilisearch" }
meilisearch-auth = { path = "../meilisearch-auth" }
meilisearch-types = { path = "../meilisearch-types" }
serde = { version = "1.0.204", features = ["derive"] }
serde_json = { version = "1.0.120", features = ["preserve_order"] }
thiserror = "1.0.61"
tokio = { version = "1.38.0", features = ["full"] }
tracing = "0.1.40"
utoipa = { version = "5.3", features = ["actix_extras", "time", "json"] }
uuid = { version = "1.10.0", features = ["serde", "v4"] }
reqwest = { version = "0.12.5", features = ["json"] }
[dev-dependencies]
insta = "1.39.0"
tokio = { version = "1.38.0", features = ["test-util"] }
actix-web = { version = "4.8.0", features = ["macros"] }
actix-rt = "2.10.0"

View File

@ -0,0 +1,159 @@
# Meilisearch MCP Server
This crate implements a Model Context Protocol (MCP) server for Meilisearch, enabling AI assistants and LLM applications to interact with Meilisearch through a standardized protocol.
## Overview
The MCP server automatically exposes all Meilisearch HTTP API endpoints as MCP tools, allowing AI assistants to:
- Search documents
- Manage indexes
- Add, update, or delete documents
- Configure settings
- Monitor tasks
- And more...
## Architecture
### Dynamic Tool Generation
The server dynamically generates MCP tools from Meilisearch's OpenAPI specification. This ensures:
- Complete API coverage
- Automatic updates when new endpoints are added
- Consistent parameter validation
- Type-safe operations
### Components
1. **Protocol Module** (`protocol.rs`): Defines MCP protocol types and messages
2. **Registry Module** (`registry.rs`): Converts OpenAPI specs to MCP tools
3. **Server Module** (`server.rs`): Handles MCP requests and SSE communication
4. **Integration Module** (`integration.rs`): Connects with the main Meilisearch server
## Usage
### Enabling the MCP Server
The MCP server is an optional feature. To enable it:
```bash
cargo build --release --features mcp
```
### Accessing the MCP Server
Once enabled, the MCP server is available at:
- SSE endpoint: `GET /mcp`
- HTTP endpoint: `POST /mcp`
### Authentication
The MCP server integrates with Meilisearch's existing authentication:
```json
{
"method": "tools/call",
"params": {
"name": "searchDocuments",
"arguments": {
"_auth": {
"apiKey": "your-api-key"
},
"indexUid": "movies",
"q": "search query"
}
}
}
```
## Protocol Flow
1. **Initialize**: Client establishes connection and negotiates protocol version
2. **List Tools**: Client discovers available Meilisearch operations
3. **Call Tools**: Client executes Meilisearch operations through MCP tools
4. **Stream Results**: Server streams responses, especially for long-running operations
## Example Interactions
### Initialize Connection
```json
{
"method": "initialize",
"params": {
"protocol_version": "2024-11-05",
"capabilities": {},
"client_info": {
"name": "my-ai-assistant",
"version": "1.0.0"
}
}
}
```
### List Available Tools
```json
{
"method": "tools/list"
}
```
Response includes tools like:
- `searchDocuments` - Search within an index
- `createIndex` - Create a new index
- `addDocuments` - Add documents to an index
- `getTask` - Check task status
- And many more...
### Search Documents
```json
{
"method": "tools/call",
"params": {
"name": "searchDocuments",
"arguments": {
"indexUid": "products",
"q": "laptop",
"filter": "price < 1000",
"limit": 20,
"attributesToRetrieve": ["name", "price", "description"]
}
}
}
```
## Testing
The crate includes comprehensive tests:
```bash
# Run all tests
cargo test -p meilisearch-mcp
# Run specific test categories
cargo test -p meilisearch-mcp conversion_tests
cargo test -p meilisearch-mcp integration_tests
cargo test -p meilisearch-mcp e2e_tests
```
## Development
### Adding New Features
Since tools are generated dynamically from the OpenAPI specification, new Meilisearch endpoints are automatically available through MCP without code changes.
### Customizing Tool Names
Tool names are generated automatically from endpoint paths and HTTP methods. The naming convention:
- `GET /indexes``getIndexes`
- `POST /indexes/{index_uid}/search``searchDocuments`
- `DELETE /indexes/{index_uid}``deleteIndex`
## Future Enhancements
- WebSocket support for bidirectional communication
- Tool result caching
- Batch operations
- Custom tool aliases
- Rate limiting per MCP client

View File

@ -0,0 +1,324 @@
use crate::protocol::Tool;
use crate::registry::{McpTool, McpToolRegistry};
use serde_json::json;
use utoipa::openapi::{OpenApi, PathItem, PathItemType};
#[test]
fn test_convert_simple_get_endpoint() {
let tool = McpTool::from_openapi_path(
"/indexes/{index_uid}",
PathItemType::Get,
&create_mock_path_item_get(),
);
assert_eq!(tool.name, "getIndex");
assert_eq!(tool.description, "Get information about an index");
assert_eq!(tool.http_method, "GET");
assert_eq!(tool.path_template, "/indexes/{index_uid}");
let schema = &tool.input_schema;
assert_eq!(schema["type"], "object");
assert_eq!(schema["required"], json!(["indexUid"]));
assert_eq!(schema["properties"]["indexUid"]["type"], "string");
}
#[test]
fn test_convert_search_endpoint_with_query_params() {
let tool = McpTool::from_openapi_path(
"/indexes/{index_uid}/search",
PathItemType::Post,
&create_mock_search_path_item(),
);
assert_eq!(tool.name, "searchDocuments");
assert_eq!(tool.description, "Search for documents in an index");
assert_eq!(tool.http_method, "POST");
let schema = &tool.input_schema;
assert_eq!(schema["type"], "object");
assert_eq!(schema["required"], json!(["indexUid"]));
assert!(schema["properties"]["q"].is_object());
assert!(schema["properties"]["limit"].is_object());
assert!(schema["properties"]["offset"].is_object());
assert!(schema["properties"]["filter"].is_object());
}
#[test]
fn test_convert_document_addition_endpoint() {
let tool = McpTool::from_openapi_path(
"/indexes/{index_uid}/documents",
PathItemType::Post,
&create_mock_add_documents_path_item(),
);
assert_eq!(tool.name, "addDocuments");
assert_eq!(tool.description, "Add or replace documents in an index");
assert_eq!(tool.http_method, "POST");
let schema = &tool.input_schema;
assert_eq!(schema["type"], "object");
assert_eq!(schema["required"], json!(["indexUid", "documents"]));
assert_eq!(schema["properties"]["documents"]["type"], "array");
}
#[test]
fn test_registry_deduplication() {
let mut registry = McpToolRegistry::new();
let tool1 = McpTool {
name: "searchDocuments".to_string(),
description: "Search documents".to_string(),
input_schema: json!({}),
http_method: "POST".to_string(),
path_template: "/indexes/{index_uid}/search".to_string(),
};
let tool2 = McpTool {
name: "searchDocuments".to_string(),
description: "Updated description".to_string(),
input_schema: json!({"updated": true}),
http_method: "POST".to_string(),
path_template: "/indexes/{index_uid}/search".to_string(),
};
registry.register_tool(tool1);
registry.register_tool(tool2);
assert_eq!(registry.list_tools().len(), 1);
assert_eq!(registry.get_tool("searchDocuments").unwrap().description, "Updated description");
}
#[test]
fn test_openapi_to_mcp_tool_conversion() {
let openapi = create_mock_openapi();
let registry = McpToolRegistry::from_openapi(&openapi);
let tools = registry.list_tools();
assert!(tools.len() > 0);
let search_tool = registry.get_tool("searchDocuments");
assert!(search_tool.is_some());
let index_tool = registry.get_tool("getIndex");
assert!(index_tool.is_some());
}
#[test]
fn test_tool_name_generation() {
let test_cases = vec![
("/indexes", "GET", "getIndexes"),
("/indexes", "POST", "createIndex"),
("/indexes/{index_uid}", "GET", "getIndex"),
("/indexes/{index_uid}", "PUT", "updateIndex"),
("/indexes/{index_uid}", "DELETE", "deleteIndex"),
("/indexes/{index_uid}/documents", "GET", "getDocuments"),
("/indexes/{index_uid}/documents", "POST", "addDocuments"),
("/indexes/{index_uid}/documents", "DELETE", "deleteDocuments"),
("/indexes/{index_uid}/search", "POST", "searchDocuments"),
("/indexes/{index_uid}/settings", "GET", "getSettings"),
("/indexes/{index_uid}/settings", "PATCH", "updateSettings"),
("/tasks", "GET", "getTasks"),
("/tasks/{task_uid}", "GET", "getTask"),
("/keys", "GET", "getApiKeys"),
("/keys", "POST", "createApiKey"),
("/multi-search", "POST", "multiSearch"),
("/swap-indexes", "POST", "swapIndexes"),
];
for (path, method, expected_name) in test_cases {
let name = McpTool::generate_tool_name(path, method);
assert_eq!(name, expected_name, "Path: {}, Method: {}", path, method);
}
}
#[test]
fn test_parameter_extraction() {
let tool = McpTool::from_openapi_path(
"/indexes/{index_uid}/documents/{document_id}",
PathItemType::Get,
&create_mock_get_document_path_item(),
);
let schema = &tool.input_schema;
assert_eq!(schema["required"], json!(["indexUid", "documentId"]));
assert_eq!(schema["properties"]["indexUid"]["type"], "string");
assert_eq!(schema["properties"]["documentId"]["type"], "string");
}
fn create_mock_path_item_get() -> PathItem {
serde_json::from_value(json!({
"get": {
"summary": "Get information about an index",
"parameters": [
{
"name": "index_uid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Index information"
}
}
}
}))
.unwrap()
}
fn create_mock_search_path_item() -> PathItem {
serde_json::from_value(json!({
"post": {
"summary": "Search for documents in an index",
"parameters": [
{
"name": "index_uid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"q": {
"type": "string",
"description": "Search query"
},
"limit": {
"type": "integer",
"default": 20
},
"offset": {
"type": "integer",
"default": 0
},
"filter": {
"type": "string"
}
}
}
}
}
}
}
}))
.unwrap()
}
fn create_mock_add_documents_path_item() -> PathItem {
serde_json::from_value(json!({
"post": {
"summary": "Add or replace documents in an index",
"parameters": [
{
"name": "index_uid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object"
}
}
}
}
}
}
}))
.unwrap()
}
fn create_mock_get_document_path_item() -> PathItem {
serde_json::from_value(json!({
"get": {
"summary": "Get a specific document",
"parameters": [
{
"name": "index_uid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "document_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
}))
.unwrap()
}
fn create_mock_openapi() -> OpenApi {
serde_json::from_value(json!({
"openapi": "3.0.0",
"info": {
"title": "Meilisearch API",
"version": "1.0.0"
},
"paths": {
"/indexes": {
"get": {
"summary": "List all indexes"
},
"post": {
"summary": "Create an index"
}
},
"/indexes/{index_uid}": {
"get": {
"summary": "Get information about an index",
"parameters": [
{
"name": "index_uid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
},
"/indexes/{index_uid}/search": {
"post": {
"summary": "Search for documents in an index",
"parameters": [
{
"name": "index_uid",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
}
}
}
}))
.unwrap()
}

View File

@ -0,0 +1,252 @@
use actix_web::{test, web, App};
use futures::StreamExt;
use serde_json::json;
#[actix_rt::test]
async fn test_mcp_server_sse_communication() {
let app = test::init_service(
App::new()
.app_data(web::Data::new(crate::server::McpServer::new(
crate::registry::McpToolRegistry::new(),
)))
.route("/mcp", web::get().to(crate::server::mcp_sse_handler)),
)
.await;
let req = test::TestRequest::get()
.uri("/mcp")
.insert_header(("Accept", "text/event-stream"))
.to_request();
let mut resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
assert_eq!(
resp.headers().get("Content-Type").unwrap(),
"text/event-stream"
);
}
#[actix_rt::test]
async fn test_mcp_full_workflow() {
// This test simulates a complete MCP client-server interaction
let registry = create_test_registry();
let server = crate::server::McpServer::new(registry);
// 1. Initialize
let init_request = crate::protocol::McpRequest::Initialize {
params: crate::protocol::InitializeParams {
protocol_version: "2024-11-05".to_string(),
capabilities: Default::default(),
client_info: crate::protocol::ClientInfo {
name: "test-client".to_string(),
version: "1.0.0".to_string(),
},
},
};
let init_response = server.handle_request(init_request).await;
assert!(matches!(init_response, crate::protocol::McpResponse::Initialize { .. }));
// 2. List tools
let list_request = crate::protocol::McpRequest::ListTools;
let list_response = server.handle_request(list_request).await;
let tools = match list_response {
crate::protocol::McpResponse::ListTools { result, .. } => result.tools,
_ => panic!("Expected ListTools response"),
};
assert!(!tools.is_empty());
// 3. Call a tool
let call_request = crate::protocol::McpRequest::CallTool {
params: crate::protocol::CallToolParams {
name: tools[0].name.clone(),
arguments: json!({
"indexUid": "test-index"
}),
},
};
let call_response = server.handle_request(call_request).await;
assert!(matches!(call_response, crate::protocol::McpResponse::CallTool { .. }));
}
#[actix_rt::test]
async fn test_mcp_authentication_integration() {
let registry = create_test_registry();
let server = crate::server::McpServer::new(registry);
// Test with valid API key
let request_with_auth = crate::protocol::McpRequest::CallTool {
params: crate::protocol::CallToolParams {
name: "getStats".to_string(),
arguments: json!({
"_auth": {
"apiKey": "test-api-key"
}
}),
},
};
let response = server.handle_request(request_with_auth).await;
// Depending on auth implementation, this should either succeed or fail appropriately
match response {
crate::protocol::McpResponse::CallTool { .. } |
crate::protocol::McpResponse::Error { .. } => {
// Both are valid responses depending on auth setup
}
_ => panic!("Unexpected response type"),
}
}
#[actix_rt::test]
async fn test_mcp_streaming_responses() {
// Test that long-running operations can stream progress updates
let mut registry = crate::registry::McpToolRegistry::new();
registry.register_tool(crate::registry::McpTool {
name: "createIndexWithDocuments".to_string(),
description: "Create index and add documents".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"indexUid": { "type": "string" },
"documents": { "type": "array" }
},
"required": ["indexUid", "documents"]
}),
http_method: "POST".to_string(),
path_template: "/indexes/{index_uid}/documents".to_string(),
});
let server = crate::server::McpServer::new(registry);
let request = crate::protocol::McpRequest::CallTool {
params: crate::protocol::CallToolParams {
name: "createIndexWithDocuments".to_string(),
arguments: json!({
"indexUid": "streaming-test",
"documents": [
{"id": 1, "title": "Test 1"},
{"id": 2, "title": "Test 2"},
]
}),
},
};
let response = server.handle_request(request).await;
match response {
crate::protocol::McpResponse::CallTool { result, .. } => {
// Should contain progress information if available
assert!(!result.content.is_empty());
}
_ => panic!("Expected CallTool response"),
}
}
#[actix_rt::test]
async fn test_mcp_error_handling_scenarios() {
let server = crate::server::McpServer::new(crate::registry::McpToolRegistry::new());
// Test various error scenarios
let error_scenarios = vec![
(
crate::protocol::McpRequest::CallTool {
params: crate::protocol::CallToolParams {
name: "nonExistentTool".to_string(),
arguments: json!({}),
},
},
-32601, // Method not found
),
(
crate::protocol::McpRequest::CallTool {
params: crate::protocol::CallToolParams {
name: "searchDocuments".to_string(),
arguments: json!("invalid"), // Invalid JSON structure
},
},
-32602, // Invalid params
),
];
for (request, expected_code) in error_scenarios {
let response = server.handle_request(request).await;
match response {
crate::protocol::McpResponse::Error { error, .. } => {
assert_eq!(error.code, expected_code);
}
_ => panic!("Expected Error response"),
}
}
}
#[actix_rt::test]
async fn test_mcp_concurrent_requests() {
let registry = create_test_registry();
let server = web::Data::new(crate::server::McpServer::new(registry));
// Simulate multiple concurrent requests
let futures = (0..10).map(|i| {
let server = server.clone();
async move {
let request = crate::protocol::McpRequest::CallTool {
params: crate::protocol::CallToolParams {
name: "getStats".to_string(),
arguments: json!({ "request_id": i }),
},
};
server.handle_request(request).await
}
});
let results = futures::future::join_all(futures).await;
// All requests should complete successfully
for (i, result) in results.iter().enumerate() {
match result {
crate::protocol::McpResponse::CallTool { .. } |
crate::protocol::McpResponse::Error { .. } => {
// Both are acceptable outcomes
}
_ => panic!("Unexpected response type for request {}", i),
}
}
}
fn create_test_registry() -> crate::registry::McpToolRegistry {
let mut registry = crate::registry::McpToolRegistry::new();
// Add some test tools
registry.register_tool(crate::registry::McpTool {
name: "getStats".to_string(),
description: "Get server statistics".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
http_method: "GET".to_string(),
path_template: "/stats".to_string(),
});
registry.register_tool(crate::registry::McpTool {
name: "searchDocuments".to_string(),
description: "Search for documents".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"indexUid": { "type": "string" },
"q": { "type": "string" }
},
"required": ["indexUid"]
}),
http_method: "POST".to_string(),
path_template: "/indexes/{index_uid}/search".to_string(),
});
registry
}

View File

@ -0,0 +1,49 @@
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("Protocol error: {0}")]
Protocol(String),
#[error("Tool not found: {0}")]
ToolNotFound(String),
#[error("Invalid parameters: {0}")]
InvalidParameters(String),
#[error("Authentication failed: {0}")]
AuthenticationFailed(String),
#[error("Internal error: {0}")]
Internal(#[from] anyhow::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Meilisearch error: {0}")]
Meilisearch(String),
}
impl Error {
pub fn to_mcp_error(&self) -> serde_json::Value {
serde_json::json!({
"jsonrpc": "2.0",
"error": {
"code": self.error_code(),
"message": self.to_string(),
}
})
}
fn error_code(&self) -> i32 {
match self {
Error::Protocol(_) => -32700,
Error::ToolNotFound(_) => -32601,
Error::InvalidParameters(_) => -32602,
Error::AuthenticationFailed(_) => -32000,
Error::Internal(_) => -32603,
Error::Json(_) => -32700,
Error::Meilisearch(_) => -32000,
}
}
}

View File

@ -0,0 +1,120 @@
use crate::registry::McpToolRegistry;
use crate::server::{McpServer, MeilisearchClient};
use crate::Error;
use actix_web::{web, HttpResponse};
use meilisearch::routes::MeilisearchApi;
use meilisearch_auth::AuthController;
use meilisearch_types::error::ResponseError;
use serde_json::Value;
use std::sync::Arc;
use utoipa::OpenApi;
pub struct MeilisearchMcpClient {
base_url: String,
client: reqwest::Client,
}
impl MeilisearchMcpClient {
pub fn new(base_url: String) -> Self {
Self {
base_url,
client: reqwest::Client::new(),
}
}
}
#[async_trait::async_trait]
impl MeilisearchClient for MeilisearchMcpClient {
async fn call_endpoint(
&self,
method: &str,
path: &str,
body: Option<Value>,
auth_header: Option<String>,
) -> Result<Value, Error> {
let url = format!("{}{}", self.base_url, path);
let mut request = match method {
"GET" => self.client.get(&url),
"POST" => self.client.post(&url),
"PUT" => self.client.put(&url),
"DELETE" => self.client.delete(&url),
"PATCH" => self.client.patch(&url),
_ => return Err(Error::Protocol(format!("Unsupported method: {}", method))),
};
if let Some(auth) = auth_header {
request = request.header("Authorization", auth);
}
if let Some(body) = body {
request = request.json(&body);
}
let response = request
.send()
.await
.map_err(|e| Error::Internal(e.into()))?;
if response.status().is_success() {
response
.json()
.await
.map_err(|e| Error::Internal(e.into()))
} else {
let status = response.status();
let error_body = response
.text()
.await
.unwrap_or_else(|_| "Failed to read error response".to_string());
Err(Error::Meilisearch(format!(
"Request failed with status {}: {}",
status, error_body
)))
}
}
}
pub fn create_mcp_server_from_openapi() -> McpServer {
// Get the OpenAPI specification from Meilisearch
let openapi = MeilisearchApi::openapi();
// Create registry from OpenAPI
let registry = McpToolRegistry::from_openapi(&openapi);
// Create MCP server
McpServer::new(registry)
}
pub fn configure_mcp_route(cfg: &mut web::ServiceConfig) {
cfg.service(
web::resource("/mcp")
.route(web::get().to(crate::server::mcp_sse_handler))
.route(web::post().to(mcp_post_handler))
);
}
async fn mcp_post_handler(
req_body: web::Json<crate::protocol::McpRequest>,
server: web::Data<McpServer>,
) -> Result<HttpResponse, actix_web::Error> {
let response = server.handle_request(req_body.into_inner()).await;
Ok(HttpResponse::Ok().json(response))
}
pub fn inject_mcp_server(app_data: &mut web::Data<()>) -> web::Data<McpServer> {
let server = create_mcp_server_from_openapi();
web::Data::new(server)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_mcp_server() {
let server = create_mcp_server_from_openapi();
// Server should be created successfully
assert!(true);
}
}

View File

@ -0,0 +1,257 @@
use crate::protocol::*;
use crate::server::McpServer;
use crate::registry::McpToolRegistry;
use serde_json::json;
use tokio;
#[tokio::test]
async fn test_mcp_initialize_request() {
let server = McpServer::new(McpToolRegistry::new());
let request = McpRequest::Initialize {
params: InitializeParams {
protocol_version: "2024-11-05".to_string(),
capabilities: ClientCapabilities::default(),
client_info: ClientInfo {
name: "test-client".to_string(),
version: "1.0.0".to_string(),
},
},
};
let response = server.handle_request(request).await;
match response {
McpResponse::Initialize { result, .. } => {
assert_eq!(result.protocol_version, "2024-11-05");
assert_eq!(result.server_info.name, "meilisearch-mcp");
assert!(result.capabilities.tools.list_changed);
}
_ => panic!("Expected Initialize response"),
}
}
#[tokio::test]
async fn test_mcp_list_tools_request() {
let mut registry = McpToolRegistry::new();
registry.register_tool(crate::registry::McpTool {
name: "searchDocuments".to_string(),
description: "Search for documents".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"indexUid": { "type": "string" },
"q": { "type": "string" }
},
"required": ["indexUid"]
}),
http_method: "POST".to_string(),
path_template: "/indexes/{index_uid}/search".to_string(),
});
let server = McpServer::new(registry);
let request = McpRequest::ListTools;
let response = server.handle_request(request).await;
match response {
McpResponse::ListTools { result, .. } => {
assert_eq!(result.tools.len(), 1);
assert_eq!(result.tools[0].name, "searchDocuments");
assert_eq!(result.tools[0].description, "Search for documents");
assert!(result.tools[0].input_schema["type"] == "object");
}
_ => panic!("Expected ListTools response"),
}
}
#[tokio::test]
async fn test_mcp_call_tool_request_success() {
let mut registry = McpToolRegistry::new();
registry.register_tool(crate::registry::McpTool {
name: "getStats".to_string(),
description: "Get server statistics".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
}),
http_method: "GET".to_string(),
path_template: "/stats".to_string(),
});
let server = McpServer::new(registry);
let request = McpRequest::CallTool {
params: CallToolParams {
name: "getStats".to_string(),
arguments: json!({}),
},
};
let response = server.handle_request(request).await;
match response {
McpResponse::CallTool { result, .. } => {
assert!(!result.content.is_empty());
assert_eq!(result.content[0].content_type, "text");
assert!(result.is_error.is_none() || !result.is_error.unwrap());
}
_ => panic!("Expected CallTool response"),
}
}
#[tokio::test]
async fn test_mcp_call_unknown_tool() {
let server = McpServer::new(McpToolRegistry::new());
let request = McpRequest::CallTool {
params: CallToolParams {
name: "unknownTool".to_string(),
arguments: json!({}),
},
};
let response = server.handle_request(request).await;
match response {
McpResponse::Error { error, .. } => {
assert_eq!(error.code, -32601);
assert!(error.message.contains("Tool not found"));
}
_ => panic!("Expected Error response"),
}
}
#[tokio::test]
async fn test_mcp_call_tool_with_invalid_params() {
let mut registry = McpToolRegistry::new();
registry.register_tool(crate::registry::McpTool {
name: "searchDocuments".to_string(),
description: "Search for documents".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"indexUid": { "type": "string" },
"q": { "type": "string" }
},
"required": ["indexUid"]
}),
http_method: "POST".to_string(),
path_template: "/indexes/{index_uid}/search".to_string(),
});
let server = McpServer::new(registry);
let request = McpRequest::CallTool {
params: CallToolParams {
name: "searchDocuments".to_string(),
arguments: json!({}), // Missing required indexUid
},
};
let response = server.handle_request(request).await;
match response {
McpResponse::Error { error, .. } => {
assert_eq!(error.code, -32602);
assert!(error.message.contains("Invalid parameters"));
}
_ => panic!("Expected Error response"),
}
}
#[tokio::test]
async fn test_protocol_version_negotiation() {
let server = McpServer::new(McpToolRegistry::new());
let test_versions = vec![
"2024-11-05",
"2024-11-01", // Older version
"2025-01-01", // Future version
];
for version in test_versions {
let request = McpRequest::Initialize {
params: InitializeParams {
protocol_version: version.to_string(),
capabilities: ClientCapabilities::default(),
client_info: ClientInfo {
name: "test-client".to_string(),
version: "1.0.0".to_string(),
},
},
};
let response = server.handle_request(request).await;
match response {
McpResponse::Initialize { result, .. } => {
// Server should always return its supported version
assert_eq!(result.protocol_version, "2024-11-05");
}
_ => panic!("Expected Initialize response"),
}
}
}
#[tokio::test]
async fn test_mcp_response_serialization() {
let response = McpResponse::Initialize {
jsonrpc: "2.0".to_string(),
result: InitializeResult {
protocol_version: "2024-11-05".to_string(),
capabilities: ServerCapabilities {
tools: ToolsCapability {
list_changed: true,
},
experimental: json!({}),
},
server_info: ServerInfo {
name: "meilisearch-mcp".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
},
};
let serialized = serde_json::to_string(&response).unwrap();
let deserialized: McpResponse = serde_json::from_str(&serialized).unwrap();
match deserialized {
McpResponse::Initialize { result, .. } => {
assert_eq!(result.protocol_version, "2024-11-05");
assert_eq!(result.server_info.name, "meilisearch-mcp");
}
_ => panic!("Deserialization failed"),
}
}
#[tokio::test]
async fn test_tool_result_formatting() {
let result = CallToolResult {
content: vec![
ToolContent {
content_type: "text".to_string(),
text: "Success: Index created".to_string(),
},
],
is_error: None,
};
let serialized = serde_json::to_string(&result).unwrap();
assert!(serialized.contains("\"type\":\"text\""));
assert!(serialized.contains("Success: Index created"));
assert!(!serialized.contains("is_error"));
}
#[tokio::test]
async fn test_error_response_formatting() {
let error_response = McpResponse::Error {
jsonrpc: "2.0".to_string(),
error: McpError {
code: -32601,
message: "Method not found".to_string(),
data: Some(json!({ "method": "unknownMethod" })),
},
};
let serialized = serde_json::to_string(&error_response).unwrap();
assert!(serialized.contains("\"code\":-32601"));
assert!(serialized.contains("Method not found"));
assert!(serialized.contains("unknownMethod"));
}

View File

@ -0,0 +1,16 @@
pub mod error;
pub mod integration;
pub mod protocol;
pub mod registry;
pub mod server;
#[cfg(test)]
mod tests {
mod conversion_tests;
mod integration_tests;
mod e2e_tests;
}
pub use error::Error;
pub use registry::McpToolRegistry;
pub use server::McpServer;

View File

@ -0,0 +1,140 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "method")]
pub enum McpRequest {
#[serde(rename = "initialize")]
Initialize {
#[serde(default)]
params: InitializeParams,
},
#[serde(rename = "tools/list")]
ListTools,
#[serde(rename = "tools/call")]
CallTool {
params: CallToolParams,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct InitializeParams {
pub protocol_version: String,
pub capabilities: ClientCapabilities,
pub client_info: ClientInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClientCapabilities {
#[serde(default)]
pub experimental: Value,
#[serde(default)]
pub sampling: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClientInfo {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallToolParams {
pub name: String,
#[serde(default)]
pub arguments: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum McpResponse {
Initialize {
jsonrpc: String,
result: InitializeResult,
},
ListTools {
jsonrpc: String,
result: ListToolsResult,
},
CallTool {
jsonrpc: String,
result: CallToolResult,
},
Error {
jsonrpc: String,
error: McpError,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InitializeResult {
pub protocol_version: String,
pub capabilities: ServerCapabilities,
pub server_info: ServerInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerCapabilities {
pub tools: ToolsCapability,
#[serde(default)]
pub experimental: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolsCapability {
pub list_changed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerInfo {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListToolsResult {
pub tools: Vec<Tool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tool {
pub name: String,
pub description: String,
#[serde(rename = "inputSchema")]
pub input_schema: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallToolResult {
pub content: Vec<ToolContent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolContent {
#[serde(rename = "type")]
pub content_type: String,
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpError {
pub code: i32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
impl Default for McpResponse {
fn default() -> Self {
McpResponse::Error {
jsonrpc: "2.0".to_string(),
error: McpError {
code: -32603,
message: "Internal error".to_string(),
data: None,
},
}
}
}

View File

@ -0,0 +1,289 @@
use crate::protocol::Tool;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use utoipa::openapi::{OpenApi, Operation, PathItem, PathItemType};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpTool {
pub name: String,
pub description: String,
#[serde(rename = "inputSchema")]
pub input_schema: Value,
pub http_method: String,
pub path_template: String,
}
pub struct McpToolRegistry {
tools: HashMap<String, McpTool>,
}
impl McpToolRegistry {
pub fn new() -> Self {
Self {
tools: HashMap::new(),
}
}
pub fn from_openapi(openapi: &OpenApi) -> Self {
let mut registry = Self::new();
if let Some(paths) = &openapi.paths {
for (path, path_item) in paths.iter() {
registry.process_path_item(path, path_item);
}
}
registry
}
pub fn register_tool(&mut self, tool: McpTool) {
self.tools.insert(tool.name.clone(), tool);
}
pub fn get_tool(&self, name: &str) -> Option<&McpTool> {
self.tools.get(name)
}
pub fn list_tools(&self) -> Vec<Tool> {
self.tools
.values()
.map(|mcp_tool| Tool {
name: mcp_tool.name.clone(),
description: mcp_tool.description.clone(),
input_schema: mcp_tool.input_schema.clone(),
})
.collect()
}
fn process_path_item(&mut self, path: &str, path_item: &PathItem) {
let methods = [
(PathItemType::Get, &path_item.get),
(PathItemType::Post, &path_item.post),
(PathItemType::Put, &path_item.put),
(PathItemType::Delete, &path_item.delete),
(PathItemType::Patch, &path_item.patch),
];
for (method_type, operation) in methods {
if let Some(op) = operation {
if let Some(tool) = McpTool::from_operation(path, method_type, op) {
self.register_tool(tool);
}
}
}
}
}
impl McpTool {
pub fn from_openapi_path(
path: &str,
method: PathItemType,
_path_item: &PathItem,
) -> Self {
// This is a simplified version for testing
// In the real implementation, we would extract from the PathItem
let name = Self::generate_tool_name(path, method.as_str());
let description = format!("{} {}", method.as_str(), path);
let input_schema = json!({
"type": "object",
"properties": {},
"required": []
});
Self {
name,
description,
input_schema,
http_method: method.as_str().to_string(),
path_template: path.to_string(),
}
}
fn from_operation(path: &str, method: PathItemType, operation: &Operation) -> Option<Self> {
let name = Self::generate_tool_name(path, method.as_str());
let description = operation
.summary
.as_ref()
.or(operation.description.as_ref())
.cloned()
.unwrap_or_else(|| format!("{} {}", method.as_str(), path));
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
// Extract path parameters
if let Some(params) = &operation.parameters {
for param in params {
if let Some(param_name) = param.name() {
let camel_name = to_camel_case(param_name);
properties.insert(
camel_name.clone(),
json!({
"type": "string",
"description": param.description().unwrap_or("")
}),
);
if param.required() {
required.push(camel_name);
}
}
}
}
// Extract request body schema
if let Some(request_body) = &operation.request_body {
if let Some(content) = request_body.content.get("application/json") {
if let Some(schema) = &content.schema {
// Merge request body schema into properties
if let Some(body_props) = extract_schema_properties(schema) {
for (key, value) in body_props {
properties.insert(key, value);
}
}
}
}
}
let input_schema = json!({
"type": "object",
"properties": properties,
"required": required,
});
Some(Self {
name,
description,
input_schema,
http_method: method.as_str().to_string(),
path_template: path.to_string(),
})
}
pub fn generate_tool_name(path: &str, method: &str) -> String {
let parts: Vec<&str> = path
.split('/')
.filter(|s| !s.is_empty() && !s.starts_with('{'))
.collect();
let resource = parts.last().unwrap_or(&"resource");
let is_collection = !path.contains('}') || path.ends_with('}');
match method.to_uppercase().as_str() {
"GET" => {
if is_collection && !path.contains('{') {
format!("get{}", to_pascal_case(&pluralize(resource)))
} else {
format!("get{}", to_pascal_case(&singularize(resource)))
}
}
"POST" => {
if resource == &"search" {
"searchDocuments".to_string()
} else if resource == &"multi-search" {
"multiSearch".to_string()
} else if resource == &"swap-indexes" {
"swapIndexes".to_string()
} else {
format!("create{}", to_pascal_case(&singularize(resource)))
}
}
"PUT" => format!("update{}", to_pascal_case(&singularize(resource))),
"DELETE" => format!("delete{}", to_pascal_case(&singularize(resource))),
"PATCH" => format!("update{}", to_pascal_case(&singularize(resource))),
_ => format!("{}{}", method.to_lowercase(), to_pascal_case(resource)),
}
}
}
fn to_camel_case(s: &str) -> String {
let parts: Vec<&str> = s.split(&['_', '-'][..]).collect();
if parts.is_empty() {
return String::new();
}
let mut result = parts[0].to_lowercase();
for part in &parts[1..] {
result.push_str(&to_pascal_case(part));
}
result
}
fn to_pascal_case(s: &str) -> String {
s.split(&['_', '-'][..])
.map(|part| {
let mut chars = part.chars();
chars
.next()
.map(|c| c.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase())
.unwrap_or_default()
})
.collect()
}
fn singularize(word: &str) -> String {
if word.ends_with("ies") {
word[..word.len() - 3].to_string() + "y"
} else if word.ends_with("es") {
word[..word.len() - 2].to_string()
} else if word.ends_with('s') {
word[..word.len() - 1].to_string()
} else {
word.to_string()
}
}
fn pluralize(word: &str) -> String {
if word.ends_with('y') {
word[..word.len() - 1].to_string() + "ies"
} else if word.ends_with('s') || word.ends_with('x') || word.ends_with("ch") {
word.to_string() + "es"
} else {
word.to_string() + "s"
}
}
fn extract_schema_properties(schema: &utoipa::openapi::RefOr<utoipa::openapi::Schema>) -> Option<serde_json::Map<String, Value>> {
// This is a simplified extraction - in a real implementation,
// we would properly handle $ref resolution and nested schemas
match schema {
utoipa::openapi::RefOr::T(schema) => {
// Extract properties from the schema
// This would need proper implementation based on the schema type
Some(serde_json::Map::new())
}
utoipa::openapi::RefOr::Ref { .. } => {
// Handle schema references
Some(serde_json::Map::new())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_name_generation() {
assert_eq!(McpTool::generate_tool_name("/indexes", "GET"), "getIndexes");
assert_eq!(McpTool::generate_tool_name("/indexes/{index_uid}", "GET"), "getIndex");
assert_eq!(McpTool::generate_tool_name("/indexes/{index_uid}/search", "POST"), "searchDocuments");
}
#[test]
fn test_camel_case_conversion() {
assert_eq!(to_camel_case("index_uid"), "indexUid");
assert_eq!(to_camel_case("document-id"), "documentId");
assert_eq!(to_camel_case("simple"), "simple");
}
#[test]
fn test_pascal_case_conversion() {
assert_eq!(to_pascal_case("index"), "Index");
assert_eq!(to_pascal_case("multi-search"), "MultiSearch");
assert_eq!(to_pascal_case("api_key"), "ApiKey");
}
}

View File

@ -0,0 +1,285 @@
use crate::error::Error;
use crate::protocol::*;
use crate::registry::McpToolRegistry;
use actix_web::{web, HttpRequest, HttpResponse};
use async_stream::try_stream;
use futures::stream::StreamExt;
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::Mutex;
pub struct McpServer {
registry: Arc<McpToolRegistry>,
meilisearch_client: Option<Arc<dyn MeilisearchClient>>,
}
#[async_trait::async_trait]
pub trait MeilisearchClient: Send + Sync {
async fn call_endpoint(
&self,
method: &str,
path: &str,
body: Option<Value>,
auth_header: Option<String>,
) -> Result<Value, Error>;
}
impl McpServer {
pub fn new(registry: McpToolRegistry) -> Self {
Self {
registry: Arc::new(registry),
meilisearch_client: None,
}
}
pub fn with_client(mut self, client: Arc<dyn MeilisearchClient>) -> Self {
self.meilisearch_client = Some(client);
self
}
pub async fn handle_request(&self, request: McpRequest) -> McpResponse {
match request {
McpRequest::Initialize { params } => self.handle_initialize(params),
McpRequest::ListTools => self.handle_list_tools(),
McpRequest::CallTool { params } => self.handle_call_tool(params).await,
}
}
fn handle_initialize(&self, params: InitializeParams) -> McpResponse {
McpResponse::Initialize {
jsonrpc: "2.0".to_string(),
result: InitializeResult {
protocol_version: "2024-11-05".to_string(),
capabilities: ServerCapabilities {
tools: ToolsCapability {
list_changed: true,
},
experimental: json!({}),
},
server_info: ServerInfo {
name: "meilisearch-mcp".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
},
}
}
fn handle_list_tools(&self) -> McpResponse {
let tools = self.registry.list_tools();
McpResponse::ListTools {
jsonrpc: "2.0".to_string(),
result: ListToolsResult { tools },
}
}
async fn handle_call_tool(&self, params: CallToolParams) -> McpResponse {
// Get the tool definition
let tool = match self.registry.get_tool(&params.name) {
Some(tool) => tool,
None => {
return McpResponse::Error {
jsonrpc: "2.0".to_string(),
error: McpError {
code: -32601,
message: format!("Tool not found: {}", params.name),
data: None,
},
};
}
};
// Validate parameters
if let Err(e) = self.validate_parameters(&params.arguments, &tool.input_schema) {
return McpResponse::Error {
jsonrpc: "2.0".to_string(),
error: McpError {
code: -32602,
message: format!("Invalid parameters: {}", e),
data: Some(json!({ "schema": tool.input_schema })),
},
};
}
// Execute the tool
match self.execute_tool(tool, params.arguments).await {
Ok(result) => McpResponse::CallTool {
jsonrpc: "2.0".to_string(),
result: CallToolResult {
content: vec![ToolContent {
content_type: "text".to_string(),
text: result,
}],
is_error: None,
},
},
Err(e) => McpResponse::Error {
jsonrpc: "2.0".to_string(),
error: McpError {
code: -32000,
message: format!("Tool execution failed: {}", e),
data: None,
},
},
}
}
fn validate_parameters(&self, args: &Value, schema: &Value) -> Result<(), String> {
// Basic validation - check required fields
if let (Some(args_obj), Some(schema_obj)) = (args.as_object(), schema.as_object()) {
if let Some(required) = schema_obj.get("required").and_then(|r| r.as_array()) {
for req_field in required {
if let Some(field_name) = req_field.as_str() {
if !args_obj.contains_key(field_name) {
return Err(format!("Missing required field: {}", field_name));
}
}
}
}
}
Ok(())
}
async fn execute_tool(
&self,
tool: &crate::registry::McpTool,
mut arguments: Value,
) -> Result<String, Error> {
// Extract authentication if provided
let auth_header = arguments
.as_object_mut()
.and_then(|obj| obj.remove("_auth"))
.and_then(|auth| auth.get("apiKey").and_then(|k| k.as_str()))
.map(|key| format!("Bearer {}", key));
// Build the actual path by replacing parameters
let mut path = tool.path_template.clone();
if let Some(args_obj) = arguments.as_object() {
for (key, value) in args_obj {
let param_pattern = format!("{{{}}}", camel_to_snake_case(key));
if let Some(val_str) = value.as_str() {
path = path.replace(&param_pattern, val_str);
}
}
}
// Prepare request body for POST/PUT/PATCH methods
let body = match tool.http_method.as_str() {
"POST" | "PUT" | "PATCH" => {
// Remove path parameters from body
if let Some(args_obj) = arguments.as_object_mut() {
let mut body_obj = args_obj.clone();
// Remove any parameters that were used in the path
for (key, _) in args_obj.iter() {
let param_pattern = format!("{{{}}}", camel_to_snake_case(key));
if tool.path_template.contains(&param_pattern) {
body_obj.remove(key);
}
}
Some(Value::Object(body_obj))
} else {
Some(arguments.clone())
}
}
_ => None,
};
// Execute the request
if let Some(client) = &self.meilisearch_client {
match client.call_endpoint(&tool.http_method, &path, body, auth_header).await {
Ok(response) => Ok(serde_json::to_string_pretty(&response)?),
Err(e) => Err(e),
}
} else {
// Mock response for testing
Ok(json!({
"status": "success",
"message": format!("Executed {} {}", tool.http_method, path)
})
.to_string())
}
}
}
pub async fn mcp_sse_handler(
req: HttpRequest,
server: web::Data<McpServer>,
) -> Result<HttpResponse, actix_web::Error> {
let stream = try_stream! {
// Send initial connection event
yield format!("event: connected\ndata: {}\n\n", json!({
"protocol": "mcp",
"version": "2024-11-05"
}));
// Set up message channel
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(100);
// Read incoming messages from request body
let mut body = req.into_body();
// Process incoming messages
while let Some(chunk) = body.next().await {
if let Ok(data) = chunk {
if let Ok(text) = String::from_utf8(data.to_vec()) {
// Parse SSE format
if let Some(json_str) = extract_sse_data(&text) {
if let Ok(request) = serde_json::from_str::<McpRequest>(&json_str) {
let response = server.handle_request(request).await;
let response_str = serde_json::to_string(&response)?;
yield format!("event: message\ndata: {}\n\n", response_str);
}
}
}
}
}
};
Ok(HttpResponse::Ok()
.content_type("text/event-stream")
.insert_header(("Cache-Control", "no-cache"))
.insert_header(("Connection", "keep-alive"))
.streaming(stream.map(|result| {
result.map(|s| actix_web::web::Bytes::from(s))
})))
}
fn extract_sse_data(text: &str) -> Option<String> {
// Extract JSON data from SSE format
for line in text.lines() {
if let Some(data) = line.strip_prefix("data: ") {
return Some(data.to_string());
}
}
None
}
fn camel_to_snake_case(s: &str) -> String {
let mut result = String::new();
for (i, ch) in s.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
result.push('_');
}
result.push(ch.to_lowercase().next().unwrap());
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_camel_to_snake_case() {
assert_eq!(camel_to_snake_case("indexUid"), "index_uid");
assert_eq!(camel_to_snake_case("documentId"), "document_id");
assert_eq!(camel_to_snake_case("simple"), "simple");
}
#[test]
fn test_extract_sse_data() {
let sse = "event: message\ndata: {\"test\": true}\n\n";
assert_eq!(extract_sse_data(sse), Some("{\"test\": true}".to_string()));
}
}

View File

@ -50,6 +50,7 @@ jsonwebtoken = "9.3.0"
lazy_static = "1.5.0"
meilisearch-auth = { path = "../meilisearch-auth" }
meilisearch-types = { path = "../meilisearch-types" }
meilisearch-mcp = { path = "../meilisearch-mcp", optional = true }
mimalloc = { version = "0.1.43", default-features = false }
mime = "0.3.17"
num_cpus = "1.16.0"
@ -142,6 +143,7 @@ zip = { version = "2.3.0", optional = true }
default = ["meilisearch-types/all-tokenizations", "mini-dashboard"]
swagger = ["utoipa-scalar"]
test-ollama = []
mcp = ["meilisearch-mcp"]
mini-dashboard = [
"static-files",
"anyhow",

View File

@ -630,6 +630,12 @@ pub fn configure_data(
.app_data(
web::QueryConfig::default().error_handler(|err, _req| PayloadError::from(err).into()),
);
#[cfg(feature = "mcp")]
{
let mcp_server = meilisearch_mcp::integration::create_mcp_server_from_openapi();
config.app_data(web::Data::new(mcp_server));
}
}
#[cfg(feature = "mini-dashboard")]

View File

@ -115,6 +115,12 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(web::scope("/experimental-features").configure(features::configure))
.service(web::scope("/network").configure(network::configure));
#[cfg(feature = "mcp")]
{
use meilisearch_mcp::integration::configure_mcp_route;
configure_mcp_route(cfg);
}
#[cfg(feature = "swagger")]
{
use utoipa_scalar::{Scalar, Servable as ScalarServable};