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 wildcard_star_allows_any_skill() {
133        let cfg = allow(&["*"]);
134        assert!(is_skill_allowed("anything", &cfg));
135        assert!(is_skill_allowed("web-search", &cfg));
136    }
137
138    #[test]
139    fn empty_allowlist_denies_all() {
140        let cfg = allow(&[]);
141        assert!(!is_skill_allowed("web-search", &cfg));
142        assert!(!is_skill_allowed("shell", &cfg));
143    }
144
145    #[test]
146    fn exact_match_allows_only_that_skill() {
147        let cfg = allow(&["web-search"]);
148        assert!(is_skill_allowed("web-search", &cfg));
149        assert!(!is_skill_allowed("shell", &cfg));
150        assert!(!is_skill_allowed("web-search-extra", &cfg));
151    }
152
153    #[test]
154    fn prefix_wildcard_allows_matching_skills() {
155        let cfg = allow(&["web-*"]);
156        assert!(is_skill_allowed("web-search", &cfg));
157        assert!(is_skill_allowed("web-fetch", &cfg));
158        assert!(!is_skill_allowed("shell", &cfg));
159        assert!(!is_skill_allowed("awesome-web-thing", &cfg));
160    }
161
162    #[test]
163    fn multiple_patterns_or_logic() {
164        let cfg = allow(&["shell", "web-*"]);
165        assert!(is_skill_allowed("shell", &cfg));
166        assert!(is_skill_allowed("web-search", &cfg));
167        assert!(!is_skill_allowed("memory", &cfg));
168    }
169
170    #[test]
171    fn default_config_allows_all() {
172        let cfg = ChannelSkillsConfig::default();
173        assert!(is_skill_allowed("any-skill", &cfg));
174    }
175
176    #[test]
177    fn prefix_wildcard_does_not_match_empty_suffix() {
178        let cfg = allow(&["web-*"]);
179        // "web-" itself — prefix is "web-", remainder after stripping is "", which is the name
180        // glob_match("web-*", "web-") → prefix="web-", name.starts_with("web-") is true, len > prefix
181        // but name == "web-" means remainder is "", so starts_with returns true, let's verify:
182        assert!(is_skill_allowed("web-", &cfg));
183    }
184
185    #[test]
186    fn matching_is_case_sensitive() {
187        let cfg = allow(&["Web-Search"]);
188        assert!(!is_skill_allowed("web-search", &cfg));
189        assert!(is_skill_allowed("Web-Search", &cfg));
190    }
191}
192
193fn default_slack_port() -> u16 {
194    3000
195}
196
197fn default_slack_webhook_host() -> String {
198    "127.0.0.1".into()
199}
200
201fn default_a2a_host() -> String {
202    "0.0.0.0".into()
203}
204
205fn default_a2a_port() -> u16 {
206    8080
207}
208
209fn default_a2a_rate_limit() -> u32 {
210    60
211}
212
213fn default_a2a_max_body() -> usize {
214    1_048_576
215}
216
217fn default_drain_timeout_ms() -> u64 {
218    30_000
219}
220
221fn default_max_dynamic_servers() -> usize {
222    10
223}
224
225fn default_mcp_timeout() -> u64 {
226    30
227}
228
229fn default_oauth_callback_port() -> u16 {
230    18766
231}
232
233fn default_oauth_client_name() -> String {
234    "Zeph".into()
235}
236
237/// Telegram channel configuration, nested under `[telegram]` in TOML.
238///
239/// When present, Zeph connects to Telegram as a bot using the provided token.
240/// The token must be resolved from the vault at runtime via `ZEPH_TELEGRAM_TOKEN`.
241///
242/// # Example (TOML)
243///
244/// ```toml
245/// [telegram]
246/// allowed_users = ["myusername"]
247/// ```
248#[derive(Clone, Deserialize, Serialize)]
249pub struct TelegramConfig {
250    /// Bot token. Set to `None` and resolve from vault via `ZEPH_TELEGRAM_TOKEN`.
251    pub token: Option<String>,
252    /// Telegram usernames allowed to interact with the bot (empty = allow all).
253    #[serde(default)]
254    pub allowed_users: Vec<String>,
255    /// Skill allowlist for this channel.
256    #[serde(default)]
257    pub skills: ChannelSkillsConfig,
258}
259
260impl std::fmt::Debug for TelegramConfig {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        f.debug_struct("TelegramConfig")
263            .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
264            .field("allowed_users", &self.allowed_users)
265            .field("skills", &self.skills)
266            .finish()
267    }
268}
269
270#[derive(Clone, Deserialize, Serialize)]
271pub struct DiscordConfig {
272    pub token: Option<String>,
273    pub application_id: Option<String>,
274    #[serde(default)]
275    pub allowed_user_ids: Vec<String>,
276    #[serde(default)]
277    pub allowed_role_ids: Vec<String>,
278    #[serde(default)]
279    pub allowed_channel_ids: Vec<String>,
280    #[serde(default)]
281    pub skills: ChannelSkillsConfig,
282}
283
284impl std::fmt::Debug for DiscordConfig {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        f.debug_struct("DiscordConfig")
287            .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
288            .field("application_id", &self.application_id)
289            .field("allowed_user_ids", &self.allowed_user_ids)
290            .field("allowed_role_ids", &self.allowed_role_ids)
291            .field("allowed_channel_ids", &self.allowed_channel_ids)
292            .field("skills", &self.skills)
293            .finish()
294    }
295}
296
297#[derive(Clone, Deserialize, Serialize)]
298pub struct SlackConfig {
299    pub bot_token: Option<String>,
300    pub signing_secret: Option<String>,
301    #[serde(default = "default_slack_webhook_host")]
302    pub webhook_host: String,
303    #[serde(default = "default_slack_port")]
304    pub port: u16,
305    #[serde(default)]
306    pub allowed_user_ids: Vec<String>,
307    #[serde(default)]
308    pub allowed_channel_ids: Vec<String>,
309    #[serde(default)]
310    pub skills: ChannelSkillsConfig,
311}
312
313impl std::fmt::Debug for SlackConfig {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        f.debug_struct("SlackConfig")
316            .field("bot_token", &self.bot_token.as_ref().map(|_| "[REDACTED]"))
317            .field(
318                "signing_secret",
319                &self.signing_secret.as_ref().map(|_| "[REDACTED]"), // lgtm[rust/cleartext-logging]
320            )
321            .field("webhook_host", &self.webhook_host)
322            .field("port", &self.port)
323            .field("allowed_user_ids", &self.allowed_user_ids)
324            .field("allowed_channel_ids", &self.allowed_channel_ids)
325            .field("skills", &self.skills)
326            .finish()
327    }
328}
329
330/// An IBCT signing key entry in the A2A server configuration.
331///
332/// Multiple entries allow key rotation: keep old keys until all tokens signed with them expire.
333#[derive(Debug, Clone, Deserialize, Serialize)]
334pub struct IbctKeyConfig {
335    /// Unique key identifier. Must match the `key_id` field in issued IBCT tokens.
336    pub key_id: String,
337    /// Hex-encoded HMAC-SHA256 signing key.
338    pub key_hex: String,
339}
340
341fn default_ibct_ttl() -> u64 {
342    300
343}
344
345/// A2A server configuration, nested under `[a2a]` in TOML.
346///
347/// Controls the Agent-to-Agent HTTP server that exposes the agent via the A2A protocol.
348/// The `AgentCard` served at `/.well-known/agent.json` is built from these settings combined
349/// with runtime-detected capabilities (`images`, `audio`) and the opt-in `advertise_files` flag.
350#[derive(Deserialize, Serialize)]
351#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic here
352pub struct A2aServerConfig {
353    #[serde(default)]
354    pub enabled: bool,
355    #[serde(default = "default_a2a_host")]
356    pub host: String,
357    #[serde(default = "default_a2a_port")]
358    pub port: u16,
359    #[serde(default)]
360    pub public_url: String,
361    #[serde(default)]
362    pub auth_token: Option<String>,
363    #[serde(default = "default_a2a_rate_limit")]
364    pub rate_limit: u32,
365    #[serde(default = "default_true")]
366    pub require_tls: bool,
367    #[serde(default = "default_true")]
368    pub ssrf_protection: bool,
369    #[serde(default = "default_a2a_max_body")]
370    pub max_body_size: usize,
371    #[serde(default = "default_drain_timeout_ms")]
372    pub drain_timeout_ms: u64,
373    /// When `true`, all requests are rejected with 401 if no `auth_token` is configured.
374    /// Default `false` for backward compatibility — existing deployments without a token
375    /// continue to operate. Set to `true` in production when authentication is mandatory.
376    #[serde(default)]
377    pub require_auth: bool,
378    /// IBCT signing keys for per-task delegation scoping.
379    ///
380    /// When non-empty, all A2A task requests must include a valid `X-Zeph-IBCT` header
381    /// signed with one of these keys. Multiple keys allow key rotation without downtime.
382    #[serde(default)]
383    pub ibct_keys: Vec<IbctKeyConfig>,
384    /// Vault key name to resolve the primary IBCT signing key at startup (MF-3 fix).
385    ///
386    /// When set, the vault key is resolved at startup and used to construct an
387    /// `IbctKey` with `key_id = "primary"`. Takes precedence over `ibct_keys[0]` if both
388    /// are set.  Example: `"ZEPH_A2A_IBCT_KEY"`.
389    #[serde(default)]
390    pub ibct_signing_key_vault_ref: Option<String>,
391    /// TTL (seconds) for issued IBCT tokens. Default: 300 (5 minutes).
392    #[serde(default = "default_ibct_ttl")]
393    pub ibct_ttl_secs: u64,
394    /// Advertise non-media file attachment capability on the `AgentCard`.
395    ///
396    /// When `true`, the served `/.well-known/agent.json` sets `capabilities.files = true`,
397    /// signalling to peer agents that this agent can receive `Part::File` entries that are
398    /// not image or audio (e.g., documents, archives).
399    ///
400    /// Default `false` because generic file attachments have no built-in ingestion path in
401    /// the current agent loop. Set to `true` only when the deployed agent has skills or MCP
402    /// tools that can consume file parts; otherwise the card would advertise a capability
403    /// the agent silently drops.
404    ///
405    /// Note: `images` and `audio` capability flags are auto-detected from the active LLM
406    /// provider and STT configuration — no manual override is needed for those.
407    #[serde(default)]
408    pub advertise_files: bool,
409}
410
411impl std::fmt::Debug for A2aServerConfig {
412    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
413        f.debug_struct("A2aServerConfig")
414            .field("enabled", &self.enabled)
415            .field("host", &self.host)
416            .field("port", &self.port)
417            .field("public_url", &self.public_url)
418            .field(
419                "auth_token",
420                &self.auth_token.as_ref().map(|_| "[REDACTED]"),
421            )
422            .field("rate_limit", &self.rate_limit)
423            .field("require_tls", &self.require_tls)
424            .field("ssrf_protection", &self.ssrf_protection)
425            .field("max_body_size", &self.max_body_size)
426            .field("drain_timeout_ms", &self.drain_timeout_ms)
427            .field("require_auth", &self.require_auth)
428            .field("ibct_keys_count", &self.ibct_keys.len())
429            .field(
430                "ibct_signing_key_vault_ref",
431                &self.ibct_signing_key_vault_ref,
432            )
433            .field("ibct_ttl_secs", &self.ibct_ttl_secs)
434            .field("advertise_files", &self.advertise_files)
435            .finish()
436    }
437}
438
439impl Default for A2aServerConfig {
440    fn default() -> Self {
441        Self {
442            enabled: false,
443            host: default_a2a_host(),
444            port: default_a2a_port(),
445            public_url: String::new(),
446            auth_token: None,
447            rate_limit: default_a2a_rate_limit(),
448            require_tls: true,
449            ssrf_protection: true,
450            max_body_size: default_a2a_max_body(),
451            drain_timeout_ms: default_drain_timeout_ms(),
452            require_auth: false,
453            ibct_keys: Vec::new(),
454            ibct_signing_key_vault_ref: None,
455            ibct_ttl_secs: default_ibct_ttl(),
456            advertise_files: false,
457        }
458    }
459}
460
461/// Dynamic MCP tool context pruning configuration (#2204).
462///
463/// When enabled, an LLM call evaluates which MCP tools are relevant to the current task
464/// before sending tool schemas to the main LLM, reducing context usage and improving
465/// tool selection accuracy for servers with many tools.
466#[derive(Debug, Clone, Deserialize, Serialize)]
467#[serde(default)]
468pub struct ToolPruningConfig {
469    /// Enable dynamic tool pruning. Default: `false` (opt-in).
470    pub enabled: bool,
471    /// Maximum number of MCP tools to include after pruning.
472    pub max_tools: usize,
473    /// Provider name from `[[llm.providers]]` for the pruning LLM call.
474    /// Should be a fast/cheap model. Empty string = use the default provider.
475    pub pruning_provider: ProviderName,
476    /// Minimum number of MCP tools below which pruning is skipped.
477    pub min_tools_to_prune: usize,
478    /// Tool names that are never pruned (always included in the result).
479    pub always_include: Vec<String>,
480}
481
482impl Default for ToolPruningConfig {
483    fn default() -> Self {
484        Self {
485            enabled: false,
486            max_tools: 15,
487            pruning_provider: ProviderName::default(),
488            min_tools_to_prune: 10,
489            always_include: Vec::new(),
490        }
491    }
492}
493
494/// MCP tool discovery strategy (config-side representation).
495///
496/// Converted to `zeph_mcp::ToolDiscoveryStrategy` in `zeph-core` to avoid a
497/// circular crate dependency (`zeph-config` → `zeph-mcp`).
498#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
499#[serde(rename_all = "lowercase")]
500pub enum ToolDiscoveryStrategyConfig {
501    /// Embedding-based cosine similarity retrieval.  Fast, no LLM call per turn.
502    Embedding,
503    /// LLM-based pruning via `prune_tools_cached`.  Existing behavior.
504    Llm,
505    /// No filtering — all tools are passed through.  This is the default.
506    #[default]
507    None,
508}
509
510/// MCP tool discovery configuration (#2321).
511///
512/// Nested under `[mcp.tool_discovery]`.  When `strategy = "embedding"`, the
513/// `mcp.pruning` section is ignored for this session — the embedding path
514/// supersedes LLM pruning entirely.
515#[derive(Debug, Clone, Deserialize, Serialize)]
516#[serde(default)]
517pub struct ToolDiscoveryConfig {
518    /// Discovery strategy.  Default: `none` (all tools, safe default).
519    pub strategy: ToolDiscoveryStrategyConfig,
520    /// Number of top-scoring tools to include per turn (embedding strategy only).
521    pub top_k: usize,
522    /// Minimum cosine similarity for a tool to be included (embedding strategy only).
523    pub min_similarity: f32,
524    /// Provider name from `[[llm.providers]]` for embedding computation.
525    /// Should reference a fast/cheap embedding model.  Empty = use the agent's
526    /// default embedding provider.
527    pub embedding_provider: ProviderName,
528    /// Tool names always included regardless of similarity score.
529    pub always_include: Vec<String>,
530    /// Minimum tool count below which discovery is skipped (all tools passed through).
531    pub min_tools_to_filter: usize,
532    /// When `true`, treat any embedding failure as a hard error instead of silently
533    /// falling back to all tools.  Default: `false` (soft fallback).
534    pub strict: bool,
535}
536
537impl Default for ToolDiscoveryConfig {
538    fn default() -> Self {
539        Self {
540            strategy: ToolDiscoveryStrategyConfig::None,
541            top_k: 10,
542            min_similarity: 0.2,
543            embedding_provider: ProviderName::default(),
544            always_include: Vec::new(),
545            min_tools_to_filter: 10,
546            strict: false,
547        }
548    }
549}
550
551/// Trust calibration configuration, nested under `[mcp.trust_calibration]`.
552#[derive(Debug, Clone, Deserialize, Serialize)]
553#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
554pub struct TrustCalibrationConfig {
555    /// Enable trust calibration (default: false — opt-in).
556    #[serde(default)]
557    pub enabled: bool,
558    /// Run pre-invocation probe on connect (Phase 1).
559    #[serde(default = "default_true")]
560    pub probe_on_connect: bool,
561    /// Monitor invocations for trust score updates (Phase 2).
562    #[serde(default = "default_true")]
563    pub monitor_invocations: bool,
564    /// Persist trust scores to `SQLite` (Phase 3).
565    #[serde(default = "default_true")]
566    pub persist_scores: bool,
567    /// Per-day decay rate applied to trust scores above 0.5.
568    #[serde(default = "default_decay_rate")]
569    pub decay_rate_per_day: f64,
570    /// Score penalty applied when injection is detected.
571    #[serde(default = "default_injection_penalty")]
572    pub injection_penalty: f64,
573    /// Optional LLM provider for trust verification. Empty = disabled.
574    #[serde(default)]
575    pub verifier_provider: ProviderName,
576}
577
578fn default_decay_rate() -> f64 {
579    0.01
580}
581
582fn default_injection_penalty() -> f64 {
583    0.25
584}
585
586impl Default for TrustCalibrationConfig {
587    fn default() -> Self {
588        Self {
589            enabled: false,
590            probe_on_connect: true,
591            monitor_invocations: true,
592            persist_scores: true,
593            decay_rate_per_day: default_decay_rate(),
594            injection_penalty: default_injection_penalty(),
595            verifier_provider: ProviderName::default(),
596        }
597    }
598}
599
600fn default_max_description_bytes() -> usize {
601    2048
602}
603
604fn default_max_instructions_bytes() -> usize {
605    2048
606}
607
608fn default_elicitation_timeout() -> u64 {
609    120
610}
611
612fn default_elicitation_queue_capacity() -> usize {
613    16
614}
615
616fn default_output_schema_hint_bytes() -> usize {
617    1024
618}
619
620#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
621#[derive(Debug, Clone, Deserialize, Serialize)]
622pub struct McpConfig {
623    #[serde(default)]
624    pub servers: Vec<McpServerConfig>,
625    #[serde(default)]
626    pub allowed_commands: Vec<String>,
627    #[serde(default = "default_max_dynamic_servers")]
628    pub max_dynamic_servers: usize,
629    /// Dynamic tool pruning for context optimization.
630    #[serde(default)]
631    pub pruning: ToolPruningConfig,
632    /// Trust calibration settings (opt-in, disabled by default).
633    #[serde(default)]
634    pub trust_calibration: TrustCalibrationConfig,
635    /// Embedding-based tool discovery (#2321).
636    #[serde(default)]
637    pub tool_discovery: ToolDiscoveryConfig,
638    /// Maximum byte length for MCP tool descriptions. Truncated with "..." if exceeded. Default: 2048.
639    #[serde(default = "default_max_description_bytes")]
640    pub max_description_bytes: usize,
641    /// Maximum byte length for MCP server instructions. Truncated with "..." if exceeded. Default: 2048.
642    #[serde(default = "default_max_instructions_bytes")]
643    pub max_instructions_bytes: usize,
644    /// Enable MCP elicitation (servers can request user input mid-task).
645    /// Default: false — all elicitation requests are auto-declined.
646    /// Opt-in because it interrupts agent flow and could be abused by malicious servers.
647    #[serde(default)]
648    pub elicitation_enabled: bool,
649    /// Timeout for user to respond to an elicitation request (seconds). Default: 120.
650    #[serde(default = "default_elicitation_timeout")]
651    pub elicitation_timeout: u64,
652    /// Bounded channel capacity for elicitation events. Requests beyond this limit are
653    /// auto-declined with a warning to prevent memory exhaustion from misbehaving servers.
654    /// Default: 16.
655    #[serde(default = "default_elicitation_queue_capacity")]
656    pub elicitation_queue_capacity: usize,
657    /// When true, warn the user before prompting for fields whose names match sensitive
658    /// patterns (password, token, secret, key, credential, etc.). Default: true.
659    #[serde(default = "default_true")]
660    pub elicitation_warn_sensitive_fields: bool,
661    /// Lock tool lists after initial connection for all servers.
662    ///
663    /// When `true`, `tools/list_changed` refresh events are rejected for servers that have
664    /// completed their initial connection, preventing mid-session tool injection.
665    /// Default: `false` (opt-in, backward compatible).
666    #[serde(default)]
667    pub lock_tool_list: bool,
668    /// Default env isolation for all Stdio servers. Per-server `env_isolation` overrides this.
669    ///
670    /// When `true`, spawned processes only receive a minimal base env + their declared `env` map.
671    /// Default: `false` (backward compatible).
672    #[serde(default)]
673    pub default_env_isolation: bool,
674    /// When `true`, forward MCP tool output schemas as a hint appended to the tool description.
675    ///
676    /// Disabled by default to preserve Anthropic prompt-cache hit rates. Enabling this mutates
677    /// tool descriptions, which changes the cached hash and causes a one-off cache miss after
678    /// every MCP reconnect or server redeploy.
679    ///
680    /// See `output_schema_hint_bytes` for the budget controlling hint size.
681    #[serde(default)]
682    pub forward_output_schema: bool,
683    /// Maximum bytes of the compact JSON appended to the tool description as the output schema
684    /// hint when `forward_output_schema = true`. Default: 1024.
685    ///
686    /// If the serialized schema exceeds this budget, a stub message is used instead and a WARN
687    /// is emitted once per session per tool.
688    #[serde(default = "default_output_schema_hint_bytes")]
689    pub output_schema_hint_bytes: usize,
690}
691
692impl Default for McpConfig {
693    fn default() -> Self {
694        Self {
695            servers: Vec::new(),
696            allowed_commands: Vec::new(),
697            max_dynamic_servers: default_max_dynamic_servers(),
698            pruning: ToolPruningConfig::default(),
699            trust_calibration: TrustCalibrationConfig::default(),
700            tool_discovery: ToolDiscoveryConfig::default(),
701            max_description_bytes: default_max_description_bytes(),
702            max_instructions_bytes: default_max_instructions_bytes(),
703            elicitation_enabled: false,
704            elicitation_timeout: default_elicitation_timeout(),
705            elicitation_queue_capacity: default_elicitation_queue_capacity(),
706            elicitation_warn_sensitive_fields: true,
707            lock_tool_list: false,
708            default_env_isolation: false,
709            forward_output_schema: false,
710            output_schema_hint_bytes: default_output_schema_hint_bytes(),
711        }
712    }
713}
714
715#[derive(Clone, Deserialize, Serialize)]
716pub struct McpServerConfig {
717    pub id: String,
718    /// Stdio transport: command to spawn.
719    pub command: Option<String>,
720    #[serde(default)]
721    pub args: Vec<String>,
722    #[serde(default)]
723    pub env: HashMap<String, String>,
724    /// HTTP transport: remote MCP server URL.
725    pub url: Option<String>,
726    #[serde(default = "default_mcp_timeout")]
727    pub timeout: u64,
728    /// Optional declarative policy for this server (allowlist, denylist, rate limit).
729    #[serde(default)]
730    pub policy: McpPolicy,
731    /// Static HTTP headers for the transport (e.g. `Authorization: Bearer <token>`).
732    /// Values support vault references: `${VAULT_KEY}`.
733    #[serde(default)]
734    pub headers: HashMap<String, String>,
735    /// OAuth 2.1 configuration for this server.
736    #[serde(default)]
737    pub oauth: Option<McpOAuthConfig>,
738    /// Trust level for this server. Default: Untrusted.
739    #[serde(default)]
740    pub trust_level: McpTrustLevel,
741    /// Tool allowlist. `None` means no override (inherit defaults).
742    /// `Some(vec![])` is an explicit empty list (deny all for Untrusted/Sandboxed).
743    /// `Some(vec!["a", "b"])` allows only listed tools.
744    #[serde(default)]
745    pub tool_allowlist: Option<Vec<String>>,
746    /// Expected tool names for attestation. Supplements `tool_allowlist`.
747    ///
748    /// When non-empty: tools not in this list are filtered out (Untrusted/Sandboxed)
749    /// or warned about (Trusted). Schema drift is logged when fingerprints change
750    /// between connections.
751    #[serde(default)]
752    pub expected_tools: Vec<String>,
753    /// Filesystem roots exposed to this MCP server via `roots/list`.
754    /// Each entry is a `{uri, name?}` pair. URI must use `file://` scheme.
755    /// When empty, the server receives an empty roots list.
756    #[serde(default)]
757    pub roots: Vec<McpRootEntry>,
758    /// Per-tool security metadata overrides. Keys are tool names.
759    /// When absent for a tool, metadata is inferred from the tool name via heuristics.
760    #[serde(default)]
761    pub tool_metadata: HashMap<String, ToolSecurityMeta>,
762    /// Per-server elicitation override. `None` = inherit global `elicitation_enabled`.
763    /// `Some(true)` = allow this server to elicit regardless of global setting.
764    /// `Some(false)` = always decline for this server.
765    #[serde(default)]
766    pub elicitation_enabled: Option<bool>,
767    /// Isolate the environment for this Stdio server.
768    ///
769    /// When `true` (or when `[mcp].default_env_isolation = true`), the spawned process
770    /// only sees a minimal base env (`PATH`, `HOME`, etc.) plus this server's `env` map.
771    /// Overrides `[mcp].default_env_isolation` when set explicitly.
772    /// Default: `false` (backward compatible).
773    #[serde(default)]
774    pub env_isolation: Option<bool>,
775}
776
777/// A filesystem root exposed to an MCP server via `roots/list`.
778#[derive(Debug, Clone, Deserialize, Serialize)]
779pub struct McpRootEntry {
780    /// URI of the root directory. Must use `file://` scheme.
781    pub uri: String,
782    /// Optional human-readable name for this root.
783    #[serde(default)]
784    pub name: Option<String>,
785}
786
787/// OAuth 2.1 configuration for an MCP server.
788#[derive(Debug, Clone, Deserialize, Serialize)]
789pub struct McpOAuthConfig {
790    /// Enable OAuth 2.1 for this server.
791    #[serde(default)]
792    pub enabled: bool,
793    /// Token storage backend.
794    #[serde(default)]
795    pub token_storage: OAuthTokenStorage,
796    /// OAuth scopes to request. Empty = server default.
797    #[serde(default)]
798    pub scopes: Vec<String>,
799    /// Port for the local callback server. `0` = auto-assign, `18766` = default fixed port.
800    #[serde(default = "default_oauth_callback_port")]
801    pub callback_port: u16,
802    /// Client name sent during dynamic registration.
803    #[serde(default = "default_oauth_client_name")]
804    pub client_name: String,
805}
806
807impl Default for McpOAuthConfig {
808    fn default() -> Self {
809        Self {
810            enabled: false,
811            token_storage: OAuthTokenStorage::default(),
812            scopes: Vec::new(),
813            callback_port: default_oauth_callback_port(),
814            client_name: default_oauth_client_name(),
815        }
816    }
817}
818
819/// Where OAuth tokens are stored.
820#[derive(Debug, Clone, Default, Deserialize, Serialize)]
821#[serde(rename_all = "lowercase")]
822pub enum OAuthTokenStorage {
823    /// Persisted in the age vault (default).
824    #[default]
825    Vault,
826    /// In-memory only — tokens lost on restart.
827    Memory,
828}
829
830impl std::fmt::Debug for McpServerConfig {
831    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
832        let redacted_env: HashMap<&str, &str> = self
833            .env
834            .keys()
835            .map(|k| (k.as_str(), "[REDACTED]"))
836            .collect();
837        // Redact header values to avoid leaking tokens in logs.
838        let redacted_headers: HashMap<&str, &str> = self
839            .headers
840            .keys()
841            .map(|k| (k.as_str(), "[REDACTED]"))
842            .collect();
843        f.debug_struct("McpServerConfig")
844            .field("id", &self.id)
845            .field("command", &self.command)
846            .field("args", &self.args)
847            .field("env", &redacted_env)
848            .field("url", &self.url)
849            .field("timeout", &self.timeout)
850            .field("policy", &self.policy)
851            .field("headers", &redacted_headers)
852            .field("oauth", &self.oauth)
853            .field("trust_level", &self.trust_level)
854            .field("tool_allowlist", &self.tool_allowlist)
855            .field("expected_tools", &self.expected_tools)
856            .field("roots", &self.roots)
857            .field(
858                "tool_metadata_keys",
859                &self.tool_metadata.keys().collect::<Vec<_>>(),
860            )
861            .field("elicitation_enabled", &self.elicitation_enabled)
862            .field("env_isolation", &self.env_isolation)
863            .finish()
864    }
865}