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