mirror of
https://github.com/meilisearch/meilisearch.git
synced 2025-06-07 04:35:37 +00:00
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:
parent
97aeb6db4d
commit
e5192f3bcf
169
CLAUDE.md
Normal file
169
CLAUDE.md
Normal 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
|
@ -5,6 +5,7 @@ members = [
|
|||||||
"crates/meilitool",
|
"crates/meilitool",
|
||||||
"crates/meilisearch-types",
|
"crates/meilisearch-types",
|
||||||
"crates/meilisearch-auth",
|
"crates/meilisearch-auth",
|
||||||
|
"crates/meilisearch-mcp",
|
||||||
"crates/meili-snap",
|
"crates/meili-snap",
|
||||||
"crates/index-scheduler",
|
"crates/index-scheduler",
|
||||||
"crates/dump",
|
"crates/dump",
|
||||||
|
47
README.md
47
README.md
@ -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).
|
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
|
## 📊 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.
|
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.
|
||||||
|
33
crates/meilisearch-mcp/Cargo.toml
Normal file
33
crates/meilisearch-mcp/Cargo.toml
Normal 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"
|
159
crates/meilisearch-mcp/README.md
Normal file
159
crates/meilisearch-mcp/README.md
Normal 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
|
324
crates/meilisearch-mcp/src/conversion_tests.rs
Normal file
324
crates/meilisearch-mcp/src/conversion_tests.rs
Normal 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()
|
||||||
|
}
|
252
crates/meilisearch-mcp/src/e2e_tests.rs
Normal file
252
crates/meilisearch-mcp/src/e2e_tests.rs
Normal 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
|
||||||
|
}
|
49
crates/meilisearch-mcp/src/error.rs
Normal file
49
crates/meilisearch-mcp/src/error.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
120
crates/meilisearch-mcp/src/integration.rs
Normal file
120
crates/meilisearch-mcp/src/integration.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
257
crates/meilisearch-mcp/src/integration_tests.rs
Normal file
257
crates/meilisearch-mcp/src/integration_tests.rs
Normal 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"));
|
||||||
|
}
|
16
crates/meilisearch-mcp/src/lib.rs
Normal file
16
crates/meilisearch-mcp/src/lib.rs
Normal 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;
|
140
crates/meilisearch-mcp/src/protocol.rs
Normal file
140
crates/meilisearch-mcp/src/protocol.rs
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
289
crates/meilisearch-mcp/src/registry.rs
Normal file
289
crates/meilisearch-mcp/src/registry.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
285
crates/meilisearch-mcp/src/server.rs
Normal file
285
crates/meilisearch-mcp/src/server.rs
Normal 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(¶ms.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(¶ms.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(¶m_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(¶m_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()));
|
||||||
|
}
|
||||||
|
}
|
@ -50,6 +50,7 @@ jsonwebtoken = "9.3.0"
|
|||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
meilisearch-auth = { path = "../meilisearch-auth" }
|
meilisearch-auth = { path = "../meilisearch-auth" }
|
||||||
meilisearch-types = { path = "../meilisearch-types" }
|
meilisearch-types = { path = "../meilisearch-types" }
|
||||||
|
meilisearch-mcp = { path = "../meilisearch-mcp", optional = true }
|
||||||
mimalloc = { version = "0.1.43", default-features = false }
|
mimalloc = { version = "0.1.43", default-features = false }
|
||||||
mime = "0.3.17"
|
mime = "0.3.17"
|
||||||
num_cpus = "1.16.0"
|
num_cpus = "1.16.0"
|
||||||
@ -142,6 +143,7 @@ zip = { version = "2.3.0", optional = true }
|
|||||||
default = ["meilisearch-types/all-tokenizations", "mini-dashboard"]
|
default = ["meilisearch-types/all-tokenizations", "mini-dashboard"]
|
||||||
swagger = ["utoipa-scalar"]
|
swagger = ["utoipa-scalar"]
|
||||||
test-ollama = []
|
test-ollama = []
|
||||||
|
mcp = ["meilisearch-mcp"]
|
||||||
mini-dashboard = [
|
mini-dashboard = [
|
||||||
"static-files",
|
"static-files",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
@ -630,6 +630,12 @@ pub fn configure_data(
|
|||||||
.app_data(
|
.app_data(
|
||||||
web::QueryConfig::default().error_handler(|err, _req| PayloadError::from(err).into()),
|
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")]
|
#[cfg(feature = "mini-dashboard")]
|
||||||
|
@ -115,6 +115,12 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(web::scope("/experimental-features").configure(features::configure))
|
.service(web::scope("/experimental-features").configure(features::configure))
|
||||||
.service(web::scope("/network").configure(network::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")]
|
#[cfg(feature = "swagger")]
|
||||||
{
|
{
|
||||||
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user