Skip to main content

modular_agent_core/
mcp.rs

1//! Model Context Protocol (MCP) integration for external tool servers.
2//!
3//! This module provides integration with MCP-compliant tool servers, allowing
4//! external tools to be registered and called through the standard tool registry.
5//!
6//! MCP is a protocol for connecting LLM applications with external tool providers.
7//! This module supports loading MCP server configurations from JSON files
8//! (compatible with Claude Desktop format) and manages connection pooling
9//! for efficient server communication.
10//!
11//! # Features
12//!
13//! - Load MCP server configurations from JSON files
14//! - Automatic connection pooling for MCP servers
15//! - Register MCP tools with the global tool registry
16//! - Graceful shutdown of all MCP connections
17//!
18//! # Example
19//!
20//! ```no_run
21//! use modular_agent_core::mcp::{register_tools_from_mcp_json, shutdown_all_mcp_connections};
22//!
23//! #[tokio::main]
24//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
25//!     // Load and register tools from MCP configuration
26//!     let tools = register_tools_from_mcp_json("mcp.json").await?;
27//!     println!("Registered {} MCP tools", tools.len());
28//!
29//!     // ... use tools ...
30//!
31//!     // Clean up connections on shutdown
32//!     shutdown_all_mcp_connections().await?;
33//!     Ok(())
34//! }
35//! ```
36
37#![cfg(feature = "mcp")]
38
39use std::collections::HashMap;
40use std::path::Path;
41use std::sync::atomic::{AtomicBool, Ordering};
42use std::sync::{Arc, OnceLock};
43
44use modular_agent_core::{AgentContext, AgentError, AgentValue, async_trait};
45use rmcp::{
46    model::{CallToolRequestParam, CallToolResult},
47    service::ServiceExt,
48    transport::{ConfigureCommandExt, TokioChildProcess},
49};
50use serde::Deserialize;
51use tokio::process::Command;
52use tokio::sync::Mutex as AsyncMutex;
53
54use crate::tool::{Tool, ToolInfo, register_tool};
55
56/// Tool implementation that delegates to an MCP server.
57///
58/// Uses connection pooling to efficiently reuse connections to MCP servers.
59struct MCPTool {
60    /// Name of the MCP server this tool belongs to.
61    server_name: String,
62    /// Configuration for connecting to the MCP server.
63    server_config: MCPServerConfig,
64    /// The underlying MCP tool definition.
65    tool: rmcp::model::Tool,
66    /// Tool metadata for registration.
67    info: ToolInfo,
68}
69
70impl MCPTool {
71    /// Creates a new MCPTool from server configuration and tool definition.
72    ///
73    /// # Arguments
74    ///
75    /// * `name` - The fully qualified tool name (typically "server::tool")
76    /// * `server_name` - Name of the MCP server
77    /// * `server_config` - Configuration for the MCP server
78    /// * `tool` - The MCP tool definition
79    fn new(
80        name: String,
81        server_name: String,
82        server_config: MCPServerConfig,
83        tool: rmcp::model::Tool,
84    ) -> Self {
85        let info = ToolInfo::new(
86            name,
87            tool.description.clone().unwrap_or_default().into_owned(),
88            serde_json::to_value(&tool.input_schema).ok(),
89        );
90        Self {
91            server_name,
92            server_config,
93            tool,
94            info,
95        }
96    }
97
98    /// Invokes the tool on the MCP server.
99    ///
100    /// Gets or creates a connection from the pool and calls the tool.
101    /// A transport-level failure invalidates the pooled connection,
102    /// reconnects, and retries the call exactly once.
103    async fn tool_call(
104        &self,
105        _ctx: AgentContext,
106        value: AgentValue,
107    ) -> Result<AgentValue, AgentError> {
108        let arguments = value.as_object().map(|obj| {
109            obj.iter()
110                .map(|(k, v)| {
111                    (
112                        k.clone(),
113                        serde_json::to_value(v).unwrap_or(serde_json::Value::Null),
114                    )
115                })
116                .collect::<serde_json::Map<String, serde_json::Value>>()
117        });
118
119        let entry = {
120            let mut pool = connection_pool().lock().await;
121            pool.get_or_create(&self.server_name, &self.server_config)
122                .await?
123        };
124
125        let tool_result = match self.call_once(&entry, arguments.clone()).await {
126            Ok(result) => result,
127            Err(e) => {
128                log::warn!(
129                    "MCP tool call '{}' failed ({}); reconnecting to server '{}' and retrying",
130                    self.tool.name,
131                    e,
132                    self.server_name
133                );
134                entry.dead.store(true, Ordering::Release);
135                let entry = {
136                    let mut pool = connection_pool().lock().await;
137                    pool.invalidate(&self.server_name);
138                    pool.get_or_create(&self.server_name, &self.server_config)
139                        .await?
140                };
141                // Mark the replacement dead too so the next caller reconnects
142                // immediately instead of paying a guaranteed-failed call first.
143                self.call_once(&entry, arguments)
144                    .await
145                    .inspect_err(|_| entry.dead.store(true, Ordering::Release))?
146            }
147        };
148
149        call_tool_result_to_agent_value(tool_result)
150    }
151
152    /// Performs a single tool call on the given pooled connection.
153    ///
154    /// Returns `Err` only for transport/protocol-level failures (missing
155    /// service or an `Err` from the rmcp call); tool-level failures arrive
156    /// as `Ok` with `is_error` set and must not trigger a reconnect.
157    async fn call_once(
158        &self,
159        entry: &PoolEntry,
160        arguments: Option<serde_json::Map<String, serde_json::Value>>,
161    ) -> Result<CallToolResult, AgentError> {
162        let connection = entry.conn.lock().await;
163        let service = connection.service.as_ref().ok_or_else(|| {
164            AgentError::Other(format!(
165                "MCP service for '{}' is not available (tool '{}')",
166                self.server_name, self.info.name
167            ))
168        })?;
169        service
170            .call_tool(CallToolRequestParam {
171                name: self.tool.name.clone(),
172                arguments,
173                task: None,
174            })
175            .await
176            .map_err(|e| {
177                AgentError::Other(format!("Failed to call MCP tool '{}': {e}", self.info.name))
178            })
179    }
180}
181
182#[async_trait]
183impl Tool for MCPTool {
184    fn info(&self) -> &ToolInfo {
185        &self.info
186    }
187
188    async fn call(&self, ctx: AgentContext, args: AgentValue) -> Result<AgentValue, AgentError> {
189        self.tool_call(ctx, args).await
190    }
191}
192
193/// Root configuration structure for MCP servers.
194///
195/// Compatible with the Claude Desktop MCP configuration format (`mcp.json`).
196///
197/// # Example JSON
198///
199/// ```json
200/// {
201///   "mcpServers": {
202///     "filesystem": {
203///       "command": "npx",
204///       "args": ["-y", "@anthropic/mcp-server-filesystem", "/path/to/dir"]
205///     }
206///   }
207/// }
208/// ```
209#[derive(Debug, Deserialize)]
210pub struct MCPConfig {
211    /// Map of server names to their configurations.
212    #[serde(rename = "mcpServers")]
213    pub mcp_servers: HashMap<String, MCPServerConfig>,
214}
215
216/// Configuration for a single MCP server.
217///
218/// Specifies how to start the MCP server process.
219#[derive(Debug, Clone, Deserialize)]
220pub struct MCPServerConfig {
221    /// The command to execute (e.g., "npx", "node", "python").
222    pub command: String,
223
224    /// Arguments to pass to the command.
225    pub args: Vec<String>,
226
227    /// Optional environment variables for the process.
228    #[serde(default)]
229    pub env: Option<HashMap<String, String>>,
230}
231
232/// Type alias for a running MCP service connection.
233type MCPService = rmcp::service::RunningService<rmcp::service::RoleClient, ()>;
234
235/// A single connection to an MCP server.
236struct MCPConnection {
237    /// The running service, or None if not connected.
238    service: Option<MCPService>,
239}
240
241/// A pooled connection together with its liveness flag.
242///
243/// The `dead` flag lives outside the connection mutex so that pool
244/// operations can check and set liveness without waiting behind an
245/// in-flight tool call holding the connection lock.
246#[derive(Clone)]
247struct PoolEntry {
248    conn: Arc<AsyncMutex<MCPConnection>>,
249    dead: Arc<AtomicBool>,
250}
251
252/// Connection pool for managing MCP server connections.
253///
254/// Maintains persistent connections to MCP servers and reuses them
255/// across multiple tool calls for efficiency.
256struct MCPConnectionPool {
257    /// Map of server names to their connections.
258    connections: HashMap<String, PoolEntry>,
259}
260
261impl MCPConnectionPool {
262    /// Creates a new empty connection pool.
263    fn new() -> Self {
264        Self {
265            connections: HashMap::new(),
266        }
267    }
268
269    /// Gets an existing connection or creates a new one for the server.
270    ///
271    /// A live existing connection is reused. A connection marked dead or
272    /// whose service is gone is discarded and replaced with a fresh one.
273    async fn get_or_create(
274        &mut self,
275        server_name: &str,
276        config: &MCPServerConfig,
277    ) -> Result<PoolEntry, AgentError> {
278        if let Some(entry) = self.connections.get(server_name) {
279            // try_lock only: a busy connection has an in-flight call, which
280            // implies its service is still present, so treat it as live
281            // rather than blocking the whole pool behind that call.
282            let service_gone = entry
283                .conn
284                .try_lock()
285                .map(|c| c.service.is_none())
286                .unwrap_or(false);
287            if !entry.dead.load(Ordering::Acquire) && !service_gone {
288                log::debug!("Reusing existing MCP connection for '{}'", server_name);
289                return Ok(entry.clone());
290            }
291            log::info!(
292                "Discarding dead MCP connection for '{}', creating a new one",
293                server_name
294            );
295            if let Some(stale) = self.connections.remove(server_name) {
296                cancel_in_background(stale, server_name.to_string());
297            }
298        }
299
300        log::info!(
301            "Starting MCP server '{}' (command: {})",
302            server_name,
303            config.command
304        );
305
306        // Start new MCP service
307        let service = ()
308            .serve(
309                TokioChildProcess::new(Command::new(&config.command).configure(|cmd| {
310                    for arg in &config.args {
311                        cmd.arg(arg);
312                    }
313                    if let Some(env) = &config.env {
314                        for (key, value) in env {
315                            cmd.env(key, value);
316                        }
317                    }
318                }))
319                .map_err(|e| {
320                    log::error!("Failed to start MCP process for '{}': {}", server_name, e);
321                    AgentError::Other(format!(
322                        "Failed to start MCP process for '{}': {e}",
323                        server_name
324                    ))
325                })?,
326            )
327            .await
328            .map_err(|e| {
329                log::error!("Failed to start MCP service for '{}': {}", server_name, e);
330                AgentError::Other(format!(
331                    "Failed to start MCP service for '{}': {e}",
332                    server_name
333                ))
334            })?;
335
336        log::info!("Successfully started MCP server '{}'", server_name);
337
338        let entry = PoolEntry {
339            conn: Arc::new(AsyncMutex::new(MCPConnection {
340                service: Some(service),
341            })),
342            dead: Arc::new(AtomicBool::new(false)),
343        };
344        self.connections
345            .insert(server_name.to_string(), entry.clone());
346        Ok(entry)
347    }
348
349    /// Removes the server's pooled connection if it has been marked dead.
350    ///
351    /// The dead-flag guard makes late invalidations from concurrently failed
352    /// calls no-ops once a healthy replacement connection is in the pool.
353    fn invalidate(&mut self, server_name: &str) {
354        let is_dead = self
355            .connections
356            .get(server_name)
357            .is_some_and(|e| e.dead.load(Ordering::Acquire));
358        if is_dead && let Some(entry) = self.connections.remove(server_name) {
359            log::info!("Invalidating MCP connection for '{}'", server_name);
360            cancel_in_background(entry, server_name.to_string());
361        }
362    }
363
364    /// Removes and returns all pooled connections.
365    ///
366    /// Synchronous on purpose: callers hold the pool lock, and the pool lock
367    /// must never be held across an await on a connection lock (an in-flight
368    /// rmcp call has no default timeout, so that wait could be unbounded).
369    fn take_all(&mut self) -> Vec<(String, PoolEntry)> {
370        self.connections.drain().collect()
371    }
372}
373
374/// Cancels the entry's service in a background task.
375///
376/// Cancellation needs the connection lock, which may be held by an in-flight
377/// call for its full duration; a spawned task keeps pool operations (which
378/// may hold the pool lock) from ever waiting on a connection lock.
379fn cancel_in_background(entry: PoolEntry, server_name: String) {
380    entry.dead.store(true, Ordering::Release);
381    tokio::spawn(async move {
382        let mut connection = entry.conn.lock().await;
383        if let Some(service) = connection.service.take()
384            && let Err(e) = service.cancel().await
385        {
386            log::warn!("Failed to cancel MCP service '{}': {}", server_name, e);
387        }
388    });
389}
390
391/// Global connection pool instance.
392static CONNECTION_POOL: OnceLock<AsyncMutex<MCPConnectionPool>> = OnceLock::new();
393
394/// Returns the global connection pool, initializing it if necessary.
395fn connection_pool() -> &'static AsyncMutex<MCPConnectionPool> {
396    CONNECTION_POOL.get_or_init(|| AsyncMutex::new(MCPConnectionPool::new()))
397}
398
399/// Shuts down all MCP server connections.
400///
401/// Call this during application shutdown to cleanly terminate all
402/// MCP server processes.
403///
404/// The pool remains usable after this call: a tool call running concurrently
405/// with the drain may re-create a connection, which is only cleaned up by a
406/// subsequent call to this function.
407///
408/// Connections still busy past the shutdown timeout are cancelled best-effort
409/// in background tasks, which may not complete if the tokio runtime is torn
410/// down immediately afterwards.
411///
412/// # Example
413///
414/// ```no_run
415/// use modular_agent_core::mcp::shutdown_all_mcp_connections;
416///
417/// #[tokio::main]
418/// async fn main() {
419///     // ... use MCP tools ...
420///
421///     // Clean shutdown
422///     shutdown_all_mcp_connections().await.expect("Failed to shutdown MCP");
423/// }
424/// ```
425pub async fn shutdown_all_mcp_connections() -> Result<(), AgentError> {
426    log::info!("Shutting down all MCP server connections");
427    let entries = { connection_pool().lock().await.take_all() };
428    for (name, entry) in entries {
429        entry.dead.store(true, Ordering::Release);
430        // Bound the wait: an in-flight rmcp call holds the connection lock
431        // with no default request timeout, so a wedged server could otherwise
432        // block shutdown forever. Lock via a clone so `entry` stays movable
433        // into the busy-connection fallback below.
434        let conn = entry.conn.clone();
435        match tokio::time::timeout(std::time::Duration::from_secs(5), conn.lock()).await {
436            Ok(mut connection) => {
437                if let Some(service) = connection.service.take() {
438                    if let Err(e) = service.cancel().await {
439                        log::error!("Failed to cancel MCP service '{}': {}", name, e);
440                    } else {
441                        log::debug!("Successfully shut down MCP server '{}'", name);
442                    }
443                }
444            }
445            Err(_) => {
446                log::warn!(
447                    "MCP connection '{}' busy during shutdown; cancelling in background",
448                    name
449                );
450                cancel_in_background(entry, name);
451            }
452        }
453    }
454    log::info!("All MCP server connections shut down");
455    Ok(())
456}
457
458/// Registers all tools from a single MCP server.
459///
460/// Connects to the MCP server, lists its available tools, and registers
461/// each one with the global tool registry.
462///
463/// # Arguments
464///
465/// * `server_name` - Name of the MCP server
466/// * `server_config` - Configuration for the MCP server
467///
468/// # Returns
469///
470/// A vector of registered tool names in the format "server_name::tool_name".
471async fn register_tools_from_server(
472    server_name: String,
473    server_config: MCPServerConfig,
474) -> Result<Vec<String>, AgentError> {
475    log::debug!("Registering tools from MCP server '{}'", server_name);
476
477    // Get or create connection from pool
478    let entry = {
479        let mut pool = connection_pool().lock().await;
480        pool.get_or_create(&server_name, &server_config).await?
481    };
482
483    // List all available tools from this server
484    log::debug!("Listing tools from MCP server '{}'", server_name);
485    let tools_list = {
486        let connection = entry.conn.lock().await;
487        let service = connection.service.as_ref().ok_or_else(|| {
488            log::error!("MCP service for '{}' is not available", server_name);
489            AgentError::Other(format!(
490                "MCP service for '{}' is not available",
491                server_name
492            ))
493        })?;
494        service.list_tools(Default::default()).await.map_err(|e| {
495            log::error!("Failed to list MCP tools for '{}': {}", server_name, e);
496            AgentError::Other(format!(
497                "Failed to list MCP tools for '{}': {e}",
498                server_name
499            ))
500        })?
501    };
502
503    let mut registered_tool_names = Vec::new();
504
505    // Register all tools from this server using connection pool
506    for tool_info in tools_list.tools {
507        let mcp_tool_name = format!("{}::{}", server_name, tool_info.name);
508        registered_tool_names.push(mcp_tool_name.clone());
509
510        register_tool(MCPTool::new(
511            mcp_tool_name.clone(),
512            server_name.clone(),
513            server_config.clone(),
514            tool_info,
515        ));
516        log::debug!("Registered MCP tool '{}'", mcp_tool_name);
517    }
518
519    log::info!(
520        "Registered {} tools from MCP server '{}'",
521        registered_tool_names.len(),
522        server_name
523    );
524
525    Ok(registered_tool_names)
526}
527
528/// Loads MCP configuration from a JSON file and registers all tools
529///
530/// # Arguments
531/// * `json_path` - Path to the mcp.json file
532///
533/// # Returns
534/// A vector of registered tool names in the format "server_name::tool_name"
535///
536/// # Example
537/// ```no_run
538/// use modular_agent_core::mcp::register_tools_from_mcp_json;
539///
540/// #[tokio::main]
541/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
542///     let tool_names = register_tools_from_mcp_json("mcp.json").await?;
543///     println!("Registered {} tools", tool_names.len());
544///     Ok(())
545/// }
546/// ```
547pub async fn register_tools_from_mcp_json<P: AsRef<Path>>(
548    json_path: P,
549) -> Result<Vec<String>, AgentError> {
550    let path = json_path.as_ref();
551    log::info!("Loading MCP configuration from: {}", path.display());
552
553    // Read the JSON file
554    let json_content = std::fs::read_to_string(path).map_err(|e| {
555        log::error!("Failed to read MCP config file '{}': {}", path.display(), e);
556        AgentError::Other(format!("Failed to read MCP config file: {e}"))
557    })?;
558
559    // Parse the JSON
560    let config: MCPConfig = serde_json::from_str(&json_content).map_err(|e| {
561        log::error!("Failed to parse MCP config JSON: {}", e);
562        AgentError::Other(format!("Failed to parse MCP config JSON: {e}"))
563    })?;
564
565    log::info!("Found {} MCP servers in config", config.mcp_servers.len());
566
567    let mut registered_tool_names = Vec::new();
568
569    // Iterate through each MCP server
570    for (server_name, server_config) in config.mcp_servers {
571        let tools = register_tools_from_server(server_name, server_config).await?;
572        registered_tool_names.extend(tools);
573    }
574
575    log::info!(
576        "Successfully registered {} MCP tools total",
577        registered_tool_names.len()
578    );
579
580    Ok(registered_tool_names)
581}
582
583/// Converts an MCP tool call result to an AgentValue.
584///
585/// Extracts text content from the result and returns it as an array.
586/// If the result indicates an error, returns an AgentError instead.
587fn call_tool_result_to_agent_value(result: CallToolResult) -> Result<AgentValue, AgentError> {
588    let mut contents = Vec::new();
589    for c in result.content.iter() {
590        match &c.raw {
591            rmcp::model::RawContent::Text(text) => {
592                contents.push(AgentValue::string(text.text.clone()));
593            }
594            _ => {
595                // Handle other content types as needed
596            }
597        }
598    }
599    let data = AgentValue::array(contents.into());
600    if result.is_error == Some(true) {
601        return Err(AgentError::Other(
602            serde_json::to_string(&data).map_err(|e| AgentError::InvalidValue(e.to_string()))?,
603        ));
604    }
605    Ok(data)
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    fn test_entry(dead: bool) -> PoolEntry {
613        PoolEntry {
614            conn: Arc::new(AsyncMutex::new(MCPConnection { service: None })),
615            dead: Arc::new(AtomicBool::new(dead)),
616        }
617    }
618
619    fn bogus_config() -> MCPServerConfig {
620        MCPServerConfig {
621            command: "modular-agent-test-nonexistent-command".to_string(),
622            args: Vec::new(),
623            env: None,
624        }
625    }
626
627    #[tokio::test]
628    async fn invalidate_removes_dead_entry() {
629        let mut pool = MCPConnectionPool::new();
630        pool.connections.insert("s".to_string(), test_entry(true));
631        pool.invalidate("s");
632        assert!(pool.connections.is_empty());
633    }
634
635    #[tokio::test]
636    async fn invalidate_keeps_entry_not_marked_dead() {
637        // Guards the race where a late invalidation from a concurrently failed
638        // call must not destroy a healthy replacement connection.
639        let mut pool = MCPConnectionPool::new();
640        pool.connections.insert("s".to_string(), test_entry(false));
641        pool.invalidate("s");
642        assert!(pool.connections.contains_key("s"));
643    }
644
645    #[tokio::test]
646    async fn get_or_create_reuses_busy_connection() {
647        let mut pool = MCPConnectionPool::new();
648        let entry = test_entry(false);
649        pool.connections.insert("s".to_string(), entry.clone());
650        // Hold the connection lock to simulate an in-flight call: the pool
651        // must treat a busy connection as live and return it without
652        // awaiting the lock (a bogus config would make any spawn fail).
653        let _guard = entry.conn.try_lock().unwrap();
654        let got = pool.get_or_create("s", &bogus_config()).await.unwrap();
655        assert!(Arc::ptr_eq(&got.conn, &entry.conn));
656    }
657
658    #[tokio::test]
659    async fn get_or_create_discards_dead_entry() {
660        let mut pool = MCPConnectionPool::new();
661        pool.connections.insert("s".to_string(), test_entry(true));
662        // The Err from the bogus command proves a fresh spawn was attempted
663        // instead of reusing the dead entry.
664        assert!(pool.get_or_create("s", &bogus_config()).await.is_err());
665        assert!(pool.connections.is_empty());
666    }
667
668    #[tokio::test]
669    async fn get_or_create_discards_entry_with_missing_service() {
670        let mut pool = MCPConnectionPool::new();
671        pool.connections.insert("s".to_string(), test_entry(false));
672        assert!(pool.get_or_create("s", &bogus_config()).await.is_err());
673        assert!(pool.connections.is_empty());
674    }
675}