Skip to main content

zeph_config/
channels.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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// ── MCP trust and policy types (moved from zeph-mcp) ─────────────────────────
14
15/// Trust level for an MCP server connection.
16///
17/// Controls SSRF validation, tool filtering, and data-flow policy enforcement.
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20#[non_exhaustive]
21pub enum McpTrustLevel {
22    /// Full trust — all tools exposed, SSRF check skipped. Use for operator-controlled servers.
23    Trusted,
24    /// Default. SSRF enforced. Tools exposed with a warning when allowlist is empty.
25    #[default]
26    Untrusted,
27    /// Strict sandboxing — SSRF enforced. Only allowlisted tools exposed; empty allowlist = no tools.
28    Sandboxed,
29}
30
31impl McpTrustLevel {
32    /// Returns a numeric restriction level where higher means more restricted.
33    ///
34    /// Used for "only demote, never promote automatically" comparisons.
35    #[must_use]
36    pub fn restriction_level(self) -> u8 {
37        match self {
38            Self::Trusted => 0,
39            Self::Untrusted => 1,
40            Self::Sandboxed => 2,
41        }
42    }
43}
44
45/// Rate limit configuration for a single MCP server.
46#[derive(Debug, Clone, Deserialize, Serialize)]
47pub struct RateLimit {
48    /// Maximum number of tool calls allowed per minute across all tools on this server.
49    pub max_calls_per_minute: u32,
50}
51
52/// Per-server MCP policy.
53///
54/// No policy present = allow all (backward compatible default).
55#[derive(Debug, Clone, Default, Deserialize, Serialize)]
56#[serde(default)]
57pub struct McpPolicy {
58    /// Allowlist of tool names. `None` means all tools are allowed (subject to `denied_tools`).
59    pub allowed_tools: Option<Vec<String>>,
60    /// Denylist of tool names. Takes precedence over `allowed_tools`.
61    pub denied_tools: Vec<String>,
62    /// Optional rate limit for this server.
63    pub rate_limit: Option<RateLimit>,
64}
65
66fn default_skill_allowlist() -> Vec<String> {
67    vec!["*".into()]
68}
69
70/// Per-channel skill allowlist configuration.
71///
72/// Declares which skills are permitted on a given channel. The config is parsed and
73/// `is_skill_allowed()` is available for callers to check membership. Runtime enforcement
74/// (filtering skills before prompt assembly) is tracked in issue #2507 and not yet wired.
75#[derive(Debug, Clone, Deserialize, Serialize)]
76pub struct ChannelSkillsConfig {
77    /// Skill allowlist. `["*"]` = all skills allowed. `[]` = deny all.
78    /// Supports exact names and `*` wildcard (e.g. `"web-*"` matches `"web-search"`).
79    #[serde(default = "default_skill_allowlist")]
80    pub allowed: Vec<String>,
81}
82
83impl Default for ChannelSkillsConfig {
84    fn default() -> Self {
85        Self {
86            allowed: default_skill_allowlist(),
87        }
88    }
89}
90
91/// Returns `true` if the skill `name` matches any pattern in the allowlist.
92///
93/// Pattern rules: `"*"` matches any name; `"prefix-*"` matches names starting with `"prefix-"`;
94/// exact strings match only themselves. Matching is case-sensitive.
95#[must_use]
96pub fn is_skill_allowed(name: &str, config: &ChannelSkillsConfig) -> bool {
97    config.allowed.iter().any(|p| glob_match(p, name))
98}
99
100fn glob_match(pattern: &str, name: &str) -> bool {
101    if let Some(prefix) = pattern.strip_suffix('*') {
102        if prefix.is_empty() {
103            return true;
104        }
105        name.starts_with(prefix)
106    } else {
107        pattern == name
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    fn allow(patterns: &[&str]) -> ChannelSkillsConfig {
116        ChannelSkillsConfig {
117            allowed: patterns.iter().map(ToString::to_string).collect(),
118        }
119    }
120
121    #[test]
122    fn telegram_config_defaults() {
123        // When all new fields are absent, defaults must be applied.
124        let src = r#"token = "test_token""#;
125        let cfg: TelegramConfig = toml::from_str(src).unwrap();
126        assert!(!cfg.guest_mode);
127        assert!(!cfg.bot_to_bot);
128        assert!(cfg.allowed_bots.is_empty());
129        assert_eq!(cfg.max_bot_chain_depth, 1);
130    }
131
132    #[test]
133    fn telegram_config_explicit_values() {
134        let src = r#"
135token = "test_token"
136guest_mode = true
137bot_to_bot = true
138allowed_bots = ["@bot_a", "@bot_b"]
139max_bot_chain_depth = 5
140"#;
141        let cfg: TelegramConfig = toml::from_str(src).unwrap();
142        assert!(cfg.guest_mode);
143        assert!(cfg.bot_to_bot);
144        assert_eq!(cfg.allowed_bots, vec!["@bot_a", "@bot_b"]);
145        assert_eq!(cfg.max_bot_chain_depth, 5);
146    }
147
148    #[test]
149    fn test_default_output_schema_hint_bytes_is_1024() {
150        assert_eq!(default_output_schema_hint_bytes(), 1024);
151    }
152
153    #[test]
154    fn test_mcp_config_default_output_schema_hint_bytes_is_1024() {
155        let cfg = McpConfig::default();
156        assert_eq!(cfg.output_schema_hint_bytes, 1024);
157    }
158
159    #[test]
160    fn max_connect_attempts_default_is_3() {
161        let cfg = McpConfig::default();
162        assert_eq!(cfg.max_connect_attempts, 3);
163    }
164
165    #[test]
166    fn max_connect_attempts_accepts_valid_range() {
167        for v in [1u8, 3, 10] {
168            let src = format!("max_connect_attempts = {v}\n");
169            let cfg: McpConfig = toml::from_str(&src)
170                .unwrap_or_else(|e| panic!("max_connect_attempts = {v} should be valid, got: {e}"));
171            assert_eq!(cfg.max_connect_attempts, v);
172        }
173    }
174
175    #[test]
176    fn max_connect_attempts_rejects_zero() {
177        let src = "max_connect_attempts = 0\n";
178        let result = toml::from_str::<McpConfig>(src);
179        assert!(
180            result.is_err(),
181            "max_connect_attempts = 0 should be rejected"
182        );
183        let msg = result.unwrap_err().to_string();
184        assert!(
185            msg.contains("max_connect_attempts"),
186            "error message should mention the field name, got: {msg}"
187        );
188    }
189
190    #[test]
191    fn max_connect_attempts_rejects_eleven() {
192        let src = "max_connect_attempts = 11\n";
193        let result = toml::from_str::<McpConfig>(src);
194        assert!(
195            result.is_err(),
196            "max_connect_attempts = 11 should be rejected"
197        );
198    }
199
200    #[test]
201    fn startup_retry_backoff_ms_default_is_1000() {
202        let cfg = McpConfig::default();
203        assert_eq!(cfg.startup_retry_backoff_ms, 1000);
204    }
205
206    #[test]
207    fn startup_retry_backoff_ms_deserializes_from_toml() {
208        let src = "startup_retry_backoff_ms = 500\n";
209        let cfg: McpConfig = toml::from_str(src).expect("valid toml");
210        assert_eq!(cfg.startup_retry_backoff_ms, 500);
211    }
212
213    #[test]
214    fn tool_timeout_secs_default_is_none() {
215        let cfg = McpConfig::default();
216        assert!(cfg.tool_timeout_secs.is_none());
217    }
218
219    #[test]
220    fn tool_timeout_secs_deserializes_from_toml() {
221        let src = "tool_timeout_secs = 120\n";
222        let cfg: McpConfig = toml::from_str(src).expect("valid toml");
223        assert_eq!(cfg.tool_timeout_secs, Some(120));
224    }
225
226    #[test]
227    fn tool_timeout_secs_rejects_above_3600() {
228        let src = "tool_timeout_secs = 3601\n";
229        assert!(toml::from_str::<McpConfig>(src).is_err());
230    }
231
232    #[test]
233    fn tool_timeout_secs_accepts_3600() {
234        let src = "tool_timeout_secs = 3600\n";
235        let cfg: McpConfig = toml::from_str(src).expect("valid toml");
236        assert_eq!(cfg.tool_timeout_secs, Some(3600));
237    }
238
239    #[test]
240    fn wildcard_star_allows_any_skill() {
241        let cfg = allow(&["*"]);
242        assert!(is_skill_allowed("anything", &cfg));
243        assert!(is_skill_allowed("web-search", &cfg));
244    }
245
246    #[test]
247    fn empty_allowlist_denies_all() {
248        let cfg = allow(&[]);
249        assert!(!is_skill_allowed("web-search", &cfg));
250        assert!(!is_skill_allowed("shell", &cfg));
251    }
252
253    #[test]
254    fn exact_match_allows_only_that_skill() {
255        let cfg = allow(&["web-search"]);
256        assert!(is_skill_allowed("web-search", &cfg));
257        assert!(!is_skill_allowed("shell", &cfg));
258        assert!(!is_skill_allowed("web-search-extra", &cfg));
259    }
260
261    #[test]
262    fn prefix_wildcard_allows_matching_skills() {
263        let cfg = allow(&["web-*"]);
264        assert!(is_skill_allowed("web-search", &cfg));
265        assert!(is_skill_allowed("web-fetch", &cfg));
266        assert!(!is_skill_allowed("shell", &cfg));
267        assert!(!is_skill_allowed("awesome-web-thing", &cfg));
268    }
269
270    #[test]
271    fn multiple_patterns_or_logic() {
272        let cfg = allow(&["shell", "web-*"]);
273        assert!(is_skill_allowed("shell", &cfg));
274        assert!(is_skill_allowed("web-search", &cfg));
275        assert!(!is_skill_allowed("memory", &cfg));
276    }
277
278    #[test]
279    fn default_config_allows_all() {
280        let cfg = ChannelSkillsConfig::default();
281        assert!(is_skill_allowed("any-skill", &cfg));
282    }
283
284    #[test]
285    fn prefix_wildcard_does_not_match_empty_suffix() {
286        let cfg = allow(&["web-*"]);
287        // "web-" itself — prefix is "web-", remainder after stripping is "", which is the name
288        // glob_match("web-*", "web-") → prefix="web-", name.starts_with("web-") is true, len > prefix
289        // but name == "web-" means remainder is "", so starts_with returns true, let's verify:
290        assert!(is_skill_allowed("web-", &cfg));
291    }
292
293    #[test]
294    fn matching_is_case_sensitive() {
295        let cfg = allow(&["Web-Search"]);
296        assert!(!is_skill_allowed("web-search", &cfg));
297        assert!(is_skill_allowed("Web-Search", &cfg));
298    }
299}
300
301fn default_slack_port() -> u16 {
302    3000
303}
304
305fn default_slack_webhook_host() -> String {
306    "127.0.0.1".into()
307}
308
309fn default_a2a_host() -> String {
310    "0.0.0.0".into()
311}
312
313fn default_a2a_port() -> u16 {
314    8080
315}
316
317fn default_a2a_rate_limit() -> u32 {
318    60
319}
320
321fn default_a2a_max_body() -> usize {
322    1_048_576
323}
324
325fn default_drain_timeout_ms() -> u64 {
326    30_000
327}
328
329fn default_max_dynamic_servers() -> usize {
330    10
331}
332
333fn default_mcp_timeout() -> u64 {
334    30
335}
336
337fn default_startup_retry_backoff_ms() -> u64 {
338    1000
339}
340
341fn default_tool_timeout_secs() -> Option<u64> {
342    None
343}
344
345fn default_oauth_callback_port() -> u16 {
346    18766
347}
348
349fn default_oauth_client_name() -> String {
350    "Zeph".into()
351}
352
353fn default_stream_interval_ms() -> u64 {
354    3000
355}
356
357fn default_max_bot_chain_depth() -> u32 {
358    1
359}
360
361/// Telegram channel configuration, nested under `[telegram]` in TOML.
362///
363/// When present, Zeph connects to Telegram as a bot using the provided token.
364/// The token must be resolved from the vault at runtime via `ZEPH_TELEGRAM_TOKEN`.
365///
366/// # Example (TOML)
367///
368/// ```toml
369/// [telegram]
370/// allowed_users = ["myusername"]
371/// stream_interval_ms = 3000
372/// guest_mode = true
373/// bot_to_bot = true
374/// allowed_bots = ["@my_bot"]
375/// max_bot_chain_depth = 1
376/// ```
377#[derive(Clone, Deserialize, Serialize)]
378pub struct TelegramConfig {
379    /// Bot token. Set to `None` and resolve from vault via `ZEPH_TELEGRAM_TOKEN`.
380    pub token: Option<String>,
381    /// Telegram usernames allowed to interact with the bot (empty = allow all).
382    #[serde(default)]
383    pub allowed_users: Vec<String>,
384    /// Skill allowlist for this channel.
385    #[serde(default)]
386    pub skills: ChannelSkillsConfig,
387    /// Tool allowlist for this channel. `None` means all tools are permitted.
388    /// `Some(vec![])` denies all tools. `Some(vec!["shell"])` allows only listed tools.
389    #[serde(default)]
390    pub allowed_tools: Option<Vec<String>>,
391    /// Minimum interval in milliseconds between streaming message edits.
392    ///
393    /// Defaults to 3000 ms (3 seconds) to stay within Telegram's rate limits.
394    /// Values below 500 ms are clamped to 500 ms with a warning; the Telegram
395    /// Bot API enforces a hard limit of ~30 edits/second per chat.
396    #[serde(default = "default_stream_interval_ms")]
397    pub stream_interval_ms: u64,
398    /// Enable responding to @mentions in any chat (Bot API 10.0 Guest Mode).
399    ///
400    /// When `false` (default), `guest_message` updates are ignored.
401    #[serde(default)]
402    pub guest_mode: bool,
403    /// Enable receiving messages from other bots (Bot API 10.0).
404    ///
405    /// When `false` (default), messages where `from.is_bot = true` are silently dropped.
406    #[serde(default)]
407    pub bot_to_bot: bool,
408    /// Bot usernames allowed to interact when `bot_to_bot = true`.
409    ///
410    /// Empty list (default) allows all bots. Include the `@` prefix (e.g. `"@my_bot"`).
411    #[serde(default)]
412    pub allowed_bots: Vec<String>,
413    /// Maximum reply chain depth before Zeph stops responding to bot messages.
414    ///
415    /// Prevents infinite loops between bots. Checked against both the structural
416    /// `reply_to_message` depth (spec FR-007) and the consecutive-reply counter
417    /// for the same chat. Default: 1.
418    ///
419    /// Note: Telegram API payloads only expose one level of `reply_to_message`
420    /// nesting, so values greater than 1 have no additional effect on structural
421    /// depth alone. The consecutive-reply counter provides secondary loop
422    /// prevention across multiple top-level exchanges.
423    #[serde(default = "default_max_bot_chain_depth")]
424    pub max_bot_chain_depth: u32,
425}
426
427impl std::fmt::Debug for TelegramConfig {
428    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        f.debug_struct("TelegramConfig")
430            .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
431            .field("allowed_users", &self.allowed_users)
432            .field("skills", &self.skills)
433            .field("allowed_tools", &self.allowed_tools)
434            .field("stream_interval_ms", &self.stream_interval_ms)
435            .field("guest_mode", &self.guest_mode)
436            .field("bot_to_bot", &self.bot_to_bot)
437            .field("allowed_bots_count", &self.allowed_bots.len())
438            .field("max_bot_chain_depth", &self.max_bot_chain_depth)
439            .finish()
440    }
441}
442
443#[derive(Clone, Deserialize, Serialize)]
444pub struct DiscordConfig {
445    pub token: Option<String>,
446    pub application_id: Option<String>,
447    #[serde(default)]
448    pub allowed_user_ids: Vec<String>,
449    #[serde(default)]
450    pub allowed_role_ids: Vec<String>,
451    #[serde(default)]
452    pub allowed_channel_ids: Vec<String>,
453    #[serde(default)]
454    pub skills: ChannelSkillsConfig,
455    /// Tool allowlist for this channel. `None` means all tools are permitted.
456    #[serde(default)]
457    pub allowed_tools: Option<Vec<String>>,
458}
459
460impl std::fmt::Debug for DiscordConfig {
461    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462        f.debug_struct("DiscordConfig")
463            .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
464            .field("application_id", &self.application_id)
465            .field("allowed_user_ids", &self.allowed_user_ids)
466            .field("allowed_role_ids", &self.allowed_role_ids)
467            .field("allowed_channel_ids", &self.allowed_channel_ids)
468            .field("skills", &self.skills)
469            .field("allowed_tools", &self.allowed_tools)
470            .finish()
471    }
472}
473
474#[derive(Clone, Deserialize, Serialize)]
475pub struct SlackConfig {
476    pub bot_token: Option<String>,
477    pub signing_secret: Option<String>,
478    #[serde(default = "default_slack_webhook_host")]
479    pub webhook_host: String,
480    #[serde(default = "default_slack_port")]
481    pub port: u16,
482    #[serde(default)]
483    pub allowed_user_ids: Vec<String>,
484    #[serde(default)]
485    pub allowed_channel_ids: Vec<String>,
486    #[serde(default)]
487    pub skills: ChannelSkillsConfig,
488    /// Tool allowlist for this channel. `None` means all tools are permitted.
489    #[serde(default)]
490    pub allowed_tools: Option<Vec<String>>,
491}
492
493impl std::fmt::Debug for SlackConfig {
494    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
495        f.debug_struct("SlackConfig")
496            .field("bot_token", &self.bot_token.as_ref().map(|_| "[REDACTED]"))
497            .field(
498                "signing_secret",
499                &self.signing_secret.as_ref().map(|_| "[REDACTED]"), // lgtm[rust/cleartext-logging]
500            )
501            .field("webhook_host", &self.webhook_host)
502            .field("port", &self.port)
503            .field("allowed_user_ids", &self.allowed_user_ids)
504            .field("allowed_channel_ids", &self.allowed_channel_ids)
505            .field("skills", &self.skills)
506            .field("allowed_tools", &self.allowed_tools)
507            .finish()
508    }
509}
510
511/// An IBCT signing key entry in the A2A server configuration.
512///
513/// Multiple entries allow key rotation: keep old keys until all tokens signed with them expire.
514#[derive(Debug, Clone, Deserialize, Serialize)]
515pub struct IbctKeyConfig {
516    /// Unique key identifier. Must match the `key_id` field in issued IBCT tokens.
517    pub key_id: String,
518    /// Hex-encoded HMAC-SHA256 signing key.
519    pub key_hex: String,
520}
521
522fn default_ibct_ttl() -> u64 {
523    300
524}
525
526fn default_a2a_request_timeout_ms() -> u64 {
527    300_000
528}
529
530/// A2A server configuration, nested under `[a2a]` in TOML.
531///
532/// Controls the Agent-to-Agent HTTP server that exposes the agent via the A2A protocol.
533/// The `AgentCard` served at `/.well-known/agent.json` is built from these settings combined
534/// with runtime-detected capabilities (`images`, `audio`) and the opt-in `advertise_files` flag.
535#[derive(Deserialize, Serialize)]
536#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic here
537pub struct A2aServerConfig {
538    #[serde(default)]
539    pub enabled: bool,
540    #[serde(default = "default_a2a_host")]
541    pub host: String,
542    #[serde(default = "default_a2a_port")]
543    pub port: u16,
544    #[serde(default)]
545    pub public_url: String,
546    #[serde(default)]
547    pub auth_token: Option<String>,
548    #[serde(default = "default_a2a_rate_limit")]
549    pub rate_limit: u32,
550    #[serde(default = "default_true")]
551    pub require_tls: bool,
552    #[serde(default = "default_true")]
553    pub ssrf_protection: bool,
554    #[serde(default = "default_a2a_max_body")]
555    pub max_body_size: usize,
556    #[serde(default = "default_drain_timeout_ms")]
557    pub drain_timeout_ms: u64,
558    /// When `true`, all requests are rejected with 401 if no `auth_token` is configured.
559    /// Default `false` for backward compatibility — existing deployments without a token
560    /// continue to operate. Set to `true` in production when authentication is mandatory.
561    #[serde(default)]
562    pub require_auth: bool,
563    /// IBCT signing keys for per-task delegation scoping.
564    ///
565    /// When non-empty, all A2A task requests must include a valid `X-Zeph-IBCT` header
566    /// signed with one of these keys. Multiple keys allow key rotation without downtime.
567    #[serde(default)]
568    pub ibct_keys: Vec<IbctKeyConfig>,
569    /// Vault key name to resolve the primary IBCT signing key at startup (MF-3 fix).
570    ///
571    /// When set, the vault key is resolved at startup and used to construct an
572    /// `IbctKey` with `key_id = "primary"`. Takes precedence over `ibct_keys[0]` if both
573    /// are set.  Example: `"ZEPH_A2A_IBCT_KEY"`.
574    #[serde(default)]
575    pub ibct_signing_key_vault_ref: Option<String>,
576    /// TTL (seconds) for issued IBCT tokens. Default: 300 (5 minutes).
577    #[serde(default = "default_ibct_ttl")]
578    pub ibct_ttl_secs: u64,
579    /// Advertise non-media file attachment capability on the `AgentCard`.
580    ///
581    /// When `true`, the served `/.well-known/agent.json` sets `capabilities.files = true`,
582    /// signalling to peer agents that this agent can receive `Part::File` entries that are
583    /// not image or audio (e.g., documents, archives).
584    ///
585    /// Default `false` because generic file attachments have no built-in ingestion path in
586    /// the current agent loop. Set to `true` only when the deployed agent has skills or MCP
587    /// tools that can consume file parts; otherwise the card would advertise a capability
588    /// the agent silently drops.
589    ///
590    /// Note: `images` and `audio` capability flags are auto-detected from the active LLM
591    /// provider and STT configuration — no manual override is needed for those.
592    #[serde(default)]
593    pub advertise_files: bool,
594    /// Request processing timeout in milliseconds.
595    ///
596    /// Applies to both `message/send` and `tasks/stream` handlers.
597    /// On timeout the task is set to `Failed` and the HTTP connection is closed.
598    /// Defaults to 300 000 ms (5 minutes).
599    #[serde(default = "default_a2a_request_timeout_ms")]
600    pub request_timeout_ms: u64,
601}
602
603impl std::fmt::Debug for A2aServerConfig {
604    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
605        f.debug_struct("A2aServerConfig")
606            .field("enabled", &self.enabled)
607            .field("host", &self.host)
608            .field("port", &self.port)
609            .field("public_url", &self.public_url)
610            .field(
611                "auth_token",
612                &self.auth_token.as_ref().map(|_| "[REDACTED]"),
613            )
614            .field("rate_limit", &self.rate_limit)
615            .field("require_tls", &self.require_tls)
616            .field("ssrf_protection", &self.ssrf_protection)
617            .field("max_body_size", &self.max_body_size)
618            .field("drain_timeout_ms", &self.drain_timeout_ms)
619            .field("require_auth", &self.require_auth)
620            .field("ibct_keys_count", &self.ibct_keys.len())
621            .field(
622                "ibct_signing_key_vault_ref",
623                &self.ibct_signing_key_vault_ref,
624            )
625            .field("ibct_ttl_secs", &self.ibct_ttl_secs)
626            .field("advertise_files", &self.advertise_files)
627            .field("request_timeout_ms", &self.request_timeout_ms)
628            .finish()
629    }
630}
631
632impl Default for A2aServerConfig {
633    fn default() -> Self {
634        Self {
635            enabled: false,
636            host: default_a2a_host(),
637            port: default_a2a_port(),
638            public_url: String::new(),
639            auth_token: None,
640            rate_limit: default_a2a_rate_limit(),
641            require_tls: true,
642            ssrf_protection: true,
643            max_body_size: default_a2a_max_body(),
644            drain_timeout_ms: default_drain_timeout_ms(),
645            require_auth: false,
646            ibct_keys: Vec::new(),
647            ibct_signing_key_vault_ref: None,
648            ibct_ttl_secs: default_ibct_ttl(),
649            advertise_files: false,
650            request_timeout_ms: default_a2a_request_timeout_ms(),
651        }
652    }
653}
654
655/// Dynamic MCP tool context pruning configuration (#2204).
656///
657/// When enabled, an LLM call evaluates which MCP tools are relevant to the current task
658/// before sending tool schemas to the main LLM, reducing context usage and improving
659/// tool selection accuracy for servers with many tools.
660#[derive(Debug, Clone, Deserialize, Serialize)]
661#[serde(default)]
662pub struct ToolPruningConfig {
663    /// Enable dynamic tool pruning. Default: `false` (opt-in).
664    pub enabled: bool,
665    /// Maximum number of MCP tools to include after pruning.
666    pub max_tools: usize,
667    /// Provider name from `[[llm.providers]]` for the pruning LLM call.
668    /// Should be a fast/cheap model. Empty string = use the default provider.
669    pub pruning_provider: ProviderName,
670    /// Minimum number of MCP tools below which pruning is skipped.
671    pub min_tools_to_prune: usize,
672    /// Tool names that are never pruned (always included in the result).
673    pub always_include: Vec<String>,
674}
675
676impl Default for ToolPruningConfig {
677    fn default() -> Self {
678        Self {
679            enabled: false,
680            max_tools: 15,
681            pruning_provider: ProviderName::default(),
682            min_tools_to_prune: 10,
683            always_include: Vec::new(),
684        }
685    }
686}
687
688/// MCP tool discovery strategy (config-side representation).
689///
690/// Converted to `zeph_mcp::ToolDiscoveryStrategy` in `zeph-core` to avoid a
691/// circular crate dependency (`zeph-config` → `zeph-mcp`).
692#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
693#[serde(rename_all = "lowercase")]
694#[non_exhaustive]
695pub enum ToolDiscoveryStrategyConfig {
696    /// Embedding-based cosine similarity retrieval.  Fast, no LLM call per turn.
697    Embedding,
698    /// LLM-based pruning via `prune_tools_cached`.  Existing behavior.
699    Llm,
700    /// No filtering — all tools are passed through.  This is the default.
701    #[default]
702    None,
703}
704
705/// MCP tool discovery configuration (#2321).
706///
707/// Nested under `[mcp.tool_discovery]`.  When `strategy = "embedding"`, the
708/// `mcp.pruning` section is ignored for this session — the embedding path
709/// supersedes LLM pruning entirely.
710#[derive(Debug, Clone, Deserialize, Serialize)]
711#[serde(default)]
712pub struct ToolDiscoveryConfig {
713    /// Discovery strategy.  Default: `none` (all tools, safe default).
714    pub strategy: ToolDiscoveryStrategyConfig,
715    /// Number of top-scoring tools to include per turn (embedding strategy only).
716    pub top_k: usize,
717    /// Minimum cosine similarity for a tool to be included (embedding strategy only).
718    pub min_similarity: f32,
719    /// Provider name from `[[llm.providers]]` for embedding computation.
720    /// Should reference a fast/cheap embedding model.  Empty = use the agent's
721    /// default embedding provider.
722    pub embedding_provider: ProviderName,
723    /// Tool names always included regardless of similarity score.
724    pub always_include: Vec<String>,
725    /// Minimum tool count below which discovery is skipped (all tools passed through).
726    pub min_tools_to_filter: usize,
727    /// When `true`, treat any embedding failure as a hard error instead of silently
728    /// falling back to all tools.  Default: `false` (soft fallback).
729    pub strict: bool,
730}
731
732impl Default for ToolDiscoveryConfig {
733    fn default() -> Self {
734        Self {
735            strategy: ToolDiscoveryStrategyConfig::None,
736            top_k: 10,
737            min_similarity: 0.2,
738            embedding_provider: ProviderName::default(),
739            always_include: Vec::new(),
740            min_tools_to_filter: 10,
741            strict: false,
742        }
743    }
744}
745
746/// Trust calibration configuration, nested under `[mcp.trust_calibration]`.
747#[derive(Debug, Clone, Deserialize, Serialize)]
748#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
749pub struct TrustCalibrationConfig {
750    /// Enable trust calibration (default: false — opt-in).
751    #[serde(default)]
752    pub enabled: bool,
753    /// Run pre-invocation probe on connect (Phase 1).
754    #[serde(default = "default_true")]
755    pub probe_on_connect: bool,
756    /// Monitor invocations for trust score updates (Phase 2).
757    #[serde(default = "default_true")]
758    pub monitor_invocations: bool,
759    /// Persist trust scores to `SQLite` (Phase 3).
760    #[serde(default = "default_true")]
761    pub persist_scores: bool,
762    /// Per-day decay rate applied to trust scores above 0.5.
763    #[serde(default = "default_decay_rate")]
764    pub decay_rate_per_day: f64,
765    /// Score penalty applied when injection is detected.
766    #[serde(default = "default_injection_penalty")]
767    pub injection_penalty: f64,
768    /// Optional LLM provider for trust verification. Empty = disabled.
769    #[serde(default)]
770    pub verifier_provider: ProviderName,
771}
772
773fn default_decay_rate() -> f64 {
774    0.01
775}
776
777fn default_injection_penalty() -> f64 {
778    0.25
779}
780
781impl Default for TrustCalibrationConfig {
782    fn default() -> Self {
783        Self {
784            enabled: false,
785            probe_on_connect: true,
786            monitor_invocations: true,
787            persist_scores: true,
788            decay_rate_per_day: default_decay_rate(),
789            injection_penalty: default_injection_penalty(),
790            verifier_provider: ProviderName::default(),
791        }
792    }
793}
794
795fn default_max_description_bytes() -> usize {
796    2048
797}
798
799fn default_max_instructions_bytes() -> usize {
800    2048
801}
802
803fn default_elicitation_timeout() -> u64 {
804    120
805}
806
807fn default_elicitation_queue_capacity() -> usize {
808    16
809}
810
811fn default_output_schema_hint_bytes() -> usize {
812    1024
813}
814
815fn default_max_connect_attempts() -> u8 {
816    3
817}
818
819fn validate_max_connect_attempts<'de, D>(d: D) -> Result<u8, D::Error>
820where
821    D: serde::Deserializer<'de>,
822{
823    let v = u8::deserialize(d)?;
824    if !(1..=10).contains(&v) {
825        return Err(serde::de::Error::custom(format!(
826            "mcp.max_connect_attempts must be in 1..=10 (got {v})"
827        )));
828    }
829    Ok(v)
830}
831
832fn validate_tool_timeout_secs<'de, D>(d: D) -> Result<Option<u64>, D::Error>
833where
834    D: serde::Deserializer<'de>,
835{
836    let v = Option::<u64>::deserialize(d)?;
837    if let Some(n) = v
838        && n > 3600
839    {
840        return Err(serde::de::Error::custom(format!(
841            "mcp.tool_timeout_secs must be \u{2264} 3600 (got {n})"
842        )));
843    }
844    Ok(v)
845}
846
847#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
848#[derive(Debug, Clone, Deserialize, Serialize)]
849pub struct McpConfig {
850    #[serde(default)]
851    pub servers: Vec<McpServerConfig>,
852    #[serde(default)]
853    pub allowed_commands: Vec<String>,
854    #[serde(default = "default_max_dynamic_servers")]
855    pub max_dynamic_servers: usize,
856    /// Dynamic tool pruning for context optimization.
857    #[serde(default)]
858    pub pruning: ToolPruningConfig,
859    /// Trust calibration settings (opt-in, disabled by default).
860    #[serde(default)]
861    pub trust_calibration: TrustCalibrationConfig,
862    /// Embedding-based tool discovery (#2321).
863    #[serde(default)]
864    pub tool_discovery: ToolDiscoveryConfig,
865    /// Maximum byte length for MCP tool descriptions. Truncated with "..." if exceeded. Default: 2048.
866    #[serde(default = "default_max_description_bytes")]
867    pub max_description_bytes: usize,
868    /// Maximum byte length for MCP server instructions. Truncated with "..." if exceeded. Default: 2048.
869    #[serde(default = "default_max_instructions_bytes")]
870    pub max_instructions_bytes: usize,
871    /// Enable MCP elicitation (servers can request user input mid-task).
872    /// Default: false — all elicitation requests are auto-declined.
873    /// Opt-in because it interrupts agent flow and could be abused by malicious servers.
874    #[serde(default)]
875    pub elicitation_enabled: bool,
876    /// Timeout for user to respond to an elicitation request (seconds). Default: 120.
877    #[serde(default = "default_elicitation_timeout")]
878    pub elicitation_timeout: u64,
879    /// Bounded channel capacity for elicitation events. Requests beyond this limit are
880    /// auto-declined with a warning to prevent memory exhaustion from misbehaving servers.
881    /// Default: 16.
882    #[serde(default = "default_elicitation_queue_capacity")]
883    pub elicitation_queue_capacity: usize,
884    /// When true, warn the user before prompting for fields whose names match sensitive
885    /// patterns (password, token, secret, key, credential, etc.). Default: true.
886    #[serde(default = "default_true")]
887    pub elicitation_warn_sensitive_fields: bool,
888    /// Maximum number of connection attempts for each MCP server at startup.
889    ///
890    /// Value `1` means one attempt with no retry. Value `3` (default) means up to three
891    /// attempts with exponential backoff: 500 ms then 1 s between attempts.
892    ///
893    /// For `max_connect_attempts = N`, the inter-attempt delay sequence is
894    /// `min(500 * 2^(k-1), 8_000) ms` for k = 1..N-1, giving at most ~47 s total backoff
895    /// at the cap of `10`. Must be in `1..=10`.
896    ///
897    /// Note: dynamic `add_server` calls retain single-attempt behaviour regardless of this
898    /// setting; a follow-up issue tracks extending retry there.
899    #[serde(
900        default = "default_max_connect_attempts",
901        deserialize_with = "validate_max_connect_attempts"
902    )]
903    pub max_connect_attempts: u8,
904    /// Lock tool lists after initial connection for all servers.
905    ///
906    /// When `true`, `tools/list_changed` refresh events are rejected for servers that have
907    /// completed their initial connection, preventing mid-session tool injection.
908    /// Default: `false` (opt-in, backward compatible).
909    #[serde(default)]
910    pub lock_tool_list: bool,
911    /// Default env isolation for all Stdio servers. Per-server `env_isolation` overrides this.
912    ///
913    /// When `true`, spawned processes only receive a minimal base env + their declared `env` map.
914    /// Default: `false` (backward compatible).
915    #[serde(default)]
916    pub default_env_isolation: bool,
917    /// When `true`, forward MCP tool output schemas as a hint appended to the tool description.
918    ///
919    /// Disabled by default to preserve Anthropic prompt-cache hit rates. Enabling this mutates
920    /// tool descriptions, which changes the cached hash and causes a one-off cache miss after
921    /// every MCP reconnect or server redeploy.
922    ///
923    /// See `output_schema_hint_bytes` for the budget controlling hint size.
924    #[serde(default)]
925    pub forward_output_schema: bool,
926    /// Maximum bytes of the compact JSON appended to the tool description as the output schema
927    /// hint when `forward_output_schema = true`. Default: 1024.
928    ///
929    /// If the serialized schema exceeds this budget, a stub message is used instead and a WARN
930    /// is emitted once per session per tool.
931    #[serde(default = "default_output_schema_hint_bytes")]
932    pub output_schema_hint_bytes: usize,
933    /// Base delay in milliseconds before each retry attempt at startup.
934    ///
935    /// The actual backoff is computed as `min(startup_retry_backoff_ms * 2^(k-1), 8_000) ms`
936    /// where `k` is the 1-based attempt index. Default: 1000 ms.
937    ///
938    /// Set to a lower value for faster failover in test/development environments.
939    #[serde(default = "default_startup_retry_backoff_ms")]
940    pub startup_retry_backoff_ms: u64,
941    /// Per-call timeout in seconds applied to each MCP tool invocation.
942    ///
943    /// This is separate from `[[mcp.servers]].timeout`, which controls the handshake and
944    /// `tools/list` timeout. `tool_timeout_secs` applies after the connection is established,
945    /// for each `tools/call` request.
946    ///
947    /// When absent (the default), the per-server `timeout` governs `tools/call` as well.
948    /// Set to a lower value to cap runaway tools without changing the handshake timeout.
949    /// Maximum accepted value is 3600 s; values above that are rejected at parse time.
950    #[serde(
951        default = "default_tool_timeout_secs",
952        deserialize_with = "validate_tool_timeout_secs"
953    )]
954    pub tool_timeout_secs: Option<u64>,
955}
956
957impl Default for McpConfig {
958    fn default() -> Self {
959        Self {
960            servers: Vec::new(),
961            allowed_commands: Vec::new(),
962            max_dynamic_servers: default_max_dynamic_servers(),
963            pruning: ToolPruningConfig::default(),
964            trust_calibration: TrustCalibrationConfig::default(),
965            tool_discovery: ToolDiscoveryConfig::default(),
966            max_description_bytes: default_max_description_bytes(),
967            max_instructions_bytes: default_max_instructions_bytes(),
968            elicitation_enabled: false,
969            elicitation_timeout: default_elicitation_timeout(),
970            elicitation_queue_capacity: default_elicitation_queue_capacity(),
971            elicitation_warn_sensitive_fields: true,
972            lock_tool_list: false,
973            default_env_isolation: false,
974            forward_output_schema: false,
975            output_schema_hint_bytes: default_output_schema_hint_bytes(),
976            max_connect_attempts: default_max_connect_attempts(),
977            startup_retry_backoff_ms: default_startup_retry_backoff_ms(),
978            tool_timeout_secs: None,
979        }
980    }
981}
982
983#[derive(Clone, Deserialize, Serialize)]
984pub struct McpServerConfig {
985    pub id: String,
986    /// Stdio transport: command to spawn.
987    pub command: Option<String>,
988    #[serde(default)]
989    pub args: Vec<String>,
990    #[serde(default)]
991    pub env: HashMap<String, String>,
992    /// HTTP transport: remote MCP server URL.
993    pub url: Option<String>,
994    #[serde(default = "default_mcp_timeout")]
995    pub timeout: u64,
996    /// Optional declarative policy for this server (allowlist, denylist, rate limit).
997    #[serde(default)]
998    pub policy: McpPolicy,
999    /// Static HTTP headers for the transport (e.g. `Authorization: Bearer <token>`).
1000    /// Values support vault references: `${VAULT_KEY}`.
1001    #[serde(default)]
1002    pub headers: HashMap<String, String>,
1003    /// OAuth 2.1 configuration for this server.
1004    #[serde(default)]
1005    pub oauth: Option<McpOAuthConfig>,
1006    /// Trust level for this server. Default: Untrusted.
1007    #[serde(default)]
1008    pub trust_level: McpTrustLevel,
1009    /// Tool allowlist. `None` means no override (inherit defaults).
1010    /// `Some(vec![])` is an explicit empty list (deny all for Untrusted/Sandboxed).
1011    /// `Some(vec!["a", "b"])` allows only listed tools.
1012    #[serde(default)]
1013    pub tool_allowlist: Option<Vec<String>>,
1014    /// Expected tool names for attestation. Supplements `tool_allowlist`.
1015    ///
1016    /// When non-empty: tools not in this list are filtered out (Untrusted/Sandboxed)
1017    /// or warned about (Trusted). Schema drift is logged when fingerprints change
1018    /// between connections.
1019    #[serde(default)]
1020    pub expected_tools: Vec<String>,
1021    /// Filesystem roots exposed to this MCP server via `roots/list`.
1022    /// Each entry is a `{uri, name?}` pair. URI must use `file://` scheme.
1023    /// When empty, the server receives an empty roots list.
1024    #[serde(default)]
1025    pub roots: Vec<McpRootEntry>,
1026    /// Per-tool security metadata overrides. Keys are tool names.
1027    /// When absent for a tool, metadata is inferred from the tool name via heuristics.
1028    #[serde(default)]
1029    pub tool_metadata: HashMap<String, ToolSecurityMeta>,
1030    /// Per-server elicitation override. `None` = inherit global `elicitation_enabled`.
1031    /// `Some(true)` = allow this server to elicit regardless of global setting.
1032    /// `Some(false)` = always decline for this server.
1033    #[serde(default)]
1034    pub elicitation_enabled: Option<bool>,
1035    /// Isolate the environment for this Stdio server.
1036    ///
1037    /// When `true` (or when `[mcp].default_env_isolation = true`), the spawned process
1038    /// only sees a minimal base env (`PATH`, `HOME`, etc.) plus this server's `env` map.
1039    /// Overrides `[mcp].default_env_isolation` when set explicitly.
1040    /// Default: `false` (backward compatible).
1041    #[serde(default)]
1042    pub env_isolation: Option<bool>,
1043}
1044
1045/// A filesystem root exposed to an MCP server via `roots/list`.
1046#[derive(Debug, Clone, Deserialize, Serialize)]
1047pub struct McpRootEntry {
1048    /// URI of the root directory. Must use `file://` scheme.
1049    pub uri: String,
1050    /// Optional human-readable name for this root.
1051    #[serde(default)]
1052    pub name: Option<String>,
1053}
1054
1055/// OAuth 2.1 configuration for an MCP server.
1056#[derive(Debug, Clone, Deserialize, Serialize)]
1057pub struct McpOAuthConfig {
1058    /// Enable OAuth 2.1 for this server.
1059    #[serde(default)]
1060    pub enabled: bool,
1061    /// Token storage backend.
1062    #[serde(default)]
1063    pub token_storage: OAuthTokenStorage,
1064    /// OAuth scopes to request. Empty = server default.
1065    #[serde(default)]
1066    pub scopes: Vec<String>,
1067    /// Port for the local callback server. `0` = auto-assign, `18766` = default fixed port.
1068    #[serde(default = "default_oauth_callback_port")]
1069    pub callback_port: u16,
1070    /// Client name sent during dynamic registration.
1071    #[serde(default = "default_oauth_client_name")]
1072    pub client_name: String,
1073}
1074
1075impl Default for McpOAuthConfig {
1076    fn default() -> Self {
1077        Self {
1078            enabled: false,
1079            token_storage: OAuthTokenStorage::default(),
1080            scopes: Vec::new(),
1081            callback_port: default_oauth_callback_port(),
1082            client_name: default_oauth_client_name(),
1083        }
1084    }
1085}
1086
1087/// Where OAuth tokens are stored.
1088#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1089#[serde(rename_all = "lowercase")]
1090#[non_exhaustive]
1091pub enum OAuthTokenStorage {
1092    /// Persisted in the age vault (default).
1093    #[default]
1094    Vault,
1095    /// In-memory only — tokens lost on restart.
1096    Memory,
1097}
1098
1099impl std::fmt::Debug for McpServerConfig {
1100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1101        let redacted_env: HashMap<&str, &str> = self
1102            .env
1103            .keys()
1104            .map(|k| (k.as_str(), "[REDACTED]"))
1105            .collect();
1106        // Redact header values to avoid leaking tokens in logs.
1107        let redacted_headers: HashMap<&str, &str> = self
1108            .headers
1109            .keys()
1110            .map(|k| (k.as_str(), "[REDACTED]"))
1111            .collect();
1112        f.debug_struct("McpServerConfig")
1113            .field("id", &self.id)
1114            .field("command", &self.command)
1115            .field("args", &self.args)
1116            .field("env", &redacted_env)
1117            .field("url", &self.url)
1118            .field("timeout", &self.timeout)
1119            .field("policy", &self.policy)
1120            .field("headers", &redacted_headers)
1121            .field("oauth", &self.oauth)
1122            .field("trust_level", &self.trust_level)
1123            .field("tool_allowlist", &self.tool_allowlist)
1124            .field("expected_tools", &self.expected_tools)
1125            .field("roots", &self.roots)
1126            .field(
1127                "tool_metadata_keys",
1128                &self.tool_metadata.keys().collect::<Vec<_>>(),
1129            )
1130            .field("elicitation_enabled", &self.elicitation_enabled)
1131            .field("env_isolation", &self.env_isolation)
1132            .finish()
1133    }
1134}