1use regex::Regex;
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, HashMap};
4
5#[derive(Debug, Clone, Deserialize, Serialize)]
7pub struct McpClientConfig {
8 #[serde(default = "default_mcp_enabled")]
10 pub enabled: bool,
11
12 #[serde(default)]
14 pub ui: McpUiConfig,
15
16 #[serde(default)]
18 pub providers: Vec<McpProviderConfig>,
19
20 #[serde(default)]
22 pub server: McpServerConfig,
23
24 #[serde(default)]
26 pub allowlist: McpAllowListConfig,
27
28 #[serde(default = "default_max_concurrent_connections")]
30 pub max_concurrent_connections: usize,
31
32 #[serde(default = "default_request_timeout_seconds")]
34 pub request_timeout_seconds: u64,
35
36 #[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#[derive(Debug, Clone, Deserialize, Serialize)]
58pub struct McpUiConfig {
59 #[serde(default = "default_mcp_ui_mode")]
61 pub mode: McpUiMode,
62
63 #[serde(default = "default_max_mcp_events")]
65 pub max_events: usize,
66
67 #[serde(default = "default_show_provider_names")]
69 pub show_provider_names: bool,
70
71 #[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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
114#[serde(rename_all = "snake_case")]
115pub enum McpUiMode {
116 Compact,
118 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
139#[serde(rename_all = "kebab-case")]
140pub enum McpRendererProfile {
141 Context7,
143 SequentialThinking,
145}
146
147#[derive(Debug, Clone, Deserialize, Serialize)]
149pub struct McpProviderConfig {
150 pub name: String,
152
153 #[serde(flatten)]
155 pub transport: McpTransportConfig,
156
157 #[serde(default)]
159 pub env: HashMap<String, String>,
160
161 #[serde(default = "default_provider_enabled")]
163 pub enabled: bool,
164
165 #[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#[derive(Debug, Clone, Deserialize, Serialize)]
184pub struct McpAllowListConfig {
185 #[serde(default = "default_allowlist_enforced")]
187 pub enforce: bool,
188
189 #[serde(default)]
191 pub default: McpAllowListRules,
192
193 #[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 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 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 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 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 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(®ex::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(®ex::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(®ex::escape(&literal_buffer));
349 }
350
351 regex_pattern.push('$');
352
353 Regex::new(®ex_pattern)
354 .map(|regex| regex.is_match(candidate))
355 .unwrap_or(false)
356}
357
358#[derive(Debug, Clone, Deserialize, Serialize, Default)]
360pub struct McpAllowListRules {
361 #[serde(default)]
363 pub tools: Option<Vec<String>>,
364
365 #[serde(default)]
367 pub resources: Option<Vec<String>>,
368
369 #[serde(default)]
371 pub prompts: Option<Vec<String>>,
372
373 #[serde(default)]
375 pub logging: Option<Vec<String>>,
376
377 #[serde(default)]
379 pub configuration: Option<BTreeMap<String, Vec<String>>>,
380}
381
382#[derive(Debug, Clone, Deserialize, Serialize)]
384pub struct McpServerConfig {
385 #[serde(default = "default_mcp_server_enabled")]
387 pub enabled: bool,
388
389 #[serde(default = "default_mcp_server_bind")]
391 pub bind_address: String,
392
393 #[serde(default = "default_mcp_server_port")]
395 pub port: u16,
396
397 #[serde(default = "default_mcp_server_transport")]
399 pub transport: McpServerTransport,
400
401 #[serde(default = "default_mcp_server_name")]
403 pub name: String,
404
405 #[serde(default = "default_mcp_server_version")]
407 pub version: String,
408
409 #[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#[derive(Debug, Clone, Deserialize, Serialize)]
430#[serde(rename_all = "snake_case")]
431pub enum McpServerTransport {
432 Sse,
434 Http,
436}
437
438impl Default for McpServerTransport {
439 fn default() -> Self {
440 McpServerTransport::Sse
441 }
442}
443
444#[derive(Debug, Clone, Deserialize, Serialize)]
446#[serde(untagged)]
447pub enum McpTransportConfig {
448 Stdio(McpStdioServerConfig),
450 Http(McpHttpServerConfig),
452}
453
454#[derive(Debug, Clone, Deserialize, Serialize)]
456pub struct McpStdioServerConfig {
457 pub command: String,
459
460 pub args: Vec<String>,
462
463 #[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#[derive(Debug, Clone, Deserialize, Serialize)]
484pub struct McpHttpServerConfig {
485 pub endpoint: String,
487
488 #[serde(default)]
490 pub api_key_env: Option<String>,
491
492 #[serde(default = "default_mcp_protocol_version")]
494 pub protocol_version: String,
495
496 #[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
512fn 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}