Skip to main content

vtcode_config/
mcp.rs

1use hashbrown::HashMap;
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5
6mod transport;
7pub use transport::{
8    MCP_LEGACY_PROTOCOL_VERSION, MCP_STABLE_PROTOCOL_VERSION, McpHttpHandshakeMode, McpHttpServerConfig,
9    McpProviderConfig, McpStdioServerConfig, McpTransportConfig,
10};
11
12/// Top-level MCP configuration
13#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
14#[derive(Debug, Clone, Deserialize, Serialize)]
15pub struct McpClientConfig {
16    /// Enable MCP functionality
17    #[serde(default = "default_mcp_enabled")]
18    pub enabled: bool,
19
20    /// MCP UI display configuration
21    #[serde(default)]
22    pub ui: McpUiConfig,
23
24    /// Configured MCP providers
25    #[serde(default)]
26    pub providers: Vec<McpProviderConfig>,
27
28    /// Optional transport-identity requirements for provider admission
29    #[serde(default)]
30    pub requirements: McpRequirementsConfig,
31
32    /// MCP server configuration (for vtcode to expose tools)
33    #[serde(default)]
34    pub server: McpServerConfig,
35
36    /// Allow list configuration for MCP access control
37    #[serde(default)]
38    pub allowlist: McpAllowListConfig,
39
40    /// Maximum number of concurrent MCP connections
41    #[serde(default = "default_max_concurrent_connections")]
42    pub max_concurrent_connections: usize,
43
44    /// Timeout (seconds) for a single MCP tool call.
45    ///
46    /// Distinct from `connection_timeout_seconds`, which bounds the
47    /// connect-and-handshake phase during startup.
48    #[serde(default = "default_request_timeout_seconds")]
49    pub request_timeout_seconds: u64,
50
51    /// Connection retry attempts
52    #[serde(default = "default_retry_attempts")]
53    pub retry_attempts: u32,
54
55    /// Optional timeout (seconds) when starting providers
56    #[serde(default)]
57    pub startup_timeout_seconds: Option<u64>,
58
59    /// Optional timeout (seconds) for tool execution
60    #[serde(default)]
61    pub tool_timeout_seconds: Option<u64>,
62
63    /// Toggle experimental RMCP client features
64    #[serde(default = "default_experimental_use_rmcp_client")]
65    pub experimental_use_rmcp_client: bool,
66
67    /// Enable connection pooling for better performance
68    #[serde(default = "default_connection_pooling_enabled")]
69    pub connection_pooling_enabled: bool,
70
71    /// Cache capacity for tool discovery results
72    #[serde(default = "default_tool_cache_capacity")]
73    pub tool_cache_capacity: usize,
74
75    /// Connection timeout in seconds
76    #[serde(default = "default_connection_timeout_seconds")]
77    pub connection_timeout_seconds: u64,
78
79    /// Security configuration for MCP
80    #[serde(default)]
81    pub security: McpSecurityConfig,
82
83    /// Model-driven MCP lifecycle controls (connect/disconnect).
84    #[serde(default)]
85    pub lifecycle: McpLifecycleConfig,
86}
87
88/// Security configuration for MCP
89#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
90#[derive(Debug, Clone, Deserialize, Serialize)]
91pub struct McpSecurityConfig {
92    /// Enable authentication for MCP server
93    #[serde(default = "default_mcp_auth_enabled")]
94    pub auth_enabled: bool,
95
96    /// API key for MCP server authentication (environment variable name)
97    #[serde(default)]
98    pub api_key_env: Option<String>,
99
100    /// Rate limiting configuration
101    #[serde(default)]
102    rate_limit: McpRateLimitConfig,
103
104    /// Tool call validation configuration
105    #[serde(default)]
106    pub validation: McpValidationConfig,
107}
108
109impl Default for McpSecurityConfig {
110    fn default() -> Self {
111        Self {
112            auth_enabled: default_mcp_auth_enabled(),
113            api_key_env: None,
114            rate_limit: McpRateLimitConfig::default(),
115            validation: McpValidationConfig::default(),
116        }
117    }
118}
119
120/// Rate limiting configuration for MCP
121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
122#[derive(Debug, Clone, Deserialize, Serialize)]
123pub struct McpRateLimitConfig {
124    /// Maximum requests per minute per client
125    #[serde(default = "default_requests_per_minute")]
126    requests_per_minute: u32,
127
128    /// Maximum concurrent requests per client
129    #[serde(default = "default_concurrent_requests")]
130    concurrent_requests: u32,
131}
132
133impl Default for McpRateLimitConfig {
134    fn default() -> Self {
135        Self {
136            requests_per_minute: default_requests_per_minute(),
137            concurrent_requests: default_concurrent_requests(),
138        }
139    }
140}
141
142/// Validation configuration for MCP
143#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
144#[derive(Debug, Clone, Deserialize, Serialize)]
145pub struct McpValidationConfig {
146    /// Enable JSON schema validation for tool arguments
147    #[serde(default = "default_schema_validation_enabled")]
148    schema_validation_enabled: bool,
149
150    /// Enable path traversal protection
151    #[serde(default = "default_path_traversal_protection_enabled")]
152    pub path_traversal_protection: bool,
153
154    /// Maximum argument size in bytes
155    #[serde(default = "default_max_argument_size")]
156    pub max_argument_size: u32,
157}
158
159impl Default for McpValidationConfig {
160    fn default() -> Self {
161        Self {
162            schema_validation_enabled: default_schema_validation_enabled(),
163            path_traversal_protection: default_path_traversal_protection_enabled(),
164            max_argument_size: default_max_argument_size(),
165        }
166    }
167}
168
169impl Default for McpClientConfig {
170    fn default() -> Self {
171        Self {
172            enabled: default_mcp_enabled(),
173            ui: McpUiConfig::default(),
174            providers: Vec::new(),
175            requirements: McpRequirementsConfig::default(),
176            server: McpServerConfig::default(),
177            allowlist: McpAllowListConfig::default(),
178            max_concurrent_connections: default_max_concurrent_connections(),
179            request_timeout_seconds: default_request_timeout_seconds(),
180            retry_attempts: default_retry_attempts(),
181            startup_timeout_seconds: None,
182            tool_timeout_seconds: None,
183            experimental_use_rmcp_client: default_experimental_use_rmcp_client(),
184            security: McpSecurityConfig::default(),
185            lifecycle: McpLifecycleConfig::default(),
186            connection_pooling_enabled: default_connection_pooling_enabled(),
187            connection_timeout_seconds: default_connection_timeout_seconds(),
188            tool_cache_capacity: default_tool_cache_capacity(),
189        }
190    }
191}
192
193/// Lifecycle controls for MCP server management.
194#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
195#[derive(Debug, Clone, Deserialize, Serialize)]
196pub struct McpLifecycleConfig {
197    /// Allow model-callable tools to connect/disconnect MCP servers at runtime.
198    #[serde(default = "default_allow_model_lifecycle_control")]
199    pub allow_model_control: bool,
200}
201
202impl Default for McpLifecycleConfig {
203    fn default() -> Self {
204        Self {
205            allow_model_control: default_allow_model_lifecycle_control(),
206        }
207    }
208}
209
210/// Requirements used to admit MCP providers by transport identity.
211#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
212#[derive(Debug, Clone, Deserialize, Serialize)]
213pub struct McpRequirementsConfig {
214    /// Whether requirement checks are enforced
215    #[serde(default = "default_mcp_requirements_enforce")]
216    pub enforce: bool,
217
218    /// Allowed stdio command names when enforcement is enabled
219    #[serde(default)]
220    pub allowed_stdio_commands: Vec<String>,
221
222    /// Allowed HTTP endpoints when enforcement is enabled
223    #[serde(default)]
224    pub allowed_http_endpoints: Vec<String>,
225}
226
227impl Default for McpRequirementsConfig {
228    fn default() -> Self {
229        Self {
230            enforce: default_mcp_requirements_enforce(),
231            allowed_stdio_commands: Vec::new(),
232            allowed_http_endpoints: Vec::new(),
233        }
234    }
235}
236
237/// UI configuration for MCP display
238#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
239#[derive(Debug, Clone, Deserialize, Serialize)]
240pub struct McpUiConfig {
241    /// UI mode for MCP events: "compact" or "full"
242    #[serde(default = "default_mcp_ui_mode")]
243    pub mode: McpUiMode,
244
245    /// Maximum number of MCP events to display
246    #[serde(default = "default_max_mcp_events")]
247    pub max_events: usize,
248
249    /// Show MCP provider names in UI
250    #[serde(default = "default_show_provider_names")]
251    pub show_provider_names: bool,
252
253    /// Custom renderer profiles for provider-specific output formatting
254    #[serde(default)]
255    #[cfg_attr(feature = "schema", schemars(with = "BTreeMap<String, McpRendererProfile>"))]
256    pub renderers: HashMap<String, McpRendererProfile>,
257
258    /// MCP Apps preview tier ("off" refuses app UI without fetching and emits a sandbox-denied event,
259    /// "static" shows disclosure rows plus an inert HTML preview, "interactive" needs an app engine and is
260    /// currently rejected). Options: "off", "static", "interactive"
261    #[serde(default = "default_mcp_ui_apps")]
262    pub apps: McpUiAppsTier,
263}
264
265impl Default for McpUiConfig {
266    fn default() -> Self {
267        Self {
268            mode: default_mcp_ui_mode(),
269            max_events: default_max_mcp_events(),
270            show_provider_names: default_show_provider_names(),
271            renderers: HashMap::new(),
272            apps: default_mcp_ui_apps(),
273        }
274    }
275}
276
277impl McpUiConfig {
278    /// Resolve renderer profile for a provider or tool identifier
279    fn renderer_for_identifier(&self, identifier: &str) -> Option<McpRendererProfile> {
280        let normalized_identifier = normalize_mcp_identifier(identifier);
281        if normalized_identifier.is_empty() {
282            return None;
283        }
284
285        self.renderers.iter().find_map(|(key, profile)| {
286            let normalized_key = normalize_mcp_identifier(key);
287            if normalized_identifier.starts_with(&normalized_key) {
288                Some(*profile)
289            } else {
290                None
291            }
292        })
293    }
294
295    /// Resolve renderer profile for a fully qualified tool name
296    pub fn renderer_for_tool(&self, tool_name: &str) -> Option<McpRendererProfile> {
297        let identifier = tool_name.strip_prefix("mcp_").unwrap_or(tool_name);
298        self.renderer_for_identifier(identifier)
299    }
300}
301
302/// UI mode for MCP event display
303#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
305#[serde(rename_all = "snake_case")]
306#[derive(Default)]
307pub enum McpUiMode {
308    /// Compact mode - shows only event titles
309    #[default]
310    Compact,
311    /// Full mode - shows detailed event logs
312    Full,
313}
314
315impl std::fmt::Display for McpUiMode {
316    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317        match self {
318            McpUiMode::Compact => write!(f, "compact"),
319            McpUiMode::Full => write!(f, "full"),
320        }
321    }
322}
323
324/// MCP Apps presentation tier: how a surface may present `ui://` app resources.
325#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
326#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
327#[serde(rename_all = "snake_case")]
328#[derive(Default)]
329pub enum McpUiAppsTier {
330    /// Refuse app UI: no `ui://` fetch, no disclosure rows. Refused URIs
331    /// surface through a sandbox-denied event instead.
332    #[default]
333    Off,
334    /// Disclosure rows plus an opt-in static preview of sanitized app structure; no interactivity.
335    Static,
336    /// Full interactive app. Rejected until a sandboxed webview host exists.
337    Interactive,
338}
339
340impl McpUiAppsTier {
341    /// Whether this tier may advertise the `io.modelcontextprotocol/ui` extension.
342    ///
343    /// Only `Interactive` can honor app round-trips; a static preview must not
344    /// advertise support it cannot fulfill.
345    #[must_use]
346    pub fn advertises_ui_extension(self) -> bool {
347        matches!(self, McpUiAppsTier::Interactive)
348    }
349}
350
351impl std::fmt::Display for McpUiAppsTier {
352    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353        match self {
354            McpUiAppsTier::Off => write!(f, "off"),
355            McpUiAppsTier::Static => write!(f, "static"),
356            McpUiAppsTier::Interactive => write!(f, "interactive"),
357        }
358    }
359}
360
361/// Named renderer profiles for MCP tool output formatting
362#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
364#[serde(rename_all = "kebab-case")]
365pub enum McpRendererProfile {
366    /// Context7 knowledge base renderer
367    Context7,
368    /// Sequential thinking trace renderer
369    SequentialThinking,
370}
371
372/// Allow list configuration for MCP providers
373#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
374#[derive(Debug, Clone, Deserialize, Serialize)]
375pub struct McpAllowListConfig {
376    /// Whether to enforce allow list checks
377    #[serde(default = "default_allowlist_enforced")]
378    pub enforce: bool,
379
380    /// Default rules applied when provider-specific rules are absent
381    #[serde(default)]
382    pub default: McpAllowListRules,
383
384    /// Provider-specific allow list rules
385    #[serde(default)]
386    pub providers: BTreeMap<String, McpAllowListRules>,
387}
388
389impl Default for McpAllowListConfig {
390    fn default() -> Self {
391        Self {
392            enforce: default_allowlist_enforced(),
393            default: McpAllowListRules::default(),
394            providers: BTreeMap::new(),
395        }
396    }
397}
398
399impl McpAllowListConfig {
400    /// Determine whether a tool is permitted for the given provider
401    #[must_use]
402    pub fn is_tool_allowed(&self, provider: &str, tool_name: &str) -> bool {
403        if !self.enforce {
404            return true;
405        }
406
407        self.resolve_match(provider, tool_name, |rules| rules.tools.as_deref())
408    }
409
410    /// Determine whether a resource is permitted for the given provider
411    #[must_use]
412    pub fn is_resource_allowed(&self, provider: &str, resource: &str) -> bool {
413        if !self.enforce {
414            return true;
415        }
416
417        self.resolve_match(provider, resource, |rules| rules.resources.as_deref())
418    }
419
420    /// Determine whether a prompt is permitted for the given provider
421    #[must_use]
422    pub fn is_prompt_allowed(&self, provider: &str, prompt: &str) -> bool {
423        if !self.enforce {
424            return true;
425        }
426
427        self.resolve_match(provider, prompt, |rules| rules.prompts.as_deref())
428    }
429
430    /// Determine whether a logging channel is permitted
431    fn is_logging_channel_allowed(&self, provider: Option<&str>, channel: &str) -> bool {
432        if !self.enforce {
433            return true;
434        }
435
436        if let Some(name) = provider
437            && let Some(rules) = self.providers.get(name)
438            && let Some(patterns) = &rules.logging
439        {
440            return pattern_matches(patterns, channel);
441        }
442
443        if let Some(patterns) = &self.default.logging
444            && pattern_matches(patterns, channel)
445        {
446            return true;
447        }
448
449        false
450    }
451
452    /// Determine whether a configuration key can be modified
453    fn is_configuration_allowed(&self, provider: Option<&str>, category: &str, key: &str) -> bool {
454        if !self.enforce {
455            return true;
456        }
457
458        if let Some(name) = provider
459            && let Some(rules) = self.providers.get(name)
460            && let Some(result) = configuration_allowed(rules, category, key)
461        {
462            return result;
463        }
464
465        if let Some(result) = configuration_allowed(&self.default, category, key) {
466            return result;
467        }
468
469        false
470    }
471
472    fn resolve_match<'a, F>(&'a self, provider: &str, candidate: &str, accessor: F) -> bool
473    where
474        F: Fn(&'a McpAllowListRules) -> Option<&'a [String]>,
475    {
476        if let Some(rules) = self.providers.get(provider)
477            && let Some(patterns) = accessor(rules)
478        {
479            return pattern_matches(patterns, candidate);
480        }
481
482        if let Some(patterns) = accessor(&self.default)
483            && pattern_matches(patterns, candidate)
484        {
485            return true;
486        }
487
488        false
489    }
490}
491
492fn configuration_allowed(rules: &McpAllowListRules, category: &str, key: &str) -> Option<bool> {
493    rules
494        .configuration
495        .as_ref()
496        .and_then(|entries| entries.get(category).map(|patterns| pattern_matches(patterns, key)))
497}
498
499fn pattern_matches(patterns: &[String], candidate: &str) -> bool {
500    patterns.iter().any(|pattern| wildcard_match(pattern, candidate))
501}
502
503fn wildcard_match(pattern: &str, candidate: &str) -> bool {
504    if pattern == "*" {
505        return true;
506    }
507
508    let mut regex_pattern = String::from("^");
509    let mut literal_buffer = String::new();
510
511    for ch in pattern.chars() {
512        match ch {
513            '*' => {
514                if !literal_buffer.is_empty() {
515                    regex_pattern.push_str(&regex::escape(&literal_buffer));
516                    literal_buffer.clear();
517                }
518                regex_pattern.push_str(".*");
519            }
520            '?' => {
521                if !literal_buffer.is_empty() {
522                    regex_pattern.push_str(&regex::escape(&literal_buffer));
523                    literal_buffer.clear();
524                }
525                regex_pattern.push('.');
526            }
527            _ => literal_buffer.push(ch),
528        }
529    }
530
531    if !literal_buffer.is_empty() {
532        regex_pattern.push_str(&regex::escape(&literal_buffer));
533    }
534
535    regex_pattern.push('$');
536
537    Regex::new(&regex_pattern)
538        .map(|regex| regex.is_match(candidate))
539        .unwrap_or(false)
540}
541
542/// Allow list rules for a provider or default configuration
543#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
544#[derive(Debug, Clone, Deserialize, Serialize, Default)]
545pub struct McpAllowListRules {
546    /// Tool name patterns permitted for the provider
547    #[serde(default)]
548    pub tools: Option<Vec<String>>,
549
550    /// Resource name patterns permitted for the provider
551    #[serde(default)]
552    pub resources: Option<Vec<String>>,
553
554    /// Prompt name patterns permitted for the provider
555    #[serde(default)]
556    pub prompts: Option<Vec<String>>,
557
558    /// Logging channels permitted for the provider
559    #[serde(default)]
560    pub logging: Option<Vec<String>>,
561
562    /// Configuration keys permitted for the provider grouped by category
563    #[serde(default)]
564    pub configuration: Option<BTreeMap<String, Vec<String>>>,
565}
566
567/// Configuration for the MCP server (vtcode acting as an MCP server)
568#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
569#[derive(Debug, Clone, Deserialize, Serialize)]
570pub struct McpServerConfig {
571    /// Enable vtcode's MCP server capability
572    #[serde(default = "default_mcp_server_enabled")]
573    pub enabled: bool,
574
575    /// Bind address for the MCP server
576    #[serde(default = "default_mcp_server_bind")]
577    pub bind_address: String,
578
579    /// Port for the MCP server
580    #[serde(default = "default_mcp_server_port")]
581    pub port: u16,
582
583    /// Server transport type
584    #[serde(default = "default_mcp_server_transport")]
585    pub transport: McpServerTransport,
586
587    /// Server identifier
588    #[serde(default = "default_mcp_server_name")]
589    pub name: String,
590
591    /// Server version
592    #[serde(default = "default_mcp_server_version")]
593    pub version: String,
594
595    /// Tools exposed by the vtcode MCP server
596    #[serde(default)]
597    pub exposed_tools: Vec<String>,
598}
599
600impl Default for McpServerConfig {
601    fn default() -> Self {
602        Self {
603            enabled: default_mcp_server_enabled(),
604            bind_address: default_mcp_server_bind(),
605            port: default_mcp_server_port(),
606            transport: default_mcp_server_transport(),
607            name: default_mcp_server_name(),
608            version: default_mcp_server_version(),
609            exposed_tools: Vec::new(),
610        }
611    }
612}
613
614/// MCP server transport types
615#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
616#[derive(Debug, Clone, Deserialize, Serialize)]
617#[serde(rename_all = "snake_case")]
618#[derive(Default)]
619pub enum McpServerTransport {
620    /// Server Sent Events transport
621    #[default]
622    Sse,
623    /// HTTP transport
624    Http,
625}
626
627/// Default value functions
628fn default_mcp_enabled() -> bool {
629    false
630}
631
632fn default_mcp_ui_mode() -> McpUiMode {
633    McpUiMode::Compact
634}
635
636fn default_max_mcp_events() -> usize {
637    50
638}
639
640fn default_show_provider_names() -> bool {
641    true
642}
643
644fn default_mcp_ui_apps() -> McpUiAppsTier {
645    McpUiAppsTier::Off
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_allowlist_enforced() -> bool {
665    false
666}
667
668fn default_mcp_server_enabled() -> bool {
669    false
670}
671
672fn default_connection_pooling_enabled() -> bool {
673    true
674}
675
676fn default_tool_cache_capacity() -> usize {
677    100
678}
679
680fn default_connection_timeout_seconds() -> u64 {
681    30
682}
683
684fn default_allow_model_lifecycle_control() -> bool {
685    false
686}
687
688fn default_mcp_server_bind() -> String {
689    "127.0.0.1".into()
690}
691
692fn default_mcp_server_port() -> u16 {
693    3000
694}
695
696fn default_mcp_server_transport() -> McpServerTransport {
697    McpServerTransport::Sse
698}
699
700fn default_mcp_server_name() -> String {
701    "vtcode-mcp-server".into()
702}
703
704fn default_mcp_server_version() -> String {
705    env!("CARGO_PKG_VERSION").into()
706}
707
708fn normalize_mcp_identifier(value: &str) -> String {
709    value
710        .chars()
711        .filter(|ch| ch.is_ascii_alphanumeric())
712        .map(|ch| ch.to_ascii_lowercase())
713        .collect()
714}
715
716fn default_mcp_auth_enabled() -> bool {
717    false
718}
719
720fn default_requests_per_minute() -> u32 {
721    100
722}
723
724fn default_concurrent_requests() -> u32 {
725    10
726}
727
728fn default_schema_validation_enabled() -> bool {
729    true
730}
731
732fn default_path_traversal_protection_enabled() -> bool {
733    true
734}
735
736fn default_max_argument_size() -> u32 {
737    1024 * 1024 // 1MB
738}
739
740fn default_mcp_requirements_enforce() -> bool {
741    false
742}
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747    use crate::constants::mcp as mcp_constants;
748    use std::collections::BTreeMap;
749
750    #[test]
751    fn test_mcp_config_defaults() {
752        let config = McpClientConfig::default();
753        assert!(!config.enabled);
754        assert_eq!(config.ui.mode, McpUiMode::Compact);
755        assert_eq!(config.ui.max_events, 50);
756        assert!(config.ui.show_provider_names);
757        assert!(config.ui.renderers.is_empty());
758        assert_eq!(config.ui.apps, McpUiAppsTier::Off);
759        assert_eq!(config.max_concurrent_connections, 5);
760        assert_eq!(config.request_timeout_seconds, 30);
761        assert_eq!(config.retry_attempts, 3);
762        assert!(!config.lifecycle.allow_model_control);
763        assert!(config.providers.is_empty());
764        assert!(!config.requirements.enforce);
765        assert!(config.requirements.allowed_stdio_commands.is_empty());
766        assert!(config.requirements.allowed_http_endpoints.is_empty());
767        assert!(!config.server.enabled);
768        assert!(!config.allowlist.enforce);
769        assert!(config.allowlist.default.tools.is_none());
770    }
771
772    #[test]
773    fn test_ui_apps_tier_advertises_only_interactive() {
774        assert!(!McpUiAppsTier::Off.advertises_ui_extension());
775        assert!(!McpUiAppsTier::Static.advertises_ui_extension());
776        assert!(McpUiAppsTier::Interactive.advertises_ui_extension());
777    }
778
779    #[test]
780    fn test_ui_apps_tier_serde_names() {
781        for (tier, name) in [
782            (McpUiAppsTier::Off, "off"),
783            (McpUiAppsTier::Static, "static"),
784            (McpUiAppsTier::Interactive, "interactive"),
785        ] {
786            let value = serde_json::to_value(tier).expect("serialize tier");
787            assert_eq!(value, serde_json::Value::String(name.to_string()));
788            let round_trip: McpUiAppsTier = serde_json::from_value(value).expect("deserialize tier");
789            assert_eq!(round_trip, tier);
790            assert_eq!(tier.to_string(), name);
791        }
792        let ui: McpUiConfig = toml::from_str("mode = \"full\"\n").expect("partial ui table");
793        assert_eq!(ui.apps, McpUiAppsTier::Off);
794        let ui: McpUiConfig = toml::from_str("apps = \"static\"\n").expect("apps key");
795        assert_eq!(ui.apps, McpUiAppsTier::Static);
796    }
797
798    #[test]
799    fn test_allowlist_pattern_matching() {
800        let patterns = vec!["get_*".to_string(), "convert_timezone".to_string()];
801        assert!(pattern_matches(&patterns, "get_current_time"));
802        assert!(pattern_matches(&patterns, "convert_timezone"));
803        assert!(!pattern_matches(&patterns, "delete_timezone"));
804    }
805
806    #[test]
807    fn test_allowlist_provider_override() {
808        let mut config = McpAllowListConfig {
809            enforce: true,
810            default: McpAllowListRules {
811                tools: Some(vec!["get_*".to_string()]),
812                ..Default::default()
813            },
814            ..Default::default()
815        };
816
817        let provider_rules = McpAllowListRules {
818            tools: Some(vec!["list_*".to_string()]),
819            ..Default::default()
820        };
821        config.providers.insert("context7".to_string(), provider_rules);
822
823        assert!(config.is_tool_allowed("context7", "list_documents"));
824        assert!(!config.is_tool_allowed("context7", "get_current_time"));
825        assert!(config.is_tool_allowed("other", "get_timezone"));
826        assert!(!config.is_tool_allowed("other", "list_documents"));
827    }
828
829    #[test]
830    fn test_allowlist_configuration_rules() {
831        let mut config = McpAllowListConfig {
832            enforce: true,
833            default: McpAllowListRules {
834                configuration: Some(BTreeMap::from([(
835                    "ui".to_string(),
836                    vec!["mode".to_string(), "max_events".to_string()],
837                )])),
838                ..Default::default()
839            },
840            ..Default::default()
841        };
842
843        let provider_rules = McpAllowListRules {
844            configuration: Some(BTreeMap::from([(
845                "provider".to_string(),
846                vec!["max_concurrent_requests".to_string()],
847            )])),
848            ..Default::default()
849        };
850        config.providers.insert("time".to_string(), provider_rules);
851
852        assert!(config.is_configuration_allowed(None, "ui", "mode"));
853        assert!(!config.is_configuration_allowed(None, "ui", "show_provider_names"));
854        assert!(config.is_configuration_allowed(Some("time"), "provider", "max_concurrent_requests"));
855        assert!(!config.is_configuration_allowed(Some("time"), "provider", "retry_attempts"));
856    }
857
858    #[test]
859    fn test_allowlist_resource_override() {
860        let mut config = McpAllowListConfig {
861            enforce: true,
862            default: McpAllowListRules {
863                resources: Some(vec!["docs/**/*".to_string()]),
864                ..Default::default()
865            },
866            ..Default::default()
867        };
868
869        let provider_rules = McpAllowListRules {
870            resources: Some(vec!["journals/*".to_string()]),
871            ..Default::default()
872        };
873        config.providers.insert("context7".to_string(), provider_rules);
874
875        assert!(config.is_resource_allowed("context7", "journals/2024"));
876        assert!(config.is_resource_allowed("other", "docs/config/config.md"));
877        assert!(config.is_resource_allowed("other", "docs/guides/zed-acp.md"));
878        assert!(!config.is_resource_allowed("other", "journals/2023"));
879    }
880
881    #[test]
882    fn test_allowlist_logging_override() {
883        let mut config = McpAllowListConfig {
884            enforce: true,
885            default: McpAllowListRules {
886                logging: Some(vec!["info".to_string(), "debug".to_string()]),
887                ..Default::default()
888            },
889            ..Default::default()
890        };
891
892        let provider_rules = McpAllowListRules {
893            logging: Some(vec!["audit".to_string()]),
894            ..Default::default()
895        };
896        config.providers.insert("sequential".to_string(), provider_rules);
897
898        assert!(config.is_logging_channel_allowed(Some("sequential"), "audit"));
899        assert!(!config.is_logging_channel_allowed(Some("sequential"), "info"));
900        assert!(config.is_logging_channel_allowed(Some("other"), "info"));
901        assert!(!config.is_logging_channel_allowed(Some("other"), "trace"));
902    }
903
904    #[test]
905    fn test_mcp_ui_renderer_resolution() {
906        let mut config = McpUiConfig::default();
907        config
908            .renderers
909            .insert(mcp_constants::RENDERER_CONTEXT7.to_string(), McpRendererProfile::Context7);
910        config
911            .renderers
912            .insert(mcp_constants::RENDERER_SEQUENTIAL_THINKING.to_string(), McpRendererProfile::SequentialThinking);
913
914        assert_eq!(config.renderer_for_tool("mcp_context7_lookup"), Some(McpRendererProfile::Context7));
915        assert_eq!(config.renderer_for_tool("mcp_context7lookup"), Some(McpRendererProfile::Context7));
916        assert_eq!(
917            config.renderer_for_tool("mcp_sequentialthinking_run"),
918            Some(McpRendererProfile::SequentialThinking)
919        );
920        assert_eq!(
921            config.renderer_for_identifier("sequential-thinking-analyze"),
922            Some(McpRendererProfile::SequentialThinking)
923        );
924        assert_eq!(config.renderer_for_tool("mcp_unknown"), None);
925    }
926}