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