Skip to main content

vtcode_mcp/
provider.rs

1use anyhow::{Context, Result, anyhow};
2use arc_swap::ArcSwap;
3use hashbrown::HashMap;
4use rmcp::model::{
5    CallToolRequestParams, CallToolResult, GetPromptRequestParams, InitializeRequestParams, Prompt,
6    ReadResourceRequestParams, Resource, ServerPeerInfo, Tool,
7};
8use rmcp::service::ClientLifecycleMode;
9use serde_json::{Map, Value};
10use std::ffi::OsString;
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::time::Duration;
14use tokio::sync::{Mutex, Semaphore};
15use tracing::{Instrument, Span, warn};
16use url::Url;
17
18use super::rmcp_client::{auto_lifecycle_mode, latest_protocol_version};
19use super::{LATEST_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS};
20
21use vtcode_config::auth::McpOAuthService;
22use vtcode_config::mcp::{McpAllowListConfig, McpHttpHandshakeMode, McpProviderConfig, McpTransportConfig};
23use vtcode_utility_tool_specs::parse_mcp_tool;
24
25use super::{McpClient, McpSandboxContext, RmcpClient};
26use super::{
27    McpElicitationHandler, McpPromptDetail, McpPromptInfo, McpResourceData, McpResourceInfo, McpToolInfo,
28    TIMEZONE_ARGUMENT, apply_ui_extension_gate, build_headers, ensure_timezone_argument, schema_requires_field,
29};
30
31pub struct McpProvider {
32    pub(super) name: String,
33    #[expect(
34        dead_code,
35        reason = "Intentional compatibility, platform, test, or API-shape suppression."
36    )]
37    protocol_version: String,
38    client: ArcSwap<RmcpClient>,
39    /// Stored config so we can reconnect after disconnection.
40    config: McpProviderConfig,
41    /// Stored elicitation handler for reconnection.
42    elicitation_handler: Option<Arc<dyn McpElicitationHandler>>,
43    /// Whether to advertise the MCP Apps UI extension, retained for reconnects.
44    advertise_ui_apps: bool,
45    /// Sandbox inherited by stdio launches and reconnects.
46    sandbox_context: Option<McpSandboxContext>,
47    pub(crate) semaphore: Arc<Semaphore>,
48    caches: Mutex<ProviderCaches>,
49    initialize_result: Mutex<Option<ServerPeerInfo>>,
50}
51
52#[derive(Default)]
53struct ProviderCaches {
54    tools: Option<Arc<Vec<McpToolInfo>>>,
55    resources: Option<Arc<Vec<McpResourceInfo>>>,
56    prompts: Option<Arc<Vec<McpPromptInfo>>>,
57}
58
59impl McpProvider {
60    pub(super) async fn connect(
61        config: McpProviderConfig,
62        elicitation_handler: Option<Arc<dyn McpElicitationHandler>>,
63        sandbox_context: Option<McpSandboxContext>,
64        advertise_ui_apps: bool,
65    ) -> Result<Self> {
66        if config.name.trim().is_empty() {
67            return Err(anyhow!("MCP provider name cannot be empty"));
68        }
69
70        let max_requests = std::cmp::max(1, config.max_concurrent_requests);
71
72        let (client, protocol_version) = match &config.transport {
73            McpTransportConfig::Stdio(stdio) => {
74                let program = OsString::from(&stdio.command);
75                let args: Vec<OsString> = stdio.args.iter().map(OsString::from).collect();
76                let working_dir = stdio.working_directory.as_ref().map(PathBuf::from);
77                let env: HashMap<OsString, OsString> = config
78                    .env
79                    .iter()
80                    .map(|(key, value)| (OsString::from(key), OsString::from(value)))
81                    .collect();
82                let client = RmcpClient::new_stdio_client(
83                    config.name.clone(),
84                    program,
85                    args,
86                    working_dir,
87                    Some(env),
88                    elicitation_handler.clone(),
89                    sandbox_context.clone(),
90                )
91                .await?;
92                (client, LATEST_PROTOCOL_VERSION.to_string())
93            }
94            McpTransportConfig::Http(http) => {
95                if !SUPPORTED_PROTOCOL_VERSIONS
96                    .iter()
97                    .any(|supported| supported == &http.protocol_version)
98                {
99                    return Err(anyhow!(
100                        "MCP HTTP provider '{}' requested unsupported protocol version '{}'",
101                        config.name,
102                        http.protocol_version
103                    ));
104                }
105
106                let bearer_token = if let Some(oauth) = http.oauth.as_ref() {
107                    McpOAuthService::new()
108                        .resolve_access_token_for_resource(&config.name, oauth, &http.endpoint)
109                        .await?
110                        .ok_or_else(|| {
111                            anyhow!(
112                                "MCP HTTP provider '{}' requires OAuth login. Run `vtcode mcp login {}`.",
113                                config.name,
114                                config.name
115                            )
116                        })
117                        .map(Some)?
118                } else {
119                    match http.api_key_env.as_ref() {
120                        Some(var) => Some(
121                            std::env::var(var)
122                                .with_context(|| format!("Missing MCP API key environment variable: {var}"))?,
123                        ),
124                        None => None,
125                    }
126                };
127
128                let headers = build_headers(&http.http_headers, &http.env_http_headers);
129                let client = RmcpClient::new_streamable_http_client(
130                    config.name.clone(),
131                    &http.endpoint,
132                    bearer_token,
133                    headers,
134                    elicitation_handler.clone(),
135                )
136                .await?;
137                (client, http.protocol_version.clone())
138            }
139        };
140
141        Ok(Self {
142            name: config.name.clone(),
143            protocol_version,
144            client: ArcSwap::from_pointee(client),
145            config,
146            elicitation_handler,
147            advertise_ui_apps,
148            sandbox_context,
149            semaphore: Arc::new(Semaphore::new(max_requests)),
150            caches: Mutex::new(ProviderCaches::default()),
151            initialize_result: Mutex::new(None),
152        })
153    }
154
155    pub(super) fn invalidate_caches(&self) {
156        if let Ok(mut caches) = self.caches.try_lock() {
157            caches.tools = None;
158            caches.resources = None;
159            caches.prompts = None;
160        }
161    }
162
163    pub(super) async fn initialize(
164        &self,
165        params: InitializeRequestParams,
166        startup_timeout: Option<Duration>,
167        _tool_timeout: Option<Duration>,
168        _allowlist: &McpAllowListConfig,
169    ) -> Result<()> {
170        let client = self.client.load_full();
171        let result = client.initialize(params, startup_timeout, self.handshake_lifecycle()).await?;
172
173        let protocol_version_str = result.protocol_version.to_string();
174        if !SUPPORTED_PROTOCOL_VERSIONS
175            .iter()
176            .any(|supported| *supported == protocol_version_str)
177        {
178            return Err(anyhow!(
179                "MCP server for '{}' negotiated unsupported protocol version '{}'",
180                self.name,
181                protocol_version_str
182            ));
183        }
184
185        *self.initialize_result.lock().await = Some(result);
186        Ok(())
187    }
188
189    /// Lifecycle mode for the rmcp handshake, derived from the transport.
190    ///
191    /// stdio always probes `server/discover` first (`Auto`) since a local
192    /// child process answers immediately either way. HTTP defaults to the
193    /// direct legacy handshake so legacy-only servers (e.g. DeepWiki) are not
194    /// penalized by the discover-timeout fallback; opt in to `Auto` per
195    /// provider via `handshake = "auto"` for modern servers.
196    fn handshake_lifecycle(&self) -> ClientLifecycleMode {
197        match &self.config.transport {
198            McpTransportConfig::Stdio(_) => auto_lifecycle_mode(),
199            McpTransportConfig::Http(http) => match http.handshake {
200                McpHttpHandshakeMode::Legacy => ClientLifecycleMode::Initialize,
201                McpHttpHandshakeMode::Auto => auto_lifecycle_mode(),
202            },
203        }
204    }
205
206    /// Protocol version negotiated during the last successful handshake, if any.
207    pub(super) async fn negotiated_protocol_version(&self) -> Option<String> {
208        self.initialize_result
209            .lock()
210            .await
211            .as_ref()
212            .map(|info| info.protocol_version.to_string())
213    }
214
215    pub(super) async fn list_tools(
216        &self,
217        allowlist: &McpAllowListConfig,
218        timeout: Option<Duration>,
219    ) -> Result<Vec<McpToolInfo>> {
220        Ok(self.list_tools_shared(allowlist, timeout).await?.as_ref().clone())
221    }
222
223    async fn list_tools_shared(
224        &self,
225        allowlist: &McpAllowListConfig,
226        timeout: Option<Duration>,
227    ) -> Result<Arc<Vec<McpToolInfo>>> {
228        let mut caches = self.caches.lock().await;
229        if self.client.load_full().take_tool_list_changed() {
230            caches.tools = None;
231        }
232
233        if let Some(cache) = &caches.tools {
234            return Ok(Arc::clone(cache));
235        }
236        drop(caches);
237
238        self.refresh_tools_shared(allowlist, timeout).await
239    }
240
241    pub(super) async fn refresh_tools(
242        &self,
243        allowlist: &McpAllowListConfig,
244        timeout: Option<Duration>,
245    ) -> Result<Vec<McpToolInfo>> {
246        Ok(self.refresh_tools_shared(allowlist, timeout).await?.as_ref().clone())
247    }
248
249    async fn refresh_tools_shared(
250        &self,
251        allowlist: &McpAllowListConfig,
252        timeout: Option<Duration>,
253    ) -> Result<Arc<Vec<McpToolInfo>>> {
254        let client = self.client.load_full();
255        let tools = client.list_all_tools(timeout).await?;
256        let filtered = Arc::new(self.filter_tools(tools, allowlist));
257        self.caches.lock().await.tools = Some(Arc::clone(&filtered));
258        Ok(filtered)
259    }
260
261    pub(super) async fn has_tool(
262        &self,
263        tool_name: &str,
264        allowlist: &McpAllowListConfig,
265        timeout: Option<Duration>,
266    ) -> Result<bool> {
267        let tools = self.list_tools_shared(allowlist, timeout).await?;
268        Ok(tools.iter().any(|tool| tool.name == tool_name))
269    }
270
271    pub(super) async fn call_tool(
272        &self,
273        tool_name: &str,
274        args: &Value,
275        timeout: Option<Duration>,
276        allowlist: &McpAllowListConfig,
277    ) -> Result<CallToolResult> {
278        if !allowlist.is_tool_allowed(&self.name, tool_name) {
279            return Err(anyhow!("Tool '{}' is blocked by the MCP allow list for provider '{}'", tool_name, self.name));
280        }
281
282        let _permit = self
283            .semaphore
284            .clone()
285            .acquire_owned()
286            .await
287            .context("Failed to acquire MCP request slot")?;
288        let mut arguments = McpClient::normalize_arguments(args);
289        self.add_argument_defaults(tool_name, &mut arguments, allowlist, timeout)
290            .await
291            .with_context(|| {
292                format!("failed to prepare arguments for MCP tool '{}' on provider '{}'", tool_name, self.name)
293            })?;
294        let params = CallToolRequestParams::new(tool_name.to_string()).with_arguments(arguments);
295        let client = self.client.load_full();
296        async move { client.call_tool(params, timeout).await }
297            .instrument(mcp_tool_call_span(&self.name, tool_name, &self.config.transport))
298            .await
299    }
300
301    async fn add_argument_defaults(
302        &self,
303        tool_name: &str,
304        arguments: &mut Map<String, Value>,
305        allowlist: &McpAllowListConfig,
306        timeout: Option<Duration>,
307    ) -> Result<()> {
308        let requires_timezone = self
309            .tool_requires_field(tool_name, TIMEZONE_ARGUMENT, allowlist, timeout)
310            .await?;
311        ensure_timezone_argument(arguments, requires_timezone)?;
312        Ok(())
313    }
314
315    async fn tool_requires_field(
316        &self,
317        tool_name: &str,
318        field: &str,
319        allowlist: &McpAllowListConfig,
320        timeout: Option<Duration>,
321    ) -> Result<bool> {
322        if let Some(tools) = &self.caches.lock().await.tools
323            && let Some(tool) = tools.iter().find(|tool| tool.name == tool_name)
324        {
325            return Ok(schema_requires_field(&tool.input_schema, field));
326        }
327
328        match self.refresh_tools_shared(allowlist, timeout).await {
329            Ok(tools) => Ok(tools
330                .iter()
331                .find(|tool| tool.name == tool_name)
332                .map(|tool| schema_requires_field(&tool.input_schema, field))
333                .unwrap_or(false)),
334            Err(err) => {
335                warn!(
336                    "Failed to refresh tools while inspecting schema for '{}' on provider '{}': {err}",
337                    tool_name, self.name
338                );
339                Ok(false)
340            }
341        }
342    }
343
344    pub(super) async fn list_resources(
345        &self,
346        allowlist: &McpAllowListConfig,
347        timeout: Option<Duration>,
348    ) -> Result<Vec<McpResourceInfo>> {
349        Ok(self.list_resources_shared(allowlist, timeout).await?.as_ref().clone())
350    }
351
352    async fn list_resources_shared(
353        &self,
354        allowlist: &McpAllowListConfig,
355        timeout: Option<Duration>,
356    ) -> Result<Arc<Vec<McpResourceInfo>>> {
357        let mut caches = self.caches.lock().await;
358        if self.client.load_full().take_resource_list_changed() {
359            caches.resources = None;
360        }
361
362        if let Some(cache) = &caches.resources {
363            return Ok(Arc::clone(cache));
364        }
365        drop(caches);
366
367        self.refresh_resources_shared(allowlist, timeout).await
368    }
369
370    pub(super) async fn refresh_resources(
371        &self,
372        allowlist: &McpAllowListConfig,
373        timeout: Option<Duration>,
374    ) -> Result<Vec<McpResourceInfo>> {
375        Ok(self.refresh_resources_shared(allowlist, timeout).await?.as_ref().clone())
376    }
377
378    async fn refresh_resources_shared(
379        &self,
380        allowlist: &McpAllowListConfig,
381        timeout: Option<Duration>,
382    ) -> Result<Arc<Vec<McpResourceInfo>>> {
383        let client = self.client.load_full();
384        let resources = client.list_all_resources(timeout).await?;
385        let filtered = Arc::new(self.filter_resources(resources, allowlist));
386        self.caches.lock().await.resources = Some(Arc::clone(&filtered));
387        Ok(filtered)
388    }
389
390    pub(super) async fn has_resource(
391        &self,
392        uri: &str,
393        allowlist: &McpAllowListConfig,
394        timeout: Option<Duration>,
395    ) -> Result<bool> {
396        let resources = self.list_resources_shared(allowlist, timeout).await?;
397        Ok(resources.iter().any(|resource| resource.uri == uri))
398    }
399
400    pub(super) async fn read_resource(
401        &self,
402        uri: &str,
403        timeout: Option<Duration>,
404        allowlist: &McpAllowListConfig,
405    ) -> Result<McpResourceData> {
406        if !allowlist.is_resource_allowed(&self.name, uri) {
407            return Err(anyhow!("Resource '{}' is blocked by the MCP allow list for provider '{}'", uri, self.name));
408        }
409
410        let _permit = self
411            .semaphore
412            .clone()
413            .acquire_owned()
414            .await
415            .context("Failed to acquire MCP request slot")?;
416        let params = ReadResourceRequestParams::new(uri.to_string());
417        let client = self.client.load_full();
418        let result = client.read_resource(params, timeout).await?;
419        Ok(McpResourceData {
420            provider: self.name.clone(),
421            uri: uri.to_string(),
422            contents: result.contents,
423            meta: Map::new(),
424        })
425    }
426
427    pub(super) async fn list_prompts(
428        &self,
429        allowlist: &McpAllowListConfig,
430        timeout: Option<Duration>,
431    ) -> Result<Vec<McpPromptInfo>> {
432        Ok(self.list_prompts_shared(allowlist, timeout).await?.as_ref().clone())
433    }
434
435    async fn list_prompts_shared(
436        &self,
437        allowlist: &McpAllowListConfig,
438        timeout: Option<Duration>,
439    ) -> Result<Arc<Vec<McpPromptInfo>>> {
440        let mut caches = self.caches.lock().await;
441        if self.client.load_full().take_prompt_list_changed() {
442            caches.prompts = None;
443        }
444
445        if let Some(cache) = &caches.prompts {
446            return Ok(Arc::clone(cache));
447        }
448        drop(caches);
449
450        self.refresh_prompts_shared(allowlist, timeout).await
451    }
452
453    pub(super) async fn refresh_prompts(
454        &self,
455        allowlist: &McpAllowListConfig,
456        timeout: Option<Duration>,
457    ) -> Result<Vec<McpPromptInfo>> {
458        Ok(self.refresh_prompts_shared(allowlist, timeout).await?.as_ref().clone())
459    }
460
461    async fn refresh_prompts_shared(
462        &self,
463        allowlist: &McpAllowListConfig,
464        timeout: Option<Duration>,
465    ) -> Result<Arc<Vec<McpPromptInfo>>> {
466        let client = self.client.load_full();
467        let prompts = client.list_all_prompts(timeout).await?;
468        let filtered = Arc::new(self.filter_prompts(prompts, allowlist));
469        self.caches.lock().await.prompts = Some(Arc::clone(&filtered));
470        Ok(filtered)
471    }
472
473    pub(super) async fn has_prompt(
474        &self,
475        prompt_name: &str,
476        allowlist: &McpAllowListConfig,
477        timeout: Option<Duration>,
478    ) -> Result<bool> {
479        let prompts = self.list_prompts_shared(allowlist, timeout).await?;
480        Ok(prompts.iter().any(|prompt| prompt.name == prompt_name))
481    }
482
483    pub(super) async fn get_prompt(
484        &self,
485        prompt_name: &str,
486        arguments: HashMap<String, String>,
487        timeout: Option<Duration>,
488        allowlist: &McpAllowListConfig,
489    ) -> Result<McpPromptDetail> {
490        if !allowlist.is_prompt_allowed(&self.name, prompt_name) {
491            return Err(anyhow!(
492                "Prompt '{}' is blocked by the MCP allow list for provider '{}'",
493                prompt_name,
494                self.name
495            ));
496        }
497
498        let _permit = self
499            .semaphore
500            .clone()
501            .acquire_owned()
502            .await
503            .context("Failed to acquire MCP request slot")?;
504        // Convert HashMap<String, String> to JsonObject (BTreeMap<String, Value>)
505        let args_json: Map<String, Value> = arguments.into_iter().map(|(k, v)| (k, Value::String(v))).collect();
506
507        let params = GetPromptRequestParams::new(prompt_name.to_string()).with_arguments(args_json);
508        let client = self.client.load_full();
509        let result = client.get_prompt(params, timeout).await?;
510        Ok(McpPromptDetail {
511            provider: self.name.clone(),
512            name: prompt_name.to_string(),
513            description: result.description,
514            messages: result.messages,
515            meta: Map::new(),
516        })
517    }
518
519    /// Return the cached tool list as a shared handle.
520    ///
521    /// The cache already stores `Arc<Vec<McpToolInfo>>`; returning the `Arc`
522    /// avoids deep-cloning every tool description and JSON schema on each hit.
523    pub(super) async fn cached_tools_shared(&self) -> Option<Arc<Vec<McpToolInfo>>> {
524        self.caches.lock().await.tools.as_ref().map(Arc::clone)
525    }
526
527    /// Return cached tools or refresh them, handing back the shared cache entry.
528    ///
529    /// Callers that only need to read/record the list can keep the `Arc` and
530    /// skip the per-call deep clone that the owned-`Vec` return used to force.
531    pub(super) async fn cached_tools_or_refresh_shared(
532        &self,
533        allowlist: &McpAllowListConfig,
534        timeout: Option<Duration>,
535    ) -> Result<Arc<Vec<McpToolInfo>>> {
536        if let Some(tools) = self.cached_tools_shared().await {
537            return Ok(tools);
538        }
539
540        self.refresh_tools_shared(allowlist, timeout).await
541    }
542
543    pub(super) async fn shutdown(&self) -> Result<()> {
544        let client = self.client.load_full();
545        client.shutdown().await
546    }
547
548    /// Returns `true` when the underlying transport is still connected and responsive.
549    pub(super) async fn is_healthy(&self) -> bool {
550        let client = self.client.load_full();
551        client.is_healthy().await
552    }
553
554    /// Attempt to re-establish the MCP connection using the stored configuration.
555    ///
556    /// This replaces the inner [`RmcpClient`] with a freshly connected one, then
557    /// re-initialises the provider (tools/resources/prompts caches are invalidated).
558    pub(super) async fn reconnect(
559        &self,
560        startup_timeout: Option<Duration>,
561        tool_timeout: Option<Duration>,
562        allowlist: &McpAllowListConfig,
563    ) -> Result<()> {
564        tracing::info!(provider = self.name.as_str(), "Attempting MCP reconnection");
565
566        // Shut down the old client (best-effort).
567        {
568            let old = self.client.load_full();
569            drop(old.shutdown().await);
570        }
571
572        // Create a fresh client from the stored config.
573        let new_provider = McpProvider::connect(
574            self.config.clone(),
575            self.elicitation_handler.clone(),
576            self.sandbox_context.clone(),
577            self.advertise_ui_apps,
578        )
579        .await
580        .with_context(|| format!("MCP reconnect failed for provider '{}'", self.name))?;
581
582        // Swap inner client.
583        {
584            let new_client = new_provider.client.load_full();
585            self.client.store(new_client);
586        }
587
588        // Invalidate all caches before re-initialisation.
589        self.invalidate_caches();
590
591        // Re-initialise (handshake + tool refresh).
592        let init_params = build_reconnect_initialize_params(self.advertise_ui_apps);
593        self.initialize(init_params, startup_timeout, tool_timeout, allowlist)
594            .await
595            .with_context(|| format!("MCP re-initialization failed for provider '{}'", self.name))?;
596
597        tracing::info!(provider = self.name.as_str(), "MCP reconnection successful");
598        Ok(())
599    }
600
601    fn filter_tools(&self, tools: Vec<Tool>, allowlist: &McpAllowListConfig) -> Vec<McpToolInfo> {
602        filter_tools_sorted(&self.name, tools, allowlist)
603    }
604
605    fn filter_resources(&self, resources: Vec<Resource>, allowlist: &McpAllowListConfig) -> Vec<McpResourceInfo> {
606        filter_resources_sorted(&self.name, resources, allowlist)
607    }
608
609    fn filter_prompts(&self, prompts: Vec<Prompt>, allowlist: &McpAllowListConfig) -> Vec<McpPromptInfo> {
610        filter_prompts_sorted(&self.name, prompts, allowlist)
611    }
612}
613
614/// Build initialize params for a provider reconnect, retaining the UI advertisement.
615fn build_reconnect_initialize_params(advertise_ui_apps: bool) -> InitializeRequestParams {
616    let mut capabilities = rmcp::model::ClientCapabilities::default();
617    apply_ui_extension_gate(&mut capabilities, advertise_ui_apps);
618    InitializeRequestParams::new(capabilities, super::utils::build_client_implementation())
619        .with_protocol_version(latest_protocol_version())
620}
621
622// Servers may reorder lists between calls; a stable order keeps LLM prompt-cache prefixes valid.
623fn filter_tools_sorted(provider: &str, tools: Vec<Tool>, allowlist: &McpAllowListConfig) -> Vec<McpToolInfo> {
624    let mut filtered: Vec<McpToolInfo> = tools
625        .into_iter()
626        .filter(|tool| allowlist.is_tool_allowed(provider, &tool.name))
627        .map(|tool| {
628            let parsed = parse_mcp_tool(&tool);
629            McpToolInfo {
630                description: parsed.description,
631                input_schema: parsed.input_schema,
632                output_schema: parsed.output_schema,
633                provider: provider.to_owned(),
634                name: parsed.name,
635                ui: parsed.ui,
636                visibility: parsed.visibility,
637            }
638        })
639        .collect();
640    filtered.sort_by(|a, b| a.name.cmp(&b.name));
641    filtered
642}
643
644fn filter_resources_sorted(
645    provider: &str,
646    resources: Vec<Resource>,
647    allowlist: &McpAllowListConfig,
648) -> Vec<McpResourceInfo> {
649    let mut filtered: Vec<McpResourceInfo> = resources
650        .into_iter()
651        .filter(|resource| allowlist.is_resource_allowed(provider, &resource.uri))
652        .map(|resource| McpResourceInfo {
653            provider: provider.to_owned(),
654            uri: resource.uri.clone(),
655            name: resource.name.clone(),
656            description: resource.description.clone(),
657            mime_type: resource.mime_type.clone(),
658            size: resource.size.map(|s| i64::try_from(s).unwrap_or(i64::MAX)),
659        })
660        .collect();
661    filtered.sort_by(|a, b| a.uri.cmp(&b.uri));
662    filtered
663}
664
665fn filter_prompts_sorted(provider: &str, prompts: Vec<Prompt>, allowlist: &McpAllowListConfig) -> Vec<McpPromptInfo> {
666    let mut filtered: Vec<McpPromptInfo> = prompts
667        .into_iter()
668        .filter(|prompt| allowlist.is_prompt_allowed(provider, &prompt.name))
669        .map(|prompt| McpPromptInfo {
670            provider: provider.to_owned(),
671            name: prompt.name.clone(),
672            description: prompt.description.clone(),
673            arguments: prompt.arguments.clone().unwrap_or_default(),
674        })
675        .collect();
676    filtered.sort_by(|a, b| a.name.cmp(&b.name));
677    filtered
678}
679
680fn mcp_tool_call_span(provider_name: &str, tool_name: &str, transport: &McpTransportConfig) -> Span {
681    let (transport_label, server_address, server_port) = match transport {
682        McpTransportConfig::Stdio(_) => ("stdio", String::new(), 0_u16),
683        McpTransportConfig::Http(http) => {
684            let (server_address, server_port) = Url::parse(&http.endpoint)
685                .ok()
686                .and_then(|url| {
687                    url.host_str()
688                        .map(|host| (host.to_string(), url.port_or_known_default().unwrap_or_default()))
689                })
690                .unwrap_or_default();
691            ("streamable_http", server_address, server_port)
692        }
693    };
694
695    tracing::info_span!(
696        "mcp.tools.call",
697        provider = provider_name,
698        tool = tool_name,
699        rpc_system = "jsonrpc",
700        rpc_method = "tools/call",
701        transport = transport_label,
702        server_address = server_address.as_str(),
703        server_port = server_port,
704    )
705}
706
707#[cfg(test)]
708mod tests {
709    use super::mcp_tool_call_span;
710    use std::fs::{self, File, OpenOptions};
711    use std::io::{BufWriter, Write};
712    use std::sync::{Arc, Mutex};
713    use tempfile::tempdir;
714    use tracing_subscriber::{fmt::format::FmtSpan, prelude::*};
715    use vtcode_config::mcp::{McpHttpServerConfig, McpStdioServerConfig, McpTransportConfig};
716
717    /// Minimal clonable buffered writer for tests. Implements `Write` so it can
718    /// be passed to `tracing_subscriber::fmt::layer().with_writer(..)`.
719    #[derive(Clone)]
720    struct TestWriter(Arc<Mutex<BufWriter<File>>>);
721
722    impl TestWriter {
723        fn open(path: &std::path::Path) -> Self {
724            let file = OpenOptions::new()
725                .create(true)
726                .truncate(true)
727                .write(true)
728                .open(path)
729                .expect("open trace log");
730            Self(Arc::new(Mutex::new(BufWriter::new(file))))
731        }
732
733        fn flush(&self) {
734            if let Ok(mut w) = self.0.lock() {
735                drop(w.flush());
736            }
737        }
738    }
739
740    impl Write for TestWriter {
741        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
742            self.0.lock().map_err(|e| std::io::Error::other(e.to_string()))?.write(buf)
743        }
744
745        fn flush(&mut self) -> std::io::Result<()> {
746            self.0.lock().map_err(|e| std::io::Error::other(e.to_string()))?.flush()
747        }
748    }
749
750    #[test]
751    fn mcp_tool_call_span_records_http_metadata() {
752        let tempdir = tempdir().expect("tempdir");
753        let log_file = tempdir.path().join("trace.log");
754        let writer = TestWriter::open(&log_file);
755        let writer_for_layer = writer.clone();
756        let subscriber = tracing_subscriber::registry().with(
757            tracing_subscriber::fmt::layer()
758                .with_writer(move || writer_for_layer.clone())
759                .with_span_events(FmtSpan::FULL)
760                .with_ansi(false),
761        );
762        let _guard = tracing::subscriber::set_default(subscriber);
763
764        {
765            let span = mcp_tool_call_span(
766                "calendar",
767                "get_events",
768                &McpTransportConfig::Http(McpHttpServerConfig {
769                    endpoint: "https://example.com:8443/mcp".to_string(),
770                    api_key_env: None,
771                    oauth: None,
772                    protocol_version: "2024-11-05".to_string(),
773                    handshake: McpHttpHandshakeMode::Legacy,
774                    http_headers: Default::default(),
775                    env_http_headers: Default::default(),
776                }),
777            );
778            let _entered = span.enter();
779        }
780
781        writer.flush();
782        let logs = fs::read_to_string(&log_file).expect("trace log");
783        assert!(logs.contains("mcp.tools.call"));
784        assert!(logs.contains("provider=\"calendar\""));
785        assert!(logs.contains("tool=\"get_events\""));
786        assert!(logs.contains("rpc_system=\"jsonrpc\""));
787        assert!(logs.contains("rpc_method=\"tools/call\""));
788        assert!(logs.contains("transport=\"streamable_http\""));
789        assert!(logs.contains("server_address=\"example.com\""));
790        assert!(logs.contains("server_port=8443"));
791    }
792
793    #[test]
794    fn mcp_tool_call_span_defaults_stdio_transport() {
795        let span = mcp_tool_call_span(
796            "filesystem",
797            "read_file",
798            &McpTransportConfig::Stdio(McpStdioServerConfig {
799                command: "rmcp-server".to_string(),
800                args: Vec::new(),
801                working_directory: None,
802            }),
803        );
804
805        assert_eq!(span.metadata().expect("metadata").name(), "mcp.tools.call");
806    }
807
808    use rmcp::model::{ProtocolVersion, ServerCapabilities, ServerPeerInfo};
809    use rmcp::service::ClientLifecycleMode;
810    use vtcode_config::mcp::{McpHttpHandshakeMode, McpProviderConfig};
811
812    async fn stdio_provider(name: &str) -> super::McpProvider {
813        let config = McpProviderConfig {
814            name: name.to_string(),
815            transport: McpTransportConfig::Stdio(McpStdioServerConfig {
816                command: "true".to_string(),
817                args: Vec::new(),
818                working_directory: None,
819            }),
820            ..McpProviderConfig::default()
821        };
822        super::McpProvider::connect(config, None, None, false)
823            .await
824            .expect("stdio provider connects")
825    }
826
827    async fn http_provider(name: &str, handshake: McpHttpHandshakeMode) -> super::McpProvider {
828        let config = McpProviderConfig {
829            name: name.to_string(),
830            transport: McpTransportConfig::Http(McpHttpServerConfig {
831                endpoint: "https://example.com/mcp".to_string(),
832                handshake,
833                ..McpHttpServerConfig::default()
834            }),
835            ..McpProviderConfig::default()
836        };
837        super::McpProvider::connect(config, None, None, false)
838            .await
839            .expect("http provider connects")
840    }
841
842    #[tokio::test]
843    async fn handshake_lifecycle_defaults_to_legacy_initialize_for_http() {
844        let provider = http_provider("legacy", McpHttpHandshakeMode::Legacy).await;
845        assert!(matches!(provider.handshake_lifecycle(), ClientLifecycleMode::Initialize));
846    }
847
848    #[tokio::test]
849    async fn handshake_lifecycle_uses_auto_for_stdio_and_opt_in_http() {
850        let stdio = stdio_provider("local").await;
851        match stdio.handshake_lifecycle() {
852            ClientLifecycleMode::Auto { preferred_versions, legacy_version } => {
853                let versions: Vec<String> = preferred_versions.iter().map(ToString::to_string).collect();
854                assert_eq!(versions, vec!["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"]);
855                assert_eq!(legacy_version.map(|version| version.to_string()), Some("2024-11-05".to_string()));
856            }
857            other => panic!("expected Auto lifecycle, got {other:?}"),
858        }
859
860        let modern = http_provider("modern", McpHttpHandshakeMode::Auto).await;
861        assert!(matches!(modern.handshake_lifecycle(), ClientLifecycleMode::Auto { .. }));
862    }
863
864    #[tokio::test]
865    async fn negotiated_protocol_version_tracks_last_handshake() {
866        let provider = http_provider("probe", McpHttpHandshakeMode::Legacy).await;
867        assert_eq!(provider.negotiated_protocol_version().await, None);
868
869        *provider.initialize_result.lock().await =
870            Some(ServerPeerInfo::new(ProtocolVersion::V_2025_11_25, ServerCapabilities::default()));
871        assert_eq!(provider.negotiated_protocol_version().await.as_deref(), Some("2025-11-25"));
872    }
873
874    #[test]
875    fn filtered_catalogs_are_sorted_regardless_of_server_order() {
876        use super::{filter_prompts_sorted, filter_resources_sorted, filter_tools_sorted};
877        use rmcp::model::{Prompt, Resource, Tool};
878        use serde_json::Map;
879        use std::sync::Arc;
880        use vtcode_config::mcp::McpAllowListConfig;
881
882        let allowlist = McpAllowListConfig::default();
883        // Uppercase sorts before lowercase and a prefix sorts before its extension in byte order.
884        let tool_names = ["search_v2", "Zeta", "search", "alpha"];
885        let tools = tool_names
886            .iter()
887            .map(|name| Tool::new(*name, "desc", Arc::new(Map::new())))
888            .collect();
889        let sorted_tools: Vec<String> = filter_tools_sorted("p", tools, &allowlist)
890            .into_iter()
891            .map(|tool| tool.name)
892            .collect();
893        assert_eq!(sorted_tools, ["Zeta", "alpha", "search", "search_v2"]);
894
895        let resources = ["file:///b", "file:///a/z", "file:///a"]
896            .iter()
897            .map(|uri| Resource::new(*uri, "r"))
898            .collect();
899        let sorted_resources: Vec<String> = filter_resources_sorted("p", resources, &allowlist)
900            .into_iter()
901            .map(|resource| resource.uri)
902            .collect();
903        assert_eq!(sorted_resources, ["file:///a", "file:///a/z", "file:///b"]);
904
905        let prompts = ["review", "Explain", "review2"]
906            .iter()
907            .map(|name| Prompt::new(*name, None::<String>, None))
908            .collect();
909        let sorted_prompts: Vec<String> = filter_prompts_sorted("p", prompts, &allowlist)
910            .into_iter()
911            .map(|prompt| prompt.name)
912            .collect();
913        assert_eq!(sorted_prompts, ["Explain", "review", "review2"]);
914    }
915
916    #[test]
917    fn reconnect_initialize_params_gate_ui_extension_on_flag() {
918        let advertised = super::build_reconnect_initialize_params(true);
919        assert!(
920            advertised
921                .capabilities
922                .extensions
923                .as_ref()
924                .is_some_and(|extensions| extensions.contains_key("io.modelcontextprotocol/ui")),
925        );
926        let silent = super::build_reconnect_initialize_params(false);
927        assert_eq!(silent.capabilities.extensions, None);
928    }
929
930    #[tokio::test]
931    async fn connect_retains_ui_advertisement_flag_for_reconnects() {
932        let config = McpProviderConfig {
933            name: "flagged".to_string(),
934            transport: McpTransportConfig::Stdio(McpStdioServerConfig {
935                command: "true".to_string(),
936                args: Vec::new(),
937                working_directory: None,
938            }),
939            ..McpProviderConfig::default()
940        };
941        let provider = super::McpProvider::connect(config, None, None, true)
942            .await
943            .expect("stdio provider connects");
944        assert!(provider.advertise_ui_apps);
945    }
946}