1use regex::Regex;
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, HashMap};
4
5#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
7#[derive(Debug, Clone, Deserialize, Serialize)]
8pub struct McpClientConfig {
9 #[serde(default = "default_mcp_enabled")]
11 pub enabled: bool,
12
13 #[serde(default)]
15 pub ui: McpUiConfig,
16
17 #[serde(default)]
19 pub providers: Vec<McpProviderConfig>,
20
21 #[serde(default)]
23 pub server: McpServerConfig,
24
25 #[serde(default)]
27 pub allowlist: McpAllowListConfig,
28
29 #[serde(default = "default_max_concurrent_connections")]
31 pub max_concurrent_connections: usize,
32
33 #[serde(default = "default_request_timeout_seconds")]
35 pub request_timeout_seconds: u64,
36
37 #[serde(default = "default_retry_attempts")]
39 pub retry_attempts: u32,
40
41 #[serde(default)]
43 pub startup_timeout_seconds: Option<u64>,
44
45 #[serde(default)]
47 pub tool_timeout_seconds: Option<u64>,
48
49 #[serde(default = "default_experimental_use_rmcp_client")]
51 pub experimental_use_rmcp_client: bool,
52
53 #[serde(default)]
55 pub security: McpSecurityConfig,
56}
57
58#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[derive(Debug, Clone, Deserialize, Serialize)]
61pub struct McpSecurityConfig {
62 #[serde(default = "default_mcp_auth_enabled")]
64 pub auth_enabled: bool,
65
66 #[serde(default)]
68 pub api_key_env: Option<String>,
69
70 #[serde(default)]
72 pub rate_limit: McpRateLimitConfig,
73
74 #[serde(default)]
76 pub validation: McpValidationConfig,
77}
78
79impl Default for McpSecurityConfig {
80 fn default() -> Self {
81 Self {
82 auth_enabled: default_mcp_auth_enabled(),
83 api_key_env: None,
84 rate_limit: McpRateLimitConfig::default(),
85 validation: McpValidationConfig::default(),
86 }
87 }
88}
89
90#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
92#[derive(Debug, Clone, Deserialize, Serialize)]
93pub struct McpRateLimitConfig {
94 #[serde(default = "default_requests_per_minute")]
96 pub requests_per_minute: u32,
97
98 #[serde(default = "default_concurrent_requests")]
100 pub concurrent_requests: u32,
101}
102
103impl Default for McpRateLimitConfig {
104 fn default() -> Self {
105 Self {
106 requests_per_minute: default_requests_per_minute(),
107 concurrent_requests: default_concurrent_requests(),
108 }
109 }
110}
111
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
114#[derive(Debug, Clone, Deserialize, Serialize)]
115pub struct McpValidationConfig {
116 #[serde(default = "default_schema_validation_enabled")]
118 pub schema_validation_enabled: bool,
119
120 #[serde(default = "default_path_traversal_protection_enabled")]
122 pub path_traversal_protection: bool,
123
124 #[serde(default = "default_max_argument_size")]
126 pub max_argument_size: u32,
127}
128
129impl Default for McpValidationConfig {
130 fn default() -> Self {
131 Self {
132 schema_validation_enabled: default_schema_validation_enabled(),
133 path_traversal_protection: default_path_traversal_protection_enabled(),
134 max_argument_size: default_max_argument_size(),
135 }
136 }
137}
138
139impl Default for McpClientConfig {
140 fn default() -> Self {
141 Self {
142 enabled: default_mcp_enabled(),
143 ui: McpUiConfig::default(),
144 providers: Vec::new(),
145 server: McpServerConfig::default(),
146 allowlist: McpAllowListConfig::default(),
147 max_concurrent_connections: default_max_concurrent_connections(),
148 request_timeout_seconds: default_request_timeout_seconds(),
149 retry_attempts: default_retry_attempts(),
150 startup_timeout_seconds: None,
151 tool_timeout_seconds: None,
152 experimental_use_rmcp_client: default_experimental_use_rmcp_client(),
153 security: McpSecurityConfig::default(),
154 }
155 }
156}
157
158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
160#[derive(Debug, Clone, Deserialize, Serialize)]
161pub struct McpUiConfig {
162 #[serde(default = "default_mcp_ui_mode")]
164 pub mode: McpUiMode,
165
166 #[serde(default = "default_max_mcp_events")]
168 pub max_events: usize,
169
170 #[serde(default = "default_show_provider_names")]
172 pub show_provider_names: bool,
173
174 #[serde(default)]
176 pub renderers: HashMap<String, McpRendererProfile>,
177}
178
179impl Default for McpUiConfig {
180 fn default() -> Self {
181 Self {
182 mode: default_mcp_ui_mode(),
183 max_events: default_max_mcp_events(),
184 show_provider_names: default_show_provider_names(),
185 renderers: HashMap::new(),
186 }
187 }
188}
189
190impl McpUiConfig {
191 pub fn renderer_for_identifier(&self, identifier: &str) -> Option<McpRendererProfile> {
193 let normalized_identifier = normalize_mcp_identifier(identifier);
194 if normalized_identifier.is_empty() {
195 return None;
196 }
197
198 self.renderers.iter().find_map(|(key, profile)| {
199 let normalized_key = normalize_mcp_identifier(key);
200 if normalized_identifier.starts_with(&normalized_key) {
201 Some(*profile)
202 } else {
203 None
204 }
205 })
206 }
207
208 pub fn renderer_for_tool(&self, tool_name: &str) -> Option<McpRendererProfile> {
210 let identifier = tool_name.strip_prefix("mcp_").unwrap_or(tool_name);
211 self.renderer_for_identifier(identifier)
212 }
213}
214
215#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
218#[serde(rename_all = "snake_case")]
219#[derive(Default)]
220pub enum McpUiMode {
221 #[default]
223 Compact,
224 Full,
226}
227
228impl std::fmt::Display for McpUiMode {
229 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230 match self {
231 McpUiMode::Compact => write!(f, "compact"),
232 McpUiMode::Full => write!(f, "full"),
233 }
234 }
235}
236
237#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
240#[serde(rename_all = "kebab-case")]
241pub enum McpRendererProfile {
242 Context7,
244 SequentialThinking,
246}
247
248#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
250#[derive(Debug, Clone, Deserialize, Serialize)]
251pub struct McpProviderConfig {
252 pub name: String,
254
255 #[serde(flatten)]
257 pub transport: McpTransportConfig,
258
259 #[serde(default)]
261 pub env: HashMap<String, String>,
262
263 #[serde(default = "default_provider_enabled")]
265 pub enabled: bool,
266
267 #[serde(default = "default_provider_max_concurrent")]
269 pub max_concurrent_requests: usize,
270
271 #[serde(default)]
273 pub startup_timeout_ms: Option<u64>,
274}
275
276impl Default for McpProviderConfig {
277 fn default() -> Self {
278 Self {
279 name: String::new(),
280 transport: McpTransportConfig::Stdio(McpStdioServerConfig::default()),
281 env: HashMap::new(),
282 enabled: default_provider_enabled(),
283 max_concurrent_requests: default_provider_max_concurrent(),
284 startup_timeout_ms: None,
285 }
286 }
287}
288
289#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
291#[derive(Debug, Clone, Deserialize, Serialize)]
292pub struct McpAllowListConfig {
293 #[serde(default = "default_allowlist_enforced")]
295 pub enforce: bool,
296
297 #[serde(default)]
299 pub default: McpAllowListRules,
300
301 #[serde(default)]
303 pub providers: BTreeMap<String, McpAllowListRules>,
304}
305
306impl Default for McpAllowListConfig {
307 fn default() -> Self {
308 Self {
309 enforce: default_allowlist_enforced(),
310 default: McpAllowListRules::default(),
311 providers: BTreeMap::new(),
312 }
313 }
314}
315
316impl McpAllowListConfig {
317 pub fn is_tool_allowed(&self, provider: &str, tool_name: &str) -> bool {
319 if !self.enforce {
320 return true;
321 }
322
323 self.resolve_match(provider, tool_name, |rules| &rules.tools)
324 }
325
326 pub fn is_resource_allowed(&self, provider: &str, resource: &str) -> bool {
328 if !self.enforce {
329 return true;
330 }
331
332 self.resolve_match(provider, resource, |rules| &rules.resources)
333 }
334
335 pub fn is_prompt_allowed(&self, provider: &str, prompt: &str) -> bool {
337 if !self.enforce {
338 return true;
339 }
340
341 self.resolve_match(provider, prompt, |rules| &rules.prompts)
342 }
343
344 pub fn is_logging_channel_allowed(&self, provider: Option<&str>, channel: &str) -> bool {
346 if !self.enforce {
347 return true;
348 }
349
350 if let Some(name) = provider
351 && let Some(rules) = self.providers.get(name)
352 && let Some(patterns) = &rules.logging
353 {
354 return pattern_matches(patterns, channel);
355 }
356
357 if let Some(patterns) = &self.default.logging
358 && pattern_matches(patterns, channel)
359 {
360 return true;
361 }
362
363 false
364 }
365
366 pub fn is_configuration_allowed(
368 &self,
369 provider: Option<&str>,
370 category: &str,
371 key: &str,
372 ) -> bool {
373 if !self.enforce {
374 return true;
375 }
376
377 if let Some(name) = provider
378 && let Some(rules) = self.providers.get(name)
379 && let Some(result) = configuration_allowed(rules, category, key)
380 {
381 return result;
382 }
383
384 if let Some(result) = configuration_allowed(&self.default, category, key) {
385 return result;
386 }
387
388 false
389 }
390
391 fn resolve_match<'a, F>(&'a self, provider: &str, candidate: &str, accessor: F) -> bool
392 where
393 F: Fn(&'a McpAllowListRules) -> &'a Option<Vec<String>>,
394 {
395 if let Some(rules) = self.providers.get(provider)
396 && let Some(patterns) = accessor(rules)
397 {
398 return pattern_matches(patterns, candidate);
399 }
400
401 if let Some(patterns) = accessor(&self.default)
402 && pattern_matches(patterns, candidate)
403 {
404 return true;
405 }
406
407 false
408 }
409}
410
411fn configuration_allowed(rules: &McpAllowListRules, category: &str, key: &str) -> Option<bool> {
412 rules.configuration.as_ref().and_then(|entries| {
413 entries
414 .get(category)
415 .map(|patterns| pattern_matches(patterns, key))
416 })
417}
418
419fn pattern_matches(patterns: &[String], candidate: &str) -> bool {
420 patterns
421 .iter()
422 .any(|pattern| wildcard_match(pattern, candidate))
423}
424
425fn wildcard_match(pattern: &str, candidate: &str) -> bool {
426 if pattern == "*" {
427 return true;
428 }
429
430 let mut regex_pattern = String::from("^");
431 let mut literal_buffer = String::new();
432
433 for ch in pattern.chars() {
434 match ch {
435 '*' => {
436 if !literal_buffer.is_empty() {
437 regex_pattern.push_str(®ex::escape(&literal_buffer));
438 literal_buffer.clear();
439 }
440 regex_pattern.push_str(".*");
441 }
442 '?' => {
443 if !literal_buffer.is_empty() {
444 regex_pattern.push_str(®ex::escape(&literal_buffer));
445 literal_buffer.clear();
446 }
447 regex_pattern.push('.');
448 }
449 _ => literal_buffer.push(ch),
450 }
451 }
452
453 if !literal_buffer.is_empty() {
454 regex_pattern.push_str(®ex::escape(&literal_buffer));
455 }
456
457 regex_pattern.push('$');
458
459 Regex::new(®ex_pattern)
460 .map(|regex| regex.is_match(candidate))
461 .unwrap_or(false)
462}
463
464#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
466#[derive(Debug, Clone, Deserialize, Serialize, Default)]
467pub struct McpAllowListRules {
468 #[serde(default)]
470 pub tools: Option<Vec<String>>,
471
472 #[serde(default)]
474 pub resources: Option<Vec<String>>,
475
476 #[serde(default)]
478 pub prompts: Option<Vec<String>>,
479
480 #[serde(default)]
482 pub logging: Option<Vec<String>>,
483
484 #[serde(default)]
486 pub configuration: Option<BTreeMap<String, Vec<String>>>,
487}
488
489#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
491#[derive(Debug, Clone, Deserialize, Serialize)]
492pub struct McpServerConfig {
493 #[serde(default = "default_mcp_server_enabled")]
495 pub enabled: bool,
496
497 #[serde(default = "default_mcp_server_bind")]
499 pub bind_address: String,
500
501 #[serde(default = "default_mcp_server_port")]
503 pub port: u16,
504
505 #[serde(default = "default_mcp_server_transport")]
507 pub transport: McpServerTransport,
508
509 #[serde(default = "default_mcp_server_name")]
511 pub name: String,
512
513 #[serde(default = "default_mcp_server_version")]
515 pub version: String,
516
517 #[serde(default)]
519 pub exposed_tools: Vec<String>,
520}
521
522impl Default for McpServerConfig {
523 fn default() -> Self {
524 Self {
525 enabled: default_mcp_server_enabled(),
526 bind_address: default_mcp_server_bind(),
527 port: default_mcp_server_port(),
528 transport: default_mcp_server_transport(),
529 name: default_mcp_server_name(),
530 version: default_mcp_server_version(),
531 exposed_tools: Vec::new(),
532 }
533 }
534}
535
536#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
538#[derive(Debug, Clone, Deserialize, Serialize)]
539#[serde(rename_all = "snake_case")]
540#[derive(Default)]
541pub enum McpServerTransport {
542 #[default]
544 Sse,
545 Http,
547}
548
549#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
551#[derive(Debug, Clone, Deserialize, Serialize)]
552#[serde(untagged)]
553pub enum McpTransportConfig {
554 Stdio(McpStdioServerConfig),
556 Http(McpHttpServerConfig),
558}
559
560#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
562#[derive(Debug, Clone, Deserialize, Serialize, Default)]
563pub struct McpStdioServerConfig {
564 pub command: String,
566
567 pub args: Vec<String>,
569
570 #[serde(default)]
572 pub working_directory: Option<String>,
573}
574
575#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
581#[derive(Debug, Clone, Deserialize, Serialize)]
582pub struct McpHttpServerConfig {
583 pub endpoint: String,
585
586 #[serde(default)]
588 pub api_key_env: Option<String>,
589
590 #[serde(default = "default_mcp_protocol_version")]
592 pub protocol_version: String,
593
594 #[serde(default, alias = "headers")]
596 pub http_headers: HashMap<String, String>,
597
598 #[serde(default)]
601 pub env_http_headers: HashMap<String, String>,
602}
603
604impl Default for McpHttpServerConfig {
605 fn default() -> Self {
606 Self {
607 endpoint: String::new(),
608 api_key_env: None,
609 protocol_version: default_mcp_protocol_version(),
610 http_headers: HashMap::new(),
611 env_http_headers: HashMap::new(),
612 }
613 }
614}
615
616fn default_mcp_enabled() -> bool {
618 false
619}
620
621fn default_mcp_ui_mode() -> McpUiMode {
622 McpUiMode::Compact
623}
624
625fn default_max_mcp_events() -> usize {
626 50
627}
628
629fn default_show_provider_names() -> bool {
630 true
631}
632
633fn default_max_concurrent_connections() -> usize {
634 5
635}
636
637fn default_request_timeout_seconds() -> u64 {
638 30
639}
640
641fn default_retry_attempts() -> u32 {
642 3
643}
644
645fn default_experimental_use_rmcp_client() -> bool {
646 true
647}
648
649fn default_provider_enabled() -> bool {
650 true
651}
652
653fn default_provider_max_concurrent() -> usize {
654 3
655}
656
657fn default_allowlist_enforced() -> bool {
658 false
659}
660
661fn default_mcp_protocol_version() -> String {
662 "2024-11-05".to_string()
663}
664
665fn default_mcp_server_enabled() -> bool {
666 false
667}
668
669fn default_mcp_server_bind() -> String {
670 "127.0.0.1".to_string()
671}
672
673fn default_mcp_server_port() -> u16 {
674 3000
675}
676
677fn default_mcp_server_transport() -> McpServerTransport {
678 McpServerTransport::Sse
679}
680
681fn default_mcp_server_name() -> String {
682 "vtcode-mcp-server".to_string()
683}
684
685fn default_mcp_server_version() -> String {
686 env!("CARGO_PKG_VERSION").to_string()
687}
688
689fn normalize_mcp_identifier(value: &str) -> String {
690 value
691 .chars()
692 .filter(|ch| ch.is_ascii_alphanumeric())
693 .map(|ch| ch.to_ascii_lowercase())
694 .collect()
695}
696
697fn default_mcp_auth_enabled() -> bool {
698 false
699}
700
701fn default_requests_per_minute() -> u32 {
702 100
703}
704
705fn default_concurrent_requests() -> u32 {
706 10
707}
708
709fn default_schema_validation_enabled() -> bool {
710 true
711}
712
713fn default_path_traversal_protection_enabled() -> bool {
714 true
715}
716
717fn default_max_argument_size() -> u32 {
718 1024 * 1024 }
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724 use crate::constants::mcp as mcp_constants;
725 use std::collections::BTreeMap;
726
727 #[test]
728 fn test_mcp_config_defaults() {
729 let config = McpClientConfig::default();
730 assert!(!config.enabled);
731 assert_eq!(config.ui.mode, McpUiMode::Compact);
732 assert_eq!(config.ui.max_events, 50);
733 assert!(config.ui.show_provider_names);
734 assert!(config.ui.renderers.is_empty());
735 assert_eq!(config.max_concurrent_connections, 5);
736 assert_eq!(config.request_timeout_seconds, 30);
737 assert_eq!(config.retry_attempts, 3);
738 assert!(config.providers.is_empty());
739 assert!(!config.server.enabled);
740 assert!(!config.allowlist.enforce);
741 assert!(config.allowlist.default.tools.is_none());
742 }
743
744 #[test]
745 fn test_allowlist_pattern_matching() {
746 let patterns = vec!["get_*".to_string(), "convert_timezone".to_string()];
747 assert!(pattern_matches(&patterns, "get_current_time"));
748 assert!(pattern_matches(&patterns, "convert_timezone"));
749 assert!(!pattern_matches(&patterns, "delete_timezone"));
750 }
751
752 #[test]
753 fn test_allowlist_provider_override() {
754 let mut config = McpAllowListConfig::default();
755 config.enforce = true;
756 config.default.tools = Some(vec!["get_*".to_string()]);
757
758 let mut provider_rules = McpAllowListRules::default();
759 provider_rules.tools = Some(vec!["list_*".to_string()]);
760 config
761 .providers
762 .insert("context7".to_string(), provider_rules);
763
764 assert!(config.is_tool_allowed("context7", "list_documents"));
765 assert!(!config.is_tool_allowed("context7", "get_current_time"));
766 assert!(config.is_tool_allowed("other", "get_timezone"));
767 assert!(!config.is_tool_allowed("other", "list_documents"));
768 }
769
770 #[test]
771 fn test_allowlist_configuration_rules() {
772 let mut config = McpAllowListConfig::default();
773 config.enforce = true;
774
775 let mut default_rules = McpAllowListRules::default();
776 default_rules.configuration = Some(BTreeMap::from([(
777 "ui".to_string(),
778 vec!["mode".to_string(), "max_events".to_string()],
779 )]));
780 config.default = default_rules;
781
782 let mut provider_rules = McpAllowListRules::default();
783 provider_rules.configuration = Some(BTreeMap::from([(
784 "provider".to_string(),
785 vec!["max_concurrent_requests".to_string()],
786 )]));
787 config.providers.insert("time".to_string(), provider_rules);
788
789 assert!(config.is_configuration_allowed(None, "ui", "mode"));
790 assert!(!config.is_configuration_allowed(None, "ui", "show_provider_names"));
791 assert!(config.is_configuration_allowed(
792 Some("time"),
793 "provider",
794 "max_concurrent_requests"
795 ));
796 assert!(!config.is_configuration_allowed(Some("time"), "provider", "retry_attempts"));
797 }
798
799 #[test]
800 fn test_allowlist_resource_override() {
801 let mut config = McpAllowListConfig::default();
802 config.enforce = true;
803 config.default.resources = Some(vec!["docs/*".to_string()]);
804
805 let mut provider_rules = McpAllowListRules::default();
806 provider_rules.resources = Some(vec!["journals/*".to_string()]);
807 config
808 .providers
809 .insert("context7".to_string(), provider_rules);
810
811 assert!(config.is_resource_allowed("context7", "journals/2024"));
812 assert!(!config.is_resource_allowed("context7", "docs/manual"));
813 assert!(config.is_resource_allowed("other", "docs/reference"));
814 assert!(!config.is_resource_allowed("other", "journals/2023"));
815 }
816
817 #[test]
818 fn test_allowlist_logging_override() {
819 let mut config = McpAllowListConfig::default();
820 config.enforce = true;
821 config.default.logging = Some(vec!["info".to_string(), "debug".to_string()]);
822
823 let mut provider_rules = McpAllowListRules::default();
824 provider_rules.logging = Some(vec!["audit".to_string()]);
825 config
826 .providers
827 .insert("sequential".to_string(), provider_rules);
828
829 assert!(config.is_logging_channel_allowed(Some("sequential"), "audit"));
830 assert!(!config.is_logging_channel_allowed(Some("sequential"), "info"));
831 assert!(config.is_logging_channel_allowed(Some("other"), "info"));
832 assert!(!config.is_logging_channel_allowed(Some("other"), "trace"));
833 }
834
835 #[test]
836 fn test_mcp_ui_renderer_resolution() {
837 let mut config = McpUiConfig::default();
838 config.renderers.insert(
839 mcp_constants::RENDERER_CONTEXT7.to_string(),
840 McpRendererProfile::Context7,
841 );
842 config.renderers.insert(
843 mcp_constants::RENDERER_SEQUENTIAL_THINKING.to_string(),
844 McpRendererProfile::SequentialThinking,
845 );
846
847 assert_eq!(
848 config.renderer_for_tool("mcp_context7_lookup"),
849 Some(McpRendererProfile::Context7)
850 );
851 assert_eq!(
852 config.renderer_for_tool("mcp_context7lookup"),
853 Some(McpRendererProfile::Context7)
854 );
855 assert_eq!(
856 config.renderer_for_tool("mcp_sequentialthinking_run"),
857 Some(McpRendererProfile::SequentialThinking)
858 );
859 assert_eq!(
860 config.renderer_for_identifier("sequential-thinking-analyze"),
861 Some(McpRendererProfile::SequentialThinking)
862 );
863 assert_eq!(config.renderer_for_tool("mcp_unknown"), None);
864 }
865}