Skip to main content

a3s_code_core/mcp/
manager.rs

1//! MCP Manager
2//!
3//! Manages MCP server lifecycle and provides unified access to MCP tools.
4
5use crate::mcp::client::McpClient;
6use crate::mcp::oauth;
7use crate::mcp::protocol::{
8    CallToolResult, McpServerConfig, McpTool, McpTransportConfig, OAuthConfig,
9};
10use crate::mcp::transport::http_sse::HttpSseTransport;
11use crate::mcp::transport::stdio::StdioTransport;
12use crate::mcp::transport::streamable_http::StreamableHttpTransport;
13use crate::mcp::transport::McpTransport;
14use anyhow::{anyhow, Result};
15use std::collections::HashMap;
16use std::sync::Arc;
17use tokio::sync::RwLock;
18
19pub use crate::mcp::result::tool_result_to_string;
20
21/// MCP server status
22#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
23pub struct McpServerStatus {
24    pub name: String,
25    pub connected: bool,
26    pub enabled: bool,
27    pub tool_count: usize,
28    pub error: Option<String>,
29}
30
31/// MCP Manager for managing multiple MCP servers
32pub struct McpManager {
33    /// Connected clients
34    clients: RwLock<HashMap<String, Arc<McpClient>>>,
35    /// Server configurations
36    configs: RwLock<HashMap<String, McpServerConfig>>,
37    /// Last connection error per server, cleared on successful connect
38    connect_errors: RwLock<HashMap<String, String>>,
39    /// Last-used timestamp per connected server (Unix epoch ms).
40    /// Updated by `connect` (initial use) and `call_tool` (active use).
41    /// Read by hosts via [`McpManager::last_used_at_ms`] / used by
42    /// [`McpManager::disconnect_idle`] to release FDs and background
43    /// workers from servers that are no longer in active use.
44    last_used_at_ms: RwLock<HashMap<String, u64>>,
45}
46
47impl McpManager {
48    /// Create a new MCP manager
49    pub fn new() -> Self {
50        Self {
51            clients: RwLock::new(HashMap::new()),
52            configs: RwLock::new(HashMap::new()),
53            connect_errors: RwLock::new(HashMap::new()),
54            last_used_at_ms: RwLock::new(HashMap::new()),
55        }
56    }
57
58    /// Register a server configuration
59    pub async fn register_server(&self, config: McpServerConfig) {
60        let name = config.name.clone();
61        let mut configs = self.configs.write().await;
62        configs.insert(name.clone(), config);
63        tracing::info!("Registered MCP server: {}", name);
64    }
65
66    /// Connect to a registered server, recording success or failure internally.
67    ///
68    /// On success the stored error (if any) is cleared; on failure the error
69    /// message is stored and visible via [`McpManager::get_status`].
70    pub async fn connect(&self, name: &str) -> Result<()> {
71        let result = self.do_connect(name).await;
72        match &result {
73            Ok(_) => {
74                self.connect_errors.write().await.remove(name);
75            }
76            Err(e) => {
77                self.connect_errors
78                    .write()
79                    .await
80                    .insert(name.to_string(), e.to_string());
81            }
82        }
83        result
84    }
85
86    async fn do_connect(&self, name: &str) -> Result<()> {
87        // Get config
88        let config = {
89            let configs = self.configs.read().await;
90            configs
91                .get(name)
92                .cloned()
93                .ok_or_else(|| anyhow!("MCP server not found: {}", name))?
94        };
95
96        if !config.enabled {
97            return Err(anyhow!("MCP server is disabled: {}", name));
98        }
99
100        // Resolve OAuth token into an Authorization header (if configured)
101        let auth_header = Self::resolve_auth_header(config.oauth.as_ref()).await?;
102
103        // Create transport based on config
104        let transport: Arc<dyn McpTransport> = match &config.transport {
105            McpTransportConfig::Stdio { command, args } => Arc::new(
106                StdioTransport::spawn_with_timeout(
107                    command,
108                    args,
109                    &config.env,
110                    config.tool_timeout_secs,
111                )
112                .await?,
113            ),
114            McpTransportConfig::Http { url, headers } => {
115                let mut merged = headers.clone();
116                if let Some((k, v)) = &auth_header {
117                    merged.insert(k.clone(), v.clone());
118                }
119                Arc::new(
120                    HttpSseTransport::connect_with_timeout(url, merged, config.tool_timeout_secs)
121                        .await?,
122                )
123            }
124            McpTransportConfig::StreamableHttp { url, headers } => {
125                let mut merged = headers.clone();
126                if let Some((k, v)) = &auth_header {
127                    merged.insert(k.clone(), v.clone());
128                }
129                Arc::new(
130                    StreamableHttpTransport::connect_with_timeout(
131                        url,
132                        merged,
133                        config.tool_timeout_secs,
134                    )
135                    .await?,
136                )
137            }
138        };
139
140        // Create client
141        let client = Arc::new(McpClient::new(name.to_string(), transport));
142
143        // Initialize and fetch tools before publishing the client. A failed
144        // handshake must not leave a live transport detached from manager
145        // state.
146        if let Err(error) = client.initialize().await {
147            if let Err(close_error) = client.close().await {
148                tracing::warn!(
149                    server = %name,
150                    error = %close_error,
151                    "Failed to close MCP transport after initialize failure"
152                );
153            }
154            return Err(error);
155        }
156
157        let tools = match client.list_tools().await {
158            Ok(tools) => tools,
159            Err(error) => {
160                if let Err(close_error) = client.close().await {
161                    tracing::warn!(
162                        server = %name,
163                        error = %close_error,
164                        "Failed to close MCP transport after tool discovery failure"
165                    );
166                }
167                return Err(error);
168            }
169        };
170        tracing::info!("MCP server '{}' connected with {} tools", name, tools.len());
171
172        // Store client + stamp initial last-used time so idle reapers
173        // see freshly-connected servers as active.
174        {
175            let mut clients = self.clients.write().await;
176            clients.insert(name.to_string(), client);
177        }
178        self.last_used_at_ms
179            .write()
180            .await
181            .insert(name.to_string(), now_epoch_ms());
182
183        Ok(())
184    }
185
186    /// Disconnect from a server
187    pub async fn disconnect(&self, name: &str) -> Result<()> {
188        let client = {
189            let mut clients = self.clients.write().await;
190            clients.remove(name)
191        };
192        self.last_used_at_ms.write().await.remove(name);
193
194        if let Some(client) = client {
195            client.close().await?;
196            tracing::info!("MCP server '{}' disconnected", name);
197        }
198
199        Ok(())
200    }
201
202    /// Disconnect and forget a registered server.
203    ///
204    /// This is distinct from [`disconnect`](Self::disconnect), which preserves
205    /// configuration so a host can reconnect later. Session-level live removal
206    /// uses this method so a removed local source no longer shadows an inherited
207    /// server with the same name in status and capability views.
208    pub async fn remove_server(&self, name: &str) -> Result<bool> {
209        // Commit the logical removal before awaiting fallible transport
210        // cleanup. A close error must not leave a configuration that claims a
211        // half-removed server still belongs to the manager.
212        let client = self.clients.write().await.remove(name);
213        let had_error = self.connect_errors.write().await.remove(name).is_some();
214        let had_timestamp = self.last_used_at_ms.write().await.remove(name).is_some();
215        let had_config = self.configs.write().await.remove(name).is_some();
216        let removed = client.is_some() || had_error || had_timestamp || had_config;
217
218        if let Some(client) = client {
219            client.close().await?;
220            tracing::info!("MCP server '{}' removed", name);
221        }
222
223        Ok(removed)
224    }
225
226    /// Return whether a server configuration is registered.
227    pub async fn contains_server(&self, name: &str) -> bool {
228        self.configs.read().await.contains_key(name)
229    }
230
231    #[cfg(test)]
232    pub(crate) async fn insert_client_for_test(&self, name: &str, client: Arc<McpClient>) {
233        self.clients.write().await.insert(name.to_string(), client);
234        self.last_used_at_ms
235            .write()
236            .await
237            .insert(name.to_string(), now_epoch_ms());
238    }
239
240    /// Return the last-used timestamp (Unix epoch ms) for a connected
241    /// server, or `None` if the server is unknown / not connected.
242    pub async fn last_used_at_ms(&self, name: &str) -> Option<u64> {
243        self.last_used_at_ms.read().await.get(name).copied()
244    }
245
246    /// Mark a server as active right now. The framework calls this
247    /// automatically on connect and on every successful
248    /// [`call_tool`](Self::call_tool); hosts can call it explicitly
249    /// to keep a server "warm" out of band (e.g. when a tool result
250    /// comes back via a different channel).
251    pub async fn touch(&self, name: &str) {
252        self.last_used_at_ms
253            .write()
254            .await
255            .insert(name.to_string(), now_epoch_ms());
256    }
257
258    /// Disconnect every connected server whose last-used timestamp is
259    /// older than `now - idle_threshold_ms`. Returns the names of
260    /// servers that were disconnected.
261    ///
262    /// Servers without a recorded timestamp are treated as **infinitely
263    /// idle** and disconnected. The disconnect call itself can fail
264    /// per-server (e.g. transport already closed); those failures are
265    /// warn-logged but never panic — the result vec still includes
266    /// every name the manager attempted to drop.
267    ///
268    /// Hosts running thousands of long-lived sessions should call this
269    /// periodically (e.g. every 60s with a 5-min threshold) to release
270    /// file descriptors and background workers from quiet MCP servers
271    /// without losing the server's configuration. A subsequent
272    /// [`call_tool`](Self::call_tool) on the same server name will
273    /// require an explicit `connect` to come back online.
274    pub async fn disconnect_idle(&self, idle_threshold_ms: u64) -> Vec<String> {
275        let cutoff = now_epoch_ms().saturating_sub(idle_threshold_ms);
276        // Snapshot candidates so we don't hold both locks across await.
277        let candidates: Vec<String> = {
278            let clients = self.clients.read().await;
279            let last_used = self.last_used_at_ms.read().await;
280            clients
281                .keys()
282                .filter(|name| match last_used.get(*name) {
283                    Some(ts) => *ts < cutoff,
284                    // No timestamp -> never used since connect; treat as
285                    // infinitely idle.
286                    None => true,
287                })
288                .cloned()
289                .collect()
290        };
291        let mut disconnected = Vec::with_capacity(candidates.len());
292        for name in candidates {
293            match self.disconnect(&name).await {
294                Ok(()) => disconnected.push(name),
295                Err(e) => tracing::warn!(
296                    server = %name,
297                    error = %e,
298                    "MCP idle disconnect failed; entry already removed from registry"
299                ),
300            }
301        }
302        // Opportunistically purge orphan timestamps for servers that are no
303        // longer connected — `touch()` records a timestamp unconditionally
304        // (even for a never-connected name), and the candidate scan above
305        // only iterates `clients.keys()`, so without this sweep those
306        // orphan entries in `last_used_at_ms` would accumulate unbounded
307        // across the lifetime of a long-running manager.
308        {
309            let clients = self.clients.read().await;
310            self.last_used_at_ms
311                .write()
312                .await
313                .retain(|name, _| clients.contains_key(name));
314        }
315        disconnected
316    }
317
318    /// Get all registered server configurations
319    pub async fn all_configs(&self) -> Vec<McpServerConfig> {
320        self.configs.read().await.values().cloned().collect()
321    }
322
323    /// Get all MCP tools, grouped by server name.
324    ///
325    /// Returns `(server_name, tool)` pairs — the caller is responsible for
326    /// constructing the `mcp__<server>__<tool>` prefix (e.g. via
327    /// [`create_mcp_tools`](crate::mcp::create_mcp_tools)).
328    pub async fn get_all_tools(&self) -> Vec<(String, McpTool)> {
329        let clients = self.clients.read().await;
330        let mut all_tools = Vec::new();
331
332        for (server_name, client) in clients.iter() {
333            let tools = client.get_cached_tools().await;
334            for tool in tools {
335                all_tools.push((server_name.clone(), tool));
336            }
337        }
338
339        all_tools
340    }
341
342    /// Call an MCP tool by full name
343    ///
344    /// Full name format: `mcp__<server>__<tool>`
345    pub async fn call_tool(
346        &self,
347        full_name: &str,
348        arguments: Option<serde_json::Value>,
349    ) -> Result<CallToolResult> {
350        // Parse full name
351        let (server_name, tool_name) = Self::parse_tool_name(full_name)?;
352
353        // Get client
354        let client = {
355            let clients = self.clients.read().await;
356            clients
357                .get(&server_name)
358                .cloned()
359                .ok_or_else(|| anyhow!("MCP server not connected: {}", server_name))?
360        };
361
362        // Refresh the activity timestamp before the await so an idle
363        // sweep running concurrently sees this server as recently used.
364        self.last_used_at_ms
365            .write()
366            .await
367            .insert(server_name.clone(), now_epoch_ms());
368
369        // Call tool
370        client.call_tool(&tool_name, arguments).await
371    }
372
373    /// Resolve an OAuth config into a `(header-name, header-value)` pair.
374    ///
375    /// - If `oauth.access_token` is set, uses it directly (static token).
376    /// - Otherwise, performs a client credentials exchange.
377    /// - If `oauth` is `None`, returns `Ok(None)` (no auth needed).
378    async fn resolve_auth_header(oauth: Option<&OAuthConfig>) -> Result<Option<(String, String)>> {
379        let Some(oauth) = oauth else {
380            return Ok(None);
381        };
382
383        let token = if let Some(static_token) = &oauth.access_token {
384            static_token.clone()
385        } else {
386            oauth::exchange_client_credentials(
387                &oauth.token_url,
388                &oauth.client_id,
389                oauth.client_secret.as_deref().unwrap_or(""),
390                &oauth.scopes,
391            )
392            .await?
393        };
394
395        Ok(Some((
396            "Authorization".to_string(),
397            format!("Bearer {}", token),
398        )))
399    }
400
401    /// Parse MCP tool full name into (server, tool)
402    fn parse_tool_name(full_name: &str) -> Result<(String, String)> {
403        // Format: mcp__<server>__<tool>
404        if !full_name.starts_with("mcp__") {
405            return Err(anyhow!("Invalid MCP tool name: {}", full_name));
406        }
407
408        let rest = &full_name[5..]; // Skip "mcp__"
409        let parts: Vec<&str> = rest.splitn(2, "__").collect();
410
411        if parts.len() != 2 {
412            return Err(anyhow!("Invalid MCP tool name format: {}", full_name));
413        }
414
415        Ok((parts[0].to_string(), parts[1].to_string()))
416    }
417
418    /// Get status of all servers
419    pub async fn get_status(&self) -> HashMap<String, McpServerStatus> {
420        let configs = self.configs.read().await;
421        let clients = self.clients.read().await;
422        let errors = self.connect_errors.read().await;
423        let mut status = HashMap::new();
424
425        for (name, config) in configs.iter() {
426            let client = clients.get(name);
427            let (connected, tool_count) = if let Some(c) = client {
428                (c.is_connected(), c.get_cached_tools().await.len())
429            } else {
430                (false, 0)
431            };
432
433            status.insert(
434                name.clone(),
435                McpServerStatus {
436                    name: name.clone(),
437                    connected,
438                    enabled: config.enabled,
439                    tool_count,
440                    error: errors.get(name).cloned(),
441                },
442            );
443        }
444
445        status
446    }
447
448    /// Get a specific client
449    pub async fn get_client(&self, name: &str) -> Option<Arc<McpClient>> {
450        let clients = self.clients.read().await;
451        clients.get(name).cloned()
452    }
453
454    /// Check if a server is connected
455    pub async fn is_connected(&self, name: &str) -> bool {
456        let clients = self.clients.read().await;
457        clients.get(name).map(|c| c.is_connected()).unwrap_or(false)
458    }
459
460    /// List connected server names
461    pub async fn list_connected(&self) -> Vec<String> {
462        let clients = self.clients.read().await;
463        clients.keys().cloned().collect()
464    }
465
466    /// Get cached tools for a specific connected server.
467    pub async fn get_server_tools(&self, name: &str) -> Vec<McpTool> {
468        let clients = self.clients.read().await;
469        match clients.get(name) {
470            Some(client) => client.get_cached_tools().await,
471            None => Vec::new(),
472        }
473    }
474}
475
476impl Default for McpManager {
477    fn default() -> Self {
478        Self::new()
479    }
480}
481
482/// Wall-clock now() in Unix epoch milliseconds. Used internally by the
483/// activity-tracking + idle-disconnect path. Kept as a free function
484/// (rather than going through `HostEnv`) because the MCP manager
485/// predates host_env wiring and the host's `Clock` impl is not yet
486/// threaded into the manager.
487fn now_epoch_ms() -> u64 {
488    std::time::SystemTime::now()
489        .duration_since(std::time::UNIX_EPOCH)
490        .map(|d| d.as_millis() as u64)
491        .unwrap_or(0)
492}
493
494#[cfg(test)]
495#[path = "manager/tests.rs"]
496mod tests;