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 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        // "web-" itself — prefix is "web-", remainder after stripping is "", which is the name
221        // glob_match("web-*", "web-") → prefix="web-", name.starts_with("web-") is true, len > prefix
222        // but name == "web-" means remainder is "", so starts_with returns true, let's verify:
223        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/// Telegram channel configuration, nested under `[telegram]` in TOML.
279///
280/// When present, Zeph connects to Telegram as a bot using the provided token.
281/// The token must be resolved from the vault at runtime via `ZEPH_TELEGRAM_TOKEN`.
282///
283/// # Example (TOML)
284///
285/// ```toml
286/// [telegram]
287/// allowed_users = ["myusername"]
288/// ```
289#[derive(Clone, Deserialize, Serialize)]
290pub struct TelegramConfig {
291    /// Bot token. Set to `None` and resolve from vault via `ZEPH_TELEGRAM_TOKEN`.
292    pub token: Option<String>,
293    /// Telegram usernames allowed to interact with the bot (empty = allow all).
294    #[serde(default)]
295    pub allowed_users: Vec<String>,
296    /// Skill allowlist for this channel.
297    #[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]"), // lgtm[rust/cleartext-logging]
361            )
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/// An IBCT signing key entry in the A2A server configuration.
372///
373/// Multiple entries allow key rotation: keep old keys until all tokens signed with them expire.
374#[derive(Debug, Clone, Deserialize, Serialize)]
375pub struct IbctKeyConfig {
376    /// Unique key identifier. Must match the `key_id` field in issued IBCT tokens.
377    pub key_id: String,
378    /// Hex-encoded HMAC-SHA256 signing key.
379    pub key_hex: String,
380}
381
382fn default_ibct_ttl() -> u64 {
383    300
384}
385
386/// A2A server configuration, nested under `[a2a]` in TOML.
387///
388/// Controls the Agent-to-Agent HTTP server that exposes the agent via the A2A protocol.
389/// The `AgentCard` served at `/.well-known/agent.json` is built from these settings combined
390/// with runtime-detected capabilities (`images`, `audio`) and the opt-in `advertise_files` flag.
391#[derive(Deserialize, Serialize)]
392#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic here
393pub 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    /// When `true`, all requests are rejected with 401 if no `auth_token` is configured.
415    /// Default `false` for backward compatibility — existing deployments without a token
416    /// continue to operate. Set to `true` in production when authentication is mandatory.
417    #[serde(default)]
418    pub require_auth: bool,
419    /// IBCT signing keys for per-task delegation scoping.
420    ///
421    /// When non-empty, all A2A task requests must include a valid `X-Zeph-IBCT` header
422    /// signed with one of these keys. Multiple keys allow key rotation without downtime.
423    #[serde(default)]
424    pub ibct_keys: Vec<IbctKeyConfig>,
425    /// Vault key name to resolve the primary IBCT signing key at startup (MF-3 fix).
426    ///
427    /// When set, the vault key is resolved at startup and used to construct an
428    /// `IbctKey` with `key_id = "primary"`. Takes precedence over `ibct_keys[0]` if both
429    /// are set.  Example: `"ZEPH_A2A_IBCT_KEY"`.
430    #[serde(default)]
431    pub ibct_signing_key_vault_ref: Option<String>,
432    /// TTL (seconds) for issued IBCT tokens. Default: 300 (5 minutes).
433    #[serde(default = "default_ibct_ttl")]
434    pub ibct_ttl_secs: u64,
435    /// Advertise non-media file attachment capability on the `AgentCard`.
436    ///
437    /// When `true`, the served `/.well-known/agent.json` sets `capabilities.files = true`,
438    /// signalling to peer agents that this agent can receive `Part::File` entries that are
439    /// not image or audio (e.g., documents, archives).
440    ///
441    /// Default `false` because generic file attachments have no built-in ingestion path in
442    /// the current agent loop. Set to `true` only when the deployed agent has skills or MCP
443    /// tools that can consume file parts; otherwise the card would advertise a capability
444    /// the agent silently drops.
445    ///
446    /// Note: `images` and `audio` capability flags are auto-detected from the active LLM
447    /// provider and STT configuration — no manual override is needed for those.
448    #[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/// Dynamic MCP tool context pruning configuration (#2204).
503///
504/// When enabled, an LLM call evaluates which MCP tools are relevant to the current task
505/// before sending tool schemas to the main LLM, reducing context usage and improving
506/// tool selection accuracy for servers with many tools.
507#[derive(Debug, Clone, Deserialize, Serialize)]
508#[serde(default)]
509pub struct ToolPruningConfig {
510    /// Enable dynamic tool pruning. Default: `false` (opt-in).
511    pub enabled: bool,
512    /// Maximum number of MCP tools to include after pruning.
513    pub max_tools: usize,
514    /// Provider name from `[[llm.providers]]` for the pruning LLM call.
515    /// Should be a fast/cheap model. Empty string = use the default provider.
516    pub pruning_provider: ProviderName,
517    /// Minimum number of MCP tools below which pruning is skipped.
518    pub min_tools_to_prune: usize,
519    /// Tool names that are never pruned (always included in the result).
520    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/// MCP tool discovery strategy (config-side representation).
536///
537/// Converted to `zeph_mcp::ToolDiscoveryStrategy` in `zeph-core` to avoid a
538/// circular crate dependency (`zeph-config` → `zeph-mcp`).
539#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
540#[serde(rename_all = "lowercase")]
541pub enum ToolDiscoveryStrategyConfig {
542    /// Embedding-based cosine similarity retrieval.  Fast, no LLM call per turn.
543    Embedding,
544    /// LLM-based pruning via `prune_tools_cached`.  Existing behavior.
545    Llm,
546    /// No filtering — all tools are passed through.  This is the default.
547    #[default]
548    None,
549}
550
551/// MCP tool discovery configuration (#2321).
552///
553/// Nested under `[mcp.tool_discovery]`.  When `strategy = "embedding"`, the
554/// `mcp.pruning` section is ignored for this session — the embedding path
555/// supersedes LLM pruning entirely.
556#[derive(Debug, Clone, Deserialize, Serialize)]
557#[serde(default)]
558pub struct ToolDiscoveryConfig {
559    /// Discovery strategy.  Default: `none` (all tools, safe default).
560    pub strategy: ToolDiscoveryStrategyConfig,
561    /// Number of top-scoring tools to include per turn (embedding strategy only).
562    pub top_k: usize,
563    /// Minimum cosine similarity for a tool to be included (embedding strategy only).
564    pub min_similarity: f32,
565    /// Provider name from `[[llm.providers]]` for embedding computation.
566    /// Should reference a fast/cheap embedding model.  Empty = use the agent's
567    /// default embedding provider.
568    pub embedding_provider: ProviderName,
569    /// Tool names always included regardless of similarity score.
570    pub always_include: Vec<String>,
571    /// Minimum tool count below which discovery is skipped (all tools passed through).
572    pub min_tools_to_filter: usize,
573    /// When `true`, treat any embedding failure as a hard error instead of silently
574    /// falling back to all tools.  Default: `false` (soft fallback).
575    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/// Trust calibration configuration, nested under `[mcp.trust_calibration]`.
593#[derive(Debug, Clone, Deserialize, Serialize)]
594#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
595pub struct TrustCalibrationConfig {
596    /// Enable trust calibration (default: false — opt-in).
597    #[serde(default)]
598    pub enabled: bool,
599    /// Run pre-invocation probe on connect (Phase 1).
600    #[serde(default = "default_true")]
601    pub probe_on_connect: bool,
602    /// Monitor invocations for trust score updates (Phase 2).
603    #[serde(default = "default_true")]
604    pub monitor_invocations: bool,
605    /// Persist trust scores to `SQLite` (Phase 3).
606    #[serde(default = "default_true")]
607    pub persist_scores: bool,
608    /// Per-day decay rate applied to trust scores above 0.5.
609    #[serde(default = "default_decay_rate")]
610    pub decay_rate_per_day: f64,
611    /// Score penalty applied when injection is detected.
612    #[serde(default = "default_injection_penalty")]
613    pub injection_penalty: f64,
614    /// Optional LLM provider for trust verification. Empty = disabled.
615    #[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)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
679#[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    /// Dynamic tool pruning for context optimization.
688    #[serde(default)]
689    pub pruning: ToolPruningConfig,
690    /// Trust calibration settings (opt-in, disabled by default).
691    #[serde(default)]
692    pub trust_calibration: TrustCalibrationConfig,
693    /// Embedding-based tool discovery (#2321).
694    #[serde(default)]
695    pub tool_discovery: ToolDiscoveryConfig,
696    /// Maximum byte length for MCP tool descriptions. Truncated with "..." if exceeded. Default: 2048.
697    #[serde(default = "default_max_description_bytes")]
698    pub max_description_bytes: usize,
699    /// Maximum byte length for MCP server instructions. Truncated with "..." if exceeded. Default: 2048.
700    #[serde(default = "default_max_instructions_bytes")]
701    pub max_instructions_bytes: usize,
702    /// Enable MCP elicitation (servers can request user input mid-task).
703    /// Default: false — all elicitation requests are auto-declined.
704    /// Opt-in because it interrupts agent flow and could be abused by malicious servers.
705    #[serde(default)]
706    pub elicitation_enabled: bool,
707    /// Timeout for user to respond to an elicitation request (seconds). Default: 120.
708    #[serde(default = "default_elicitation_timeout")]
709    pub elicitation_timeout: u64,
710    /// Bounded channel capacity for elicitation events. Requests beyond this limit are
711    /// auto-declined with a warning to prevent memory exhaustion from misbehaving servers.
712    /// Default: 16.
713    #[serde(default = "default_elicitation_queue_capacity")]
714    pub elicitation_queue_capacity: usize,
715    /// When true, warn the user before prompting for fields whose names match sensitive
716    /// patterns (password, token, secret, key, credential, etc.). Default: true.
717    #[serde(default = "default_true")]
718    pub elicitation_warn_sensitive_fields: bool,
719    /// Maximum number of connection attempts for each MCP server at startup.
720    ///
721    /// Value `1` means one attempt with no retry. Value `3` (default) means up to three
722    /// attempts with exponential backoff: 500 ms then 1 s between attempts.
723    ///
724    /// For `max_connect_attempts = N`, the inter-attempt delay sequence is
725    /// `min(500 * 2^(k-1), 8_000) ms` for k = 1..N-1, giving at most ~47 s total backoff
726    /// at the cap of `10`. Must be in `1..=10`.
727    ///
728    /// Note: dynamic `add_server` calls retain single-attempt behaviour regardless of this
729    /// setting; a follow-up issue tracks extending retry there.
730    #[serde(
731        default = "default_max_connect_attempts",
732        deserialize_with = "validate_max_connect_attempts"
733    )]
734    pub max_connect_attempts: u8,
735    /// Lock tool lists after initial connection for all servers.
736    ///
737    /// When `true`, `tools/list_changed` refresh events are rejected for servers that have
738    /// completed their initial connection, preventing mid-session tool injection.
739    /// Default: `false` (opt-in, backward compatible).
740    #[serde(default)]
741    pub lock_tool_list: bool,
742    /// Default env isolation for all Stdio servers. Per-server `env_isolation` overrides this.
743    ///
744    /// When `true`, spawned processes only receive a minimal base env + their declared `env` map.
745    /// Default: `false` (backward compatible).
746    #[serde(default)]
747    pub default_env_isolation: bool,
748    /// When `true`, forward MCP tool output schemas as a hint appended to the tool description.
749    ///
750    /// Disabled by default to preserve Anthropic prompt-cache hit rates. Enabling this mutates
751    /// tool descriptions, which changes the cached hash and causes a one-off cache miss after
752    /// every MCP reconnect or server redeploy.
753    ///
754    /// See `output_schema_hint_bytes` for the budget controlling hint size.
755    #[serde(default)]
756    pub forward_output_schema: bool,
757    /// Maximum bytes of the compact JSON appended to the tool description as the output schema
758    /// hint when `forward_output_schema = true`. Default: 1024.
759    ///
760    /// If the serialized schema exceeds this budget, a stub message is used instead and a WARN
761    /// is emitted once per session per tool.
762    #[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    /// Stdio transport: command to spawn.
794    pub command: Option<String>,
795    #[serde(default)]
796    pub args: Vec<String>,
797    #[serde(default)]
798    pub env: HashMap<String, String>,
799    /// HTTP transport: remote MCP server URL.
800    pub url: Option<String>,
801    #[serde(default = "default_mcp_timeout")]
802    pub timeout: u64,
803    /// Optional declarative policy for this server (allowlist, denylist, rate limit).
804    #[serde(default)]
805    pub policy: McpPolicy,
806    /// Static HTTP headers for the transport (e.g. `Authorization: Bearer <token>`).
807    /// Values support vault references: `${VAULT_KEY}`.
808    #[serde(default)]
809    pub headers: HashMap<String, String>,
810    /// OAuth 2.1 configuration for this server.
811    #[serde(default)]
812    pub oauth: Option<McpOAuthConfig>,
813    /// Trust level for this server. Default: Untrusted.
814    #[serde(default)]
815    pub trust_level: McpTrustLevel,
816    /// Tool allowlist. `None` means no override (inherit defaults).
817    /// `Some(vec![])` is an explicit empty list (deny all for Untrusted/Sandboxed).
818    /// `Some(vec!["a", "b"])` allows only listed tools.
819    #[serde(default)]
820    pub tool_allowlist: Option<Vec<String>>,
821    /// Expected tool names for attestation. Supplements `tool_allowlist`.
822    ///
823    /// When non-empty: tools not in this list are filtered out (Untrusted/Sandboxed)
824    /// or warned about (Trusted). Schema drift is logged when fingerprints change
825    /// between connections.
826    #[serde(default)]
827    pub expected_tools: Vec<String>,
828    /// Filesystem roots exposed to this MCP server via `roots/list`.
829    /// Each entry is a `{uri, name?}` pair. URI must use `file://` scheme.
830    /// When empty, the server receives an empty roots list.
831    #[serde(default)]
832    pub roots: Vec<McpRootEntry>,
833    /// Per-tool security metadata overrides. Keys are tool names.
834    /// When absent for a tool, metadata is inferred from the tool name via heuristics.
835    #[serde(default)]
836    pub tool_metadata: HashMap<String, ToolSecurityMeta>,
837    /// Per-server elicitation override. `None` = inherit global `elicitation_enabled`.
838    /// `Some(true)` = allow this server to elicit regardless of global setting.
839    /// `Some(false)` = always decline for this server.
840    #[serde(default)]
841    pub elicitation_enabled: Option<bool>,
842    /// Isolate the environment for this Stdio server.
843    ///
844    /// When `true` (or when `[mcp].default_env_isolation = true`), the spawned process
845    /// only sees a minimal base env (`PATH`, `HOME`, etc.) plus this server's `env` map.
846    /// Overrides `[mcp].default_env_isolation` when set explicitly.
847    /// Default: `false` (backward compatible).
848    #[serde(default)]
849    pub env_isolation: Option<bool>,
850}
851
852/// A filesystem root exposed to an MCP server via `roots/list`.
853#[derive(Debug, Clone, Deserialize, Serialize)]
854pub struct McpRootEntry {
855    /// URI of the root directory. Must use `file://` scheme.
856    pub uri: String,
857    /// Optional human-readable name for this root.
858    #[serde(default)]
859    pub name: Option<String>,
860}
861
862/// OAuth 2.1 configuration for an MCP server.
863#[derive(Debug, Clone, Deserialize, Serialize)]
864pub struct McpOAuthConfig {
865    /// Enable OAuth 2.1 for this server.
866    #[serde(default)]
867    pub enabled: bool,
868    /// Token storage backend.
869    #[serde(default)]
870    pub token_storage: OAuthTokenStorage,
871    /// OAuth scopes to request. Empty = server default.
872    #[serde(default)]
873    pub scopes: Vec<String>,
874    /// Port for the local callback server. `0` = auto-assign, `18766` = default fixed port.
875    #[serde(default = "default_oauth_callback_port")]
876    pub callback_port: u16,
877    /// Client name sent during dynamic registration.
878    #[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/// Where OAuth tokens are stored.
895#[derive(Debug, Clone, Default, Deserialize, Serialize)]
896#[serde(rename_all = "lowercase")]
897pub enum OAuthTokenStorage {
898    /// Persisted in the age vault (default).
899    #[default]
900    Vault,
901    /// In-memory only — tokens lost on restart.
902    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        // Redact header values to avoid leaking tokens in logs.
913        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}