1#![allow(
2 clippy::large_futures,
3 missing_docs,
4 dead_code,
5 unused_imports,
6 reason = "Intentional compatibility, platform, or test-only suppression."
7)]
8mod client;
23pub mod connection_pool;
24pub(crate) mod conversion;
25pub mod enhanced_config;
26pub mod errors;
27mod provider;
28mod rmcp_client;
29pub mod rmcp_transport;
30mod sandbox_context;
31pub(crate) mod schema;
32pub mod tool_discovery;
33pub mod tool_discovery_cache;
34pub mod traits;
35pub mod trust;
36pub mod types;
37pub(crate) mod utils;
38
39pub use client::McpClient;
40
41pub(crate) use connection_pool::{
42 ConnectionPoolStats, McpConnectionPool, McpPoolError, PooledMcpManager, PooledMcpStats,
43};
44pub use errors::{
45 ErrorCode, McpResult, configuration_error, initialization_timeout, provider_not_found, provider_unavailable,
46 schema_invalid, tool_invocation_failed, tool_not_found,
47};
48pub use provider::McpProvider;
49pub(crate) use rmcp_client::RmcpClient;
50pub use rmcp_transport::{
51 HttpTransport, create_http_transport, create_stdio_transport, create_stdio_transport_with_stderr,
52};
53pub use sandbox_context::McpSandboxContext;
54pub(crate) use schema::{validate_against_schema, validate_tool_input};
55pub use tool_discovery::{DetailLevel, ToolDiscovery, ToolDiscoveryResult};
56pub use traits::{McpElicitationHandler, McpToolExecutor};
57pub use trust::{MAX_UNTRUSTED_MCP_DESCRIPTION_BYTES, render_untrusted_mcp_description};
58pub use types::{
59 FileParamSchemaEntry, FileUploadResult, MCP_APP_DISCLOSURE_KEY, MCP_APP_SUPPRESSED_KEY, MCP_UI_EXTENSION_ID,
60 MCP_UI_RESOURCE_MIME_TYPE, MCP_UI_RESOURCE_SCHEME, McpAppDisclosure, McpClientStatus, McpElicitationRequest,
61 McpElicitationResponse, McpPromptDetail, McpPromptInfo, McpResourceData, McpResourceInfo, McpToolInfo,
62 McpToolVisibility, McpUiResourceMeta, McpUiToolMeta, OPENAI_FILE_PARAMS_META_KEY, OPENAI_FILE_PARAMS_VALUE,
63 ProvidedFilePayload, disclose_app_resource, parse_ui_resource_meta,
64};
65pub(crate) use utils::{
66 LOCAL_TIMEZONE_ENV_VAR, TIMEZONE_ARGUMENT, TZ_ENV_VAR, apply_ui_extension_gate, build_headers,
67 detect_local_timezone, ensure_timezone_argument, reject_unsupported_ui_apps_tier, schema_requires_field,
68};
69
70use anyhow::{Result, anyhow};
71use hashbrown::HashMap;
72pub use rmcp::model::{ElicitationAction, MetaObject, ResourceContents};
73use std::ffi::OsString;
74use std::fmt::Write;
75
76pub(crate) const LATEST_PROTOCOL_VERSION: &str = "2026-07-28";
78pub(crate) const SUPPORTED_PROTOCOL_VERSIONS: &[&str] =
79 &["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
80
81pub(crate) fn convert_to_rmcp<T, U>(value: T) -> Result<U>
83where
84 T: serde::Serialize,
85 U: serde::de::DeserializeOwned,
86{
87 let json = serde_json::to_value(value)?;
88 serde_json::from_value(json).map_err(|err| anyhow!(err))
89}
90
91pub(crate) fn create_env_for_mcp_server(extra_env: Option<HashMap<OsString, OsString>>) -> HashMap<OsString, OsString> {
92 DEFAULT_ENV_VARS
93 .iter()
94 .filter_map(|var| std::env::var_os(var).map(|value| (OsString::from(*var), value)))
95 .chain(extra_env.unwrap_or_default())
96 .collect()
97}
98
99pub fn validate_mcp_config(config: &vtcode_config::mcp::McpClientConfig) -> Result<()> {
101 if config.server.enabled {
103 if config.server.port == 0 {
105 return Err(anyhow::anyhow!("Invalid server port: {}", config.server.port));
106 }
107
108 if config.server.bind_address.is_empty() {
110 return Err(anyhow::anyhow!("Server bind address cannot be empty"));
111 }
112
113 if config.security.auth_enabled && config.security.api_key_env.is_none() {
115 return Err(anyhow::anyhow!("API key environment variable must be set when auth is enabled"));
116 }
117 }
118
119 if let Some(startup_timeout) = config.startup_timeout_seconds
121 && startup_timeout > 300
122 {
123 return Err(anyhow::anyhow!("Startup timeout cannot exceed 300 seconds"));
125 }
126
127 if let Some(tool_timeout) = config.tool_timeout_seconds
128 && tool_timeout > 3600
129 {
130 return Err(anyhow::anyhow!("Tool timeout cannot exceed 3600 seconds"));
132 }
133
134 for provider in &config.providers {
136 if provider.name.is_empty() {
137 return Err(anyhow::anyhow!("MCP provider name cannot be empty"));
138 }
139
140 if provider.max_concurrent_requests == 0 {
142 return Err(anyhow::anyhow!(
143 "Max concurrent requests must be greater than 0 for provider '{}'",
144 provider.name
145 ));
146 }
147 }
148
149 Ok(())
150}
151
152#[cfg(unix)]
153const DEFAULT_ENV_VARS: &[&str] = &[
154 "HOME",
155 "LOGNAME",
156 "PATH",
157 "SHELL",
158 "USER",
159 "__CF_USER_TEXT_ENCODING",
160 "LANG",
161 "LC_ALL",
162 "TERM",
163 "TMPDIR",
164 "TZ",
165];
166
167#[cfg(windows)]
168const DEFAULT_ENV_VARS: &[&str] = &[
169 "PATH",
171 "PATHEXT",
172 "COMSPEC",
174 "SYSTEMROOT",
175 "SYSTEMDRIVE",
176 "USERNAME",
178 "USERDOMAIN",
179 "USERPROFILE",
180 "HOMEDRIVE",
181 "HOMEPATH",
182 "PROGRAMFILES",
184 "PROGRAMFILES(X86)",
185 "PROGRAMW6432",
186 "PROGRAMDATA",
187 "LOCALAPPDATA",
189 "APPDATA",
190 "TEMP",
192 "TMP",
193 "POWERSHELL",
195 "PWSH",
196];
197
198pub(crate) fn sanitize_filename(name: &str) -> String {
202 name.chars()
203 .map(|c| {
204 if c.is_alphanumeric() || c == '_' || c == '-' {
205 c
206 } else {
207 '_'
208 }
209 })
210 .collect()
211}
212
213pub(crate) fn format_tool_markdown(tool: &McpToolInfo) -> String {
215 let mut content = String::new();
216 let _format_result = write!(content, "# {}\n\n", tool.name);
217 let _format_result = write!(content, "**Provider**: {}\n\n", tool.provider);
218 content.push_str("## Description\n\n");
219 content.push_str(&tool.description);
220 content.push_str("\n\n");
221
222 content.push_str("## Input Schema\n\n");
223 content.push_str("```json\n");
224 content
225 .push_str(&serde_json::to_string_pretty(&tool.input_schema).unwrap_or_else(|_| tool.input_schema.to_string()));
226 content.push_str("\n```\n\n");
227
228 if let Some(output_schema) = tool.output_schema.as_ref() {
229 content.push_str("## Output Schema\n\n");
230 content.push_str("```json\n");
231 content.push_str(&serde_json::to_string_pretty(output_schema).unwrap_or_else(|_| output_schema.to_string()));
232 content.push_str("\n```\n\n");
233 }
234
235 if let Some(obj) = tool.input_schema.as_object() {
237 if let Some(required) = obj.get("required").and_then(|v| v.as_array())
238 && !required.is_empty()
239 {
240 content.push_str("## Required Parameters\n\n");
241 for req in required {
242 if let Some(name) = req.as_str() {
243 let _format_result = writeln!(content, "- `{name}`");
244 }
245 }
246 content.push('\n');
247 }
248
249 if let Some(props) = obj.get("properties").and_then(|v| v.as_object())
251 && !props.is_empty()
252 {
253 content.push_str("## Parameters\n\n");
254 for (param_name, param_schema) in props {
255 let param_type = param_schema.get("type").and_then(|t| t.as_str()).unwrap_or("any");
256 let param_desc = param_schema.get("description").and_then(|d| d.as_str()).unwrap_or("");
257 let _format_result = write!(content, "### `{param_name}`\n\n");
258 let _format_result = writeln!(content, "- **Type**: {param_type}");
259 if !param_desc.is_empty() {
260 let _format_result = writeln!(content, "- **Description**: {param_desc}");
261 }
262 content.push('\n');
263 }
264 }
265 }
266
267 content.push_str("---\n");
268 content.push_str("*Generated automatically for dynamic context discovery.*\n");
269
270 content
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276 use crate::utils::{LOCAL_TIMEZONE_ENV_VAR, TIMEZONE_ARGUMENT, clear_test_env_override, set_test_env_override};
277 use hashbrown::HashMap;
278 use rmcp::model::ElicitationAction;
279 use serde_json::{Map, Value, json};
280
281 #[cfg(unix)]
282 use serial_test::serial;
283
284 #[cfg(unix)]
285 use std::os::unix::ffi::OsStringExt;
286
287 struct EnvGuard {
288 key: &'static str,
289 }
290
291 impl EnvGuard {
292 fn set(key: &'static str, value: &str) -> Self {
293 set_test_env_override(key, Some(value));
294 Self { key }
295 }
296 }
297
298 impl Drop for EnvGuard {
299 fn drop(&mut self) {
300 clear_test_env_override(self.key);
301 }
302 }
303
304 #[test]
305 fn schema_detection_handles_required_entries() {
306 let schema = json!({
307 "type": "object",
308 "required": [TIMEZONE_ARGUMENT],
309 "properties": {
310 TIMEZONE_ARGUMENT: { "type": "string" }
311 }
312 });
313
314 assert!(schema_requires_field(&schema, TIMEZONE_ARGUMENT));
315 assert!(!schema_requires_field(&schema, "location"));
316 }
317
318 #[test]
319 fn ensure_timezone_injects_from_override_env() {
320 let _guard = EnvGuard::set(LOCAL_TIMEZONE_ENV_VAR, "Etc/UTC");
321 let mut arguments = Map::new();
322
323 ensure_timezone_argument(&mut arguments, true).unwrap();
324
325 assert_eq!(arguments.get(TIMEZONE_ARGUMENT).and_then(Value::as_str), Some("Etc/UTC"));
326 }
327
328 #[test]
329 fn ensure_timezone_does_not_override_existing_value() {
330 let mut arguments = Map::new();
331 drop(arguments.insert(TIMEZONE_ARGUMENT.to_string(), Value::String("America/New_York".to_owned())));
332
333 ensure_timezone_argument(&mut arguments, true).unwrap();
334
335 assert_eq!(arguments.get(TIMEZONE_ARGUMENT).and_then(Value::as_str), Some("America/New_York"));
336 }
337
338 #[test]
339 fn create_env_merges_configured_values() {
340 let mut extra_env = HashMap::new();
341 drop(extra_env.insert(OsString::from("A"), OsString::from("1")));
342 drop(extra_env.insert(OsString::from("B"), OsString::from("2")));
343
344 let env = create_env_for_mcp_server(Some(extra_env));
345
346 assert_eq!(env.get(&OsString::from("A")), Some(&OsString::from("1")));
347 assert_eq!(env.get(&OsString::from("B")), Some(&OsString::from("2")));
348 }
349
350 #[test]
351 #[cfg(unix)]
352 #[serial]
353 fn create_env_preserves_non_utf8_path() {
354 let env_guard = vtcode_commons::env_lock::lock();
355 let original_path = std::env::var_os("PATH");
356 let non_utf8_path = OsString::from_vec(b"/tmp/alpha:\xFFbeta".to_vec());
357
358 env_guard.set_var("PATH", &non_utf8_path);
359
360 let env = create_env_for_mcp_server(None);
361
362 env_guard.restore_var("PATH", original_path);
363
364 assert_eq!(env.get(&OsString::from("PATH")), Some(&non_utf8_path));
365 }
366
367 #[tokio::test]
368 async fn convert_to_rmcp_round_trip() {
369 use rmcp::model::{ClientCapabilities, Implementation, InitializeRequestParams, RootsCapabilities};
370
371 let mut capabilities = ClientCapabilities::default();
372 let mut roots = RootsCapabilities::default();
373 roots.list_changed = Some(true);
374 capabilities.roots = Some(roots);
375 let params = InitializeRequestParams::new(capabilities, Implementation::new("vtcode", "1.0"))
376 .with_protocol_version(rmcp::model::ProtocolVersion::V_2026_07_28);
377
378 let converted: InitializeRequestParams = convert_to_rmcp(params.clone()).unwrap();
379 assert_eq!(converted.client_info.name, "vtcode");
381 assert_eq!(converted.client_info.version, "1.0");
382 }
383
384 #[test]
385 fn supported_protocol_versions_cover_known_rmcp_versions_newest_first() {
386 let known: Vec<String> = rmcp::model::ProtocolVersion::KNOWN_VERSIONS
387 .iter()
388 .map(|version| version.to_string())
389 .collect();
390 let mut known_newest_first = known.clone();
391 known_newest_first.reverse();
392
393 assert_eq!(
394 SUPPORTED_PROTOCOL_VERSIONS,
395 known_newest_first.as_slice(),
396 "supported MCP versions must track rmcp's known versions, newest first"
397 );
398 }
399
400 #[test]
401 fn validate_elicitation_payload_rejects_invalid_content() {
402 use crate::rmcp_client::{build_elicitation_validator, validate_elicitation_payload};
403
404 let schema = json!({
405 "type": "object",
406 "properties": {
407 "name": { "type": "string" }
408 },
409 "required": ["name"]
410 });
411 let validator = build_elicitation_validator("test", &schema).expect("schema should compile");
412
413 let result = validate_elicitation_payload(
414 "test",
415 Some(&validator),
416 &ElicitationAction::Accept,
417 Some(&json!({ "name": 42 })),
418 );
419
420 assert!(result.is_err());
421 }
422
423 #[test]
424 fn validate_elicitation_payload_accepts_valid_content() {
425 use crate::rmcp_client::{build_elicitation_validator, validate_elicitation_payload};
426
427 let schema = json!({
428 "type": "object",
429 "properties": {
430 "email": { "type": "string", "format": "email" }
431 },
432 "required": ["email"]
433 });
434 let validator = build_elicitation_validator("test", &schema).expect("schema should compile");
435
436 let result = validate_elicitation_payload(
437 "test",
438 Some(&validator),
439 &ElicitationAction::Accept,
440 Some(&json!({ "email": "user@example.com" })),
441 );
442
443 result.unwrap();
444 }
445
446 #[tokio::test]
447 async fn provider_max_concurrency_defaults_to_one() {
448 use crate::provider::McpProvider;
449 use vtcode_config::mcp::{McpProviderConfig, McpStdioServerConfig, McpTransportConfig};
450
451 let config = McpProviderConfig {
452 name: "test".into(),
453 transport: McpTransportConfig::Stdio(McpStdioServerConfig {
454 command: "cat".into(),
455 args: vec![],
456 working_directory: None,
457 }),
458 env: HashMap::new(),
459 enabled: true,
460 max_concurrent_requests: 0,
461 startup_timeout_ms: None,
462 };
463
464 let provider = McpProvider::connect(config, None, None, false).await.unwrap();
465 assert_eq!(provider.semaphore.available_permits(), 1);
466 }
467
468 #[test]
469 fn directory_to_file_uri_generates_file_scheme() {
470 use crate::rmcp_client::directory_to_file_uri;
471
472 let temp_dir = std::env::temp_dir();
473 let uri = directory_to_file_uri(temp_dir.as_path()).expect("should create file uri for temp directory");
474 assert!(uri.starts_with("file://"));
475 }
476}