Skip to main content

vtcode_mcp/
lib.rs

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)]
8//! Model Context Protocol (MCP) client management.
9//!
10//! This crate adapts reference MCP client, server, and type definitions
11//! from [openai/codex] to integrate them with VT Code's multi-provider
12//! configuration model (Apache-2.0). Copyright 2025 OpenAI. See the
13//! repository `THIRD-PARTY-NOTICES` file for the full license text and
14//! attribution.
15//!
16//! The VT Code-specific surface (allow lists, tool indexing, status
17//! reporting) is original; only the protocol interaction layer mirrors
18//! Codex' `mcp-client` crate.
19//!
20//! [openai/codex]: https://github.com/openai/codex
21
22mod 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
76/// MCP protocol version constants
77pub(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
81/// Convert any serializable type to rmcp model type via JSON serialization
82pub(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
99/// Validate MCP configuration settings
100pub fn validate_mcp_config(config: &vtcode_config::mcp::McpClientConfig) -> Result<()> {
101    // Validate server configuration if enabled
102    if config.server.enabled {
103        // Validate port range
104        if config.server.port == 0 {
105            return Err(anyhow::anyhow!("Invalid server port: {}", config.server.port));
106        }
107
108        // Validate bind address
109        if config.server.bind_address.is_empty() {
110            return Err(anyhow::anyhow!("Server bind address cannot be empty"));
111        }
112
113        // Validate security settings if auth is enabled
114        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    // Validate timeouts
120    if let Some(startup_timeout) = config.startup_timeout_seconds
121        && startup_timeout > 300
122    {
123        // Max 5 minutes
124        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        // Max 1 hour
131        return Err(anyhow::anyhow!("Tool timeout cannot exceed 3600 seconds"));
132    }
133
134    // Validate provider configurations
135    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        // Validate max_concurrent_requests
141        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    // Core path resolution
170    "PATH",
171    "PATHEXT",
172    // Shell and system roots
173    "COMSPEC",
174    "SYSTEMROOT",
175    "SYSTEMDRIVE",
176    // User context and profiles
177    "USERNAME",
178    "USERDOMAIN",
179    "USERPROFILE",
180    "HOMEDRIVE",
181    "HOMEPATH",
182    // Program locations
183    "PROGRAMFILES",
184    "PROGRAMFILES(X86)",
185    "PROGRAMW6432",
186    "PROGRAMDATA",
187    // App data and caches
188    "LOCALAPPDATA",
189    "APPDATA",
190    // Temp locations
191    "TEMP",
192    "TMP",
193    // Common shells/pwsh hints
194    "POWERSHELL",
195    "PWSH",
196];
197
198// Helper functions for file-based tool discovery
199
200/// Sanitize a string for use in a filename
201pub(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
213/// Format a tool description as Markdown
214pub(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    // Extract required fields if present
236    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        // Extract properties descriptions
250        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        // Verify the conversion succeeded by checking the name
380        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}