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 config: McpProviderConfig,
41 elicitation_handler: Option<Arc<dyn McpElicitationHandler>>,
43 advertise_ui_apps: bool,
45 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 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 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 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 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 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 pub(super) async fn is_healthy(&self) -> bool {
550 let client = self.client.load_full();
551 client.is_healthy().await
552 }
553
554 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 {
568 let old = self.client.load_full();
569 drop(old.shutdown().await);
570 }
571
572 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 {
584 let new_client = new_provider.client.load_full();
585 self.client.store(new_client);
586 }
587
588 self.invalidate_caches();
590
591 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
614fn 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
622fn 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 #[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 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}