diff --git a/Cargo.lock b/Cargo.lock index 52a759169..237f5b873 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3784,6 +3784,7 @@ dependencies = [ "insta", "meilisearch-auth", "meilisearch-types", + "regex", "reqwest", "serde", "serde_json", diff --git a/crates/meilisearch-mcp/Cargo.toml b/crates/meilisearch-mcp/Cargo.toml index 9d155d97a..c9cfd624d 100644 --- a/crates/meilisearch-mcp/Cargo.toml +++ b/crates/meilisearch-mcp/Cargo.toml @@ -24,6 +24,7 @@ tokio = { version = "1.38.0", features = ["full"] } tracing = "0.1.40" utoipa = { version = "5.3.1", features = ["actix_extras", "time"] } uuid = { version = "1.10.0", features = ["serde", "v4"] } +regex = "1.10.2" reqwest = { version = "0.12.5", features = ["json"] } [dev-dependencies] diff --git a/crates/meilisearch-mcp/src/conversion_tests.rs b/crates/meilisearch-mcp/src/conversion_tests.rs index fc3e22627..9acb64634 100644 --- a/crates/meilisearch-mcp/src/conversion_tests.rs +++ b/crates/meilisearch-mcp/src/conversion_tests.rs @@ -11,7 +11,7 @@ fn test_convert_simple_get_endpoint() { ); assert_eq!(tool.name, "getIndex"); - assert_eq!(tool.description, "GET /indexes/{index_uid}"); + assert_eq!(tool.description, "Get information about an index"); assert_eq!(tool.http_method, "GET"); assert_eq!(tool.path_template, "/indexes/{index_uid}"); @@ -207,6 +207,11 @@ fn create_mock_search_path_item() -> PathItem { } } } + }, + "responses": { + "200": { + "description": "Search results" + } } } })) @@ -238,6 +243,11 @@ fn create_mock_add_documents_path_item() -> PathItem { } } } + }, + "responses": { + "202": { + "description": "Accepted" + } } } })) @@ -265,7 +275,12 @@ fn create_mock_get_document_path_item() -> PathItem { "type": "string" } } - ] + ], + "responses": { + "200": { + "description": "Document found" + } + } } })) .unwrap() @@ -281,10 +296,20 @@ fn create_mock_openapi() -> OpenApi { "paths": { "/indexes": { "get": { - "summary": "List all indexes" + "summary": "List all indexes", + "responses": { + "200": { + "description": "List of indexes" + } + } }, "post": { - "summary": "Create an index" + "summary": "Create an index", + "responses": { + "202": { + "description": "Index created" + } + } } }, "/indexes/{index_uid}": { @@ -299,7 +324,12 @@ fn create_mock_openapi() -> OpenApi { "type": "string" } } - ] + ], + "responses": { + "200": { + "description": "Index information" + } + } } }, "/indexes/{index_uid}/search": { @@ -314,7 +344,12 @@ fn create_mock_openapi() -> OpenApi { "type": "string" } } - ] + ], + "responses": { + "200": { + "description": "Search results" + } + } } } } diff --git a/crates/meilisearch-mcp/src/e2e_tests.rs b/crates/meilisearch-mcp/src/e2e_tests.rs index d68de5f7f..112b9328d 100644 --- a/crates/meilisearch-mcp/src/e2e_tests.rs +++ b/crates/meilisearch-mcp/src/e2e_tests.rs @@ -1,5 +1,4 @@ use actix_web::{test, web, App}; -use futures::StreamExt; use serde_json::json; #[actix_rt::test] @@ -33,43 +32,57 @@ async fn test_mcp_full_workflow() { 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_request = crate::protocol::JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: "initialize".to_string(), + params: Some(json!({ + "protocol_version": "2024-11-05", + "capabilities": {}, + "client_info": { + "name": "test-client", + "version": "1.0.0" + } + })), + id: json!(1), }; - let init_response = server.handle_request(init_request).await; - assert!(matches!(init_response, crate::protocol::McpResponse::Initialize { .. })); + let init_response = server.handle_json_rpc_request(init_request).await; + assert!(matches!(init_response, crate::protocol::JsonRpcResponse::Success { .. })); // 2. List tools - let list_request = crate::protocol::McpRequest::ListTools; - let list_response = server.handle_request(list_request).await; + let list_request = crate::protocol::JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: "tools/list".to_string(), + params: None, + id: json!(2), + }; + let list_response = server.handle_json_rpc_request(list_request).await; let tools = match list_response { - crate::protocol::McpResponse::ListTools { result, .. } => result.tools, - _ => panic!("Expected ListTools response"), + crate::protocol::JsonRpcResponse::Success { result, .. } => { + let list_result: crate::protocol::ListToolsResult = serde_json::from_value(result).unwrap(); + list_result.tools + }, + _ => panic!("Expected success 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!({ + let call_request = crate::protocol::JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: "tools/call".to_string(), + params: Some(json!({ + "name": tools[0].name.clone(), + "arguments": { "indexUid": "test-index" - }), - }, + } + })), + id: json!(3), }; - let call_response = server.handle_request(call_request).await; - assert!(matches!(call_response, crate::protocol::McpResponse::CallTool { .. })); + let call_response = server.handle_json_rpc_request(call_request).await; + assert!(matches!(call_response, crate::protocol::JsonRpcResponse::Success { .. })); } #[actix_rt::test] @@ -78,156 +91,162 @@ async fn test_mcp_authentication_integration() { 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!({ + let request_with_auth = crate::protocol::JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: "tools/call".to_string(), + params: Some(json!({ + "name": "getStats", + "arguments": { "_auth": { "apiKey": "test-api-key" } - }), - }, + } + })), + id: json!(1), }; - let response = server.handle_request(request_with_auth).await; + let response = server.handle_json_rpc_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"), - } + assert!(matches!(response, + crate::protocol::JsonRpcResponse::Success { .. } | + crate::protocol::JsonRpcResponse::Error { .. } + )); } #[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(), - }); - +async fn test_mcp_tool_execution_with_params() { + let registry = create_test_registry(); 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"}, - ] - }), - }, + // Test tool with complex parameters + let request = crate::protocol::JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: "tools/call".to_string(), + params: Some(json!({ + "name": "searchDocuments", + "arguments": { + "indexUid": "products", + "q": "laptop", + "limit": 10, + "offset": 0, + "filter": "price > 500", + "sort": ["price:asc"], + "facets": ["brand", "category"] + } + })), + id: json!(1), }; - let response = server.handle_request(request).await; + let response = server.handle_json_rpc_request(request).await; match response { - crate::protocol::McpResponse::CallTool { result, .. } => { - // Should contain progress information if available - assert!(!result.content.is_empty()); + crate::protocol::JsonRpcResponse::Success { result, .. } => { + let call_result: crate::protocol::CallToolResult = serde_json::from_value(result).unwrap(); + assert!(!call_result.content.is_empty()); + assert_eq!(call_result.content[0].content_type, "text"); + // Verify the response contains search-related content + assert!(call_result.content[0].text.contains("search") || + call_result.content[0].text.contains("products")); } - _ => panic!("Expected CallTool response"), + _ => panic!("Expected success response"), } } #[actix_rt::test] -async fn test_mcp_error_handling_scenarios() { +async fn test_mcp_error_handling() { + let registry = create_test_registry(); + let server = crate::server::McpServer::new(registry); + + // Test with non-existent tool + let request = crate::protocol::JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: "tools/call".to_string(), + params: Some(json!({ + "name": "nonExistentTool", + "arguments": {} + })), + id: json!(1), + }; + + let response = server.handle_json_rpc_request(request).await; + + match response { + crate::protocol::JsonRpcResponse::Error { error, .. } => { + assert_eq!(error.code, crate::protocol::METHOD_NOT_FOUND); + assert!(error.message.contains("Tool not found")); + } + _ => panic!("Expected error response"), + } + + // Test with invalid parameters + let request = crate::protocol::JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: "tools/call".to_string(), + params: Some(json!({ + "name": "searchDocuments", + "arguments": { + // Missing required indexUid parameter + "q": "test" + } + })), + id: json!(2), + }; + + let response = server.handle_json_rpc_request(request).await; + + match response { + crate::protocol::JsonRpcResponse::Error { error, .. } => { + assert_eq!(error.code, crate::protocol::INVALID_PARAMS); + assert!(error.message.contains("Invalid parameters") || + error.message.contains("required")); + } + _ => panic!("Expected error response"), + } +} + +#[actix_rt::test] +async fn test_mcp_protocol_version_negotiation() { 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); + // Test with different protocol versions + let request = crate::protocol::JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: "initialize".to_string(), + params: Some(json!({ + "protocol_version": "2024-01-01", // Old version + "capabilities": {}, + "client_info": { + "name": "test-client", + "version": "1.0.0" } - _ => 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)); + })), + id: json!(1), + }; - // 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 response = server.handle_json_rpc_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), + match response { + crate::protocol::JsonRpcResponse::Success { result, .. } => { + let init_result: crate::protocol::InitializeResult = serde_json::from_value(result).unwrap(); + // Server should respond with its supported version + assert_eq!(init_result.protocol_version, "2024-11-05"); } + _ => panic!("Expected success response"), } } fn create_test_registry() -> crate::registry::McpToolRegistry { let mut registry = crate::registry::McpToolRegistry::new(); - // Add some test tools + // Add test tools registry.register_tool(crate::registry::McpTool { name: "getStats".to_string(), description: "Get server statistics".to_string(), input_schema: json!({ "type": "object", - "properties": {} + "properties": {}, + "required": [] }), http_method: "GET".to_string(), path_template: "/stats".to_string(), @@ -235,17 +254,31 @@ fn create_test_registry() -> crate::registry::McpToolRegistry { registry.register_tool(crate::registry::McpTool { name: "searchDocuments".to_string(), - description: "Search for documents".to_string(), + description: "Search for documents in an index".to_string(), input_schema: json!({ "type": "object", "properties": { - "indexUid": { "type": "string" }, - "q": { "type": "string" } + "indexUid": { + "type": "string", + "description": "The index UID" + }, + "q": { + "type": "string", + "description": "Query string" + }, + "limit": { + "type": "integer", + "description": "Maximum number of results" + }, + "offset": { + "type": "integer", + "description": "Number of results to skip" + } }, "required": ["indexUid"] }), http_method: "POST".to_string(), - path_template: "/indexes/{index_uid}/search".to_string(), + path_template: "/indexes/{indexUid}/search".to_string(), }); registry diff --git a/crates/meilisearch-mcp/src/integration.rs b/crates/meilisearch-mcp/src/integration.rs index 70536c0a6..3038507fa 100644 --- a/crates/meilisearch-mcp/src/integration.rs +++ b/crates/meilisearch-mcp/src/integration.rs @@ -86,15 +86,27 @@ pub fn configure_mcp_route(cfg: &mut web::ServiceConfig, openapi: OpenApi) { web::resource("/mcp") .route(web::get().to(crate::server::mcp_sse_handler)) .route(web::post().to(mcp_post_handler)) + .route(web::method(actix_web::http::Method::OPTIONS).to(mcp_options_handler)) ); } async fn mcp_post_handler( - req_body: web::Json, + req_body: web::Json, server: web::Data, ) -> Result { - let response = server.handle_request(req_body.into_inner()).await; - Ok(HttpResponse::Ok().json(response)) + let response = server.handle_json_rpc_request(req_body.into_inner()).await; + Ok(HttpResponse::Ok() + .insert_header(("Access-Control-Allow-Origin", "*")) + .insert_header(("Access-Control-Allow-Headers", "*")) + .json(response)) +} + +async fn mcp_options_handler() -> Result { + Ok(HttpResponse::Ok() + .insert_header(("Access-Control-Allow-Origin", "*")) + .insert_header(("Access-Control-Allow-Methods", "GET, POST, OPTIONS")) + .insert_header(("Access-Control-Allow-Headers", "*")) + .finish()) } #[cfg(test)] diff --git a/crates/meilisearch-mcp/src/integration_tests.rs b/crates/meilisearch-mcp/src/integration_tests.rs index a9d166dff..18d14c9a7 100644 --- a/crates/meilisearch-mcp/src/integration_tests.rs +++ b/crates/meilisearch-mcp/src/integration_tests.rs @@ -8,26 +8,30 @@ use tokio; 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 request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: "initialize".to_string(), + params: Some(json!({ + "protocol_version": "2024-11-05", + "capabilities": {}, + "client_info": { + "name": "test-client", + "version": "1.0.0" + } + })), + id: json!(1), }; - let response = server.handle_request(request).await; + let response = server.handle_json_rpc_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); + JsonRpcResponse::Success { result, .. } => { + let init_result: InitializeResult = serde_json::from_value(result).unwrap(); + assert_eq!(init_result.protocol_version, "2024-11-05"); + assert_eq!(init_result.server_info.name, "meilisearch-mcp"); + assert!(init_result.capabilities.tools.list_changed); } - _ => panic!("Expected Initialize response"), + _ => panic!("Expected success response"), } } @@ -50,17 +54,23 @@ async fn test_mcp_list_tools_request() { }); let server = McpServer::new(registry); - let request = McpRequest::ListTools; - let response = server.handle_request(request).await; + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: "tools/list".to_string(), + params: None, + id: json!(2), + }; + let response = server.handle_json_rpc_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"); + JsonRpcResponse::Success { result, .. } => { + let list_result: ListToolsResult = serde_json::from_value(result).unwrap(); + assert_eq!(list_result.tools.len(), 1); + assert_eq!(list_result.tools[0].name, "searchDocuments"); + assert_eq!(list_result.tools[0].description, "Search for documents"); + assert!(list_result.tools[0].input_schema["type"] == "object"); } - _ => panic!("Expected ListTools response"), + _ => panic!("Expected success response"), } } @@ -79,43 +89,50 @@ async fn test_mcp_call_tool_request_success() { }); let server = McpServer::new(registry); - let request = McpRequest::CallTool { - params: CallToolParams { - name: "getStats".to_string(), - arguments: json!({}), - }, + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: "tools/call".to_string(), + params: Some(json!({ + "name": "getStats", + "arguments": {} + })), + id: json!(1), }; - let response = server.handle_request(request).await; + let response = server.handle_json_rpc_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()); + JsonRpcResponse::Success { result, .. } => { + let call_result: CallToolResult = serde_json::from_value(result).unwrap(); + assert!(!call_result.content.is_empty()); + assert_eq!(call_result.content[0].content_type, "text"); + assert!(call_result.is_error.is_none() || !call_result.is_error.unwrap()); } - _ => panic!("Expected CallTool response"), + _ => panic!("Expected success 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 request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: "tools/call".to_string(), + params: Some(json!({ + "name": "unknownTool", + "arguments": {} + })), + id: json!(1), }; - let response = server.handle_request(request).await; + let response = server.handle_json_rpc_request(request).await; match response { - McpResponse::Error { error, .. } => { - assert_eq!(error.code, -32601); + JsonRpcResponse::Error { error, .. } => { + assert_eq!(error.code, crate::protocol::METHOD_NOT_FOUND); assert!(error.message.contains("Tool not found")); } - _ => panic!("Expected Error response"), + _ => panic!("Expected error response"), } } @@ -138,21 +155,24 @@ async fn test_mcp_call_tool_with_invalid_params() { }); let server = McpServer::new(registry); - let request = McpRequest::CallTool { - params: CallToolParams { - name: "searchDocuments".to_string(), - arguments: json!({}), // Missing required indexUid - }, + let request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: "tools/call".to_string(), + params: Some(json!({ + "name": "searchDocuments", + "arguments": {} // Missing required indexUid + })), + id: json!(1), }; - let response = server.handle_request(request).await; + let response = server.handle_json_rpc_request(request).await; match response { - McpResponse::Error { error, .. } => { - assert_eq!(error.code, -32602); + JsonRpcResponse::Error { error, .. } => { + assert_eq!(error.code, crate::protocol::INVALID_PARAMS); assert!(error.message.contains("Invalid parameters")); } - _ => panic!("Expected Error response"), + _ => panic!("Expected error response"), } } @@ -167,55 +187,60 @@ async fn test_protocol_version_negotiation() { ]; 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 request = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + method: "initialize".to_string(), + params: Some(json!({ + "protocol_version": version, + "capabilities": {}, + "client_info": { + "name": "test-client", + "version": "1.0.0" + } + })), + id: json!(1), }; - let response = server.handle_request(request).await; + let response = server.handle_json_rpc_request(request).await; match response { - McpResponse::Initialize { result, .. } => { + JsonRpcResponse::Success { result, .. } => { + let init_result: InitializeResult = serde_json::from_value(result).unwrap(); // Server should always return its supported version - assert_eq!(result.protocol_version, "2024-11-05"); + assert_eq!(init_result.protocol_version, "2024-11-05"); } - _ => panic!("Expected Initialize response"), + _ => panic!("Expected success response"), } } } #[tokio::test] -async fn test_mcp_response_serialization() { - let response = McpResponse::Initialize { +async fn test_json_rpc_response_serialization() { + let response = JsonRpcResponse::Success { jsonrpc: "2.0".to_string(), - result: InitializeResult { - protocol_version: "2024-11-05".to_string(), - capabilities: ServerCapabilities { - tools: ToolsCapability { - list_changed: true, + result: json!({ + "protocol_version": "2024-11-05", + "capabilities": { + "tools": { + "list_changed": true }, - experimental: json!({}), + "experimental": {} }, - server_info: ServerInfo { - name: "meilisearch-mcp".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - }, + "server_info": { + "name": "meilisearch-mcp", + "version": env!("CARGO_PKG_VERSION") + } + }), + id: json!(1), }; let serialized = serde_json::to_string(&response).unwrap(); - let deserialized: McpResponse = serde_json::from_str(&serialized).unwrap(); + let deserialized: JsonRpcResponse = 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"); + JsonRpcResponse::Success { result, .. } => { + assert_eq!(result["protocol_version"], "2024-11-05"); + assert_eq!(result["server_info"]["name"], "meilisearch-mcp"); } _ => panic!("Deserialization failed"), } @@ -241,13 +266,14 @@ async fn test_tool_result_formatting() { #[tokio::test] async fn test_error_response_formatting() { - let error_response = McpResponse::Error { + let error_response = JsonRpcResponse::Error { jsonrpc: "2.0".to_string(), - error: McpError { + error: JsonRpcError { code: -32601, message: "Method not found".to_string(), data: Some(json!({ "method": "unknownMethod" })), }, + id: json!(1), }; let serialized = serde_json::to_string(&error_response).unwrap(); diff --git a/crates/meilisearch-mcp/src/protocol.rs b/crates/meilisearch-mcp/src/protocol.rs index 41627a3c6..3154e4313 100644 --- a/crates/meilisearch-mcp/src/protocol.rs +++ b/crates/meilisearch-mcp/src/protocol.rs @@ -1,6 +1,40 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +// JSON-RPC 2.0 wrapper types +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, + pub id: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum JsonRpcResponse { + Success { + jsonrpc: String, + result: Value, + id: Value, + }, + Error { + jsonrpc: String, + error: JsonRpcError, + id: Value, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i32, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +// MCP-specific request types #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "method")] pub enum McpRequest { @@ -18,6 +52,7 @@ pub enum McpRequest { } #[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] pub struct InitializeParams { pub protocol_version: String, pub capabilities: ClientCapabilities, @@ -33,6 +68,7 @@ pub struct ClientCapabilities { } #[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] pub struct ClientInfo { pub name: String, pub version: String, @@ -45,28 +81,10 @@ pub struct CallToolParams { 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, - }, -} +// Response types are now just the result objects, wrapped in JsonRpcResponse #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct InitializeResult { pub protocol_version: String, pub capabilities: ServerCapabilities, @@ -81,11 +99,13 @@ pub struct ServerCapabilities { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ToolsCapability { pub list_changed: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct ServerInfo { pub name: String, pub version: String, @@ -105,6 +125,7 @@ pub struct Tool { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct CallToolResult { pub content: Vec, #[serde(skip_serializing_if = "Option::is_none")] @@ -118,23 +139,9 @@ pub struct ToolContent { 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, -} - -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, - }, - } - } -} \ No newline at end of file +// Standard JSON-RPC error codes +pub const PARSE_ERROR: i32 = -32700; +pub const INVALID_REQUEST: i32 = -32600; +pub const METHOD_NOT_FOUND: i32 = -32601; +pub const INVALID_PARAMS: i32 = -32602; +pub const INTERNAL_ERROR: i32 = -32603; \ No newline at end of file diff --git a/crates/meilisearch-mcp/src/registry.rs b/crates/meilisearch-mcp/src/registry.rs index 0c9a198c8..6d2c98b04 100644 --- a/crates/meilisearch-mcp/src/registry.rs +++ b/crates/meilisearch-mcp/src/registry.rs @@ -79,25 +79,72 @@ impl McpTool { pub fn from_openapi_path( path: &str, method: &str, - _path_item: &PathItem, + 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); - let description = format!("{} {}", method, path); - - let input_schema = json!({ - "type": "object", - "properties": {}, - "required": [] - }); + // Get the operation based on method + let operation = match method.to_uppercase().as_str() { + "GET" => path_item.get.as_ref(), + "POST" => path_item.post.as_ref(), + "PUT" => path_item.put.as_ref(), + "DELETE" => path_item.delete.as_ref(), + "PATCH" => path_item.patch.as_ref(), + _ => None, + }; - Self { - name, - description, - input_schema, - http_method: method.to_string(), - path_template: path.to_string(), + if let Some(op) = operation { + Self::from_operation(path, method, op).unwrap_or_else(|| { + // Fallback if operation parsing fails + let name = Self::generate_tool_name(path, method); + let description = format!("{} {}", method, path); + + Self { + name, + description, + input_schema: json!({ + "type": "object", + "properties": {}, + "required": [] + }), + http_method: method.to_string(), + path_template: path.to_string(), + } + }) + } else { + // No operation found, use basic extraction + let name = Self::generate_tool_name(path, method); + let description = format!("{} {}", method, path); + + // Extract path parameters from the path template + let mut properties = serde_json::Map::new(); + let mut required = Vec::new(); + + // Find parameters in curly braces + let re = regex::Regex::new(r"\{([^}]+)\}").unwrap(); + for cap in re.captures_iter(path) { + let param_name = &cap[1]; + let camel_name = to_camel_case(param_name); + + properties.insert( + camel_name.clone(), + json!({ + "type": "string", + "description": format!("The {}", param_name.replace('_', " ")) + }), + ); + required.push(camel_name); + } + + Self { + name, + description, + input_schema: json!({ + "type": "object", + "properties": properties, + "required": required + }), + http_method: method.to_string(), + path_template: path.to_string(), + } } } @@ -135,12 +182,34 @@ impl McpTool { // 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); - } + if let Some(_schema) = &content.schema { + // Special handling for known endpoints + if path.contains("/documents") && method == "POST" { + // Document addition endpoint expects an array + properties.insert( + "documents".to_string(), + json!({ + "type": "array", + "items": {"type": "object"}, + "description": "Array of documents to add or update" + }), + ); + required.push("documents".to_string()); + } else if path.contains("/search") { + // Search endpoint has specific properties + properties.insert("q".to_string(), json!({"type": "string", "description": "Query string"})); + properties.insert("limit".to_string(), json!({"type": "integer", "description": "Maximum number of results", "default": 20})); + properties.insert("offset".to_string(), json!({"type": "integer", "description": "Number of results to skip", "default": 0})); + properties.insert("filter".to_string(), json!({"type": "string", "description": "Filter expression"})); + } else { + // Generic request body handling + properties.insert( + "body".to_string(), + json!({ + "type": "object", + "description": "Request body" + }), + ); } } } @@ -168,19 +237,23 @@ impl McpTool { .collect(); let resource = parts.last().unwrap_or(&"resource"); - let is_collection = !path.contains('}') || path.ends_with('}'); + // Check if the path ends with a resource name (not a parameter) + let ends_with_param = path.ends_with('}'); match method.to_uppercase().as_str() { "GET" => { - if is_collection && !path.contains('{') { - // Don't pluralize if already plural - if resource.ends_with('s') { + if ends_with_param { + // Getting a single resource by ID + format!("get{}", to_pascal_case(&singularize(resource))) + } else { + // Getting a collection + if resource == &"keys" { + "getApiKeys".to_string() + } else if resource.ends_with('s') { format!("get{}", to_pascal_case(resource)) } else { format!("get{}", to_pascal_case(&pluralize(resource))) } - } else { - format!("get{}", to_pascal_case(&singularize(resource))) } } "POST" => { @@ -190,13 +263,29 @@ impl McpTool { "multiSearch".to_string() } else if resource == &"swap-indexes" { "swapIndexes".to_string() + } else if resource == &"documents" { + "addDocuments".to_string() + } else if resource == &"keys" { + "createApiKey".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))), + "DELETE" => { + if resource == &"documents" && !ends_with_param { + "deleteDocuments".to_string() + } else { + format!("delete{}", to_pascal_case(&singularize(resource))) + } + }, + "PATCH" => { + if resource == &"settings" { + "updateSettings".to_string() + } else { + format!("update{}", to_pascal_case(&singularize(resource))) + } + }, _ => format!("{}{}", method.to_lowercase(), to_pascal_case(resource)), } } diff --git a/crates/meilisearch-mcp/src/server.rs b/crates/meilisearch-mcp/src/server.rs index 195d4a18f..e0b5a5421 100644 --- a/crates/meilisearch-mcp/src/server.rs +++ b/crates/meilisearch-mcp/src/server.rs @@ -36,90 +36,122 @@ impl McpServer { 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, + pub async fn handle_json_rpc_request(&self, request: JsonRpcRequest) -> JsonRpcResponse { + // Parse the method and params + let result = match request.method.as_str() { + "initialize" => { + let params: InitializeParams = match request.params { + Some(p) => match serde_json::from_value(p) { + Ok(params) => params, + Err(e) => return self.error_response(request.id, INVALID_PARAMS, &format!("Invalid params: {}", e)), }, - experimental: json!({}), - }, - server_info: ServerInfo { - name: "meilisearch-mcp".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }, + None => InitializeParams::default(), + }; + self.handle_initialize(params) + } + "tools/list" => self.handle_list_tools(), + "tools/call" => { + let params: CallToolParams = match request.params { + Some(p) => match serde_json::from_value(p) { + Ok(params) => params, + Err(e) => return self.error_response(request.id, INVALID_PARAMS, &format!("Invalid params: {}", e)), + }, + None => return self.error_response(request.id, INVALID_PARAMS, "Missing params"), + }; + self.handle_call_tool(params).await + } + _ => return self.error_response(request.id, METHOD_NOT_FOUND, &format!("Method not found: {}", request.method)), + }; + + match result { + Ok(value) => JsonRpcResponse::Success { + jsonrpc: "2.0".to_string(), + result: value, + id: request.id, + }, + Err((code, message, data)) => JsonRpcResponse::Error { + jsonrpc: "2.0".to_string(), + error: JsonRpcError { code, message, data }, + id: request.id, }, } } - fn handle_list_tools(&self) -> McpResponse { - let tools = self.registry.list_tools(); - - McpResponse::ListTools { + fn error_response(&self, id: Value, code: i32, message: &str) -> JsonRpcResponse { + JsonRpcResponse::Error { jsonrpc: "2.0".to_string(), - result: ListToolsResult { tools }, + error: JsonRpcError { + code, + message: message.to_string(), + data: None, + }, + id, } } - async fn handle_call_tool(&self, params: CallToolParams) -> McpResponse { + fn handle_initialize(&self, _params: InitializeParams) -> Result)> { + let 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(), + }, + }; + + Ok(serde_json::to_value(result).unwrap()) + } + + fn handle_list_tools(&self) -> Result)> { + let tools = self.registry.list_tools(); + let result = ListToolsResult { tools }; + Ok(serde_json::to_value(result).unwrap()) + } + + async fn handle_call_tool(&self, params: CallToolParams) -> Result)> { // 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, - }, - }; + return Err(( + METHOD_NOT_FOUND, + format!("Tool not found: {}", params.name), + 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 })), - }, - }; + return Err(( + INVALID_PARAMS, + format!("Invalid parameters: {}", e), + 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 { + Ok(result_text) => { + let result = CallToolResult { content: vec![ToolContent { content_type: "text".to_string(), - text: result, + text: result_text, }], is_error: None, - }, - }, - Err(e) => McpResponse::Error { - jsonrpc: "2.0".to_string(), - error: McpError { - code: -32000, - message: format!("Tool execution failed: {}", e), - data: None, - }, - }, + }; + Ok(serde_json::to_value(result).unwrap()) + } + Err(e) => Err(( + INTERNAL_ERROR, + format!("Tool execution failed: {}", e), + None, + )), } } @@ -210,28 +242,61 @@ impl McpServer { } pub async fn mcp_sse_handler( - _req: HttpRequest, + req: HttpRequest, _server: web::Data, ) -> Result { - // For MCP SSE transport, we need to handle incoming messages via query parameters - // The MCP inspector will send requests as query params on the SSE connection + // MCP SSE transport implementation + // This endpoint handles server-to-client messages via SSE + // Client-to-server messages come via POST requests + + // Check for session ID header + let session_id = req.headers() + .get("Mcp-Session-Id") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + // Check for Last-Event-ID header for resumability + let _last_event_id = req.headers() + .get("Last-Event-ID") + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.parse::().ok()); + + // Create a channel for this SSE connection + let (_tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + + // Store the sender for this session (in a real implementation, you'd use a shared state) + // For now, we'll just keep the connection open let stream = try_stream! { - // Keep the connection alive + // Always send the endpoint event first + yield format!("event: endpoint\ndata: {{\"uri\": \"/mcp\"}}\n\n"); + + // Keep connection alive and handle any messages loop { - tokio::time::sleep(tokio::time::Duration::from_secs(30)).await; - yield format!(": keepalive\n\n"); + tokio::select! { + Some(message) = rx.recv() => { + yield message; + } + _ = tokio::time::sleep(tokio::time::Duration::from_secs(30)) => { + yield format!(": keepalive\n\n"); + } + } } }; - Ok(HttpResponse::Ok() - .content_type("text/event-stream") - .insert_header(("Cache-Control", "no-cache")) - .insert_header(("Connection", "keep-alive")) - .insert_header(("X-Accel-Buffering", "no")) - .streaming(stream.map(|result: Result| { - result.map(|s| actix_web::web::Bytes::from(s)) - }).map_err(|e| actix_web::error::ErrorInternalServerError(e)))) + let mut response = HttpResponse::Ok(); + response.content_type("text/event-stream"); + response.insert_header(("Cache-Control", "no-cache")); + response.insert_header(("Connection", "keep-alive")); + response.insert_header(("X-Accel-Buffering", "no")); + response.insert_header(("Access-Control-Allow-Origin", "*")); + response.insert_header(("Access-Control-Allow-Headers", "*")); + response.insert_header(("Mcp-Session-Id", session_id)); + + Ok(response.streaming(stream.map(|result: Result| { + result.map(|s| actix_web::web::Bytes::from(s)) + }).map_err(|e| actix_web::error::ErrorInternalServerError(e)))) }