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")]
20pub enum McpTrustLevel {
21    /// Full trust — all tools exposed, SSRF check skipped. Use for operator-controlled servers.
22    Trusted,
23    /// Default. SSRF enforced. Tools exposed with a warning when allowlist is empty.
24    #[default]
25    Untrusted,
26    /// Strict sandboxing — SSRF enforced. Only allowlisted tools exposed; empty allowlist = no tools.
27    Sandboxed,
28}
29
30impl McpTrustLevel {
31    /// Returns a numeric restriction level where higher means more restricted.
32    ///
33    /// Used for "only demote, never promote automatically" comparisons.
34    #[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/// Rate limit configuration for a single MCP server.
45#[derive(Debug, Clone, Deserialize, Serialize)]
46pub struct RateLimit {
47    /// Maximum number of tool calls allowed per minute across all tools on this server.
48    pub max_calls_per_minute: u32,
49}
50
51/// Per-server MCP policy.
52///
53/// No policy present = allow all (backward compatible default).
54#[derive(Debug, Clone, Default, Deserialize, Serialize)]
55#[serde(default)]
56pub struct McpPolicy {
57    /// Allowlist of tool names. `None` means all tools are allowed (subject to `denied_tools`).
58    pub allowed_tools: Option<Vec<String>>,
59    /// Denylist of tool names. Takes precedence over `allowed_tools`.
60    pub denied_tools: Vec<String>,
61    /// Optional rate limit for this server.
62    pub rate_limit: Option<RateLimit>,
63}
64
65fn default_skill_allowlist() -> Vec<String> {
66    vec!["*".into()]
67}
68
69/// Per-channel skill allowlist configuration.
70///
71/// Declares which skills are permitted on a given channel. The config is parsed and
72/// `is_skill_allowed()` is available for callers to check membership. Runtime enforcement
73/// (filtering skills before prompt assembly) is tracked in issue #2507 and not yet wired.
74#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct ChannelSkillsConfig {
76    /// Skill allowlist. `["*"]` = all skills allowed. `[]` = deny all.
77    /// Supports exact names and `*` wildcard (e.g. `"web-*"` matches `"web-search"`).
78    #[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/// Returns `true` if the skill `name` matches any pattern in the allowlist.
91///
92/// Pattern rules: `"*"` matches any name; `"prefix-*"` matches names starting with `"prefix-"`;
93/// exact strings match only themselves. Matching is case-sensitive.
94#[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        // When all new fields are absent, defaults must be applied.
123        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        // "web-" itself — prefix is "web-", remainder after stripping is "", which is the name
248        // glob_match("web-*", "web-") → prefix="web-", name.starts_with("web-") is true, len > prefix
249        // but name == "web-" means remainder is "", so starts_with returns true, let's verify:
250        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/// Telegram channel configuration, nested under `[telegram]` in TOML.
314///
315/// When present, Zeph connects to Telegram as a bot using the provided token.
316/// The token must be resolved from the vault at runtime via `ZEPH_TELEGRAM_TOKEN`.
317///
318/// # Example (TOML)
319///
320/// ```toml
321/// [telegram]
322/// allowed_users = ["myusername"]
323/// stream_interval_ms = 3000
324/// guest_mode = true
325/// bot_to_bot = true
326/// allowed_bots = ["@my_bot"]
327/// max_bot_chain_depth = 1
328/// ```
329#[derive(Clone, Deserialize, Serialize)]
330pub struct TelegramConfig {
331    /// Bot token. Set to `None` and resolve from vault via `ZEPH_TELEGRAM_TOKEN`.
332    pub token: Option<String>,
333    /// Telegram usernames allowed to interact with the bot (empty = allow all).
334    #[serde(default)]
335    pub allowed_users: Vec<String>,
336    /// Skill allowlist for this channel.
337    #[serde(default)]
338    pub skills: ChannelSkillsConfig,
339    /// Minimum interval in milliseconds between streaming message edits.
340    ///
341    /// Defaults to 3000 ms (3 seconds) to stay within Telegram's rate limits.
342    /// Values below 500 ms are clamped to 500 ms with a warning; the Telegram
343    /// Bot API enforces a hard limit of ~30 edits/second per chat.
344    #[serde(default = "default_stream_interval_ms")]
345    pub stream_interval_ms: u64,
346    /// Enable responding to @mentions in any chat (Bot API 10.0 Guest Mode).
347    ///
348    /// When `false` (default), `guest_message` updates are ignored.
349    #[serde(default)]
350    pub guest_mode: bool,
351    /// Enable receiving messages from other bots (Bot API 10.0).
352    ///
353    /// When `false` (default), messages where `from.is_bot = true` are silently dropped.
354    #[serde(default)]
355    pub bot_to_bot: bool,
356    /// Bot usernames allowed to interact when `bot_to_bot = true`.
357    ///
358    /// Empty list (default) allows all bots. Include the `@` prefix (e.g. `"@my_bot"`).
359    #[serde(default)]
360    pub allowed_bots: Vec<String>,
361    /// Maximum reply chain depth before Zeph stops responding to bot messages.
362    ///
363    /// Prevents infinite loops between bots. Checked against both the structural
364    /// `reply_to_message` depth (spec FR-007) and the consecutive-reply counter
365    /// for the same chat. Default: 1.
366    ///
367    /// Note: Telegram API payloads only expose one level of `reply_to_message`
368    /// nesting, so values greater than 1 have no additional effect on structural
369    /// depth alone. The consecutive-reply counter provides secondary loop
370    /// prevention across multiple top-level exchanges.
371    #[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]"), // lgtm[rust/cleartext-logging]
440            )
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/// An IBCT signing key entry in the A2A server configuration.
451///
452/// Multiple entries allow key rotation: keep old keys until all tokens signed with them expire.
453#[derive(Debug, Clone, Deserialize, Serialize)]
454pub struct IbctKeyConfig {
455    /// Unique key identifier. Must match the `key_id` field in issued IBCT tokens.
456    pub key_id: String,
457    /// Hex-encoded HMAC-SHA256 signing key.
458    pub key_hex: String,
459}
460
461fn default_ibct_ttl() -> u64 {
462    300
463}
464
465/// A2A server configuration, nested under `[a2a]` in TOML.
466///
467/// Controls the Agent-to-Agent HTTP server that exposes the agent via the A2A protocol.
468/// The `AgentCard` served at `/.well-known/agent.json` is built from these settings combined
469/// with runtime-detected capabilities (`images`, `audio`) and the opt-in `advertise_files` flag.
470#[derive(Deserialize, Serialize)]
471#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic here
472pub 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    /// When `true`, all requests are rejected with 401 if no `auth_token` is configured.
494    /// Default `false` for backward compatibility — existing deployments without a token
495    /// continue to operate. Set to `true` in production when authentication is mandatory.
496    #[serde(default)]
497    pub require_auth: bool,
498    /// IBCT signing keys for per-task delegation scoping.
499    ///
500    /// When non-empty, all A2A task requests must include a valid `X-Zeph-IBCT` header
501    /// signed with one of these keys. Multiple keys allow key rotation without downtime.
502    #[serde(default)]
503    pub ibct_keys: Vec<IbctKeyConfig>,
504    /// Vault key name to resolve the primary IBCT signing key at startup (MF-3 fix).
505    ///
506    /// When set, the vault key is resolved at startup and used to construct an
507    /// `IbctKey` with `key_id = "primary"`. Takes precedence over `ibct_keys[0]` if both
508    /// are set.  Example: `"ZEPH_A2A_IBCT_KEY"`.
509    #[serde(default)]
510    pub ibct_signing_key_vault_ref: Option<String>,
511    /// TTL (seconds) for issued IBCT tokens. Default: 300 (5 minutes).
512    #[serde(default = "default_ibct_ttl")]
513    pub ibct_ttl_secs: u64,
514    /// Advertise non-media file attachment capability on the `AgentCard`.
515    ///
516    /// When `true`, the served `/.well-known/agent.json` sets `capabilities.files = true`,
517    /// signalling to peer agents that this agent can receive `Part::File` entries that are
518    /// not image or audio (e.g., documents, archives).
519    ///
520    /// Default `false` because generic file attachments have no built-in ingestion path in
521    /// the current agent loop. Set to `true` only when the deployed agent has skills or MCP
522    /// tools that can consume file parts; otherwise the card would advertise a capability
523    /// the agent silently drops.
524    ///
525    /// Note: `images` and `audio` capability flags are auto-detected from the active LLM
526    /// provider and STT configuration — no manual override is needed for those.
527    #[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/// Dynamic MCP tool context pruning configuration (#2204).
582///
583/// When enabled, an LLM call evaluates which MCP tools are relevant to the current task
584/// before sending tool schemas to the main LLM, reducing context usage and improving
585/// tool selection accuracy for servers with many tools.
586#[derive(Debug, Clone, Deserialize, Serialize)]
587#[serde(default)]
588pub struct ToolPruningConfig {
589    /// Enable dynamic tool pruning. Default: `false` (opt-in).
590    pub enabled: bool,
591    /// Maximum number of MCP tools to include after pruning.
592    pub max_tools: usize,
593    /// Provider name from `[[llm.providers]]` for the pruning LLM call.
594    /// Should be a fast/cheap model. Empty string = use the default provider.
595    pub pruning_provider: ProviderName,
596    /// Minimum number of MCP tools below which pruning is skipped.
597    pub min_tools_to_prune: usize,
598    /// Tool names that are never pruned (always included in the result).
599    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/// MCP tool discovery strategy (config-side representation).
615///
616/// Converted to `zeph_mcp::ToolDiscoveryStrategy` in `zeph-core` to avoid a
617/// circular crate dependency (`zeph-config` → `zeph-mcp`).
618#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
619#[serde(rename_all = "lowercase")]
620pub enum ToolDiscoveryStrategyConfig {
621    /// Embedding-based cosine similarity retrieval.  Fast, no LLM call per turn.
622    Embedding,
623    /// LLM-based pruning via `prune_tools_cached`.  Existing behavior.
624    Llm,
625    /// No filtering — all tools are passed through.  This is the default.
626    #[default]
627    None,
628}
629
630/// MCP tool discovery configuration (#2321).
631///
632/// Nested under `[mcp.tool_discovery]`.  When `strategy = "embedding"`, the
633/// `mcp.pruning` section is ignored for this session — the embedding path
634/// supersedes LLM pruning entirely.
635#[derive(Debug, Clone, Deserialize, Serialize)]
636#[serde(default)]
637pub struct ToolDiscoveryConfig {
638    /// Discovery strategy.  Default: `none` (all tools, safe default).
639    pub strategy: ToolDiscoveryStrategyConfig,
640    /// Number of top-scoring tools to include per turn (embedding strategy only).
641    pub top_k: usize,
642    /// Minimum cosine similarity for a tool to be included (embedding strategy only).
643    pub min_similarity: f32,
644    /// Provider name from `[[llm.providers]]` for embedding computation.
645    /// Should reference a fast/cheap embedding model.  Empty = use the agent's
646    /// default embedding provider.
647    pub embedding_provider: ProviderName,
648    /// Tool names always included regardless of similarity score.
649    pub always_include: Vec<String>,
650    /// Minimum tool count below which discovery is skipped (all tools passed through).
651    pub min_tools_to_filter: usize,
652    /// When `true`, treat any embedding failure as a hard error instead of silently
653    /// falling back to all tools.  Default: `false` (soft fallback).
654    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/// Trust calibration configuration, nested under `[mcp.trust_calibration]`.
672#[derive(Debug, Clone, Deserialize, Serialize)]
673#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
674pub struct TrustCalibrationConfig {
675    /// Enable trust calibration (default: false — opt-in).
676    #[serde(default)]
677    pub enabled: bool,
678    /// Run pre-invocation probe on connect (Phase 1).
679    #[serde(default = "default_true")]
680    pub probe_on_connect: bool,
681    /// Monitor invocations for trust score updates (Phase 2).
682    #[serde(default = "default_true")]
683    pub monitor_invocations: bool,
684    /// Persist trust scores to `SQLite` (Phase 3).
685    #[serde(default = "default_true")]
686    pub persist_scores: bool,
687    /// Per-day decay rate applied to trust scores above 0.5.
688    #[serde(default = "default_decay_rate")]
689    pub decay_rate_per_day: f64,
690    /// Score penalty applied when injection is detected.
691    #[serde(default = "default_injection_penalty")]
692    pub injection_penalty: f64,
693    /// Optional LLM provider for trust verification. Empty = disabled.
694    #[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)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
758#[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    /// Dynamic tool pruning for context optimization.
767    #[serde(default)]
768    pub pruning: ToolPruningConfig,
769    /// Trust calibration settings (opt-in, disabled by default).
770    #[serde(default)]
771    pub trust_calibration: TrustCalibrationConfig,
772    /// Embedding-based tool discovery (#2321).
773    #[serde(default)]
774    pub tool_discovery: ToolDiscoveryConfig,
775    /// Maximum byte length for MCP tool descriptions. Truncated with "..." if exceeded. Default: 2048.
776    #[serde(default = "default_max_description_bytes")]
777    pub max_description_bytes: usize,
778    /// Maximum byte length for MCP server instructions. Truncated with "..." if exceeded. Default: 2048.
779    #[serde(default = "default_max_instructions_bytes")]
780    pub max_instructions_bytes: usize,
781    /// Enable MCP elicitation (servers can request user input mid-task).
782    /// Default: false — all elicitation requests are auto-declined.
783    /// Opt-in because it interrupts agent flow and could be abused by malicious servers.
784    #[serde(default)]
785    pub elicitation_enabled: bool,
786    /// Timeout for user to respond to an elicitation request (seconds). Default: 120.
787    #[serde(default = "default_elicitation_timeout")]
788    pub elicitation_timeout: u64,
789    /// Bounded channel capacity for elicitation events. Requests beyond this limit are
790    /// auto-declined with a warning to prevent memory exhaustion from misbehaving servers.
791    /// Default: 16.
792    #[serde(default = "default_elicitation_queue_capacity")]
793    pub elicitation_queue_capacity: usize,
794    /// When true, warn the user before prompting for fields whose names match sensitive
795    /// patterns (password, token, secret, key, credential, etc.). Default: true.
796    #[serde(default = "default_true")]
797    pub elicitation_warn_sensitive_fields: bool,
798    /// Maximum number of connection attempts for each MCP server at startup.
799    ///
800    /// Value `1` means one attempt with no retry. Value `3` (default) means up to three
801    /// attempts with exponential backoff: 500 ms then 1 s between attempts.
802    ///
803    /// For `max_connect_attempts = N`, the inter-attempt delay sequence is
804    /// `min(500 * 2^(k-1), 8_000) ms` for k = 1..N-1, giving at most ~47 s total backoff
805    /// at the cap of `10`. Must be in `1..=10`.
806    ///
807    /// Note: dynamic `add_server` calls retain single-attempt behaviour regardless of this
808    /// setting; a follow-up issue tracks extending retry there.
809    #[serde(
810        default = "default_max_connect_attempts",
811        deserialize_with = "validate_max_connect_attempts"
812    )]
813    pub max_connect_attempts: u8,
814    /// Lock tool lists after initial connection for all servers.
815    ///
816    /// When `true`, `tools/list_changed` refresh events are rejected for servers that have
817    /// completed their initial connection, preventing mid-session tool injection.
818    /// Default: `false` (opt-in, backward compatible).
819    #[serde(default)]
820    pub lock_tool_list: bool,
821    /// Default env isolation for all Stdio servers. Per-server `env_isolation` overrides this.
822    ///
823    /// When `true`, spawned processes only receive a minimal base env + their declared `env` map.
824    /// Default: `false` (backward compatible).
825    #[serde(default)]
826    pub default_env_isolation: bool,
827    /// When `true`, forward MCP tool output schemas as a hint appended to the tool description.
828    ///
829    /// Disabled by default to preserve Anthropic prompt-cache hit rates. Enabling this mutates
830    /// tool descriptions, which changes the cached hash and causes a one-off cache miss after
831    /// every MCP reconnect or server redeploy.
832    ///
833    /// See `output_schema_hint_bytes` for the budget controlling hint size.
834    #[serde(default)]
835    pub forward_output_schema: bool,
836    /// Maximum bytes of the compact JSON appended to the tool description as the output schema
837    /// hint when `forward_output_schema = true`. Default: 1024.
838    ///
839    /// If the serialized schema exceeds this budget, a stub message is used instead and a WARN
840    /// is emitted once per session per tool.
841    #[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    /// Stdio transport: command to spawn.
873    pub command: Option<String>,
874    #[serde(default)]
875    pub args: Vec<String>,
876    #[serde(default)]
877    pub env: HashMap<String, String>,
878    /// HTTP transport: remote MCP server URL.
879    pub url: Option<String>,
880    #[serde(default = "default_mcp_timeout")]
881    pub timeout: u64,
882    /// Optional declarative policy for this server (allowlist, denylist, rate limit).
883    #[serde(default)]
884    pub policy: McpPolicy,
885    /// Static HTTP headers for the transport (e.g. `Authorization: Bearer <token>`).
886    /// Values support vault references: `${VAULT_KEY}`.
887    #[serde(default)]
888    pub headers: HashMap<String, String>,
889    /// OAuth 2.1 configuration for this server.
890    #[serde(default)]
891    pub oauth: Option<McpOAuthConfig>,
892    /// Trust level for this server. Default: Untrusted.
893    #[serde(default)]
894    pub trust_level: McpTrustLevel,
895    /// Tool allowlist. `None` means no override (inherit defaults).
896    /// `Some(vec![])` is an explicit empty list (deny all for Untrusted/Sandboxed).
897    /// `Some(vec!["a", "b"])` allows only listed tools.
898    #[serde(default)]
899    pub tool_allowlist: Option<Vec<String>>,
900    /// Expected tool names for attestation. Supplements `tool_allowlist`.
901    ///
902    /// When non-empty: tools not in this list are filtered out (Untrusted/Sandboxed)
903    /// or warned about (Trusted). Schema drift is logged when fingerprints change
904    /// between connections.
905    #[serde(default)]
906    pub expected_tools: Vec<String>,
907    /// Filesystem roots exposed to this MCP server via `roots/list`.
908    /// Each entry is a `{uri, name?}` pair. URI must use `file://` scheme.
909    /// When empty, the server receives an empty roots list.
910    #[serde(default)]
911    pub roots: Vec<McpRootEntry>,
912    /// Per-tool security metadata overrides. Keys are tool names.
913    /// When absent for a tool, metadata is inferred from the tool name via heuristics.
914    #[serde(default)]
915    pub tool_metadata: HashMap<String, ToolSecurityMeta>,
916    /// Per-server elicitation override. `None` = inherit global `elicitation_enabled`.
917    /// `Some(true)` = allow this server to elicit regardless of global setting.
918    /// `Some(false)` = always decline for this server.
919    #[serde(default)]
920    pub elicitation_enabled: Option<bool>,
921    /// Isolate the environment for this Stdio server.
922    ///
923    /// When `true` (or when `[mcp].default_env_isolation = true`), the spawned process
924    /// only sees a minimal base env (`PATH`, `HOME`, etc.) plus this server's `env` map.
925    /// Overrides `[mcp].default_env_isolation` when set explicitly.
926    /// Default: `false` (backward compatible).
927    #[serde(default)]
928    pub env_isolation: Option<bool>,
929}
930
931/// A filesystem root exposed to an MCP server via `roots/list`.
932#[derive(Debug, Clone, Deserialize, Serialize)]
933pub struct McpRootEntry {
934    /// URI of the root directory. Must use `file://` scheme.
935    pub uri: String,
936    /// Optional human-readable name for this root.
937    #[serde(default)]
938    pub name: Option<String>,
939}
940
941/// OAuth 2.1 configuration for an MCP server.
942#[derive(Debug, Clone, Deserialize, Serialize)]
943pub struct McpOAuthConfig {
944    /// Enable OAuth 2.1 for this server.
945    #[serde(default)]
946    pub enabled: bool,
947    /// Token storage backend.
948    #[serde(default)]
949    pub token_storage: OAuthTokenStorage,
950    /// OAuth scopes to request. Empty = server default.
951    #[serde(default)]
952    pub scopes: Vec<String>,
953    /// Port for the local callback server. `0` = auto-assign, `18766` = default fixed port.
954    #[serde(default = "default_oauth_callback_port")]
955    pub callback_port: u16,
956    /// Client name sent during dynamic registration.
957    #[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/// Where OAuth tokens are stored.
974#[derive(Debug, Clone, Default, Deserialize, Serialize)]
975#[serde(rename_all = "lowercase")]
976pub enum OAuthTokenStorage {
977    /// Persisted in the age vault (default).
978    #[default]
979    Vault,
980    /// In-memory only — tokens lost on restart.
981    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        // Redact header values to avoid leaking tokens in logs.
992        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}