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