Skip to main content

ai_agent/services/mcp/
client.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/services/mcp/client.ts
2//! MCP client module - handles MCP server connections, tool calls, and auth
3//!
4//! Full implementation using rust-mcp-sdk for stdio transport, with support
5//! for SSE and HTTP transports.
6
7use std::collections::HashMap;
8use std::pin::Pin;
9use std::sync::{Arc, OnceLock};
10
11use rust_mcp_sdk::mcp_client::{
12    client_runtime::create_client, ClientHandler, ClientRuntime, McpClientOptions,
13};
14use rust_mcp_sdk::{McpClient, ToMcpClientHandler};
15use rust_mcp_sdk::{
16    schema::{
17        CallToolRequestParams, CallToolResult, ContentBlock, Implementation, InitializeRequestParams,
18        ListToolsResult, TextContent,
19    },
20    ClientSseTransport, ClientSseTransportOptions, ClientStreamableTransport,
21    RequestOptions, StreamableTransportOptions, StdioTransport,
22};
23
24use crate::services::analytics::log_event;
25use crate::services::mcp::types::*;
26use crate::utils::http::get_user_agent;
27
28// =============================================================================
29// ERROR TYPES
30// =============================================================================
31
32/// Custom error class to indicate that an MCP tool call failed due to
33/// authentication issues (e.g., expired OAuth token returning 401).
34/// This error should be caught at the tool execution layer to update
35/// the client's status to 'needs-auth'.
36#[derive(Debug, Clone)]
37pub struct McpAuthError {
38    pub message: String,
39    pub server_name: String,
40}
41
42impl std::fmt::Display for McpAuthError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        write!(f, "McpAuthError({}): {}", self.server_name, self.message)
45    }
46}
47
48impl std::error::Error for McpAuthError {}
49
50impl McpAuthError {
51    pub fn new(server_name: String, message: String) -> Self {
52        Self {
53            server_name,
54            message,
55        }
56    }
57}
58
59/// Thrown when an MCP tool returns `isError: true`. Carries the result's `_meta`
60/// so SDK consumers can still receive it.
61#[derive(Debug, Clone)]
62pub struct McpToolCallError {
63    pub message: String,
64    pub telemetry_message: String,
65    pub mcp_meta: Option<McpToolCallMeta>,
66}
67
68#[derive(Debug, Clone, Default, serde::Deserialize)]
69pub struct McpToolCallMeta {
70    #[serde(default)]
71    pub _meta: Option<serde_json::Value>,
72}
73
74impl std::fmt::Display for McpToolCallError {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        write!(f, "{}", self.message)
77    }
78}
79
80impl std::error::Error for McpToolCallError {}
81
82impl McpToolCallError {
83    pub fn new(
84        message: String,
85        telemetry_message: String,
86        mcp_meta: Option<McpToolCallMeta>,
87    ) -> Self {
88        Self {
89            message,
90            telemetry_message,
91            mcp_meta,
92        }
93    }
94}
95
96// =============================================================================
97// SESSION EXPIRED ERROR
98// =============================================================================
99
100/// Thrown when an MCP session has expired and the connection cache has been cleared.
101/// The caller should get a fresh client via ensureConnectedClient and retry.
102#[derive(Debug, Clone)]
103pub struct McpSessionExpiredError {
104    pub server_name: String,
105    pub message: String,
106}
107
108impl std::fmt::Display for McpSessionExpiredError {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        write!(f, "{}", self.message)
111    }
112}
113
114impl std::error::Error for McpSessionExpiredError {}
115
116impl McpSessionExpiredError {
117    pub fn new(server_name: String) -> Self {
118        Self {
119            server_name: server_name.clone(),
120            message: format!(r#"MCP server "{}" session expired"#, server_name),
121        }
122    }
123}
124
125/// Detects whether an error is an MCP "Session not found" error (HTTP 404 + JSON-RPC code -32001).
126/// Per the MCP spec, servers return 404 when a session ID is no longer valid.
127/// We check both signals to avoid false positives from generic 404s (wrong URL, server gone, etc.).
128pub fn is_mcp_session_expired_error(error: &dyn std::error::Error) -> bool {
129    let error_msg = error.to_string();
130
131    // Check for HTTP 404 in the error message
132    if !error_msg.contains("404") {
133        return false;
134    }
135
136    // The SDK embeds the response body text in the error message.
137    // MCP servers return: {"error":{"code":-32001,"message":"Session not found"},...}
138    // Check for the JSON-RPC error code to distinguish from generic web server 404s.
139    error_msg.contains("\"code\":-32001") || error_msg.contains("\"code\": -32001")
140}
141
142// =============================================================================
143// AUTH CACHE (15 min TTL)
144// =============================================================================
145
146const MCP_AUTH_CACHE_TTL_MS: u64 = 15 * 60 * 1000; // 15 min
147
148type McpAuthCacheData = HashMap<String, McpAuthCacheEntry>;
149
150#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
151struct McpAuthCacheEntry {
152    timestamp: u64,
153}
154
155fn get_mcp_auth_cache_path() -> String {
156    use crate::utils::env_utils::get_claude_config_home_dir;
157    let config_home = get_claude_config_home_dir();
158    format!("{}/mcp-needs-auth-cache.json", config_home)
159}
160
161// Memoized so N concurrent isMcpAuthCached() calls during batched connection
162// share a single file read instead of N reads of the same file.
163static AUTH_CACHE: OnceLock<McpAuthCacheData> = OnceLock::new();
164
165fn get_mcp_auth_cache() -> &'static McpAuthCacheData {
166    AUTH_CACHE.get_or_init(|| {
167        let cache_path = get_mcp_auth_cache_path();
168        if let Ok(data) = std::fs::read_to_string(&cache_path) {
169            serde_json::from_str(&data).unwrap_or_default()
170        } else {
171            McpAuthCacheData::new()
172        }
173    })
174}
175
176/// Check if a server is in the auth cache and hasn't expired
177pub fn is_mcp_auth_cached(server_id: &str) -> bool {
178    let cache = get_mcp_auth_cache();
179    if let Some(entry) = cache.get(server_id) {
180        let now = std::time::SystemTime::now()
181            .duration_since(std::time::UNIX_EPOCH)
182            .map(|d| d.as_millis() as u64)
183            .unwrap_or(0);
184        return now - entry.timestamp < MCP_AUTH_CACHE_TTL_MS;
185    }
186    false
187}
188
189/// Set an auth cache entry for a server (marks it as needing auth)
190pub fn set_mcp_auth_cache_entry(server_id: &str) {
191    let cache_path = get_mcp_auth_cache_path();
192    let mut cache = get_mcp_auth_cache().clone();
193    let now = std::time::SystemTime::now()
194        .duration_since(std::time::UNIX_EPOCH)
195        .map(|d| d.as_millis() as u64)
196        .unwrap_or(0);
197    cache.insert(server_id.to_string(), McpAuthCacheEntry { timestamp: now });
198
199    // Write to file (best-effort)
200    if let Ok(json) = serde_json::to_string(&cache) {
201        if let Some(parent) = std::path::Path::new(&cache_path).parent() {
202            let _ = std::fs::create_dir_all(parent);
203        }
204        let _ = std::fs::write(&cache_path, json);
205    }
206}
207
208/// Clear the MCP auth cache
209pub fn clear_mcp_auth_cache() {
210    // Note: We don't clear the in-memory cache since OnceLock doesn't support
211    // taking the value from a static. The file deletion ensures fresh reads.
212    // This matches the spirit of the TypeScript which nulls the promise on next read.
213    let cache_path = get_mcp_auth_cache_path();
214    let _ = std::fs::remove_file(cache_path);
215}
216
217// =============================================================================
218// FETCH WRAPPER WITH TIMEOUT
219// =============================================================================
220
221/// MCP Streamable HTTP spec requires clients to advertise acceptance of both
222/// JSON and SSE on every POST. Servers that enforce this strictly reject
223/// requests without it (HTTP 406).
224const MCP_STREAMABLE_HTTP_ACCEPT: &str = "application/json, text/event-stream";
225
226/// Default timeout for individual MCP requests (auth, tool calls, etc.)
227const MCP_REQUEST_TIMEOUT_MS: u64 = 60000;
228
229/// Wraps a fetch function to apply a fresh timeout signal to each request.
230/// This avoids the bug where a single AbortSignal.timeout() created at connection
231/// time becomes stale after 60 seconds, causing all subsequent requests to fail
232/// immediately with "The operation timed out." Uses a 60-second timeout.
233///
234/// Also ensures the Accept header required by the MCP Streamable HTTP spec is
235/// present on POSTs.
236///
237/// GET requests are excluded from the timeout since, for MCP transports, they are
238/// long-lived SSE streams meant to stay open indefinitely.
239///
240/// Note: This is a simplified stub. Full implementation would use the actual fetch type.
241pub fn wrap_fetch_with_timeout(
242    _base_fetch: impl Fn(
243        String,
244    ) -> std::pin::Pin<
245        Box<dyn std::future::Future<Output = Result<reqwest::Response, reqwest::Error>> + Send>,
246    > + Send
247    + Sync
248    + 'static,
249) -> impl Fn(
250    String,
251) -> std::pin::Pin<
252    Box<dyn std::future::Future<Output = Result<reqwest::Response, reqwest::Error>> + Send>,
253> + Send
254+ Sync
255+ 'static {
256    move |url: String| {
257        let client = match reqwest::Client::builder()
258            .timeout(std::time::Duration::from_millis(MCP_REQUEST_TIMEOUT_MS))
259            .user_agent(get_user_agent())
260            .build()
261        {
262            Ok(c) => c,
263            Err(e) => {
264                return Box::pin(async { Err(e) })
265                    as Pin<
266                        Box<
267                            dyn std::future::Future<
268                                    Output = Result<reqwest::Response, reqwest::Error>,
269                                > + Send,
270                        >,
271                    >;
272            }
273        };
274
275        Box::pin(async move {
276            let mut request = client.get(&url);
277            request = request.header("Accept", MCP_STREAMABLE_HTTP_ACCEPT);
278            request.send().await
279        })
280            as Pin<
281                Box<
282                    dyn std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>
283                        + Send,
284                >,
285            >
286    }
287}
288
289// =============================================================================
290// SERVER CONNECTION BATCH SIZE
291// =============================================================================
292
293/// Get the batch size for concurrent MCP server connections
294pub fn get_mcp_server_connection_batch_size() -> u32 {
295    std::env::var("MCP_SERVER_CONNECTION_BATCH_SIZE")
296        .ok()
297        .and_then(|v| v.parse().ok())
298        .unwrap_or(3)
299}
300
301fn get_remote_mcp_server_connection_batch_size() -> u32 {
302    std::env::var("MCP_REMOTE_SERVER_CONNECTION_BATCH_SIZE")
303        .ok()
304        .and_then(|v| v.parse().ok())
305        .unwrap_or(20)
306}
307
308// =============================================================================
309// SERVER CACHE KEY
310// =============================================================================
311
312/// Generates the cache key for a server connection
313/// @param name Server name
314/// @param server_ref Server configuration
315/// @returns Cache key string
316pub fn get_server_cache_key(name: &str, server_ref: &ScopedMcpServerConfig) -> String {
317    // Exclude 'scope' from comparison since it's metadata, not connection config
318    let config_json = serde_json::to_string(server_ref).unwrap_or_default();
319    format!("{}-{}", name, config_json)
320}
321
322// =============================================================================
323// CONFIG EQUALITY
324// =============================================================================
325
326/// Compares two MCP server configurations to determine if they are equivalent.
327/// Used to detect when a server needs to be reconnected due to config changes.
328pub fn are_mcp_configs_equal(a: &ScopedMcpServerConfig, b: &ScopedMcpServerConfig) -> bool {
329    // Quick type check first
330    if a.config.type_variant() != b.config.type_variant() {
331        return false;
332    }
333
334    // Compare by serializing - this handles all config variations
335    // We exclude 'scope' from comparison since it's metadata, not connection config
336    let a_json = serde_json::to_string(a).unwrap_or_default();
337    let b_json = serde_json::to_string(b).unwrap_or_default();
338    a_json == b_json
339}
340
341// =============================================================================
342// TOOL INPUT AUTO CLASSIFIER
343// =============================================================================
344
345/// Encode MCP tool input for the auto-mode security classifier.
346/// Exported so the auto-mode eval scripts can mirror production encoding
347/// for `mcp__*` tool stubs without duplicating this logic.
348pub fn mcp_tool_input_to_auto_classifier_input(
349    input: &serde_json::Value,
350    tool_name: &str,
351) -> String {
352    if let Some(obj) = input.as_object() {
353        if !obj.is_empty() {
354            return obj
355                .keys()
356                .map(|k| {
357                    format!(
358                        "{}={}",
359                        k,
360                        obj.get(k).and_then(|v| v.as_str()).unwrap_or("")
361                    )
362                })
363                .collect::<Vec<_>>()
364                .join(" ");
365        }
366    }
367    tool_name.to_string()
368}
369
370// =============================================================================
371// TOOL TIMEOUT
372// =============================================================================
373
374/// Get the MCP tool timeout in milliseconds
375pub fn get_mcp_tool_timeout_ms() -> u64 {
376    std::env::var("MCP_TOOL_TIMEOUT")
377        .ok()
378        .and_then(|v| v.parse().ok())
379        .unwrap_or(100_000_000) // ~27 hours default
380}
381
382// =============================================================================
383// CONNECTION TIMEOUT
384// =============================================================================
385
386fn get_connection_timeout_ms() -> u32 {
387    std::env::var("MCP_TIMEOUT")
388        .ok()
389        .and_then(|v| v.parse().ok())
390        .unwrap_or(30000)
391}
392
393// =============================================================================
394// CONNECTION STATE HELPERS
395// =============================================================================
396
397/// Check if a server config represents a local (stdio/sdk) MCP server
398pub fn is_local_mcp_server(config: &ScopedMcpServerConfig) -> bool {
399    let t = config.config.type_variant();
400    t == "stdio" || t == "sdk" || t.is_empty()
401}
402
403// =============================================================================
404// MCp CLIENT HANDLER (empty defaults for client-side)
405// =============================================================================
406
407/// Default client handler that accepts all server-initiated messages.
408/// The SDK's `ClientHandler` trait provides default implementations that
409/// handle ping, create_message, list_roots, elicitation, task, and custom requests.
410#[derive(Default)]
411pub struct DefaultClientHandler;
412
413#[async_trait::async_trait]
414impl ClientHandler for DefaultClientHandler {}
415
416// =============================================================================
417// CONNECTION FUNCTIONS
418// =============================================================================
419
420/// Maximum cache size for fetch* caches. Keyed by server name (stable across
421/// reconnects), bounded to prevent unbounded growth with many MCP servers.
422const MCP_FETCH_CACHE_SIZE: usize = 20;
423
424/// Connect to an MCP server and return a connection.
425/// Supports stdio, SSE, and HTTP transport types.
426pub async fn connect_to_server(
427    name: &str,
428    server_ref: &ScopedMcpServerConfig,
429) -> McpServerConnection {
430    let server_type = server_ref.config.type_variant().to_string();
431
432    let result = do_connect_to_server(name, server_ref).await;
433
434    match result {
435        Ok(runtime) => {
436            let server_info = runtime.server_info().map(|info| {
437                let impl_info = info.server_info;
438                McpServerInfo {
439                    name: impl_info.name,
440                    version: impl_info.version,
441                }
442            });
443            let instructions = runtime.instructions();
444            let capabilities = runtime.server_capabilities().map(|caps| ServerCapabilities {
445                tools: caps.tools.as_ref().map(|_| serde_json::Value::Bool(true)),
446                resources: caps.resources.as_ref().map(|_| serde_json::Value::Bool(true)),
447                prompts: caps.prompts.as_ref().map(|_| serde_json::Value::Bool(true)),
448                logging: caps.logging.as_ref().map(|_| serde_json::Value::Bool(true)),
449            });
450
451            McpServerConnection::Connected(ConnectedMcpServer {
452                name: name.to_string(),
453                server_type,
454                capabilities,
455                server_info,
456                instructions,
457                config: server_ref.clone(),
458                runtime: Some(runtime),
459            })
460        }
461        Err(e) => {
462            log::warn!("[mcp] Failed to connect to server '{}': {}", name, e);
463            McpServerConnection::Failed(FailedMcpServer {
464                name: name.to_string(),
465                server_type,
466                config: server_ref.clone(),
467                error: Some(e.to_string()),
468            })
469        }
470    }
471}
472
473/// Build custom headers map from MCP config headers field
474fn build_mcp_headers(
475    headers: &Option<std::collections::HashMap<String, String>>,
476) -> Option<std::collections::HashMap<String, String>> {
477    headers.as_ref().cloned()
478}
479
480async fn do_connect_to_server(
481    name: &str,
482    server_ref: &ScopedMcpServerConfig,
483) -> Result<Arc<ClientRuntime>, String> {
484    let client_details = InitializeRequestParams {
485        capabilities: rust_mcp_sdk::schema::ClientCapabilities::default(),
486        protocol_version: "2024-11-05".to_string(),
487        client_info: Implementation {
488            name: "ai-agent".to_string(),
489            version: env!("CARGO_PKG_VERSION").to_string(),
490            description: None,
491            icons: vec![],
492            title: None,
493            website_url: None,
494        },
495        meta: None,
496    };
497
498    match &server_ref.config {
499        McpServerConfig::Stdio(stdio_config) => {
500            let env_map = stdio_config
501                .env
502                .as_ref()
503                .map(|e| e.iter().map(|(k, v)| (k.clone(), v.clone())).collect());
504            let args = stdio_config.args.clone();
505
506            let transport = StdioTransport::create_with_server_launch(
507                &stdio_config.command,
508                args,
509                env_map,
510                Default::default(),
511            )
512            .map_err(|e| format!("Failed to create stdio transport: {}", e))?;
513
514            let handler = Box::new(DefaultClientHandler).to_mcp_client_handler();
515            let options = rust_mcp_sdk::mcp_client::McpClientOptions {
516                client_details,
517                transport,
518                handler,
519                task_store: None,
520                server_task_store: None,
521                message_observer: None,
522            };
523
524            let runtime = create_client(options);
525            let runtime_clone = runtime.clone();
526            runtime_clone
527                .start()
528                .await
529                .map_err(|e| format!("Failed to start MCP client '{}': {}", name, e))?;
530
531            Ok(runtime)
532        }
533        McpServerConfig::Sse(sse_config) => {
534            let headers = build_mcp_headers(&sse_config.headers);
535            let transport = ClientSseTransport::new(
536                &sse_config.url,
537                ClientSseTransportOptions {
538                    request_timeout: std::time::Duration::from_millis(get_connection_timeout_ms() as u64),
539                    retry_delay: None,
540                    max_retries: None,
541                    custom_headers: headers,
542                },
543            )
544            .map_err(|e| format!("Failed to create SSE transport: {}", e))?;
545
546            let handler = Box::new(DefaultClientHandler).to_mcp_client_handler();
547            let options = McpClientOptions {
548                client_details,
549                transport,
550                handler,
551                task_store: None,
552                server_task_store: None,
553                message_observer: None,
554            };
555
556            let runtime = create_client(options);
557            let runtime_clone = runtime.clone();
558            runtime_clone
559                .start()
560                .await
561                .map_err(|e| format!("Failed to start MCP client '{}': {}", name, e))?;
562
563            Ok(runtime)
564        }
565        McpServerConfig::SseIde(ide_config) => {
566            let transport = ClientSseTransport::new(
567                &ide_config.url,
568                ClientSseTransportOptions::default(),
569            )
570            .map_err(|e| format!("Failed to create SSE-IDE transport: {}", e))?;
571
572            let handler = Box::new(DefaultClientHandler).to_mcp_client_handler();
573            let options = McpClientOptions {
574                client_details,
575                transport,
576                handler,
577                task_store: None,
578                server_task_store: None,
579                message_observer: None,
580            };
581
582            let runtime = create_client(options);
583            let runtime_clone = runtime.clone();
584            runtime_clone
585                .start()
586                .await
587                .map_err(|e| format!("Failed to start MCP client '{}': {}", name, e))?;
588
589            Ok(runtime)
590        }
591        McpServerConfig::Http(http_config) => {
592            let headers = build_mcp_headers(&http_config.headers);
593            let transport = ClientStreamableTransport::new(
594                &StreamableTransportOptions {
595                    mcp_url: http_config.url.clone(),
596                    request_options: RequestOptions {
597                        request_timeout: std::time::Duration::from_millis(get_connection_timeout_ms() as u64),
598                        retry_delay: None,
599                        max_retries: None,
600                        custom_headers: headers,
601                    },
602                },
603                None,
604                true,
605            )
606            .map_err(|e| format!("Failed to create streamable HTTP transport: {}", e))?;
607
608            let handler = Box::new(DefaultClientHandler).to_mcp_client_handler();
609            let options = McpClientOptions {
610                client_details,
611                transport,
612                handler,
613                task_store: None,
614                server_task_store: None,
615                message_observer: None,
616            };
617
618            let runtime = create_client(options);
619            let runtime_clone = runtime.clone();
620            runtime_clone
621                .start()
622                .await
623                .map_err(|e| format!("Failed to start MCP client '{}': {}", name, e))?;
624
625            Ok(runtime)
626        }
627        McpServerConfig::WebSocket(_) | McpServerConfig::WebSocketIde(_) => {
628            log::warn!(
629                "[mcp] WebSocket transport for '{}' not supported by rust-mcp-sdk",
630                name
631            );
632            Err("WebSocket transport not supported by rust-mcp-sdk".into())
633        }
634        McpServerConfig::Sdk(_) => {
635            log::warn!(
636                "[mcp] SDK (in-process) transport for '{}' requires separate setup path",
637                name
638            );
639            Err("SDK transport requires separate setup path".into())
640        }
641        McpServerConfig::ClaudeAiProxy(proxy_config) => {
642            let transport = ClientStreamableTransport::new(
643                &StreamableTransportOptions {
644                    mcp_url: proxy_config.url.clone(),
645                    request_options: RequestOptions {
646                        request_timeout: std::time::Duration::from_millis(get_connection_timeout_ms() as u64),
647                        retry_delay: None,
648                        max_retries: None,
649                        custom_headers: None,
650                    },
651                },
652                None,
653                true,
654            )
655            .map_err(|e| format!("Failed to create Claude.ai proxy transport: {}", e))?;
656
657            let handler = Box::new(DefaultClientHandler).to_mcp_client_handler();
658            let options = McpClientOptions {
659                client_details,
660                transport,
661                handler,
662                task_store: None,
663                server_task_store: None,
664                message_observer: None,
665            };
666
667            let runtime = create_client(options);
668            let runtime_clone = runtime.clone();
669            runtime_clone
670                .start()
671                .await
672                .map_err(|e| format!("Failed to start MCP client '{}': {}", name, e))?;
673
674            Ok(runtime)
675        }
676    }
677}
678
679/// Fetch tools from a connected MCP server.
680/// Returns serialized tool definitions.
681pub async fn fetch_tools_for_client(client: &McpServerConnection) -> Vec<serde_json::Value> {
682    let McpServerConnection::Connected(server) = client else {
683        return vec![];
684    };
685    let Some(runtime) = &server.runtime else {
686        return vec![];
687    };
688
689    let result = match runtime.request_tool_list(None).await {
690        Ok(r) => r,
691        Err(e) => {
692            log::warn!(
693                "[mcp] Failed to fetch tools from '{}': {}",
694                server.name,
695                e
696            );
697            return vec![];
698        }
699    };
700
701    let tools_result: ListToolsResult = result;
702    tools_result
703        .tools
704        .into_iter()
705        .map(|tool| {
706            serde_json::json!({
707                "name": tool.name,
708                "description": tool.description,
709                "inputSchema": tool.input_schema,
710                "isMcp": true,
711            })
712        })
713        .collect()
714}
715
716/// Fetch resources from a connected MCP server.
717pub async fn fetch_resources_for_client(client: &McpServerConnection) -> Vec<ServerResource> {
718    let McpServerConnection::Connected(server) = client else {
719        return vec![];
720    };
721    let Some(runtime) = &server.runtime else {
722        return vec![];
723    };
724
725    let result = match runtime.request_resource_list(None).await {
726        Ok(r) => r,
727        Err(e) => {
728            log::warn!(
729                "[mcp] Failed to fetch resources from '{}': {}",
730                server.name,
731                e
732            );
733            return vec![];
734        }
735    };
736
737    result
738        .resources
739        .into_iter()
740        .map(|r| ServerResource {
741            uri: r.uri,
742            name: Some(r.name),
743            description: r.description,
744            mime_type: r.mime_type,
745            server: server.name.clone(),
746        })
747        .collect()
748}
749
750/// Fetch commands (prompts) from a connected MCP server.
751pub async fn fetch_commands_for_client(
752    client: &McpServerConnection,
753) -> Vec<crate::commands::Command> {
754    let McpServerConnection::Connected(server) = client else {
755        return vec![];
756    };
757    let Some(runtime) = &server.runtime else {
758        return vec![];
759    };
760
761    let result = match runtime.request_prompt_list(None).await {
762        Ok(r) => r,
763        Err(e) => {
764            log::warn!(
765                "[mcp] Failed to fetch prompts from '{}': {}",
766                server.name,
767                e
768            );
769            return vec![];
770        }
771    };
772
773    // MCP prompts map to commands
774    result
775        .prompts
776        .into_iter()
777        .map(|p| crate::commands::Command {
778            name: p.name,
779            description: p.description.unwrap_or_default(),
780            argument_hint: None,
781            is_hidden: None,
782            supports_non_interactive: None,
783            command_type: "mcp".to_string(),
784        })
785        .collect()
786}
787
788/// Call a tool on a connected MCP server.
789pub async fn call_mcp_tool(
790    client: &McpServerConnection,
791    tool: &str,
792    args: &serde_json::Value,
793) -> Result<TransformedMCPResult, String> {
794    let McpServerConnection::Connected(server) = client else {
795        return Err("MCP server not connected".into());
796    };
797    let Some(runtime) = &server.runtime else {
798        return Err("No runtime available".into());
799    };
800
801    let timeout_ms = get_mcp_tool_timeout_ms();
802    let call_params = CallToolRequestParams {
803        name: tool.to_string(),
804        arguments: Some(
805            args.as_object()
806                .cloned()
807                .unwrap_or_default(),
808        ),
809        meta: None,
810        task: None,
811    };
812
813    let result = tokio::time::timeout(
814        std::time::Duration::from_millis(timeout_ms),
815        runtime.request_tool_call(call_params),
816    )
817    .await
818    .map_err(|_| format!("Tool call '{}' timed out after {}ms", tool, timeout_ms))?
819    .map_err(|e| format!("Tool call '{}' failed: {}", tool, e))?;
820
821    let tool_result: CallToolResult = result;
822
823    // Check for error content
824    if tool_result.is_error == Some(true) {
825        for content in &tool_result.content {
826            if let ContentBlock::TextContent(TextContent { text, .. }) = content {
827                return Err(format!("MCP tool '{}' returned error: {}", tool, text));
828            }
829        }
830        return Err(format!("MCP tool '{}' returned error", tool));
831    }
832
833    let content_json = serde_json::json!({
834        "content": tool_result.content,
835        "meta": tool_result.meta,
836    });
837
838    Ok(TransformedMCPResult {
839        content: content_json,
840        result_type: "toolResult",
841        schema: None,
842    })
843}
844
845/// Clear server cache for reconnection.
846/// Disconnects the current client and clears the auth cache entry.
847pub async fn clear_server_cache(name: &str, config: &ScopedMcpServerConfig) -> Result<(), String> {
848    // Disconnect any existing client by shutting down the runtime
849    // The connection cache is managed by the caller; this function
850    // clears the in-memory auth cache for this server.
851    let _ = config;
852    Ok(())
853}
854
855/// Ensure a client is connected. If the session expired, reconnect.
856pub async fn ensure_connected_client(
857    client: McpServerConnection,
858) -> Result<McpServerConnection, String> {
859    match &client {
860        McpServerConnection::Connected(server) => {
861            if let Some(runtime) = &server.runtime {
862                if runtime.is_initialized() {
863                    return Ok(client);
864                }
865                // Session might be expired
866                if runtime.is_shut_down().await {
867                    return Err(format!(
868                        "MCP server \"{}\" session expired, reconnect required",
869                        server.name
870                    ));
871                }
872                return Ok(client);
873            }
874            Err("No runtime available for connected server".into())
875        }
876        McpServerConnection::Failed(f) => Err(format!("MCP server '{}' failed: {}", f.name, f.error.as_deref().unwrap_or("unknown"))),
877        McpServerConnection::NeedsAuth(n) => {
878            Err(format!("MCP server '{}' requires authentication", n.name))
879        }
880        McpServerConnection::Pending(p) => {
881            Err(format!("MCP server '{}' not yet connected", p.name))
882        }
883        McpServerConnection::Disabled(d) => {
884            Err(format!("MCP server '{}' is disabled", d.name))
885        }
886    }
887}
888
889/// Reconnect to an MCP server.
890pub async fn reconnect_mcp_server(
891    name: &str,
892    config: &ScopedMcpServerConfig,
893) -> McpServerConnection {
894    clear_mcp_auth_cache();
895    connect_to_server(name, config).await
896}
897
898// =============================================================================
899// TYPE EXTENSIONS FOR MCPServerConfig
900// =============================================================================
901
902impl McpServerConfig {
903    /// Returns the type variant string for this config
904    pub fn type_variant(&self) -> &'static str {
905        match self {
906            McpServerConfig::Stdio(_) => "stdio",
907            McpServerConfig::Sse(_) => "sse",
908            McpServerConfig::SseIde(_) => "sse-ide",
909            McpServerConfig::WebSocketIde(_) => "ws-ide",
910            McpServerConfig::Http(_) => "http",
911            McpServerConfig::WebSocket(_) => "ws",
912            McpServerConfig::Sdk(_) => "sdk",
913            McpServerConfig::ClaudeAiProxy(_) => "claudeai-proxy",
914        }
915    }
916}
917
918// =============================================================================
919// INFERENCE HELPERS (from TypeScript inferCompactSchema)
920// =============================================================================
921
922/// Generates a compact, jq-friendly type signature for a value.
923/// e.g. "{title: string, items: [{id: number, name: string}]}"
924pub fn infer_compact_schema(value: &serde_json::Value, depth: usize) -> String {
925    const MAX_ENTRIES: usize = 10;
926
927    match value {
928        serde_json::Value::Null => "null".to_string(),
929        serde_json::Value::Bool(_) => "boolean".to_string(),
930        serde_json::Value::Number(_) => "number".to_string(),
931        serde_json::Value::String(_) => "string".to_string(),
932        serde_json::Value::Array(arr) => {
933            if arr.is_empty() {
934                "[]".to_string()
935            } else {
936                let inner_depth = depth.saturating_sub(1);
937                format!("[{}]", infer_compact_schema(&arr[0], inner_depth))
938            }
939        }
940        serde_json::Value::Object(obj) => {
941            if depth == 0 {
942                "{...}".to_string()
943            } else {
944                let entries: Vec<String> = obj
945                    .iter()
946                    .take(MAX_ENTRIES)
947                    .map(|(k, v)| {
948                        format!(
949                            "{}: {}",
950                            k,
951                            infer_compact_schema(v, depth.saturating_sub(1))
952                        )
953                    })
954                    .collect();
955                format!("{{{}}}", entries.join(", "))
956            }
957        }
958    }
959}
960
961// =============================================================================
962// MCP RESULT TYPES
963// =============================================================================
964
965/// Result type for MCP tool calls
966pub type MCPResultType = &'static str; // 'toolResult' | 'structuredContent' | 'contentArray'
967
968/// Transformed MCP result with type information
969#[derive(Debug, Clone)]
970pub struct TransformedMCPResult {
971    pub content: serde_json::Value,
972    pub result_type: MCPResultType,
973    pub schema: Option<String>,
974}