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