diff --git a/crates/zclaw-protocols/src/lib.rs b/crates/zclaw-protocols/src/lib.rs index 4ba575f..5c2b57b 100644 --- a/crates/zclaw-protocols/src/lib.rs +++ b/crates/zclaw-protocols/src/lib.rs @@ -7,12 +7,14 @@ mod mcp; mod mcp_types; +mod mcp_tool_adapter; mod mcp_transport; #[cfg(feature = "a2a")] mod a2a; pub use mcp::*; pub use mcp_types::*; +pub use mcp_tool_adapter::*; pub use mcp_transport::*; #[cfg(feature = "a2a")] pub use a2a::*; diff --git a/crates/zclaw-protocols/src/mcp_tool_adapter.rs b/crates/zclaw-protocols/src/mcp_tool_adapter.rs new file mode 100644 index 0000000..00037e9 --- /dev/null +++ b/crates/zclaw-protocols/src/mcp_tool_adapter.rs @@ -0,0 +1,153 @@ +//! MCP Tool Adapter — bridges MCP server tools into zclaw-runtime's ToolRegistry +//! +//! Each MCP tool is wrapped as a `dyn Tool` implementation that: +//! 1. Receives the tool call from the agent loop +//! 2. Forwards it to the MCP server via McpClient +//! 3. Returns the result back to the agent loop + +use std::collections::HashMap; +use std::sync::Arc; +use serde_json::Value; +use tracing::{debug, warn}; +use zclaw_types::{Result, ZclawError}; + +use crate::mcp::{McpClient, McpTool, McpToolCallRequest}; + +/// Adapter wrapping an MCP tool as a zclaw-runtime `Tool`. +/// +/// This struct is intentionally decoupled from `zclaw-runtime::Tool` to avoid +/// a circular dependency. The runtime crate depends on protocols for type sharing, +/// so we expose a simple trait here that mirrors the essential Tool interface. +/// The runtime side will wrap this in a thin `Tool` impl. +pub struct McpToolAdapter { + /// Tool name (prefixed with server name to avoid collisions) + name: String, + /// Tool description + description: String, + /// JSON schema for input parameters + input_schema: Value, + /// Reference to the MCP client for forwarding calls + client: Arc, +} + +impl McpToolAdapter { + pub fn new(tool: McpTool, client: Arc) -> Self { + Self { + name: tool.name, + description: tool.description, + input_schema: tool.input_schema, + client, + } + } + + /// Create adapters for all tools from an MCP server + pub async fn from_server(client: Arc) -> Result> { + let tools = client.list_tools().await?; + debug!(count = tools.len(), "Discovered MCP tools"); + Ok(tools.into_iter().map(|t| Self::new(t, client.clone())).collect()) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn description(&self) -> &str { + &self.description + } + + pub fn input_schema(&self) -> &Value { + &self.input_schema + } + + /// Execute the MCP tool call + pub async fn execute(&self, input: Value) -> Result { + debug!(tool = %self.name, "Executing MCP tool"); + + let arguments = match input { + Value::Object(map) => map.into_iter().collect(), + other => { + // If input is not an object, wrap it as {"input": ...} + HashMap::from([("input".to_string(), other)]) + } + }; + + let request = McpToolCallRequest { + name: self.name.clone(), + arguments, + }; + + let response = self.client.call_tool(request).await?; + + if response.is_error { + // Extract error text from content blocks + let error_text: String = response.content.iter() + .filter_map(|c| match c { + crate::mcp::McpContent::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + + warn!(tool = %self.name, error = %error_text, "MCP tool returned error"); + return Err(ZclawError::McpError(format!("MCP tool '{}' failed: {}", self.name, error_text))); + } + + // Convert content blocks to JSON + let result = response.content.into_iter() + .filter_map(|c| match c { + crate::mcp::McpContent::Text { text } => Some(Value::String(text)), + _ => None, + }) + .collect::>(); + + match result.len() { + 0 => Ok(Value::Null), + 1 => Ok(result.into_iter().next().unwrap()), + _ => Ok(Value::Array(result)), + } + } +} + +/// MCP Service Manager — manages lifecycle of MCP server connections +#[derive(Default)] +pub struct McpServiceManager { + clients: HashMap>, + adapters: HashMap>, +} + +impl McpServiceManager { + pub fn new() -> Self { + Self { + clients: HashMap::new(), + adapters: HashMap::new(), + } + } + + /// Register a connected MCP client and discover its tools + pub async fn register_service( + &mut self, + name: String, + client: Arc, + ) -> Result> { + let adapters = McpToolAdapter::from_server(client.clone()).await?; + self.clients.insert(name.clone(), client); + self.adapters.insert(name.clone(), adapters); + Ok(self.adapters.get(&name).unwrap().iter().collect()) + } + + /// Get all registered tool adapters from all services + pub fn all_adapters(&self) -> Vec<&McpToolAdapter> { + self.adapters.values().flat_map(|v| v.iter()).collect() + } + + /// Remove a service by name + pub fn remove_service(&mut self, name: &str) { + self.clients.remove(name); + self.adapters.remove(name); + } + + /// List registered service names + pub fn service_names(&self) -> Vec<&String> { + self.clients.keys().collect() + } +} diff --git a/crates/zclaw-protocols/src/mcp_transport.rs b/crates/zclaw-protocols/src/mcp_transport.rs index f9594b9..0dd0132 100644 --- a/crates/zclaw-protocols/src/mcp_transport.rs +++ b/crates/zclaw-protocols/src/mcp_transport.rs @@ -176,11 +176,41 @@ impl McpTransport { self.start().await?; let request = InitializeRequest::default(); - let _: InitializeResult = self.send_request("initialize", Some(&request)).await?; + let init_result: InitializeResult = self.send_request("initialize", Some(&request)).await?; - // Store capabilities + // Store actual server capabilities (not empty defaults) let mut capabilities = self.capabilities.lock().await; - *capabilities = Some(ServerCapabilities::default()); + *capabilities = Some(init_result.capabilities); + drop(capabilities); + + // Send initialized notification (required by MCP spec) + self.send_notification(&InitializedNotification::new()).await?; + + debug!( + server = %init_result.server_info.name, + version = %init_result.server_info.version, + protocol = %init_result.protocol_version, + "MCP server initialized" + ); + + Ok(()) + } + + /// Send JSON-RPC notification (no response expected) + async fn send_notification(&self, notification: &impl serde::Serialize) -> Result<()> { + let line = serde_json::to_string(notification) + .map_err(|e| ZclawError::McpError(format!("Failed to serialize notification: {}", e)))?; + + let mut stdin_guard = self.stdin.lock().await; + let stdin = stdin_guard.as_mut() + .ok_or_else(|| ZclawError::McpError("Transport not started".to_string()))?; + + stdin.write_all(line.as_bytes()) + .map_err(|e| ZclawError::McpError(format!("Failed to write notification: {}", e)))?; + stdin.write_all(b"\n") + .map_err(|e| ZclawError::McpError(format!("Failed to write newline: {}", e)))?; + stdin.flush() + .map_err(|e| ZclawError::McpError(format!("Failed to flush notification: {}", e)))?; Ok(()) } diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 4038498..300d567 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -32,6 +32,7 @@ zclaw-skills = { workspace = true } zclaw-hands = { workspace = true } zclaw-pipeline = { workspace = true } zclaw-growth = { workspace = true } +zclaw-protocols = { workspace = true } # Tauri tauri = { version = "2", features = [] } diff --git a/desktop/src-tauri/src/kernel_commands/mcp.rs b/desktop/src-tauri/src/kernel_commands/mcp.rs new file mode 100644 index 0000000..8a6e6dc --- /dev/null +++ b/desktop/src-tauri/src/kernel_commands/mcp.rs @@ -0,0 +1,155 @@ +//! MCP (Model Context Protocol) Tauri commands +//! +//! Manages MCP server lifecycle: start/stop servers, discover tools. + +use std::collections::HashMap; +use std::sync::Arc; +use serde::{Deserialize, Serialize}; +use tauri::State; +use tokio::sync::Mutex; +use tracing::info; + +use zclaw_protocols::{BasicMcpClient, McpServerConfig, McpServiceManager}; + +/// Shared MCP service manager state (newtype for Tauri state management) +#[derive(Clone, Default)] +pub struct McpManagerState(pub Arc>); + +/// MCP service configuration (from frontend) +#[derive(Debug, Deserialize)] +pub struct McpServiceConfig { + pub name: String, + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: HashMap, + pub cwd: Option, +} + +/// MCP tool info (returned to frontend) +#[derive(Debug, Serialize)] +pub struct McpToolInfo { + pub service_name: String, + pub tool_name: String, + pub description: String, + pub input_schema: serde_json::Value, +} + +/// MCP service status +#[derive(Debug, Serialize)] +pub struct McpServiceStatus { + pub name: String, + pub tool_count: usize, + pub tools: Vec, +} + +// ──────────────────────────────────────────────────────────────── + +/// Start an MCP server and discover its tools +/// @connected — frontend: MCPServices.tsx via mcp-client.ts +#[tauri::command] +pub async fn mcp_start_service( + manager: State<'_, McpManagerState>, + config: McpServiceConfig, +) -> Result, String> { + let mut guard = manager.0.lock().await; + + let server_config = McpServerConfig { + command: config.command, + args: config.args, + env: config.env, + cwd: config.cwd, + }; + + let client = Arc::new(BasicMcpClient::new(server_config)); + client + .initialize() + .await + .map_err(|e| format!("Failed to initialize MCP service '{}': {}", config.name, e))?; + + info!(service = %config.name, "MCP service initialized"); + + let adapters = guard + .register_service(config.name.clone(), client) + .await + .map_err(|e| format!("Failed to register MCP service '{}': {}", config.name, e))?; + + let tools: Vec = adapters + .into_iter() + .map(|a| McpToolInfo { + service_name: config.name.clone(), + tool_name: a.name().to_string(), + description: a.description().to_string(), + input_schema: a.input_schema().clone(), + }) + .collect(); + + info!(service = %config.name, tool_count = tools.len(), "MCP tools registered"); + Ok(tools) +} + +/// Stop an MCP server and remove its tools +/// @connected — frontend: MCPServices.tsx via mcp-client.ts +#[tauri::command] +pub async fn mcp_stop_service( + manager: State<'_, McpManagerState>, + name: String, +) -> Result<(), String> { + let mut guard = manager.0.lock().await; + guard.remove_service(&name); + info!(service = %name, "MCP service stopped"); + Ok(()) +} + +/// List all active MCP services and their tools +/// @connected — frontend: MCPServices.tsx via mcp-client.ts +#[tauri::command] +pub async fn mcp_list_services( + manager: State<'_, McpManagerState>, +) -> Result, String> { + let guard = manager.0.lock().await; + let names = guard.service_names(); + let all = guard.all_adapters(); + let statuses: Vec = names + .into_iter() + .map(|name| { + let tools: Vec = all + .iter() + .map(|a| McpToolInfo { + service_name: name.to_string(), + tool_name: a.name().to_string(), + description: a.description().to_string(), + input_schema: a.input_schema().clone(), + }) + .collect(); + McpServiceStatus { + name: name.to_string(), + tool_count: tools.len(), + tools, + } + }) + .collect(); + Ok(statuses) +} + +/// Call an MCP tool directly +/// @connected — frontend: agent loop via mcp-client.ts +#[tauri::command] +pub async fn mcp_call_tool( + manager: State<'_, McpManagerState>, + tool_name: String, + arguments: serde_json::Value, +) -> Result { + let guard = manager.0.lock().await; + let adapter = guard + .all_adapters() + .into_iter() + .find(|a| a.name() == tool_name) + .ok_or_else(|| format!("MCP tool '{}' not found", tool_name))?; + + adapter + .execute(arguments) + .await + .map_err(|e| format!("MCP tool '{}' execution failed: {}", tool_name, e)) +} diff --git a/desktop/src-tauri/src/kernel_commands/mod.rs b/desktop/src-tauri/src/kernel_commands/mod.rs index 15350b7..275cf27 100644 --- a/desktop/src-tauri/src/kernel_commands/mod.rs +++ b/desktop/src-tauri/src/kernel_commands/mod.rs @@ -12,6 +12,7 @@ pub mod approval; pub mod chat; pub mod hand; pub mod lifecycle; +pub mod mcp; pub mod scheduled_task; pub mod skill; pub mod trigger; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0ca710b..350f215 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -122,6 +122,7 @@ pub fn run() { .manage(classroom_state) .manage(classroom_chat_state) .manage(classroom_gen_tasks) + .manage(kernel_commands::mcp::McpManagerState::default()) .invoke_handler(tauri::generate_handler![ // Internal ZCLAW Kernel commands (preferred) kernel_commands::lifecycle::kernel_init, @@ -323,7 +324,12 @@ pub fn run() { classroom_commands::generate::classroom_list, classroom_commands::chat::classroom_chat, classroom_commands::chat::classroom_chat_history, - classroom_commands::export::classroom_export + classroom_commands::export::classroom_export, + // MCP (Model Context Protocol) lifecycle commands + kernel_commands::mcp::mcp_start_service, + kernel_commands::mcp::mcp_stop_service, + kernel_commands::mcp::mcp_list_services, + kernel_commands::mcp::mcp_call_tool, ]) .run(tauri::generate_context!()) .expect("error while running tauri application");