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