1use std::collections::HashMap;
5
6use serde::{Deserialize, Serialize};
7
8use crate::defaults::default_true;
9use crate::providers::ProviderName;
10
11pub use crate::mcp_security::ToolSecurityMeta;
12
13#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum McpTrustLevel {
21 Trusted,
23 #[default]
25 Untrusted,
26 Sandboxed,
28}
29
30impl McpTrustLevel {
31 #[must_use]
35 pub fn restriction_level(self) -> u8 {
36 match self {
37 Self::Trusted => 0,
38 Self::Untrusted => 1,
39 Self::Sandboxed => 2,
40 }
41 }
42}
43
44#[derive(Debug, Clone, Deserialize, Serialize)]
46pub struct RateLimit {
47 pub max_calls_per_minute: u32,
49}
50
51#[derive(Debug, Clone, Default, Deserialize, Serialize)]
55#[serde(default)]
56pub struct McpPolicy {
57 pub allowed_tools: Option<Vec<String>>,
59 pub denied_tools: Vec<String>,
61 pub rate_limit: Option<RateLimit>,
63}
64
65fn default_skill_allowlist() -> Vec<String> {
66 vec!["*".into()]
67}
68
69#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct ChannelSkillsConfig {
76 #[serde(default = "default_skill_allowlist")]
79 pub allowed: Vec<String>,
80}
81
82impl Default for ChannelSkillsConfig {
83 fn default() -> Self {
84 Self {
85 allowed: default_skill_allowlist(),
86 }
87 }
88}
89
90#[must_use]
95pub fn is_skill_allowed(name: &str, config: &ChannelSkillsConfig) -> bool {
96 config.allowed.iter().any(|p| glob_match(p, name))
97}
98
99fn glob_match(pattern: &str, name: &str) -> bool {
100 if let Some(prefix) = pattern.strip_suffix('*') {
101 if prefix.is_empty() {
102 return true;
103 }
104 name.starts_with(prefix)
105 } else {
106 pattern == name
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 fn allow(patterns: &[&str]) -> ChannelSkillsConfig {
115 ChannelSkillsConfig {
116 allowed: patterns.iter().map(ToString::to_string).collect(),
117 }
118 }
119
120 #[test]
121 fn telegram_config_defaults() {
122 let src = r#"token = "test_token""#;
124 let cfg: TelegramConfig = toml::from_str(src).unwrap();
125 assert!(!cfg.guest_mode);
126 assert!(!cfg.bot_to_bot);
127 assert!(cfg.allowed_bots.is_empty());
128 assert_eq!(cfg.max_bot_chain_depth, 1);
129 }
130
131 #[test]
132 fn telegram_config_explicit_values() {
133 let src = r#"
134token = "test_token"
135guest_mode = true
136bot_to_bot = true
137allowed_bots = ["@bot_a", "@bot_b"]
138max_bot_chain_depth = 5
139"#;
140 let cfg: TelegramConfig = toml::from_str(src).unwrap();
141 assert!(cfg.guest_mode);
142 assert!(cfg.bot_to_bot);
143 assert_eq!(cfg.allowed_bots, vec!["@bot_a", "@bot_b"]);
144 assert_eq!(cfg.max_bot_chain_depth, 5);
145 }
146
147 #[test]
148 fn test_default_output_schema_hint_bytes_is_1024() {
149 assert_eq!(default_output_schema_hint_bytes(), 1024);
150 }
151
152 #[test]
153 fn test_mcp_config_default_output_schema_hint_bytes_is_1024() {
154 let cfg = McpConfig::default();
155 assert_eq!(cfg.output_schema_hint_bytes, 1024);
156 }
157
158 #[test]
159 fn max_connect_attempts_default_is_3() {
160 let cfg = McpConfig::default();
161 assert_eq!(cfg.max_connect_attempts, 3);
162 }
163
164 #[test]
165 fn max_connect_attempts_accepts_valid_range() {
166 for v in [1u8, 3, 10] {
167 let src = format!("max_connect_attempts = {v}\n");
168 let cfg: McpConfig = toml::from_str(&src)
169 .unwrap_or_else(|e| panic!("max_connect_attempts = {v} should be valid, got: {e}"));
170 assert_eq!(cfg.max_connect_attempts, v);
171 }
172 }
173
174 #[test]
175 fn max_connect_attempts_rejects_zero() {
176 let src = "max_connect_attempts = 0\n";
177 let result = toml::from_str::<McpConfig>(src);
178 assert!(
179 result.is_err(),
180 "max_connect_attempts = 0 should be rejected"
181 );
182 let msg = result.unwrap_err().to_string();
183 assert!(
184 msg.contains("max_connect_attempts"),
185 "error message should mention the field name, got: {msg}"
186 );
187 }
188
189 #[test]
190 fn max_connect_attempts_rejects_eleven() {
191 let src = "max_connect_attempts = 11\n";
192 let result = toml::from_str::<McpConfig>(src);
193 assert!(
194 result.is_err(),
195 "max_connect_attempts = 11 should be rejected"
196 );
197 }
198
199 #[test]
200 fn wildcard_star_allows_any_skill() {
201 let cfg = allow(&["*"]);
202 assert!(is_skill_allowed("anything", &cfg));
203 assert!(is_skill_allowed("web-search", &cfg));
204 }
205
206 #[test]
207 fn empty_allowlist_denies_all() {
208 let cfg = allow(&[]);
209 assert!(!is_skill_allowed("web-search", &cfg));
210 assert!(!is_skill_allowed("shell", &cfg));
211 }
212
213 #[test]
214 fn exact_match_allows_only_that_skill() {
215 let cfg = allow(&["web-search"]);
216 assert!(is_skill_allowed("web-search", &cfg));
217 assert!(!is_skill_allowed("shell", &cfg));
218 assert!(!is_skill_allowed("web-search-extra", &cfg));
219 }
220
221 #[test]
222 fn prefix_wildcard_allows_matching_skills() {
223 let cfg = allow(&["web-*"]);
224 assert!(is_skill_allowed("web-search", &cfg));
225 assert!(is_skill_allowed("web-fetch", &cfg));
226 assert!(!is_skill_allowed("shell", &cfg));
227 assert!(!is_skill_allowed("awesome-web-thing", &cfg));
228 }
229
230 #[test]
231 fn multiple_patterns_or_logic() {
232 let cfg = allow(&["shell", "web-*"]);
233 assert!(is_skill_allowed("shell", &cfg));
234 assert!(is_skill_allowed("web-search", &cfg));
235 assert!(!is_skill_allowed("memory", &cfg));
236 }
237
238 #[test]
239 fn default_config_allows_all() {
240 let cfg = ChannelSkillsConfig::default();
241 assert!(is_skill_allowed("any-skill", &cfg));
242 }
243
244 #[test]
245 fn prefix_wildcard_does_not_match_empty_suffix() {
246 let cfg = allow(&["web-*"]);
247 assert!(is_skill_allowed("web-", &cfg));
251 }
252
253 #[test]
254 fn matching_is_case_sensitive() {
255 let cfg = allow(&["Web-Search"]);
256 assert!(!is_skill_allowed("web-search", &cfg));
257 assert!(is_skill_allowed("Web-Search", &cfg));
258 }
259}
260
261fn default_slack_port() -> u16 {
262 3000
263}
264
265fn default_slack_webhook_host() -> String {
266 "127.0.0.1".into()
267}
268
269fn default_a2a_host() -> String {
270 "0.0.0.0".into()
271}
272
273fn default_a2a_port() -> u16 {
274 8080
275}
276
277fn default_a2a_rate_limit() -> u32 {
278 60
279}
280
281fn default_a2a_max_body() -> usize {
282 1_048_576
283}
284
285fn default_drain_timeout_ms() -> u64 {
286 30_000
287}
288
289fn default_max_dynamic_servers() -> usize {
290 10
291}
292
293fn default_mcp_timeout() -> u64 {
294 30
295}
296
297fn default_oauth_callback_port() -> u16 {
298 18766
299}
300
301fn default_oauth_client_name() -> String {
302 "Zeph".into()
303}
304
305fn default_stream_interval_ms() -> u64 {
306 3000
307}
308
309fn default_max_bot_chain_depth() -> u32 {
310 1
311}
312
313#[derive(Clone, Deserialize, Serialize)]
330pub struct TelegramConfig {
331 pub token: Option<String>,
333 #[serde(default)]
335 pub allowed_users: Vec<String>,
336 #[serde(default)]
338 pub skills: ChannelSkillsConfig,
339 #[serde(default = "default_stream_interval_ms")]
345 pub stream_interval_ms: u64,
346 #[serde(default)]
350 pub guest_mode: bool,
351 #[serde(default)]
355 pub bot_to_bot: bool,
356 #[serde(default)]
360 pub allowed_bots: Vec<String>,
361 #[serde(default = "default_max_bot_chain_depth")]
372 pub max_bot_chain_depth: u32,
373}
374
375impl std::fmt::Debug for TelegramConfig {
376 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
377 f.debug_struct("TelegramConfig")
378 .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
379 .field("allowed_users", &self.allowed_users)
380 .field("skills", &self.skills)
381 .field("stream_interval_ms", &self.stream_interval_ms)
382 .field("guest_mode", &self.guest_mode)
383 .field("bot_to_bot", &self.bot_to_bot)
384 .field("allowed_bots_count", &self.allowed_bots.len())
385 .field("max_bot_chain_depth", &self.max_bot_chain_depth)
386 .finish()
387 }
388}
389
390#[derive(Clone, Deserialize, Serialize)]
391pub struct DiscordConfig {
392 pub token: Option<String>,
393 pub application_id: Option<String>,
394 #[serde(default)]
395 pub allowed_user_ids: Vec<String>,
396 #[serde(default)]
397 pub allowed_role_ids: Vec<String>,
398 #[serde(default)]
399 pub allowed_channel_ids: Vec<String>,
400 #[serde(default)]
401 pub skills: ChannelSkillsConfig,
402}
403
404impl std::fmt::Debug for DiscordConfig {
405 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406 f.debug_struct("DiscordConfig")
407 .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
408 .field("application_id", &self.application_id)
409 .field("allowed_user_ids", &self.allowed_user_ids)
410 .field("allowed_role_ids", &self.allowed_role_ids)
411 .field("allowed_channel_ids", &self.allowed_channel_ids)
412 .field("skills", &self.skills)
413 .finish()
414 }
415}
416
417#[derive(Clone, Deserialize, Serialize)]
418pub struct SlackConfig {
419 pub bot_token: Option<String>,
420 pub signing_secret: Option<String>,
421 #[serde(default = "default_slack_webhook_host")]
422 pub webhook_host: String,
423 #[serde(default = "default_slack_port")]
424 pub port: u16,
425 #[serde(default)]
426 pub allowed_user_ids: Vec<String>,
427 #[serde(default)]
428 pub allowed_channel_ids: Vec<String>,
429 #[serde(default)]
430 pub skills: ChannelSkillsConfig,
431}
432
433impl std::fmt::Debug for SlackConfig {
434 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435 f.debug_struct("SlackConfig")
436 .field("bot_token", &self.bot_token.as_ref().map(|_| "[REDACTED]"))
437 .field(
438 "signing_secret",
439 &self.signing_secret.as_ref().map(|_| "[REDACTED]"), )
441 .field("webhook_host", &self.webhook_host)
442 .field("port", &self.port)
443 .field("allowed_user_ids", &self.allowed_user_ids)
444 .field("allowed_channel_ids", &self.allowed_channel_ids)
445 .field("skills", &self.skills)
446 .finish()
447 }
448}
449
450#[derive(Debug, Clone, Deserialize, Serialize)]
454pub struct IbctKeyConfig {
455 pub key_id: String,
457 pub key_hex: String,
459}
460
461fn default_ibct_ttl() -> u64 {
462 300
463}
464
465#[derive(Deserialize, Serialize)]
471#[allow(clippy::struct_excessive_bools)] pub struct A2aServerConfig {
473 #[serde(default)]
474 pub enabled: bool,
475 #[serde(default = "default_a2a_host")]
476 pub host: String,
477 #[serde(default = "default_a2a_port")]
478 pub port: u16,
479 #[serde(default)]
480 pub public_url: String,
481 #[serde(default)]
482 pub auth_token: Option<String>,
483 #[serde(default = "default_a2a_rate_limit")]
484 pub rate_limit: u32,
485 #[serde(default = "default_true")]
486 pub require_tls: bool,
487 #[serde(default = "default_true")]
488 pub ssrf_protection: bool,
489 #[serde(default = "default_a2a_max_body")]
490 pub max_body_size: usize,
491 #[serde(default = "default_drain_timeout_ms")]
492 pub drain_timeout_ms: u64,
493 #[serde(default)]
497 pub require_auth: bool,
498 #[serde(default)]
503 pub ibct_keys: Vec<IbctKeyConfig>,
504 #[serde(default)]
510 pub ibct_signing_key_vault_ref: Option<String>,
511 #[serde(default = "default_ibct_ttl")]
513 pub ibct_ttl_secs: u64,
514 #[serde(default)]
528 pub advertise_files: bool,
529}
530
531impl std::fmt::Debug for A2aServerConfig {
532 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
533 f.debug_struct("A2aServerConfig")
534 .field("enabled", &self.enabled)
535 .field("host", &self.host)
536 .field("port", &self.port)
537 .field("public_url", &self.public_url)
538 .field(
539 "auth_token",
540 &self.auth_token.as_ref().map(|_| "[REDACTED]"),
541 )
542 .field("rate_limit", &self.rate_limit)
543 .field("require_tls", &self.require_tls)
544 .field("ssrf_protection", &self.ssrf_protection)
545 .field("max_body_size", &self.max_body_size)
546 .field("drain_timeout_ms", &self.drain_timeout_ms)
547 .field("require_auth", &self.require_auth)
548 .field("ibct_keys_count", &self.ibct_keys.len())
549 .field(
550 "ibct_signing_key_vault_ref",
551 &self.ibct_signing_key_vault_ref,
552 )
553 .field("ibct_ttl_secs", &self.ibct_ttl_secs)
554 .field("advertise_files", &self.advertise_files)
555 .finish()
556 }
557}
558
559impl Default for A2aServerConfig {
560 fn default() -> Self {
561 Self {
562 enabled: false,
563 host: default_a2a_host(),
564 port: default_a2a_port(),
565 public_url: String::new(),
566 auth_token: None,
567 rate_limit: default_a2a_rate_limit(),
568 require_tls: true,
569 ssrf_protection: true,
570 max_body_size: default_a2a_max_body(),
571 drain_timeout_ms: default_drain_timeout_ms(),
572 require_auth: false,
573 ibct_keys: Vec::new(),
574 ibct_signing_key_vault_ref: None,
575 ibct_ttl_secs: default_ibct_ttl(),
576 advertise_files: false,
577 }
578 }
579}
580
581#[derive(Debug, Clone, Deserialize, Serialize)]
587#[serde(default)]
588pub struct ToolPruningConfig {
589 pub enabled: bool,
591 pub max_tools: usize,
593 pub pruning_provider: ProviderName,
596 pub min_tools_to_prune: usize,
598 pub always_include: Vec<String>,
600}
601
602impl Default for ToolPruningConfig {
603 fn default() -> Self {
604 Self {
605 enabled: false,
606 max_tools: 15,
607 pruning_provider: ProviderName::default(),
608 min_tools_to_prune: 10,
609 always_include: Vec::new(),
610 }
611 }
612}
613
614#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
619#[serde(rename_all = "lowercase")]
620pub enum ToolDiscoveryStrategyConfig {
621 Embedding,
623 Llm,
625 #[default]
627 None,
628}
629
630#[derive(Debug, Clone, Deserialize, Serialize)]
636#[serde(default)]
637pub struct ToolDiscoveryConfig {
638 pub strategy: ToolDiscoveryStrategyConfig,
640 pub top_k: usize,
642 pub min_similarity: f32,
644 pub embedding_provider: ProviderName,
648 pub always_include: Vec<String>,
650 pub min_tools_to_filter: usize,
652 pub strict: bool,
655}
656
657impl Default for ToolDiscoveryConfig {
658 fn default() -> Self {
659 Self {
660 strategy: ToolDiscoveryStrategyConfig::None,
661 top_k: 10,
662 min_similarity: 0.2,
663 embedding_provider: ProviderName::default(),
664 always_include: Vec::new(),
665 min_tools_to_filter: 10,
666 strict: false,
667 }
668 }
669}
670
671#[derive(Debug, Clone, Deserialize, Serialize)]
673#[allow(clippy::struct_excessive_bools)] pub struct TrustCalibrationConfig {
675 #[serde(default)]
677 pub enabled: bool,
678 #[serde(default = "default_true")]
680 pub probe_on_connect: bool,
681 #[serde(default = "default_true")]
683 pub monitor_invocations: bool,
684 #[serde(default = "default_true")]
686 pub persist_scores: bool,
687 #[serde(default = "default_decay_rate")]
689 pub decay_rate_per_day: f64,
690 #[serde(default = "default_injection_penalty")]
692 pub injection_penalty: f64,
693 #[serde(default)]
695 pub verifier_provider: ProviderName,
696}
697
698fn default_decay_rate() -> f64 {
699 0.01
700}
701
702fn default_injection_penalty() -> f64 {
703 0.25
704}
705
706impl Default for TrustCalibrationConfig {
707 fn default() -> Self {
708 Self {
709 enabled: false,
710 probe_on_connect: true,
711 monitor_invocations: true,
712 persist_scores: true,
713 decay_rate_per_day: default_decay_rate(),
714 injection_penalty: default_injection_penalty(),
715 verifier_provider: ProviderName::default(),
716 }
717 }
718}
719
720fn default_max_description_bytes() -> usize {
721 2048
722}
723
724fn default_max_instructions_bytes() -> usize {
725 2048
726}
727
728fn default_elicitation_timeout() -> u64 {
729 120
730}
731
732fn default_elicitation_queue_capacity() -> usize {
733 16
734}
735
736fn default_output_schema_hint_bytes() -> usize {
737 1024
738}
739
740fn default_max_connect_attempts() -> u8 {
741 3
742}
743
744fn validate_max_connect_attempts<'de, D>(d: D) -> Result<u8, D::Error>
745where
746 D: serde::Deserializer<'de>,
747{
748 let v = u8::deserialize(d)?;
749 if !(1..=10).contains(&v) {
750 return Err(serde::de::Error::custom(format!(
751 "mcp.max_connect_attempts must be in 1..=10 (got {v})"
752 )));
753 }
754 Ok(v)
755}
756
757#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Deserialize, Serialize)]
759pub struct McpConfig {
760 #[serde(default)]
761 pub servers: Vec<McpServerConfig>,
762 #[serde(default)]
763 pub allowed_commands: Vec<String>,
764 #[serde(default = "default_max_dynamic_servers")]
765 pub max_dynamic_servers: usize,
766 #[serde(default)]
768 pub pruning: ToolPruningConfig,
769 #[serde(default)]
771 pub trust_calibration: TrustCalibrationConfig,
772 #[serde(default)]
774 pub tool_discovery: ToolDiscoveryConfig,
775 #[serde(default = "default_max_description_bytes")]
777 pub max_description_bytes: usize,
778 #[serde(default = "default_max_instructions_bytes")]
780 pub max_instructions_bytes: usize,
781 #[serde(default)]
785 pub elicitation_enabled: bool,
786 #[serde(default = "default_elicitation_timeout")]
788 pub elicitation_timeout: u64,
789 #[serde(default = "default_elicitation_queue_capacity")]
793 pub elicitation_queue_capacity: usize,
794 #[serde(default = "default_true")]
797 pub elicitation_warn_sensitive_fields: bool,
798 #[serde(
810 default = "default_max_connect_attempts",
811 deserialize_with = "validate_max_connect_attempts"
812 )]
813 pub max_connect_attempts: u8,
814 #[serde(default)]
820 pub lock_tool_list: bool,
821 #[serde(default)]
826 pub default_env_isolation: bool,
827 #[serde(default)]
835 pub forward_output_schema: bool,
836 #[serde(default = "default_output_schema_hint_bytes")]
842 pub output_schema_hint_bytes: usize,
843}
844
845impl Default for McpConfig {
846 fn default() -> Self {
847 Self {
848 servers: Vec::new(),
849 allowed_commands: Vec::new(),
850 max_dynamic_servers: default_max_dynamic_servers(),
851 pruning: ToolPruningConfig::default(),
852 trust_calibration: TrustCalibrationConfig::default(),
853 tool_discovery: ToolDiscoveryConfig::default(),
854 max_description_bytes: default_max_description_bytes(),
855 max_instructions_bytes: default_max_instructions_bytes(),
856 elicitation_enabled: false,
857 elicitation_timeout: default_elicitation_timeout(),
858 elicitation_queue_capacity: default_elicitation_queue_capacity(),
859 elicitation_warn_sensitive_fields: true,
860 lock_tool_list: false,
861 default_env_isolation: false,
862 forward_output_schema: false,
863 output_schema_hint_bytes: default_output_schema_hint_bytes(),
864 max_connect_attempts: default_max_connect_attempts(),
865 }
866 }
867}
868
869#[derive(Clone, Deserialize, Serialize)]
870pub struct McpServerConfig {
871 pub id: String,
872 pub command: Option<String>,
874 #[serde(default)]
875 pub args: Vec<String>,
876 #[serde(default)]
877 pub env: HashMap<String, String>,
878 pub url: Option<String>,
880 #[serde(default = "default_mcp_timeout")]
881 pub timeout: u64,
882 #[serde(default)]
884 pub policy: McpPolicy,
885 #[serde(default)]
888 pub headers: HashMap<String, String>,
889 #[serde(default)]
891 pub oauth: Option<McpOAuthConfig>,
892 #[serde(default)]
894 pub trust_level: McpTrustLevel,
895 #[serde(default)]
899 pub tool_allowlist: Option<Vec<String>>,
900 #[serde(default)]
906 pub expected_tools: Vec<String>,
907 #[serde(default)]
911 pub roots: Vec<McpRootEntry>,
912 #[serde(default)]
915 pub tool_metadata: HashMap<String, ToolSecurityMeta>,
916 #[serde(default)]
920 pub elicitation_enabled: Option<bool>,
921 #[serde(default)]
928 pub env_isolation: Option<bool>,
929}
930
931#[derive(Debug, Clone, Deserialize, Serialize)]
933pub struct McpRootEntry {
934 pub uri: String,
936 #[serde(default)]
938 pub name: Option<String>,
939}
940
941#[derive(Debug, Clone, Deserialize, Serialize)]
943pub struct McpOAuthConfig {
944 #[serde(default)]
946 pub enabled: bool,
947 #[serde(default)]
949 pub token_storage: OAuthTokenStorage,
950 #[serde(default)]
952 pub scopes: Vec<String>,
953 #[serde(default = "default_oauth_callback_port")]
955 pub callback_port: u16,
956 #[serde(default = "default_oauth_client_name")]
958 pub client_name: String,
959}
960
961impl Default for McpOAuthConfig {
962 fn default() -> Self {
963 Self {
964 enabled: false,
965 token_storage: OAuthTokenStorage::default(),
966 scopes: Vec::new(),
967 callback_port: default_oauth_callback_port(),
968 client_name: default_oauth_client_name(),
969 }
970 }
971}
972
973#[derive(Debug, Clone, Default, Deserialize, Serialize)]
975#[serde(rename_all = "lowercase")]
976pub enum OAuthTokenStorage {
977 #[default]
979 Vault,
980 Memory,
982}
983
984impl std::fmt::Debug for McpServerConfig {
985 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
986 let redacted_env: HashMap<&str, &str> = self
987 .env
988 .keys()
989 .map(|k| (k.as_str(), "[REDACTED]"))
990 .collect();
991 let redacted_headers: HashMap<&str, &str> = self
993 .headers
994 .keys()
995 .map(|k| (k.as_str(), "[REDACTED]"))
996 .collect();
997 f.debug_struct("McpServerConfig")
998 .field("id", &self.id)
999 .field("command", &self.command)
1000 .field("args", &self.args)
1001 .field("env", &redacted_env)
1002 .field("url", &self.url)
1003 .field("timeout", &self.timeout)
1004 .field("policy", &self.policy)
1005 .field("headers", &redacted_headers)
1006 .field("oauth", &self.oauth)
1007 .field("trust_level", &self.trust_level)
1008 .field("tool_allowlist", &self.tool_allowlist)
1009 .field("expected_tools", &self.expected_tools)
1010 .field("roots", &self.roots)
1011 .field(
1012 "tool_metadata_keys",
1013 &self.tool_metadata.keys().collect::<Vec<_>>(),
1014 )
1015 .field("elicitation_enabled", &self.elicitation_enabled)
1016 .field("env_isolation", &self.env_isolation)
1017 .finish()
1018 }
1019}