vtcode_core/config/
mcp.rs

1use regex::Regex;
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, HashMap};
4
5/// Top-level MCP configuration
6#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct McpClientConfig {
8    /// Enable MCP functionality
9    #[serde(default = "default_mcp_enabled")]
10    pub enabled: bool,
11
12    /// MCP UI display configuration
13    #[serde(default)]
14    pub ui: McpUiConfig,
15
16    /// Configured MCP providers
17    #[serde(default)]
18    pub providers: Vec<McpProviderConfig>,
19
20    /// MCP server configuration (for vtcode to expose tools)
21    #[serde(default)]
22    pub server: McpServerConfig,
23
24    /// Allow list configuration for MCP access control
25    #[serde(default)]
26    pub allowlist: McpAllowListConfig,
27
28    /// Maximum number of concurrent MCP connections
29    #[serde(default = "default_max_concurrent_connections")]
30    pub max_concurrent_connections: usize,
31
32    /// Request timeout in seconds
33    #[serde(default = "default_request_timeout_seconds")]
34    pub request_timeout_seconds: u64,
35
36    /// Connection retry attempts
37    #[serde(default = "default_retry_attempts")]
38    pub retry_attempts: u32,
39}
40
41impl Default for McpClientConfig {
42    fn default() -> Self {
43        Self {
44            enabled: default_mcp_enabled(),
45            ui: McpUiConfig::default(),
46            providers: Vec::new(),
47            server: McpServerConfig::default(),
48            allowlist: McpAllowListConfig::default(),
49            max_concurrent_connections: default_max_concurrent_connections(),
50            request_timeout_seconds: default_request_timeout_seconds(),
51            retry_attempts: default_retry_attempts(),
52        }
53    }
54}
55
56/// UI configuration for MCP display
57#[derive(Debug, Clone, Deserialize, Serialize)]
58pub struct McpUiConfig {
59    /// UI mode for MCP events: "compact" or "full"
60    #[serde(default = "default_mcp_ui_mode")]
61    pub mode: McpUiMode,
62
63    /// Maximum number of MCP events to display
64    #[serde(default = "default_max_mcp_events")]
65    pub max_events: usize,
66
67    /// Show MCP provider names in UI
68    #[serde(default = "default_show_provider_names")]
69    pub show_provider_names: bool,
70
71    /// Custom renderer profiles for provider-specific output formatting
72    #[serde(default)]
73    pub renderers: HashMap<String, McpRendererProfile>,
74}
75
76impl Default for McpUiConfig {
77    fn default() -> Self {
78        Self {
79            mode: default_mcp_ui_mode(),
80            max_events: default_max_mcp_events(),
81            show_provider_names: default_show_provider_names(),
82            renderers: HashMap::new(),
83        }
84    }
85}
86
87impl McpUiConfig {
88    /// Resolve renderer profile for a provider or tool identifier
89    pub fn renderer_for_identifier(&self, identifier: &str) -> Option<McpRendererProfile> {
90        let normalized_identifier = normalize_mcp_identifier(identifier);
91        if normalized_identifier.is_empty() {
92            return None;
93        }
94
95        self.renderers.iter().find_map(|(key, profile)| {
96            let normalized_key = normalize_mcp_identifier(key);
97            if normalized_identifier.starts_with(&normalized_key) {
98                Some(*profile)
99            } else {
100                None
101            }
102        })
103    }
104
105    /// Resolve renderer profile for a fully qualified tool name
106    pub fn renderer_for_tool(&self, tool_name: &str) -> Option<McpRendererProfile> {
107        let identifier = tool_name.strip_prefix("mcp_").unwrap_or(tool_name);
108        self.renderer_for_identifier(identifier)
109    }
110}
111
112/// UI mode for MCP event display
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
114#[serde(rename_all = "snake_case")]
115pub enum McpUiMode {
116    /// Compact mode - shows only event titles
117    Compact,
118    /// Full mode - shows detailed event logs
119    Full,
120}
121
122impl std::fmt::Display for McpUiMode {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        match self {
125            McpUiMode::Compact => write!(f, "compact"),
126            McpUiMode::Full => write!(f, "full"),
127        }
128    }
129}
130
131impl Default for McpUiMode {
132    fn default() -> Self {
133        McpUiMode::Compact
134    }
135}
136
137/// Named renderer profiles for MCP tool output formatting
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
139#[serde(rename_all = "kebab-case")]
140pub enum McpRendererProfile {
141    /// Context7 knowledge base renderer
142    Context7,
143    /// Sequential thinking trace renderer
144    SequentialThinking,
145}
146
147/// Configuration for a single MCP provider
148#[derive(Debug, Clone, Deserialize, Serialize)]
149pub struct McpProviderConfig {
150    /// Provider name (used for identification)
151    pub name: String,
152
153    /// Transport configuration
154    #[serde(flatten)]
155    pub transport: McpTransportConfig,
156
157    /// Provider-specific environment variables
158    #[serde(default)]
159    pub env: HashMap<String, String>,
160
161    /// Whether this provider is enabled
162    #[serde(default = "default_provider_enabled")]
163    pub enabled: bool,
164
165    /// Maximum number of concurrent requests to this provider
166    #[serde(default = "default_provider_max_concurrent")]
167    pub max_concurrent_requests: usize,
168}
169
170impl Default for McpProviderConfig {
171    fn default() -> Self {
172        Self {
173            name: String::new(),
174            transport: McpTransportConfig::Stdio(McpStdioServerConfig::default()),
175            env: HashMap::new(),
176            enabled: default_provider_enabled(),
177            max_concurrent_requests: default_provider_max_concurrent(),
178        }
179    }
180}
181
182/// Allow list configuration for MCP providers
183#[derive(Debug, Clone, Deserialize, Serialize)]
184pub struct McpAllowListConfig {
185    /// Whether to enforce allow list checks
186    #[serde(default = "default_allowlist_enforced")]
187    pub enforce: bool,
188
189    /// Default rules applied when provider-specific rules are absent
190    #[serde(default)]
191    pub default: McpAllowListRules,
192
193    /// Provider-specific allow list rules
194    #[serde(default)]
195    pub providers: BTreeMap<String, McpAllowListRules>,
196}
197
198impl Default for McpAllowListConfig {
199    fn default() -> Self {
200        Self {
201            enforce: default_allowlist_enforced(),
202            default: McpAllowListRules::default(),
203            providers: BTreeMap::new(),
204        }
205    }
206}
207
208impl McpAllowListConfig {
209    /// Determine whether a tool is permitted for the given provider
210    pub fn is_tool_allowed(&self, provider: &str, tool_name: &str) -> bool {
211        if !self.enforce {
212            return true;
213        }
214
215        self.resolve_match(provider, tool_name, |rules| &rules.tools)
216    }
217
218    /// Determine whether a resource is permitted for the given provider
219    pub fn is_resource_allowed(&self, provider: &str, resource: &str) -> bool {
220        if !self.enforce {
221            return true;
222        }
223
224        self.resolve_match(provider, resource, |rules| &rules.resources)
225    }
226
227    /// Determine whether a prompt is permitted for the given provider
228    pub fn is_prompt_allowed(&self, provider: &str, prompt: &str) -> bool {
229        if !self.enforce {
230            return true;
231        }
232
233        self.resolve_match(provider, prompt, |rules| &rules.prompts)
234    }
235
236    /// Determine whether a logging channel is permitted
237    pub fn is_logging_channel_allowed(&self, provider: Option<&str>, channel: &str) -> bool {
238        if !self.enforce {
239            return true;
240        }
241
242        if let Some(name) = provider {
243            if let Some(rules) = self.providers.get(name) {
244                if let Some(patterns) = &rules.logging {
245                    return pattern_matches(patterns, channel);
246                }
247            }
248        }
249
250        if let Some(patterns) = &self.default.logging {
251            if pattern_matches(patterns, channel) {
252                return true;
253            }
254        }
255
256        false
257    }
258
259    /// Determine whether a configuration key can be modified
260    pub fn is_configuration_allowed(
261        &self,
262        provider: Option<&str>,
263        category: &str,
264        key: &str,
265    ) -> bool {
266        if !self.enforce {
267            return true;
268        }
269
270        if let Some(name) = provider {
271            if let Some(rules) = self.providers.get(name) {
272                if let Some(result) = configuration_allowed(rules, category, key) {
273                    return result;
274                }
275            }
276        }
277
278        if let Some(result) = configuration_allowed(&self.default, category, key) {
279            return result;
280        }
281
282        false
283    }
284
285    fn resolve_match<'a, F>(&'a self, provider: &str, candidate: &str, accessor: F) -> bool
286    where
287        F: Fn(&'a McpAllowListRules) -> &'a Option<Vec<String>>,
288    {
289        if let Some(rules) = self.providers.get(provider) {
290            if let Some(patterns) = accessor(rules) {
291                return pattern_matches(patterns, candidate);
292            }
293        }
294
295        if let Some(patterns) = accessor(&self.default) {
296            if pattern_matches(patterns, candidate) {
297                return true;
298            }
299        }
300
301        false
302    }
303}
304
305fn configuration_allowed(rules: &McpAllowListRules, category: &str, key: &str) -> Option<bool> {
306    rules.configuration.as_ref().and_then(|entries| {
307        entries
308            .get(category)
309            .map(|patterns| pattern_matches(patterns, key))
310    })
311}
312
313fn pattern_matches(patterns: &[String], candidate: &str) -> bool {
314    patterns
315        .iter()
316        .any(|pattern| wildcard_match(pattern, candidate))
317}
318
319fn wildcard_match(pattern: &str, candidate: &str) -> bool {
320    if pattern == "*" {
321        return true;
322    }
323
324    let mut regex_pattern = String::from("^");
325    let mut literal_buffer = String::new();
326
327    for ch in pattern.chars() {
328        match ch {
329            '*' => {
330                if !literal_buffer.is_empty() {
331                    regex_pattern.push_str(&regex::escape(&literal_buffer));
332                    literal_buffer.clear();
333                }
334                regex_pattern.push_str(".*");
335            }
336            '?' => {
337                if !literal_buffer.is_empty() {
338                    regex_pattern.push_str(&regex::escape(&literal_buffer));
339                    literal_buffer.clear();
340                }
341                regex_pattern.push('.');
342            }
343            _ => literal_buffer.push(ch),
344        }
345    }
346
347    if !literal_buffer.is_empty() {
348        regex_pattern.push_str(&regex::escape(&literal_buffer));
349    }
350
351    regex_pattern.push('$');
352
353    Regex::new(&regex_pattern)
354        .map(|regex| regex.is_match(candidate))
355        .unwrap_or(false)
356}
357
358/// Allow list rules for a provider or default configuration
359#[derive(Debug, Clone, Deserialize, Serialize, Default)]
360pub struct McpAllowListRules {
361    /// Tool name patterns permitted for the provider
362    #[serde(default)]
363    pub tools: Option<Vec<String>>,
364
365    /// Resource name patterns permitted for the provider
366    #[serde(default)]
367    pub resources: Option<Vec<String>>,
368
369    /// Prompt name patterns permitted for the provider
370    #[serde(default)]
371    pub prompts: Option<Vec<String>>,
372
373    /// Logging channels permitted for the provider
374    #[serde(default)]
375    pub logging: Option<Vec<String>>,
376
377    /// Configuration keys permitted for the provider grouped by category
378    #[serde(default)]
379    pub configuration: Option<BTreeMap<String, Vec<String>>>,
380}
381
382/// Configuration for the MCP server (vtcode acting as an MCP server)
383#[derive(Debug, Clone, Deserialize, Serialize)]
384pub struct McpServerConfig {
385    /// Enable vtcode's MCP server capability
386    #[serde(default = "default_mcp_server_enabled")]
387    pub enabled: bool,
388
389    /// Bind address for the MCP server
390    #[serde(default = "default_mcp_server_bind")]
391    pub bind_address: String,
392
393    /// Port for the MCP server
394    #[serde(default = "default_mcp_server_port")]
395    pub port: u16,
396
397    /// Server transport type
398    #[serde(default = "default_mcp_server_transport")]
399    pub transport: McpServerTransport,
400
401    /// Server identifier
402    #[serde(default = "default_mcp_server_name")]
403    pub name: String,
404
405    /// Server version
406    #[serde(default = "default_mcp_server_version")]
407    pub version: String,
408
409    /// Tools exposed by the vtcode MCP server
410    #[serde(default)]
411    pub exposed_tools: Vec<String>,
412}
413
414impl Default for McpServerConfig {
415    fn default() -> Self {
416        Self {
417            enabled: default_mcp_server_enabled(),
418            bind_address: default_mcp_server_bind(),
419            port: default_mcp_server_port(),
420            transport: default_mcp_server_transport(),
421            name: default_mcp_server_name(),
422            version: default_mcp_server_version(),
423            exposed_tools: Vec::new(),
424        }
425    }
426}
427
428/// MCP server transport types
429#[derive(Debug, Clone, Deserialize, Serialize)]
430#[serde(rename_all = "snake_case")]
431pub enum McpServerTransport {
432    /// Server Sent Events transport
433    Sse,
434    /// HTTP transport
435    Http,
436}
437
438impl Default for McpServerTransport {
439    fn default() -> Self {
440        McpServerTransport::Sse
441    }
442}
443
444/// Transport configuration for MCP providers
445#[derive(Debug, Clone, Deserialize, Serialize)]
446#[serde(untagged)]
447pub enum McpTransportConfig {
448    /// Standard I/O transport (stdio)
449    Stdio(McpStdioServerConfig),
450    /// HTTP transport
451    Http(McpHttpServerConfig),
452}
453
454/// Configuration for stdio-based MCP servers
455#[derive(Debug, Clone, Deserialize, Serialize)]
456pub struct McpStdioServerConfig {
457    /// Command to execute
458    pub command: String,
459
460    /// Command arguments
461    pub args: Vec<String>,
462
463    /// Working directory for the command
464    #[serde(default)]
465    pub working_directory: Option<String>,
466}
467
468impl Default for McpStdioServerConfig {
469    fn default() -> Self {
470        Self {
471            command: String::new(),
472            args: Vec::new(),
473            working_directory: None,
474        }
475    }
476}
477
478/// Configuration for HTTP-based MCP servers
479///
480/// Note: HTTP transport is partially implemented. Basic connectivity testing is supported,
481/// but full streamable HTTP MCP server support requires additional implementation
482/// using Server-Sent Events (SSE) or WebSocket connections.
483#[derive(Debug, Clone, Deserialize, Serialize)]
484pub struct McpHttpServerConfig {
485    /// Server endpoint URL
486    pub endpoint: String,
487
488    /// API key environment variable name
489    #[serde(default)]
490    pub api_key_env: Option<String>,
491
492    /// Protocol version
493    #[serde(default = "default_mcp_protocol_version")]
494    pub protocol_version: String,
495
496    /// Headers to include in requests
497    #[serde(default)]
498    pub headers: HashMap<String, String>,
499}
500
501impl Default for McpHttpServerConfig {
502    fn default() -> Self {
503        Self {
504            endpoint: String::new(),
505            api_key_env: None,
506            protocol_version: default_mcp_protocol_version(),
507            headers: HashMap::new(),
508        }
509    }
510}
511
512/// Default value functions
513fn default_mcp_enabled() -> bool {
514    false
515}
516
517fn default_mcp_ui_mode() -> McpUiMode {
518    McpUiMode::Compact
519}
520
521fn default_max_mcp_events() -> usize {
522    50
523}
524
525fn default_show_provider_names() -> bool {
526    true
527}
528
529fn default_max_concurrent_connections() -> usize {
530    5
531}
532
533fn default_request_timeout_seconds() -> u64 {
534    30
535}
536
537fn default_retry_attempts() -> u32 {
538    3
539}
540
541fn default_provider_enabled() -> bool {
542    true
543}
544
545fn default_provider_max_concurrent() -> usize {
546    3
547}
548
549fn default_allowlist_enforced() -> bool {
550    false
551}
552
553fn default_mcp_protocol_version() -> String {
554    "2024-11-05".to_string()
555}
556
557fn default_mcp_server_enabled() -> bool {
558    false
559}
560
561fn default_mcp_server_bind() -> String {
562    "127.0.0.1".to_string()
563}
564
565fn default_mcp_server_port() -> u16 {
566    3000
567}
568
569fn default_mcp_server_transport() -> McpServerTransport {
570    McpServerTransport::Sse
571}
572
573fn default_mcp_server_name() -> String {
574    "vtcode-mcp-server".to_string()
575}
576
577fn default_mcp_server_version() -> String {
578    env!("CARGO_PKG_VERSION").to_string()
579}
580
581fn normalize_mcp_identifier(value: &str) -> String {
582    value
583        .chars()
584        .filter(|ch| ch.is_ascii_alphanumeric())
585        .map(|ch| ch.to_ascii_lowercase())
586        .collect()
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use crate::config::constants::mcp as mcp_constants;
593    use std::collections::BTreeMap;
594
595    #[test]
596    fn test_mcp_config_defaults() {
597        let config = McpClientConfig::default();
598        assert!(!config.enabled);
599        assert_eq!(config.ui.mode, McpUiMode::Compact);
600        assert_eq!(config.ui.max_events, 50);
601        assert!(config.ui.show_provider_names);
602        assert!(config.ui.renderers.is_empty());
603        assert_eq!(config.max_concurrent_connections, 5);
604        assert_eq!(config.request_timeout_seconds, 30);
605        assert_eq!(config.retry_attempts, 3);
606        assert!(config.providers.is_empty());
607        assert!(!config.server.enabled);
608        assert!(!config.allowlist.enforce);
609        assert!(config.allowlist.default.tools.is_none());
610    }
611
612    #[test]
613    fn test_allowlist_pattern_matching() {
614        let patterns = vec!["get_*".to_string(), "convert_timezone".to_string()];
615        assert!(pattern_matches(&patterns, "get_current_time"));
616        assert!(pattern_matches(&patterns, "convert_timezone"));
617        assert!(!pattern_matches(&patterns, "delete_timezone"));
618    }
619
620    #[test]
621    fn test_allowlist_provider_override() {
622        let mut config = McpAllowListConfig::default();
623        config.enforce = true;
624        config.default.tools = Some(vec!["get_*".to_string()]);
625
626        let mut provider_rules = McpAllowListRules::default();
627        provider_rules.tools = Some(vec!["list_*".to_string()]);
628        config
629            .providers
630            .insert("context7".to_string(), provider_rules);
631
632        assert!(config.is_tool_allowed("context7", "list_documents"));
633        assert!(!config.is_tool_allowed("context7", "get_current_time"));
634        assert!(config.is_tool_allowed("other", "get_timezone"));
635        assert!(!config.is_tool_allowed("other", "list_documents"));
636    }
637
638    #[test]
639    fn test_allowlist_configuration_rules() {
640        let mut config = McpAllowListConfig::default();
641        config.enforce = true;
642
643        let mut default_rules = McpAllowListRules::default();
644        default_rules.configuration = Some(BTreeMap::from([(
645            "ui".to_string(),
646            vec!["mode".to_string(), "max_events".to_string()],
647        )]));
648        config.default = default_rules;
649
650        let mut provider_rules = McpAllowListRules::default();
651        provider_rules.configuration = Some(BTreeMap::from([(
652            "provider".to_string(),
653            vec!["max_concurrent_requests".to_string()],
654        )]));
655        config.providers.insert("time".to_string(), provider_rules);
656
657        assert!(config.is_configuration_allowed(None, "ui", "mode"));
658        assert!(!config.is_configuration_allowed(None, "ui", "show_provider_names"));
659        assert!(config.is_configuration_allowed(
660            Some("time"),
661            "provider",
662            "max_concurrent_requests"
663        ));
664        assert!(!config.is_configuration_allowed(Some("time"), "provider", "retry_attempts"));
665    }
666
667    #[test]
668    fn test_allowlist_resource_override() {
669        let mut config = McpAllowListConfig::default();
670        config.enforce = true;
671        config.default.resources = Some(vec!["docs/*".to_string()]);
672
673        let mut provider_rules = McpAllowListRules::default();
674        provider_rules.resources = Some(vec!["journals/*".to_string()]);
675        config
676            .providers
677            .insert("context7".to_string(), provider_rules);
678
679        assert!(config.is_resource_allowed("context7", "journals/2024"));
680        assert!(!config.is_resource_allowed("context7", "docs/manual"));
681        assert!(config.is_resource_allowed("other", "docs/reference"));
682        assert!(!config.is_resource_allowed("other", "journals/2023"));
683    }
684
685    #[test]
686    fn test_allowlist_logging_override() {
687        let mut config = McpAllowListConfig::default();
688        config.enforce = true;
689        config.default.logging = Some(vec!["info".to_string(), "debug".to_string()]);
690
691        let mut provider_rules = McpAllowListRules::default();
692        provider_rules.logging = Some(vec!["audit".to_string()]);
693        config
694            .providers
695            .insert("sequential".to_string(), provider_rules);
696
697        assert!(config.is_logging_channel_allowed(Some("sequential"), "audit"));
698        assert!(!config.is_logging_channel_allowed(Some("sequential"), "info"));
699        assert!(config.is_logging_channel_allowed(Some("other"), "info"));
700        assert!(!config.is_logging_channel_allowed(Some("other"), "trace"));
701    }
702
703    #[test]
704    fn test_mcp_ui_renderer_resolution() {
705        let mut config = McpUiConfig::default();
706        config.renderers.insert(
707            mcp_constants::RENDERER_CONTEXT7.to_string(),
708            McpRendererProfile::Context7,
709        );
710        config.renderers.insert(
711            mcp_constants::RENDERER_SEQUENTIAL_THINKING.to_string(),
712            McpRendererProfile::SequentialThinking,
713        );
714
715        assert_eq!(
716            config.renderer_for_tool("mcp_context7_lookup"),
717            Some(McpRendererProfile::Context7)
718        );
719        assert_eq!(
720            config.renderer_for_tool("mcp_context7lookup"),
721            Some(McpRendererProfile::Context7)
722        );
723        assert_eq!(
724            config.renderer_for_tool("mcp_sequentialthinking_run"),
725            Some(McpRendererProfile::SequentialThinking)
726        );
727        assert_eq!(
728            config.renderer_for_identifier("sequential-thinking-analyze"),
729            Some(McpRendererProfile::SequentialThinking)
730        );
731        assert_eq!(config.renderer_for_tool("mcp_unknown"), None);
732    }
733}