//! MCP Server — expose OpenFang tools via the Model Context Protocol. //! //! Implements the server-side MCP protocol so external MCP clients //! (Claude Desktop, VS Code, etc.) can use OpenFang's built-in tools. //! //! This module provides a reusable handler function — the CLI team //! wires it into a stdio transport. use openfang_types::tool::ToolDefinition; use serde_json::json; /// MCP protocol version supported by this server. const PROTOCOL_VERSION: &str = "2024-11-05"; /// Handle an incoming MCP JSON-RPC request and return a response. /// /// This is a stateless handler that can be called from any transport /// (stdio, HTTP, etc.). The caller provides the available tool definitions. pub async fn handle_mcp_request( request: &serde_json::Value, tools: &[ToolDefinition], ) -> serde_json::Value { let method = request["method"].as_str().unwrap_or(""); let id = request.get("id").cloned(); match method { "initialize" => make_response( id, json!({ "protocolVersion": PROTOCOL_VERSION, "capabilities": { "tools": {} }, "serverInfo": { "name": "openfang", "version": env!("CARGO_PKG_VERSION") } }), ), "notifications/initialized" => { // Notification — no response needed json!(null) } "tools/list" => { let tool_list: Vec = tools .iter() .map(|t| { json!({ "name": t.name, "description": t.description, "inputSchema": t.input_schema, }) }) .collect(); make_response(id, json!({ "tools": tool_list })) } "tools/call" => { let tool_name = request["params"]["name"].as_str().unwrap_or(""); let _arguments = request["params"] .get("arguments") .cloned() .unwrap_or(json!({})); // Verify the tool exists if !tools.iter().any(|t| t.name == tool_name) { return make_error(id, -32602, &format!("Unknown tool: {tool_name}")); } // Tool execution is delegated to the caller (kernel/CLI). // This handler just validates the request format. // In a full implementation, the caller would wire this to execute_tool(). make_response( id, json!({ "content": [{ "type": "text", "text": format!("Tool '{tool_name}' is available. Execution must be wired by the host.") }] }), ) } _ => make_error(id, -32601, &format!("Method not found: {method}")), } } /// Build a JSON-RPC 2.0 success response. fn make_response(id: Option, result: serde_json::Value) -> serde_json::Value { json!({ "jsonrpc": "2.0", "id": id, "result": result, }) } /// Build a JSON-RPC 2.0 error response. fn make_error(id: Option, code: i64, message: &str) -> serde_json::Value { json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message, }, }) } #[cfg(test)] mod tests { use super::*; fn test_tools() -> Vec { vec![ ToolDefinition { name: "file_read".to_string(), description: "Read a file".to_string(), input_schema: json!({"type": "object", "properties": {"path": {"type": "string"}}}), }, ToolDefinition { name: "web_fetch".to_string(), description: "Fetch a URL".to_string(), input_schema: json!({"type": "object"}), }, ] } #[tokio::test] async fn test_mcp_server_tools_list() { let tools = test_tools(); let request = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", }); let response = handle_mcp_request(&request, &tools).await; assert_eq!(response["jsonrpc"], "2.0"); assert_eq!(response["id"], 1); let tool_list = response["result"]["tools"].as_array().unwrap(); assert_eq!(tool_list.len(), 2); assert_eq!(tool_list[0]["name"], "file_read"); assert_eq!(tool_list[1]["name"], "web_fetch"); } #[tokio::test] async fn test_mcp_server_unknown_method() { let tools = test_tools(); let request = json!({ "jsonrpc": "2.0", "id": 5, "method": "nonexistent/method", }); let response = handle_mcp_request(&request, &tools).await; assert_eq!(response["jsonrpc"], "2.0"); assert_eq!(response["id"], 5); assert_eq!(response["error"]["code"], -32601); assert!(response["error"]["message"] .as_str() .unwrap() .contains("not found")); } #[tokio::test] async fn test_mcp_server_initialize() { let tools = test_tools(); let request = json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test"} } }); let response = handle_mcp_request(&request, &tools).await; assert_eq!(response["result"]["protocolVersion"], PROTOCOL_VERSION); assert!(response["result"]["serverInfo"]["name"] .as_str() .unwrap() .contains("openfang")); } }