Skip to main content

vtcode_mcp/client/
mod.rs

1use anyhow::{Context, Result, anyhow, bail};
2use async_trait::async_trait;
3use chrono::Utc;
4use parking_lot::RwLock;
5use rmcp::model::{CallToolResult, ClientCapabilities, InitializeRequestParams, RootsCapabilities};
6use rustc_hash::FxHashMap;
7use serde_json::{Map, Value, json};
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::Duration;
12use tracing::{info, warn};
13use vtcode_commons::fs::{ensure_dir_exists, write_file_with_context};
14use vtcode_config::mcp::{McpAllowListConfig, McpClientConfig, McpProviderConfig, McpTransportConfig, McpUiAppsTier};
15
16use super::McpSandboxContext;
17use super::{
18    McpClientStatus, McpElicitationHandler, McpPromptDetail, McpPromptInfo, McpProvider, McpResourceData,
19    McpResourceInfo, McpToolExecutor, McpToolInfo, apply_ui_extension_gate, format_tool_markdown,
20    reject_unsupported_ui_apps_tier, sanitize_filename,
21};
22use crate::connection_pool::McpConnectionPool;
23
24mod status;
25
26pub(super) struct McpClientState {
27    providers: FxHashMap<String, Arc<McpProvider>>,
28    allowlist: McpAllowListConfig,
29    tool_provider_index: FxHashMap<String, String>,
30    resource_provider_index: FxHashMap<String, String>,
31    prompt_provider_index: FxHashMap<String, String>,
32}
33
34/// Stable provider order keeps the aggregated catalog (and LLM prompt-cache prefix) deterministic.
35fn providers_sorted_by_name<V: Clone>(providers: &FxHashMap<String, V>) -> Vec<V> {
36    let mut entries: Vec<(&String, &V)> = providers.iter().collect();
37    entries.sort_unstable_by(|a, b| a.0.cmp(b.0));
38    entries.into_iter().map(|(_, provider)| provider.clone()).collect()
39}
40
41pub struct McpClient {
42    pub(super) config: McpClientConfig,
43    pub(super) state: RwLock<McpClientState>,
44    elicitation_handler: Option<Arc<dyn McpElicitationHandler>>,
45    sandbox_context: Option<McpSandboxContext>,
46}
47
48impl McpClient {
49    /// Create a new MCP client from the configuration.
50    pub fn new(config: McpClientConfig) -> Self {
51        Self::with_sandbox_context(config, None)
52    }
53
54    /// Create an MCP client with an optional inherited stdio sandbox context.
55    pub fn with_sandbox_context(config: McpClientConfig, sandbox_context: Option<McpSandboxContext>) -> Self {
56        let allowlist = config.allowlist.clone();
57
58        Self {
59            config,
60            state: RwLock::new(McpClientState {
61                providers: FxHashMap::default(),
62                allowlist,
63                tool_provider_index: FxHashMap::default(),
64                resource_provider_index: FxHashMap::default(),
65                prompt_provider_index: FxHashMap::default(),
66            }),
67            elicitation_handler: None,
68            sandbox_context,
69        }
70    }
71
72    /// Register a handler used to satisfy elicitation requests from providers.
73    pub fn set_elicitation_handler(&mut self, handler: Arc<dyn McpElicitationHandler>) {
74        self.elicitation_handler = Some(handler);
75    }
76
77    /// MCP Apps presentation tier from `[mcp.ui].apps`.
78    ///
79    /// Execution consults this before enriching results: `Off` refuses app UI
80    /// (marking refused URIs under [`crate::types::MCP_APP_SUPPRESSED_KEY`])
81    /// instead of fetching `ui://` resources.
82    #[must_use]
83    pub fn ui_apps_tier(&self) -> McpUiAppsTier {
84        self.config.ui.apps
85    }
86
87    /// Establish connections to all configured providers and complete the
88    /// MCP handshake.
89    pub async fn initialize(&mut self) -> Result<()> {
90        if !self.config.enabled {
91            info!("MCP client is disabled in configuration");
92            return Ok(());
93        }
94        reject_unsupported_ui_apps_tier(&self.config.ui)?;
95
96        info!("Initializing MCP client with {} configured providers", self.config.providers.len());
97
98        let max_connections = self.config.max_concurrent_connections.max(1);
99        let pool = McpConnectionPool::new(max_connections, self.config.request_timeout_seconds);
100        let tool_timeout = Some(Duration::from_secs(self.config.request_timeout_seconds));
101        let allowlist_snapshot = self.state.read().allowlist.clone();
102
103        let provider_configs: Vec<McpProviderConfig> =
104            self.config.providers.iter().filter(|c| c.enabled).cloned().collect();
105
106        let results = pool
107            .initialize_providers_parallel(
108                provider_configs,
109                self.elicitation_handler.clone(),
110                self.sandbox_context.clone(),
111                tool_timeout,
112                &allowlist_snapshot,
113                self.config.ui.apps.advertises_ui_extension(),
114            )
115            .await
116            .map_err(|e| anyhow::anyhow!("MCP connection pool initialization failed: {e}"))?;
117
118        let mut initialized = FxHashMap::default();
119        for (name, provider) in results {
120            drop(initialized.insert(name, provider));
121        }
122
123        self.state.write().providers = initialized;
124        info!("MCP client initialization complete. Active providers: {}", self.state.read().providers.len());
125
126        Ok(())
127    }
128
129    /// Validate tool arguments based on security configuration
130    fn validate_tool_arguments(&self, _tool_name: &str, args: &Value) -> Result<()> {
131        // Check argument size
132        if self.config.security.validation.max_argument_size > 0 {
133            let args_size = serde_json::to_string(args).map_or(0, |s| u32::try_from(s.len()).unwrap_or(u32::MAX));
134
135            if args_size > self.config.security.validation.max_argument_size {
136                return Err(anyhow::anyhow!(
137                    "Tool arguments exceed maximum size of {} bytes",
138                    self.config.security.validation.max_argument_size
139                ));
140            }
141        }
142
143        // Check for path traversal in file-related arguments
144        if self.config.security.validation.path_traversal_protection
145            && let Some(path) = args.get("path").and_then(|v| v.as_str())
146            && (path.contains("../") || path.starts_with("../") || path.contains("..\\") || path.starts_with("..\\"))
147        {
148            return Err(anyhow::anyhow!("Path traversal detected in arguments"));
149        }
150
151        Ok(())
152    }
153
154    /// Execute a tool call after validating arguments.
155    ///
156    /// Public-facing version that takes ownership of `args` for compatibility
157    /// with existing callers. Delegates to the reference-taking implementation
158    /// to avoid unnecessary cloning when the caller already has a reference.
159    async fn execute_tool_with_validation(&self, tool_name: &str, args: Value) -> Result<Value> {
160        self.execute_tool_with_validation_ref(tool_name, &args).await
161    }
162
163    // Internal reference-taking implementation to avoid cloning when not necessary.
164    async fn execute_tool_with_validation_ref(&self, tool_name: &str, args: &Value) -> Result<Value> {
165        if !self.config.enabled {
166            return Err(anyhow!("MCP support is disabled in the current configuration"));
167        }
168
169        self.validate_tool_arguments(tool_name, args)?;
170
171        let provider = self.resolve_provider_for_tool(tool_name).await?;
172        let allowlist_snapshot = self.state.read().allowlist.clone();
173        // Defense in depth: listing hides app-only tools, but a model could
174        // still name one directly. Unknown names fall through to the
175        // provider's own error below.
176        let tools = provider
177            .cached_tools_or_refresh_shared(&allowlist_snapshot, self.tool_timeout())
178            .await?;
179        if let Some(tool) = tools.iter().find(|tool| tool.name == tool_name)
180            && !tool.visibility.model
181        {
182            bail!("MCP tool '{tool_name}' is not available to the model (app-only visibility)");
183        }
184        let result = provider
185            .call_tool(tool_name, args, self.tool_timeout(), &allowlist_snapshot)
186            .await?;
187
188        Self::format_tool_result(&provider.name, tool_name, result)
189    }
190
191    /// Refresh the internal allow list at runtime.
192    pub fn update_allowlist(&self, allowlist: McpAllowListConfig) {
193        let providers: Vec<Arc<McpProvider>> = {
194            let mut state = self.state.write();
195            state.allowlist = allowlist;
196            state.tool_provider_index.clear();
197            state.resource_provider_index.clear();
198            state.prompt_provider_index.clear();
199            state.providers.values().cloned().collect()
200        };
201
202        for provider in providers {
203            provider.invalidate_caches();
204        }
205    }
206
207    /// Current allow list snapshot.
208    pub fn current_allowlist(&self) -> McpAllowListConfig {
209        self.state.read().allowlist.clone()
210    }
211
212    /// Return the provider name serving the given tool if previously cached.
213    pub fn provider_for_tool(&self, tool_name: &str) -> Option<String> {
214        self.state.read().tool_provider_index.get(tool_name).cloned()
215    }
216
217    /// Return the provider responsible for the given resource URI if known.
218    pub fn provider_for_resource(&self, uri: &str) -> Option<String> {
219        self.state.read().resource_provider_index.get(uri).cloned()
220    }
221
222    /// Return the provider that exposes the given prompt if known.
223    pub fn provider_for_prompt(&self, prompt_name: &str) -> Option<String> {
224        self.state.read().prompt_provider_index.get(prompt_name).cloned()
225    }
226
227    /// Execute a tool call on the appropriate provider.
228    pub async fn execute_tool(&self, tool_name: &str, args: Value) -> Result<Value> {
229        self.execute_tool_with_validation(tool_name, args).await
230    }
231
232    /// List all model-visible tools from all active providers.
233    ///
234    /// Tools without model visibility (SEP-1865 `visibility: ["app"]`) are
235    /// hidden here; provider caches keep them for future app-side callers.
236    pub async fn list_tools(&self) -> Result<Vec<McpToolInfo>> {
237        Ok(self
238            .collect_tools(false)
239            .await?
240            .into_iter()
241            .filter(|tool| tool.visibility.model)
242            .collect())
243    }
244
245    /// List all resources exposed by connected MCP providers.
246    pub async fn list_resources(&self) -> Result<Vec<McpResourceInfo>> {
247        self.collect_resources(false).await
248    }
249
250    /// Force refresh and list resources from providers.
251    pub async fn refresh_resources(&self) -> Result<Vec<McpResourceInfo>> {
252        self.collect_resources(true).await
253    }
254
255    /// List all prompts advertised by connected MCP providers.
256    pub async fn list_prompts(&self) -> Result<Vec<McpPromptInfo>> {
257        self.collect_prompts(false).await
258    }
259
260    /// Force refresh and list prompts from providers.
261    pub async fn refresh_prompts(&self) -> Result<Vec<McpPromptInfo>> {
262        self.collect_prompts(true).await
263    }
264
265    /// Read a single resource from its originating provider.
266    pub async fn read_resource(&self, uri: &str) -> Result<McpResourceData> {
267        let provider = self.resolve_provider_for_resource(uri).await?;
268        let provider_name = provider.name.clone();
269        let allowlist_snapshot = self.state.read().allowlist.clone();
270        let data = provider.read_resource(uri, self.request_timeout(), &allowlist_snapshot).await?;
271        drop(self.state.write().resource_provider_index.insert(uri.into(), provider_name));
272        Ok(data)
273    }
274
275    /// Retrieve a rendered prompt from its originating provider.
276    pub async fn get_prompt(
277        &self,
278        prompt_name: &str,
279        arguments: Option<hashbrown::HashMap<String, String>>,
280    ) -> Result<McpPromptDetail> {
281        let provider = self.resolve_provider_for_prompt(prompt_name).await?;
282        let provider_name = provider.name.clone();
283        let allowlist_snapshot = self.state.read().allowlist.clone();
284        let prompt = provider
285            .get_prompt(prompt_name, arguments.unwrap_or_default(), self.request_timeout(), &allowlist_snapshot)
286            .await?;
287        drop(
288            self.state
289                .write()
290                .prompt_provider_index
291                .insert(prompt_name.into(), provider_name),
292        );
293        Ok(prompt)
294    }
295
296    /// Shutdown all active provider connections.
297    pub async fn shutdown(&self) -> Result<()> {
298        let providers: Vec<Arc<McpProvider>> = {
299            let mut state = self.state.write();
300            let values: Vec<_> = state.providers.values().cloned().collect();
301            state.providers.clear();
302            state.tool_provider_index.clear();
303            state.resource_provider_index.clear();
304            state.prompt_provider_index.clear();
305            values
306        };
307
308        if providers.is_empty() {
309            info!("No active MCP connections to shutdown");
310            return Ok(());
311        }
312
313        info!("Shutting down {} MCP providers", providers.len());
314        for provider in providers {
315            if let Err(err) = provider.shutdown().await {
316                warn!("Provider '{}' shutdown returned error: {err}", provider.name);
317            }
318        }
319        Ok(())
320    }
321
322    async fn collect_tools(&self, force_refresh: bool) -> Result<Vec<McpToolInfo>> {
323        // Collect provider references in one pass
324        let (providers, allowlist) = {
325            let state = self.state.read();
326            (providers_sorted_by_name(&state.providers), state.allowlist.clone())
327        };
328
329        if providers.is_empty() {
330            return Ok(Vec::new());
331        }
332
333        let timeout = self.tool_timeout();
334        let mut all_tools = Vec::with_capacity(128);
335        let mut index_updates: FxHashMap<String, String> = FxHashMap::with_capacity_and_hasher(128, Default::default());
336
337        for provider in providers {
338            let provider_name = provider.name.clone();
339            let tools = if force_refresh {
340                provider.refresh_tools(&allowlist, timeout).await
341            } else {
342                provider.list_tools(&allowlist, timeout).await
343            };
344
345            match tools {
346                Ok(tools) => {
347                    for tool in &tools {
348                        let _ = index_updates.entry(tool.name.clone()).or_insert_with(|| provider_name.clone());
349                    }
350                    all_tools.extend(tools);
351                }
352                Err(err) => {
353                    warn!("Failed to list tools for provider '{}': {err}", provider_name);
354                }
355            }
356        }
357
358        if !index_updates.is_empty() || force_refresh {
359            let mut state = self.state.write();
360            if index_updates.is_empty() {
361                state.tool_provider_index.clear();
362            } else {
363                state.tool_provider_index = index_updates;
364            }
365        }
366
367        Ok(all_tools)
368    }
369
370    async fn collect_resources(&self, force_refresh: bool) -> Result<Vec<McpResourceInfo>> {
371        // Collect provider references in one pass
372        let (providers, allowlist) = {
373            let state = self.state.read();
374            (providers_sorted_by_name(&state.providers), state.allowlist.clone())
375        };
376
377        if providers.is_empty() {
378            self.state.write().resource_provider_index.clear();
379            return Ok(Vec::new());
380        }
381
382        let timeout = self.request_timeout();
383        let mut all_resources = Vec::with_capacity(64);
384
385        for provider in providers {
386            let resources = if force_refresh {
387                provider.refresh_resources(&allowlist, timeout).await
388            } else {
389                provider.list_resources(&allowlist, timeout).await
390            };
391
392            match resources {
393                Ok(resources) => {
394                    all_resources.extend(resources);
395                }
396                Err(err) => {
397                    warn!("Failed to list resources for provider '{}': {err}", provider.name);
398                }
399            }
400        }
401
402        let mut state = self.state.write();
403        let index = &mut state.resource_provider_index;
404        index.clear();
405        for resource in &all_resources {
406            let _ = index.entry(resource.uri.clone()).or_insert_with(|| resource.provider.clone());
407        }
408
409        Ok(all_resources)
410    }
411
412    async fn collect_prompts(&self, force_refresh: bool) -> Result<Vec<McpPromptInfo>> {
413        // Collect provider references in one pass
414        let (providers, allowlist) = {
415            let state = self.state.read();
416            (providers_sorted_by_name(&state.providers), state.allowlist.clone())
417        };
418
419        if providers.is_empty() {
420            self.state.write().prompt_provider_index.clear();
421            return Ok(Vec::new());
422        }
423
424        let timeout = self.request_timeout();
425        let mut all_prompts = Vec::with_capacity(32);
426
427        for provider in providers {
428            let prompts = if force_refresh {
429                provider.refresh_prompts(&allowlist, timeout).await
430            } else {
431                provider.list_prompts(&allowlist, timeout).await
432            };
433
434            match prompts {
435                Ok(prompts) => {
436                    all_prompts.extend(prompts);
437                }
438                Err(err) => {
439                    warn!("Failed to list prompts for provider '{}': {err}", provider.name);
440                }
441            }
442        }
443
444        let mut state = self.state.write();
445        let index = &mut state.prompt_provider_index;
446        index.clear();
447        for prompt in &all_prompts {
448            let _ = index.entry(prompt.name.clone()).or_insert_with(|| prompt.provider.clone());
449        }
450
451        Ok(all_prompts)
452    }
453
454    async fn resolve_provider_for_tool(&self, tool_name: &str) -> Result<Arc<McpProvider>> {
455        if !self.config.enabled {
456            return Err(anyhow!("MCP support is disabled in the current configuration"));
457        }
458
459        if let Some(provider) = self.provider_for_tool(tool_name)
460            && let Some(found) = self.state.read().providers.get(&provider)
461        {
462            return Ok(found.clone());
463        }
464
465        let (allowlist, providers) = {
466            let state = self.state.read();
467            (state.allowlist.clone(), providers_sorted_by_name(&state.providers))
468        };
469        let timeout = self.tool_timeout();
470
471        if providers.is_empty() {
472            if self.config.providers.is_empty() {
473                return Err(anyhow!(
474                    "No MCP providers are configured. Use `vtcode mcp add` or update vtcode.toml to register one."
475                ));
476            }
477
478            return Err(anyhow!(
479                "No MCP providers are currently connected. Ensure MCP initialization completed successfully."
480            ));
481        }
482
483        for provider in providers {
484            match provider.has_tool(tool_name, &allowlist, timeout).await {
485                Ok(true) => {
486                    drop(
487                        self.state
488                            .write()
489                            .tool_provider_index
490                            .insert(tool_name.into(), provider.name.clone()),
491                    );
492                    return Ok(provider);
493                }
494                Ok(false) => continue,
495                Err(err) => {
496                    warn!("Error checking tool '{}' on provider '{}': {err}", tool_name, provider.name);
497                }
498            }
499        }
500
501        match self.collect_tools(true).await {
502            Ok(_) => {
503                if let Some(provider) = self.provider_for_tool(tool_name)
504                    && let Some(found) = self.state.read().providers.get(&provider)
505                {
506                    return Ok(found.clone());
507                }
508            }
509            Err(err) => {
510                warn!("Failed to refresh MCP tool caches while resolving '{}': {err}", tool_name);
511            }
512        }
513
514        Err(anyhow!(
515            "Tool '{tool_name}' not found on any MCP provider.\n\n\
516            To use this tool:\n\
517            1. Install the MCP server: `uv tool install mcp-server-{tool_name}`\n\
518            2. Add to vtcode.toml:\n   \
519               [[mcp.providers]]\n   \
520               name = \"{tool_name}\"\n   \
521               command = \"uvx\"\n   \
522               args = [\"mcp-server-{tool_name}\"]\n\
523            3. Restart VT Code\n\n\
524            Or use the built-in alternative if available (e.g., web_fetch instead of mcp_fetch)"
525        ))
526    }
527
528    async fn resolve_provider_for_resource(&self, uri: &str) -> Result<Arc<McpProvider>> {
529        if let Some(provider) = self.provider_for_resource(uri)
530            && let Some(found) = self.state.read().providers.get(&provider)
531        {
532            return Ok(found.clone());
533        }
534
535        let (allowlist, providers) = {
536            let state = self.state.read();
537            (state.allowlist.clone(), providers_sorted_by_name(&state.providers))
538        };
539        let timeout = self.request_timeout();
540
541        for provider in providers {
542            match provider.has_resource(uri, &allowlist, timeout).await {
543                Ok(true) => {
544                    drop(
545                        self.state
546                            .write()
547                            .resource_provider_index
548                            .insert(uri.into(), provider.name.clone()),
549                    );
550                    return Ok(provider);
551                }
552                Ok(false) => continue,
553                Err(err) => {
554                    warn!("Error checking resource '{}' on provider '{}': {err}", uri, provider.name);
555                }
556            }
557        }
558
559        Err(anyhow!("Resource '{uri}' not found on any MCP provider"))
560    }
561
562    async fn resolve_provider_for_prompt(&self, prompt_name: &str) -> Result<Arc<McpProvider>> {
563        if let Some(provider) = self.provider_for_prompt(prompt_name)
564            && let Some(found) = self.state.read().providers.get(&provider)
565        {
566            return Ok(found.clone());
567        }
568
569        let (allowlist, providers) = {
570            let state = self.state.read();
571            (state.allowlist.clone(), providers_sorted_by_name(&state.providers))
572        };
573        let timeout = self.request_timeout();
574
575        for provider in providers {
576            match provider.has_prompt(prompt_name, &allowlist, timeout).await {
577                Ok(true) => {
578                    drop(
579                        self.state
580                            .write()
581                            .prompt_provider_index
582                            .insert(prompt_name.into(), provider.name.clone()),
583                    );
584                    return Ok(provider);
585                }
586                Ok(false) => continue,
587                Err(err) => {
588                    warn!("Error checking prompt '{}' on provider '{}': {err}", prompt_name, provider.name);
589                }
590            }
591        }
592
593        Err(anyhow!("Prompt '{prompt_name}' not found on any MCP provider"))
594    }
595
596    pub(super) fn record_tool_provider(&self, provider: &str, tools: &[McpToolInfo]) {
597        let mut state = self.state.write();
598        let index = &mut state.tool_provider_index;
599        for tool in tools {
600            let _ = index
601                .entry(tool.name.clone())
602                .and_modify(|owner| {
603                    if provider < owner.as_str() {
604                        *owner = provider.to_string();
605                    }
606                })
607                .or_insert_with(|| provider.to_string());
608        }
609    }
610
611    async fn connect_and_initialize_provider(
612        &self,
613        provider_config: &McpProviderConfig,
614        allowlist_snapshot: &McpAllowListConfig,
615        tool_timeout: Option<Duration>,
616    ) -> Result<McpProvider> {
617        let total_attempts = self.provider_retry_attempts();
618        let mut last_error: Option<anyhow::Error> = None;
619
620        for attempt_idx in 0..total_attempts {
621            let attempt_number = attempt_idx + 1;
622            match self
623                .connect_and_initialize_provider_once(provider_config, allowlist_snapshot, tool_timeout)
624                .await
625            {
626                Ok(provider) => return Ok(provider),
627                Err(err) => {
628                    if attempt_number == total_attempts {
629                        return Err(err);
630                    }
631
632                    let retries_remaining = total_attempts - attempt_number;
633                    warn!(
634                        provider = provider_config.name.as_str(),
635                        attempt = attempt_number,
636                        retries_remaining,
637                        error = %err,
638                        "MCP provider initialization failed; retrying"
639                    );
640                    last_error = Some(err);
641                    tokio::time::sleep(Self::provider_retry_delay(attempt_idx)).await;
642                }
643            }
644        }
645
646        Err(last_error.unwrap_or_else(|| anyhow!("Failed to initialize MCP provider '{}'", provider_config.name)))
647    }
648
649    async fn connect_and_initialize_provider_once(
650        &self,
651        provider_config: &McpProviderConfig,
652        allowlist_snapshot: &McpAllowListConfig,
653        tool_timeout: Option<Duration>,
654    ) -> Result<McpProvider> {
655        let provider = McpProvider::connect(
656            provider_config.clone(),
657            self.elicitation_handler.clone(),
658            self.sandbox_context.clone(),
659            self.config.ui.apps.advertises_ui_extension(),
660        )
661        .await
662        .with_context(|| format!("Failed to connect to MCP provider '{}'", provider_config.name))?;
663        let provider_startup_timeout = self.resolve_startup_timeout(provider_config);
664        provider
665            .initialize(self.build_initialize_params(), provider_startup_timeout, tool_timeout, allowlist_snapshot)
666            .await
667            .with_context(|| format!("Failed to initialize MCP provider '{}'", provider_config.name))?;
668        Ok(provider)
669    }
670
671    fn startup_timeout(&self) -> Option<Duration> {
672        match self.config.startup_timeout_seconds {
673            Some(0) => None,
674            Some(value) => Some(Duration::from_secs(value)),
675            None => self.request_timeout(),
676        }
677    }
678
679    pub(super) fn requirement_mismatch_reason(&self, provider_config: &McpProviderConfig) -> Option<String> {
680        let requirements = &self.config.requirements;
681        if !requirements.enforce {
682            return None;
683        }
684
685        match &provider_config.transport {
686            McpTransportConfig::Stdio(stdio) => {
687                if requirements
688                    .allowed_stdio_commands
689                    .iter()
690                    .any(|allowed| allowed == &stdio.command)
691                {
692                    None
693                } else {
694                    Some(format!("stdio command '{}' is not allowlisted", stdio.command))
695                }
696            }
697            McpTransportConfig::Http(http) => {
698                if requirements
699                    .allowed_http_endpoints
700                    .iter()
701                    .any(|allowed| allowed == &http.endpoint)
702                {
703                    None
704                } else {
705                    Some(format!("HTTP endpoint '{}' is not allowlisted", http.endpoint))
706                }
707            }
708        }
709    }
710
711    fn resolve_startup_timeout(&self, provider_config: &McpProviderConfig) -> Option<Duration> {
712        if let Some(timeout_ms) = provider_config.startup_timeout_ms {
713            if timeout_ms == 0 {
714                None
715            } else {
716                Some(Duration::from_millis(timeout_ms))
717            }
718        } else {
719            self.startup_timeout()
720        }
721    }
722
723    fn provider_retry_attempts(&self) -> usize {
724        self.config.retry_attempts.try_into().unwrap_or(usize::MAX).saturating_add(1)
725    }
726
727    fn provider_retry_delay(attempt_idx: usize) -> Duration {
728        let base_ms = 250u64;
729        let max_ms = 5000u64;
730        let exp = base_ms.saturating_mul(2u64.saturating_pow(u32::try_from(attempt_idx).unwrap_or(u32::MAX)));
731        let delay = exp.min(max_ms);
732        Duration::from_millis(delay)
733    }
734
735    pub(super) fn tool_timeout(&self) -> Option<Duration> {
736        match self.config.tool_timeout_seconds {
737            Some(0) => None,
738            Some(value) => Some(Duration::from_secs(value)),
739            None => self.request_timeout(),
740        }
741    }
742
743    fn request_timeout(&self) -> Option<Duration> {
744        if self.config.request_timeout_seconds == 0 {
745            None
746        } else {
747            Some(Duration::from_secs(self.config.request_timeout_seconds))
748        }
749    }
750
751    fn build_initialize_params(&self) -> InitializeRequestParams {
752        let mut capabilities = ClientCapabilities::default();
753        {
754            let mut roots_cap = RootsCapabilities::default();
755            roots_cap.list_changed = Some(true);
756            capabilities.roots = Some(roots_cap);
757        }
758
759        if self.elicitation_handler.is_some() {
760            // Elicitation is now a first-class capability in rmcp
761            capabilities.elicitation = Some(
762                rmcp::model::ElicitationCapability::new()
763                    .with_form(rmcp::model::FormElicitationCapability::new().with_schema_validation(true)),
764            );
765        }
766
767        apply_ui_extension_gate(&mut capabilities, self.config.ui.apps.advertises_ui_extension());
768
769        InitializeRequestParams::new(capabilities, super::utils::build_client_implementation())
770            .with_protocol_version(super::rmcp_client::latest_protocol_version())
771    }
772
773    pub(super) fn normalize_arguments(args: &Value) -> Map<String, Value> {
774        match args {
775            Value::Null => Map::new(),
776            Value::Object(map) => map.clone(),
777            other => {
778                let mut map = Map::new();
779                drop(map.insert("value".to_owned(), other.clone()));
780                map
781            }
782        }
783    }
784
785    fn format_tool_result(provider_name: &str, tool_name: &str, result: CallToolResult) -> Result<Value> {
786        if result.is_error == Some(true) {
787            let message = result
788                .content
789                .iter()
790                .filter_map(|block| block.as_text())
791                .map(|block| block.text.as_str())
792                .find(|text| !text.trim().is_empty())
793                .unwrap_or("Unknown MCP tool error");
794            return Err(anyhow!("MCP tool '{tool_name}' on provider '{provider_name}' reported an error: {message}"));
795        }
796
797        let mut payload = Map::new();
798        drop(payload.insert("provider".into(), Value::String(provider_name.to_string())));
799        drop(payload.insert("tool".into(), Value::String(tool_name.to_string())));
800
801        if !result.content.is_empty() {
802            let mut content = serde_json::to_value(result.content)?;
803            if let Some(blocks) = content.as_array_mut() {
804                for block in blocks {
805                    if let Some(block) = block.as_object_mut() {
806                        // Strip only protocol envelopes, never application data or text.
807                        drop(block.remove("_meta"));
808                        if block.get("type").and_then(Value::as_str) == Some("resource")
809                            && let Some(resource) = block.get_mut("resource").and_then(Value::as_object_mut)
810                        {
811                            drop(resource.remove("_meta"));
812                        }
813                    }
814                }
815            }
816            drop(payload.insert("content".into(), content));
817        }
818        if let Some(structured_content) = result.structured_content {
819            drop(payload.insert("structuredContent".into(), structured_content));
820        }
821
822        Ok(Value::Object(payload))
823    }
824}
825
826#[async_trait]
827impl McpToolExecutor for McpClient {
828    async fn execute_mcp_tool(&self, tool_name: &str, args: &Value) -> Result<Value> {
829        self.execute_tool_with_validation_ref(tool_name, args).await
830    }
831
832    async fn list_mcp_tools(&self) -> Result<Vec<McpToolInfo>> {
833        self.list_tools().await
834    }
835
836    async fn has_mcp_tool(&self, tool_name: &str) -> Result<bool> {
837        if !self.config.enabled {
838            return Ok(false);
839        }
840
841        if self.provider_for_tool(tool_name).is_some() {
842            return Ok(true);
843        }
844
845        if self.state.read().providers.is_empty() {
846            if self.config.providers.is_empty() {
847                return Ok(false);
848            }
849
850            bail!("No MCP providers are currently connected. Ensure MCP initialization completed successfully.");
851        }
852
853        let tools = self.collect_tools(false).await?;
854        Ok(tools.iter().any(|tool| tool.name == tool_name))
855    }
856
857    fn get_status(&self) -> McpClientStatus {
858        self.get_status()
859    }
860}
861
862#[cfg(test)]
863mod tests;