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 test_default_output_schema_hint_bytes_is_1024() {
122 assert_eq!(default_output_schema_hint_bytes(), 1024);
123 }
124
125 #[test]
126 fn test_mcp_config_default_output_schema_hint_bytes_is_1024() {
127 let cfg = McpConfig::default();
128 assert_eq!(cfg.output_schema_hint_bytes, 1024);
129 }
130
131 #[test]
132 fn max_connect_attempts_default_is_3() {
133 let cfg = McpConfig::default();
134 assert_eq!(cfg.max_connect_attempts, 3);
135 }
136
137 #[test]
138 fn max_connect_attempts_accepts_valid_range() {
139 for v in [1u8, 3, 10] {
140 let src = format!("max_connect_attempts = {v}\n");
141 let cfg: McpConfig = toml::from_str(&src)
142 .unwrap_or_else(|e| panic!("max_connect_attempts = {v} should be valid, got: {e}"));
143 assert_eq!(cfg.max_connect_attempts, v);
144 }
145 }
146
147 #[test]
148 fn max_connect_attempts_rejects_zero() {
149 let src = "max_connect_attempts = 0\n";
150 let result = toml::from_str::<McpConfig>(src);
151 assert!(
152 result.is_err(),
153 "max_connect_attempts = 0 should be rejected"
154 );
155 let msg = result.unwrap_err().to_string();
156 assert!(
157 msg.contains("max_connect_attempts"),
158 "error message should mention the field name, got: {msg}"
159 );
160 }
161
162 #[test]
163 fn max_connect_attempts_rejects_eleven() {
164 let src = "max_connect_attempts = 11\n";
165 let result = toml::from_str::<McpConfig>(src);
166 assert!(
167 result.is_err(),
168 "max_connect_attempts = 11 should be rejected"
169 );
170 }
171
172 #[test]
173 fn wildcard_star_allows_any_skill() {
174 let cfg = allow(&["*"]);
175 assert!(is_skill_allowed("anything", &cfg));
176 assert!(is_skill_allowed("web-search", &cfg));
177 }
178
179 #[test]
180 fn empty_allowlist_denies_all() {
181 let cfg = allow(&[]);
182 assert!(!is_skill_allowed("web-search", &cfg));
183 assert!(!is_skill_allowed("shell", &cfg));
184 }
185
186 #[test]
187 fn exact_match_allows_only_that_skill() {
188 let cfg = allow(&["web-search"]);
189 assert!(is_skill_allowed("web-search", &cfg));
190 assert!(!is_skill_allowed("shell", &cfg));
191 assert!(!is_skill_allowed("web-search-extra", &cfg));
192 }
193
194 #[test]
195 fn prefix_wildcard_allows_matching_skills() {
196 let cfg = allow(&["web-*"]);
197 assert!(is_skill_allowed("web-search", &cfg));
198 assert!(is_skill_allowed("web-fetch", &cfg));
199 assert!(!is_skill_allowed("shell", &cfg));
200 assert!(!is_skill_allowed("awesome-web-thing", &cfg));
201 }
202
203 #[test]
204 fn multiple_patterns_or_logic() {
205 let cfg = allow(&["shell", "web-*"]);
206 assert!(is_skill_allowed("shell", &cfg));
207 assert!(is_skill_allowed("web-search", &cfg));
208 assert!(!is_skill_allowed("memory", &cfg));
209 }
210
211 #[test]
212 fn default_config_allows_all() {
213 let cfg = ChannelSkillsConfig::default();
214 assert!(is_skill_allowed("any-skill", &cfg));
215 }
216
217 #[test]
218 fn prefix_wildcard_does_not_match_empty_suffix() {
219 let cfg = allow(&["web-*"]);
220 assert!(is_skill_allowed("web-", &cfg));
224 }
225
226 #[test]
227 fn matching_is_case_sensitive() {
228 let cfg = allow(&["Web-Search"]);
229 assert!(!is_skill_allowed("web-search", &cfg));
230 assert!(is_skill_allowed("Web-Search", &cfg));
231 }
232}
233
234fn default_slack_port() -> u16 {
235 3000
236}
237
238fn default_slack_webhook_host() -> String {
239 "127.0.0.1".into()
240}
241
242fn default_a2a_host() -> String {
243 "0.0.0.0".into()
244}
245
246fn default_a2a_port() -> u16 {
247 8080
248}
249
250fn default_a2a_rate_limit() -> u32 {
251 60
252}
253
254fn default_a2a_max_body() -> usize {
255 1_048_576
256}
257
258fn default_drain_timeout_ms() -> u64 {
259 30_000
260}
261
262fn default_max_dynamic_servers() -> usize {
263 10
264}
265
266fn default_mcp_timeout() -> u64 {
267 30
268}
269
270fn default_oauth_callback_port() -> u16 {
271 18766
272}
273
274fn default_oauth_client_name() -> String {
275 "Zeph".into()
276}
277
278#[derive(Clone, Deserialize, Serialize)]
290pub struct TelegramConfig {
291 pub token: Option<String>,
293 #[serde(default)]
295 pub allowed_users: Vec<String>,
296 #[serde(default)]
298 pub skills: ChannelSkillsConfig,
299}
300
301impl std::fmt::Debug for TelegramConfig {
302 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303 f.debug_struct("TelegramConfig")
304 .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
305 .field("allowed_users", &self.allowed_users)
306 .field("skills", &self.skills)
307 .finish()
308 }
309}
310
311#[derive(Clone, Deserialize, Serialize)]
312pub struct DiscordConfig {
313 pub token: Option<String>,
314 pub application_id: Option<String>,
315 #[serde(default)]
316 pub allowed_user_ids: Vec<String>,
317 #[serde(default)]
318 pub allowed_role_ids: Vec<String>,
319 #[serde(default)]
320 pub allowed_channel_ids: Vec<String>,
321 #[serde(default)]
322 pub skills: ChannelSkillsConfig,
323}
324
325impl std::fmt::Debug for DiscordConfig {
326 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327 f.debug_struct("DiscordConfig")
328 .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
329 .field("application_id", &self.application_id)
330 .field("allowed_user_ids", &self.allowed_user_ids)
331 .field("allowed_role_ids", &self.allowed_role_ids)
332 .field("allowed_channel_ids", &self.allowed_channel_ids)
333 .field("skills", &self.skills)
334 .finish()
335 }
336}
337
338#[derive(Clone, Deserialize, Serialize)]
339pub struct SlackConfig {
340 pub bot_token: Option<String>,
341 pub signing_secret: Option<String>,
342 #[serde(default = "default_slack_webhook_host")]
343 pub webhook_host: String,
344 #[serde(default = "default_slack_port")]
345 pub port: u16,
346 #[serde(default)]
347 pub allowed_user_ids: Vec<String>,
348 #[serde(default)]
349 pub allowed_channel_ids: Vec<String>,
350 #[serde(default)]
351 pub skills: ChannelSkillsConfig,
352}
353
354impl std::fmt::Debug for SlackConfig {
355 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356 f.debug_struct("SlackConfig")
357 .field("bot_token", &self.bot_token.as_ref().map(|_| "[REDACTED]"))
358 .field(
359 "signing_secret",
360 &self.signing_secret.as_ref().map(|_| "[REDACTED]"), )
362 .field("webhook_host", &self.webhook_host)
363 .field("port", &self.port)
364 .field("allowed_user_ids", &self.allowed_user_ids)
365 .field("allowed_channel_ids", &self.allowed_channel_ids)
366 .field("skills", &self.skills)
367 .finish()
368 }
369}
370
371#[derive(Debug, Clone, Deserialize, Serialize)]
375pub struct IbctKeyConfig {
376 pub key_id: String,
378 pub key_hex: String,
380}
381
382fn default_ibct_ttl() -> u64 {
383 300
384}
385
386#[derive(Deserialize, Serialize)]
392#[allow(clippy::struct_excessive_bools)] pub struct A2aServerConfig {
394 #[serde(default)]
395 pub enabled: bool,
396 #[serde(default = "default_a2a_host")]
397 pub host: String,
398 #[serde(default = "default_a2a_port")]
399 pub port: u16,
400 #[serde(default)]
401 pub public_url: String,
402 #[serde(default)]
403 pub auth_token: Option<String>,
404 #[serde(default = "default_a2a_rate_limit")]
405 pub rate_limit: u32,
406 #[serde(default = "default_true")]
407 pub require_tls: bool,
408 #[serde(default = "default_true")]
409 pub ssrf_protection: bool,
410 #[serde(default = "default_a2a_max_body")]
411 pub max_body_size: usize,
412 #[serde(default = "default_drain_timeout_ms")]
413 pub drain_timeout_ms: u64,
414 #[serde(default)]
418 pub require_auth: bool,
419 #[serde(default)]
424 pub ibct_keys: Vec<IbctKeyConfig>,
425 #[serde(default)]
431 pub ibct_signing_key_vault_ref: Option<String>,
432 #[serde(default = "default_ibct_ttl")]
434 pub ibct_ttl_secs: u64,
435 #[serde(default)]
449 pub advertise_files: bool,
450}
451
452impl std::fmt::Debug for A2aServerConfig {
453 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454 f.debug_struct("A2aServerConfig")
455 .field("enabled", &self.enabled)
456 .field("host", &self.host)
457 .field("port", &self.port)
458 .field("public_url", &self.public_url)
459 .field(
460 "auth_token",
461 &self.auth_token.as_ref().map(|_| "[REDACTED]"),
462 )
463 .field("rate_limit", &self.rate_limit)
464 .field("require_tls", &self.require_tls)
465 .field("ssrf_protection", &self.ssrf_protection)
466 .field("max_body_size", &self.max_body_size)
467 .field("drain_timeout_ms", &self.drain_timeout_ms)
468 .field("require_auth", &self.require_auth)
469 .field("ibct_keys_count", &self.ibct_keys.len())
470 .field(
471 "ibct_signing_key_vault_ref",
472 &self.ibct_signing_key_vault_ref,
473 )
474 .field("ibct_ttl_secs", &self.ibct_ttl_secs)
475 .field("advertise_files", &self.advertise_files)
476 .finish()
477 }
478}
479
480impl Default for A2aServerConfig {
481 fn default() -> Self {
482 Self {
483 enabled: false,
484 host: default_a2a_host(),
485 port: default_a2a_port(),
486 public_url: String::new(),
487 auth_token: None,
488 rate_limit: default_a2a_rate_limit(),
489 require_tls: true,
490 ssrf_protection: true,
491 max_body_size: default_a2a_max_body(),
492 drain_timeout_ms: default_drain_timeout_ms(),
493 require_auth: false,
494 ibct_keys: Vec::new(),
495 ibct_signing_key_vault_ref: None,
496 ibct_ttl_secs: default_ibct_ttl(),
497 advertise_files: false,
498 }
499 }
500}
501
502#[derive(Debug, Clone, Deserialize, Serialize)]
508#[serde(default)]
509pub struct ToolPruningConfig {
510 pub enabled: bool,
512 pub max_tools: usize,
514 pub pruning_provider: ProviderName,
517 pub min_tools_to_prune: usize,
519 pub always_include: Vec<String>,
521}
522
523impl Default for ToolPruningConfig {
524 fn default() -> Self {
525 Self {
526 enabled: false,
527 max_tools: 15,
528 pruning_provider: ProviderName::default(),
529 min_tools_to_prune: 10,
530 always_include: Vec::new(),
531 }
532 }
533}
534
535#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
540#[serde(rename_all = "lowercase")]
541pub enum ToolDiscoveryStrategyConfig {
542 Embedding,
544 Llm,
546 #[default]
548 None,
549}
550
551#[derive(Debug, Clone, Deserialize, Serialize)]
557#[serde(default)]
558pub struct ToolDiscoveryConfig {
559 pub strategy: ToolDiscoveryStrategyConfig,
561 pub top_k: usize,
563 pub min_similarity: f32,
565 pub embedding_provider: ProviderName,
569 pub always_include: Vec<String>,
571 pub min_tools_to_filter: usize,
573 pub strict: bool,
576}
577
578impl Default for ToolDiscoveryConfig {
579 fn default() -> Self {
580 Self {
581 strategy: ToolDiscoveryStrategyConfig::None,
582 top_k: 10,
583 min_similarity: 0.2,
584 embedding_provider: ProviderName::default(),
585 always_include: Vec::new(),
586 min_tools_to_filter: 10,
587 strict: false,
588 }
589 }
590}
591
592#[derive(Debug, Clone, Deserialize, Serialize)]
594#[allow(clippy::struct_excessive_bools)] pub struct TrustCalibrationConfig {
596 #[serde(default)]
598 pub enabled: bool,
599 #[serde(default = "default_true")]
601 pub probe_on_connect: bool,
602 #[serde(default = "default_true")]
604 pub monitor_invocations: bool,
605 #[serde(default = "default_true")]
607 pub persist_scores: bool,
608 #[serde(default = "default_decay_rate")]
610 pub decay_rate_per_day: f64,
611 #[serde(default = "default_injection_penalty")]
613 pub injection_penalty: f64,
614 #[serde(default)]
616 pub verifier_provider: ProviderName,
617}
618
619fn default_decay_rate() -> f64 {
620 0.01
621}
622
623fn default_injection_penalty() -> f64 {
624 0.25
625}
626
627impl Default for TrustCalibrationConfig {
628 fn default() -> Self {
629 Self {
630 enabled: false,
631 probe_on_connect: true,
632 monitor_invocations: true,
633 persist_scores: true,
634 decay_rate_per_day: default_decay_rate(),
635 injection_penalty: default_injection_penalty(),
636 verifier_provider: ProviderName::default(),
637 }
638 }
639}
640
641fn default_max_description_bytes() -> usize {
642 2048
643}
644
645fn default_max_instructions_bytes() -> usize {
646 2048
647}
648
649fn default_elicitation_timeout() -> u64 {
650 120
651}
652
653fn default_elicitation_queue_capacity() -> usize {
654 16
655}
656
657fn default_output_schema_hint_bytes() -> usize {
658 1024
659}
660
661fn default_max_connect_attempts() -> u8 {
662 3
663}
664
665fn validate_max_connect_attempts<'de, D>(d: D) -> Result<u8, D::Error>
666where
667 D: serde::Deserializer<'de>,
668{
669 let v = u8::deserialize(d)?;
670 if !(1..=10).contains(&v) {
671 return Err(serde::de::Error::custom(format!(
672 "mcp.max_connect_attempts must be in 1..=10 (got {v})"
673 )));
674 }
675 Ok(v)
676}
677
678#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone, Deserialize, Serialize)]
680pub struct McpConfig {
681 #[serde(default)]
682 pub servers: Vec<McpServerConfig>,
683 #[serde(default)]
684 pub allowed_commands: Vec<String>,
685 #[serde(default = "default_max_dynamic_servers")]
686 pub max_dynamic_servers: usize,
687 #[serde(default)]
689 pub pruning: ToolPruningConfig,
690 #[serde(default)]
692 pub trust_calibration: TrustCalibrationConfig,
693 #[serde(default)]
695 pub tool_discovery: ToolDiscoveryConfig,
696 #[serde(default = "default_max_description_bytes")]
698 pub max_description_bytes: usize,
699 #[serde(default = "default_max_instructions_bytes")]
701 pub max_instructions_bytes: usize,
702 #[serde(default)]
706 pub elicitation_enabled: bool,
707 #[serde(default = "default_elicitation_timeout")]
709 pub elicitation_timeout: u64,
710 #[serde(default = "default_elicitation_queue_capacity")]
714 pub elicitation_queue_capacity: usize,
715 #[serde(default = "default_true")]
718 pub elicitation_warn_sensitive_fields: bool,
719 #[serde(
731 default = "default_max_connect_attempts",
732 deserialize_with = "validate_max_connect_attempts"
733 )]
734 pub max_connect_attempts: u8,
735 #[serde(default)]
741 pub lock_tool_list: bool,
742 #[serde(default)]
747 pub default_env_isolation: bool,
748 #[serde(default)]
756 pub forward_output_schema: bool,
757 #[serde(default = "default_output_schema_hint_bytes")]
763 pub output_schema_hint_bytes: usize,
764}
765
766impl Default for McpConfig {
767 fn default() -> Self {
768 Self {
769 servers: Vec::new(),
770 allowed_commands: Vec::new(),
771 max_dynamic_servers: default_max_dynamic_servers(),
772 pruning: ToolPruningConfig::default(),
773 trust_calibration: TrustCalibrationConfig::default(),
774 tool_discovery: ToolDiscoveryConfig::default(),
775 max_description_bytes: default_max_description_bytes(),
776 max_instructions_bytes: default_max_instructions_bytes(),
777 elicitation_enabled: false,
778 elicitation_timeout: default_elicitation_timeout(),
779 elicitation_queue_capacity: default_elicitation_queue_capacity(),
780 elicitation_warn_sensitive_fields: true,
781 lock_tool_list: false,
782 default_env_isolation: false,
783 forward_output_schema: false,
784 output_schema_hint_bytes: default_output_schema_hint_bytes(),
785 max_connect_attempts: default_max_connect_attempts(),
786 }
787 }
788}
789
790#[derive(Clone, Deserialize, Serialize)]
791pub struct McpServerConfig {
792 pub id: String,
793 pub command: Option<String>,
795 #[serde(default)]
796 pub args: Vec<String>,
797 #[serde(default)]
798 pub env: HashMap<String, String>,
799 pub url: Option<String>,
801 #[serde(default = "default_mcp_timeout")]
802 pub timeout: u64,
803 #[serde(default)]
805 pub policy: McpPolicy,
806 #[serde(default)]
809 pub headers: HashMap<String, String>,
810 #[serde(default)]
812 pub oauth: Option<McpOAuthConfig>,
813 #[serde(default)]
815 pub trust_level: McpTrustLevel,
816 #[serde(default)]
820 pub tool_allowlist: Option<Vec<String>>,
821 #[serde(default)]
827 pub expected_tools: Vec<String>,
828 #[serde(default)]
832 pub roots: Vec<McpRootEntry>,
833 #[serde(default)]
836 pub tool_metadata: HashMap<String, ToolSecurityMeta>,
837 #[serde(default)]
841 pub elicitation_enabled: Option<bool>,
842 #[serde(default)]
849 pub env_isolation: Option<bool>,
850}
851
852#[derive(Debug, Clone, Deserialize, Serialize)]
854pub struct McpRootEntry {
855 pub uri: String,
857 #[serde(default)]
859 pub name: Option<String>,
860}
861
862#[derive(Debug, Clone, Deserialize, Serialize)]
864pub struct McpOAuthConfig {
865 #[serde(default)]
867 pub enabled: bool,
868 #[serde(default)]
870 pub token_storage: OAuthTokenStorage,
871 #[serde(default)]
873 pub scopes: Vec<String>,
874 #[serde(default = "default_oauth_callback_port")]
876 pub callback_port: u16,
877 #[serde(default = "default_oauth_client_name")]
879 pub client_name: String,
880}
881
882impl Default for McpOAuthConfig {
883 fn default() -> Self {
884 Self {
885 enabled: false,
886 token_storage: OAuthTokenStorage::default(),
887 scopes: Vec::new(),
888 callback_port: default_oauth_callback_port(),
889 client_name: default_oauth_client_name(),
890 }
891 }
892}
893
894#[derive(Debug, Clone, Default, Deserialize, Serialize)]
896#[serde(rename_all = "lowercase")]
897pub enum OAuthTokenStorage {
898 #[default]
900 Vault,
901 Memory,
903}
904
905impl std::fmt::Debug for McpServerConfig {
906 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
907 let redacted_env: HashMap<&str, &str> = self
908 .env
909 .keys()
910 .map(|k| (k.as_str(), "[REDACTED]"))
911 .collect();
912 let redacted_headers: HashMap<&str, &str> = self
914 .headers
915 .keys()
916 .map(|k| (k.as_str(), "[REDACTED]"))
917 .collect();
918 f.debug_struct("McpServerConfig")
919 .field("id", &self.id)
920 .field("command", &self.command)
921 .field("args", &self.args)
922 .field("env", &redacted_env)
923 .field("url", &self.url)
924 .field("timeout", &self.timeout)
925 .field("policy", &self.policy)
926 .field("headers", &redacted_headers)
927 .field("oauth", &self.oauth)
928 .field("trust_level", &self.trust_level)
929 .field("tool_allowlist", &self.tool_allowlist)
930 .field("expected_tools", &self.expected_tools)
931 .field("roots", &self.roots)
932 .field(
933 "tool_metadata_keys",
934 &self.tool_metadata.keys().collect::<Vec<_>>(),
935 )
936 .field("elicitation_enabled", &self.elicitation_enabled)
937 .field("env_isolation", &self.env_isolation)
938 .finish()
939 }
940}