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 zeph_mcp::{McpTrustLevel, tool::ToolSecurityMeta};
12
13fn default_skill_allowlist() -> Vec<String> {
14    vec!["*".into()]
15}
16
17/// Per-channel skill allowlist configuration.
18///
19/// Declares which skills are permitted on a given channel. The config is parsed and
20/// `is_skill_allowed()` is available for callers to check membership. Runtime enforcement
21/// (filtering skills before prompt assembly) is tracked in issue #2507 and not yet wired.
22#[derive(Debug, Clone, Deserialize, Serialize)]
23pub struct ChannelSkillsConfig {
24    /// Skill allowlist. `["*"]` = all skills allowed. `[]` = deny all.
25    /// Supports exact names and `*` wildcard (e.g. `"web-*"` matches `"web-search"`).
26    #[serde(default = "default_skill_allowlist")]
27    pub allowed: Vec<String>,
28}
29
30impl Default for ChannelSkillsConfig {
31    fn default() -> Self {
32        Self {
33            allowed: default_skill_allowlist(),
34        }
35    }
36}
37
38/// Returns `true` if the skill `name` matches any pattern in the allowlist.
39///
40/// Pattern rules: `"*"` matches any name; `"prefix-*"` matches names starting with `"prefix-"`;
41/// exact strings match only themselves. Matching is case-sensitive.
42#[must_use]
43pub fn is_skill_allowed(name: &str, config: &ChannelSkillsConfig) -> bool {
44    config.allowed.iter().any(|p| glob_match(p, name))
45}
46
47fn glob_match(pattern: &str, name: &str) -> bool {
48    if let Some(prefix) = pattern.strip_suffix('*') {
49        if prefix.is_empty() {
50            return true;
51        }
52        name.starts_with(prefix)
53    } else {
54        pattern == name
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    fn allow(patterns: &[&str]) -> ChannelSkillsConfig {
63        ChannelSkillsConfig {
64            allowed: patterns.iter().map(ToString::to_string).collect(),
65        }
66    }
67
68    #[test]
69    fn wildcard_star_allows_any_skill() {
70        let cfg = allow(&["*"]);
71        assert!(is_skill_allowed("anything", &cfg));
72        assert!(is_skill_allowed("web-search", &cfg));
73    }
74
75    #[test]
76    fn empty_allowlist_denies_all() {
77        let cfg = allow(&[]);
78        assert!(!is_skill_allowed("web-search", &cfg));
79        assert!(!is_skill_allowed("shell", &cfg));
80    }
81
82    #[test]
83    fn exact_match_allows_only_that_skill() {
84        let cfg = allow(&["web-search"]);
85        assert!(is_skill_allowed("web-search", &cfg));
86        assert!(!is_skill_allowed("shell", &cfg));
87        assert!(!is_skill_allowed("web-search-extra", &cfg));
88    }
89
90    #[test]
91    fn prefix_wildcard_allows_matching_skills() {
92        let cfg = allow(&["web-*"]);
93        assert!(is_skill_allowed("web-search", &cfg));
94        assert!(is_skill_allowed("web-fetch", &cfg));
95        assert!(!is_skill_allowed("shell", &cfg));
96        assert!(!is_skill_allowed("awesome-web-thing", &cfg));
97    }
98
99    #[test]
100    fn multiple_patterns_or_logic() {
101        let cfg = allow(&["shell", "web-*"]);
102        assert!(is_skill_allowed("shell", &cfg));
103        assert!(is_skill_allowed("web-search", &cfg));
104        assert!(!is_skill_allowed("memory", &cfg));
105    }
106
107    #[test]
108    fn default_config_allows_all() {
109        let cfg = ChannelSkillsConfig::default();
110        assert!(is_skill_allowed("any-skill", &cfg));
111    }
112
113    #[test]
114    fn prefix_wildcard_does_not_match_empty_suffix() {
115        let cfg = allow(&["web-*"]);
116        // "web-" itself — prefix is "web-", remainder after stripping is "", which is the name
117        // glob_match("web-*", "web-") → prefix="web-", name.starts_with("web-") is true, len > prefix
118        // but name == "web-" means remainder is "", so starts_with returns true, let's verify:
119        assert!(is_skill_allowed("web-", &cfg));
120    }
121
122    #[test]
123    fn matching_is_case_sensitive() {
124        let cfg = allow(&["Web-Search"]);
125        assert!(!is_skill_allowed("web-search", &cfg));
126        assert!(is_skill_allowed("Web-Search", &cfg));
127    }
128}
129
130fn default_slack_port() -> u16 {
131    3000
132}
133
134fn default_slack_webhook_host() -> String {
135    "127.0.0.1".into()
136}
137
138fn default_a2a_host() -> String {
139    "0.0.0.0".into()
140}
141
142fn default_a2a_port() -> u16 {
143    8080
144}
145
146fn default_a2a_rate_limit() -> u32 {
147    60
148}
149
150fn default_a2a_max_body() -> usize {
151    1_048_576
152}
153
154fn default_drain_timeout_ms() -> u64 {
155    30_000
156}
157
158fn default_max_dynamic_servers() -> usize {
159    10
160}
161
162fn default_mcp_timeout() -> u64 {
163    30
164}
165
166fn default_oauth_callback_port() -> u16 {
167    18766
168}
169
170fn default_oauth_client_name() -> String {
171    "Zeph".into()
172}
173
174/// Telegram channel configuration, nested under `[telegram]` in TOML.
175///
176/// When present, Zeph connects to Telegram as a bot using the provided token.
177/// The token must be resolved from the vault at runtime via `ZEPH_TELEGRAM_TOKEN`.
178///
179/// # Example (TOML)
180///
181/// ```toml
182/// [telegram]
183/// allowed_users = ["myusername"]
184/// ```
185#[derive(Clone, Deserialize, Serialize)]
186pub struct TelegramConfig {
187    /// Bot token. Set to `None` and resolve from vault via `ZEPH_TELEGRAM_TOKEN`.
188    pub token: Option<String>,
189    /// Telegram usernames allowed to interact with the bot (empty = allow all).
190    #[serde(default)]
191    pub allowed_users: Vec<String>,
192    /// Skill allowlist for this channel.
193    #[serde(default)]
194    pub skills: ChannelSkillsConfig,
195}
196
197impl std::fmt::Debug for TelegramConfig {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        f.debug_struct("TelegramConfig")
200            .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
201            .field("allowed_users", &self.allowed_users)
202            .field("skills", &self.skills)
203            .finish()
204    }
205}
206
207#[derive(Clone, Deserialize, Serialize)]
208pub struct DiscordConfig {
209    pub token: Option<String>,
210    pub application_id: Option<String>,
211    #[serde(default)]
212    pub allowed_user_ids: Vec<String>,
213    #[serde(default)]
214    pub allowed_role_ids: Vec<String>,
215    #[serde(default)]
216    pub allowed_channel_ids: Vec<String>,
217    #[serde(default)]
218    pub skills: ChannelSkillsConfig,
219}
220
221impl std::fmt::Debug for DiscordConfig {
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        f.debug_struct("DiscordConfig")
224            .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
225            .field("application_id", &self.application_id)
226            .field("allowed_user_ids", &self.allowed_user_ids)
227            .field("allowed_role_ids", &self.allowed_role_ids)
228            .field("allowed_channel_ids", &self.allowed_channel_ids)
229            .field("skills", &self.skills)
230            .finish()
231    }
232}
233
234#[derive(Clone, Deserialize, Serialize)]
235pub struct SlackConfig {
236    pub bot_token: Option<String>,
237    pub signing_secret: Option<String>,
238    #[serde(default = "default_slack_webhook_host")]
239    pub webhook_host: String,
240    #[serde(default = "default_slack_port")]
241    pub port: u16,
242    #[serde(default)]
243    pub allowed_user_ids: Vec<String>,
244    #[serde(default)]
245    pub allowed_channel_ids: Vec<String>,
246    #[serde(default)]
247    pub skills: ChannelSkillsConfig,
248}
249
250impl std::fmt::Debug for SlackConfig {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        f.debug_struct("SlackConfig")
253            .field("bot_token", &self.bot_token.as_ref().map(|_| "[REDACTED]"))
254            .field(
255                "signing_secret",
256                &self.signing_secret.as_ref().map(|_| "[REDACTED]"), // lgtm[rust/cleartext-logging]
257            )
258            .field("webhook_host", &self.webhook_host)
259            .field("port", &self.port)
260            .field("allowed_user_ids", &self.allowed_user_ids)
261            .field("allowed_channel_ids", &self.allowed_channel_ids)
262            .field("skills", &self.skills)
263            .finish()
264    }
265}
266
267/// An IBCT signing key entry in the A2A server configuration.
268///
269/// Multiple entries allow key rotation: keep old keys until all tokens signed with them expire.
270#[derive(Debug, Clone, Deserialize, Serialize)]
271pub struct IbctKeyConfig {
272    /// Unique key identifier. Must match the `key_id` field in issued IBCT tokens.
273    pub key_id: String,
274    /// Hex-encoded HMAC-SHA256 signing key.
275    pub key_hex: String,
276}
277
278fn default_ibct_ttl() -> u64 {
279    300
280}
281
282#[derive(Deserialize, Serialize)]
283#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic here
284pub struct A2aServerConfig {
285    #[serde(default)]
286    pub enabled: bool,
287    #[serde(default = "default_a2a_host")]
288    pub host: String,
289    #[serde(default = "default_a2a_port")]
290    pub port: u16,
291    #[serde(default)]
292    pub public_url: String,
293    #[serde(default)]
294    pub auth_token: Option<String>,
295    #[serde(default = "default_a2a_rate_limit")]
296    pub rate_limit: u32,
297    #[serde(default = "default_true")]
298    pub require_tls: bool,
299    #[serde(default = "default_true")]
300    pub ssrf_protection: bool,
301    #[serde(default = "default_a2a_max_body")]
302    pub max_body_size: usize,
303    #[serde(default = "default_drain_timeout_ms")]
304    pub drain_timeout_ms: u64,
305    /// When `true`, all requests are rejected with 401 if no `auth_token` is configured.
306    /// Default `false` for backward compatibility — existing deployments without a token
307    /// continue to operate. Set to `true` in production when authentication is mandatory.
308    #[serde(default)]
309    pub require_auth: bool,
310    /// IBCT signing keys for per-task delegation scoping.
311    ///
312    /// When non-empty, all A2A task requests must include a valid `X-Zeph-IBCT` header
313    /// signed with one of these keys. Multiple keys allow key rotation without downtime.
314    #[serde(default)]
315    pub ibct_keys: Vec<IbctKeyConfig>,
316    /// Vault key name to resolve the primary IBCT signing key at startup (MF-3 fix).
317    ///
318    /// When set, the vault key is resolved at startup and used to construct an
319    /// `IbctKey` with `key_id = "primary"`. Takes precedence over `ibct_keys[0]` if both
320    /// are set.  Example: `"ZEPH_A2A_IBCT_KEY"`.
321    #[serde(default)]
322    pub ibct_signing_key_vault_ref: Option<String>,
323    /// TTL (seconds) for issued IBCT tokens. Default: 300 (5 minutes).
324    #[serde(default = "default_ibct_ttl")]
325    pub ibct_ttl_secs: u64,
326}
327
328impl std::fmt::Debug for A2aServerConfig {
329    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330        f.debug_struct("A2aServerConfig")
331            .field("enabled", &self.enabled)
332            .field("host", &self.host)
333            .field("port", &self.port)
334            .field("public_url", &self.public_url)
335            .field(
336                "auth_token",
337                &self.auth_token.as_ref().map(|_| "[REDACTED]"),
338            )
339            .field("rate_limit", &self.rate_limit)
340            .field("require_tls", &self.require_tls)
341            .field("ssrf_protection", &self.ssrf_protection)
342            .field("max_body_size", &self.max_body_size)
343            .field("drain_timeout_ms", &self.drain_timeout_ms)
344            .field("require_auth", &self.require_auth)
345            .field("ibct_keys_count", &self.ibct_keys.len())
346            .field(
347                "ibct_signing_key_vault_ref",
348                &self.ibct_signing_key_vault_ref,
349            )
350            .field("ibct_ttl_secs", &self.ibct_ttl_secs)
351            .finish()
352    }
353}
354
355impl Default for A2aServerConfig {
356    fn default() -> Self {
357        Self {
358            enabled: false,
359            host: default_a2a_host(),
360            port: default_a2a_port(),
361            public_url: String::new(),
362            auth_token: None,
363            rate_limit: default_a2a_rate_limit(),
364            require_tls: true,
365            ssrf_protection: true,
366            max_body_size: default_a2a_max_body(),
367            drain_timeout_ms: default_drain_timeout_ms(),
368            require_auth: false,
369            ibct_keys: Vec::new(),
370            ibct_signing_key_vault_ref: None,
371            ibct_ttl_secs: default_ibct_ttl(),
372        }
373    }
374}
375
376/// Dynamic MCP tool context pruning configuration (#2204).
377///
378/// When enabled, an LLM call evaluates which MCP tools are relevant to the current task
379/// before sending tool schemas to the main LLM, reducing context usage and improving
380/// tool selection accuracy for servers with many tools.
381#[derive(Debug, Clone, Deserialize, Serialize)]
382#[serde(default)]
383pub struct ToolPruningConfig {
384    /// Enable dynamic tool pruning. Default: `false` (opt-in).
385    pub enabled: bool,
386    /// Maximum number of MCP tools to include after pruning.
387    pub max_tools: usize,
388    /// Provider name from `[[llm.providers]]` for the pruning LLM call.
389    /// Should be a fast/cheap model. Empty string = use the default provider.
390    pub pruning_provider: ProviderName,
391    /// Minimum number of MCP tools below which pruning is skipped.
392    pub min_tools_to_prune: usize,
393    /// Tool names that are never pruned (always included in the result).
394    pub always_include: Vec<String>,
395}
396
397impl Default for ToolPruningConfig {
398    fn default() -> Self {
399        Self {
400            enabled: false,
401            max_tools: 15,
402            pruning_provider: ProviderName::default(),
403            min_tools_to_prune: 10,
404            always_include: Vec::new(),
405        }
406    }
407}
408
409/// MCP tool discovery strategy (config-side representation).
410///
411/// Converted to `zeph_mcp::ToolDiscoveryStrategy` in `zeph-core` to avoid a
412/// circular crate dependency (`zeph-config` → `zeph-mcp`).
413#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
414#[serde(rename_all = "lowercase")]
415pub enum ToolDiscoveryStrategyConfig {
416    /// Embedding-based cosine similarity retrieval.  Fast, no LLM call per turn.
417    Embedding,
418    /// LLM-based pruning via `prune_tools_cached`.  Existing behavior.
419    Llm,
420    /// No filtering — all tools are passed through.  This is the default.
421    #[default]
422    None,
423}
424
425/// MCP tool discovery configuration (#2321).
426///
427/// Nested under `[mcp.tool_discovery]`.  When `strategy = "embedding"`, the
428/// `mcp.pruning` section is ignored for this session — the embedding path
429/// supersedes LLM pruning entirely.
430#[derive(Debug, Clone, Deserialize, Serialize)]
431#[serde(default)]
432pub struct ToolDiscoveryConfig {
433    /// Discovery strategy.  Default: `none` (all tools, safe default).
434    pub strategy: ToolDiscoveryStrategyConfig,
435    /// Number of top-scoring tools to include per turn (embedding strategy only).
436    pub top_k: usize,
437    /// Minimum cosine similarity for a tool to be included (embedding strategy only).
438    pub min_similarity: f32,
439    /// Provider name from `[[llm.providers]]` for embedding computation.
440    /// Should reference a fast/cheap embedding model.  Empty = use the agent's
441    /// default embedding provider.
442    pub embedding_provider: ProviderName,
443    /// Tool names always included regardless of similarity score.
444    pub always_include: Vec<String>,
445    /// Minimum tool count below which discovery is skipped (all tools passed through).
446    pub min_tools_to_filter: usize,
447    /// When `true`, treat any embedding failure as a hard error instead of silently
448    /// falling back to all tools.  Default: `false` (soft fallback).
449    pub strict: bool,
450}
451
452impl Default for ToolDiscoveryConfig {
453    fn default() -> Self {
454        Self {
455            strategy: ToolDiscoveryStrategyConfig::None,
456            top_k: 10,
457            min_similarity: 0.2,
458            embedding_provider: ProviderName::default(),
459            always_include: Vec::new(),
460            min_tools_to_filter: 10,
461            strict: false,
462        }
463    }
464}
465
466/// Trust calibration configuration, nested under `[mcp.trust_calibration]`.
467#[derive(Debug, Clone, Deserialize, Serialize)]
468#[allow(clippy::struct_excessive_bools)]
469pub struct TrustCalibrationConfig {
470    /// Enable trust calibration (default: false — opt-in).
471    #[serde(default)]
472    pub enabled: bool,
473    /// Run pre-invocation probe on connect (Phase 1).
474    #[serde(default = "default_true")]
475    pub probe_on_connect: bool,
476    /// Monitor invocations for trust score updates (Phase 2).
477    #[serde(default = "default_true")]
478    pub monitor_invocations: bool,
479    /// Persist trust scores to `SQLite` (Phase 3).
480    #[serde(default = "default_true")]
481    pub persist_scores: bool,
482    /// Per-day decay rate applied to trust scores above 0.5.
483    #[serde(default = "default_decay_rate")]
484    pub decay_rate_per_day: f64,
485    /// Score penalty applied when injection is detected.
486    #[serde(default = "default_injection_penalty")]
487    pub injection_penalty: f64,
488    /// Optional LLM provider for trust verification. Empty = disabled.
489    #[serde(default)]
490    pub verifier_provider: ProviderName,
491}
492
493fn default_decay_rate() -> f64 {
494    0.01
495}
496
497fn default_injection_penalty() -> f64 {
498    0.25
499}
500
501impl Default for TrustCalibrationConfig {
502    fn default() -> Self {
503        Self {
504            enabled: false,
505            probe_on_connect: true,
506            monitor_invocations: true,
507            persist_scores: true,
508            decay_rate_per_day: default_decay_rate(),
509            injection_penalty: default_injection_penalty(),
510            verifier_provider: ProviderName::default(),
511        }
512    }
513}
514
515fn default_max_description_bytes() -> usize {
516    2048
517}
518
519fn default_max_instructions_bytes() -> usize {
520    2048
521}
522
523fn default_elicitation_timeout() -> u64 {
524    120
525}
526
527fn default_elicitation_queue_capacity() -> usize {
528    16
529}
530
531#[allow(clippy::struct_excessive_bools)]
532#[derive(Debug, Clone, Deserialize, Serialize)]
533pub struct McpConfig {
534    #[serde(default)]
535    pub servers: Vec<McpServerConfig>,
536    #[serde(default)]
537    pub allowed_commands: Vec<String>,
538    #[serde(default = "default_max_dynamic_servers")]
539    pub max_dynamic_servers: usize,
540    /// Dynamic tool pruning for context optimization.
541    #[serde(default)]
542    pub pruning: ToolPruningConfig,
543    /// Trust calibration settings (opt-in, disabled by default).
544    #[serde(default)]
545    pub trust_calibration: TrustCalibrationConfig,
546    /// Embedding-based tool discovery (#2321).
547    #[serde(default)]
548    pub tool_discovery: ToolDiscoveryConfig,
549    /// Maximum byte length for MCP tool descriptions. Truncated with "..." if exceeded. Default: 2048.
550    #[serde(default = "default_max_description_bytes")]
551    pub max_description_bytes: usize,
552    /// Maximum byte length for MCP server instructions. Truncated with "..." if exceeded. Default: 2048.
553    #[serde(default = "default_max_instructions_bytes")]
554    pub max_instructions_bytes: usize,
555    /// Enable MCP elicitation (servers can request user input mid-task).
556    /// Default: false — all elicitation requests are auto-declined.
557    /// Opt-in because it interrupts agent flow and could be abused by malicious servers.
558    #[serde(default)]
559    pub elicitation_enabled: bool,
560    /// Timeout for user to respond to an elicitation request (seconds). Default: 120.
561    #[serde(default = "default_elicitation_timeout")]
562    pub elicitation_timeout: u64,
563    /// Bounded channel capacity for elicitation events. Requests beyond this limit are
564    /// auto-declined with a warning to prevent memory exhaustion from misbehaving servers.
565    /// Default: 16.
566    #[serde(default = "default_elicitation_queue_capacity")]
567    pub elicitation_queue_capacity: usize,
568    /// When true, warn the user before prompting for fields whose names match sensitive
569    /// patterns (password, token, secret, key, credential, etc.). Default: true.
570    #[serde(default = "default_true")]
571    pub elicitation_warn_sensitive_fields: bool,
572    /// Lock tool lists after initial connection for all servers.
573    ///
574    /// When `true`, `tools/list_changed` refresh events are rejected for servers that have
575    /// completed their initial connection, preventing mid-session tool injection.
576    /// Default: `false` (opt-in, backward compatible).
577    #[serde(default)]
578    pub lock_tool_list: bool,
579    /// Default env isolation for all Stdio servers. Per-server `env_isolation` overrides this.
580    ///
581    /// When `true`, spawned processes only receive a minimal base env + their declared `env` map.
582    /// Default: `false` (backward compatible).
583    #[serde(default)]
584    pub default_env_isolation: bool,
585}
586
587impl Default for McpConfig {
588    fn default() -> Self {
589        Self {
590            servers: Vec::new(),
591            allowed_commands: Vec::new(),
592            max_dynamic_servers: default_max_dynamic_servers(),
593            pruning: ToolPruningConfig::default(),
594            trust_calibration: TrustCalibrationConfig::default(),
595            tool_discovery: ToolDiscoveryConfig::default(),
596            max_description_bytes: default_max_description_bytes(),
597            max_instructions_bytes: default_max_instructions_bytes(),
598            elicitation_enabled: false,
599            elicitation_timeout: default_elicitation_timeout(),
600            elicitation_queue_capacity: default_elicitation_queue_capacity(),
601            elicitation_warn_sensitive_fields: true,
602            lock_tool_list: false,
603            default_env_isolation: false,
604        }
605    }
606}
607
608#[derive(Clone, Deserialize, Serialize)]
609pub struct McpServerConfig {
610    pub id: String,
611    /// Stdio transport: command to spawn.
612    pub command: Option<String>,
613    #[serde(default)]
614    pub args: Vec<String>,
615    #[serde(default)]
616    pub env: HashMap<String, String>,
617    /// HTTP transport: remote MCP server URL.
618    pub url: Option<String>,
619    #[serde(default = "default_mcp_timeout")]
620    pub timeout: u64,
621    /// Optional declarative policy for this server (allowlist, denylist, rate limit).
622    #[serde(default)]
623    pub policy: zeph_mcp::McpPolicy,
624    /// Static HTTP headers for the transport (e.g. `Authorization: Bearer <token>`).
625    /// Values support vault references: `${VAULT_KEY}`.
626    #[serde(default)]
627    pub headers: HashMap<String, String>,
628    /// OAuth 2.1 configuration for this server.
629    #[serde(default)]
630    pub oauth: Option<McpOAuthConfig>,
631    /// Trust level for this server. Default: Untrusted.
632    #[serde(default)]
633    pub trust_level: McpTrustLevel,
634    /// Tool allowlist. `None` means no override (inherit defaults).
635    /// `Some(vec![])` is an explicit empty list (deny all for Untrusted/Sandboxed).
636    /// `Some(vec!["a", "b"])` allows only listed tools.
637    #[serde(default)]
638    pub tool_allowlist: Option<Vec<String>>,
639    /// Expected tool names for attestation. Supplements `tool_allowlist`.
640    ///
641    /// When non-empty: tools not in this list are filtered out (Untrusted/Sandboxed)
642    /// or warned about (Trusted). Schema drift is logged when fingerprints change
643    /// between connections.
644    #[serde(default)]
645    pub expected_tools: Vec<String>,
646    /// Filesystem roots exposed to this MCP server via `roots/list`.
647    /// Each entry is a `{uri, name?}` pair. URI must use `file://` scheme.
648    /// When empty, the server receives an empty roots list.
649    #[serde(default)]
650    pub roots: Vec<McpRootEntry>,
651    /// Per-tool security metadata overrides. Keys are tool names.
652    /// When absent for a tool, metadata is inferred from the tool name via heuristics.
653    #[serde(default)]
654    pub tool_metadata: HashMap<String, ToolSecurityMeta>,
655    /// Per-server elicitation override. `None` = inherit global `elicitation_enabled`.
656    /// `Some(true)` = allow this server to elicit regardless of global setting.
657    /// `Some(false)` = always decline for this server.
658    #[serde(default)]
659    pub elicitation_enabled: Option<bool>,
660    /// Isolate the environment for this Stdio server.
661    ///
662    /// When `true` (or when `[mcp].default_env_isolation = true`), the spawned process
663    /// only sees a minimal base env (`PATH`, `HOME`, etc.) plus this server's `env` map.
664    /// Overrides `[mcp].default_env_isolation` when set explicitly.
665    /// Default: `false` (backward compatible).
666    #[serde(default)]
667    pub env_isolation: Option<bool>,
668}
669
670/// A filesystem root exposed to an MCP server via `roots/list`.
671#[derive(Debug, Clone, Deserialize, Serialize)]
672pub struct McpRootEntry {
673    /// URI of the root directory. Must use `file://` scheme.
674    pub uri: String,
675    /// Optional human-readable name for this root.
676    #[serde(default)]
677    pub name: Option<String>,
678}
679
680/// OAuth 2.1 configuration for an MCP server.
681#[derive(Debug, Clone, Deserialize, Serialize)]
682pub struct McpOAuthConfig {
683    /// Enable OAuth 2.1 for this server.
684    #[serde(default)]
685    pub enabled: bool,
686    /// Token storage backend.
687    #[serde(default)]
688    pub token_storage: OAuthTokenStorage,
689    /// OAuth scopes to request. Empty = server default.
690    #[serde(default)]
691    pub scopes: Vec<String>,
692    /// Port for the local callback server. `0` = auto-assign, `18766` = default fixed port.
693    #[serde(default = "default_oauth_callback_port")]
694    pub callback_port: u16,
695    /// Client name sent during dynamic registration.
696    #[serde(default = "default_oauth_client_name")]
697    pub client_name: String,
698}
699
700impl Default for McpOAuthConfig {
701    fn default() -> Self {
702        Self {
703            enabled: false,
704            token_storage: OAuthTokenStorage::default(),
705            scopes: Vec::new(),
706            callback_port: default_oauth_callback_port(),
707            client_name: default_oauth_client_name(),
708        }
709    }
710}
711
712/// Where OAuth tokens are stored.
713#[derive(Debug, Clone, Default, Deserialize, Serialize)]
714#[serde(rename_all = "lowercase")]
715pub enum OAuthTokenStorage {
716    /// Persisted in the age vault (default).
717    #[default]
718    Vault,
719    /// In-memory only — tokens lost on restart.
720    Memory,
721}
722
723impl std::fmt::Debug for McpServerConfig {
724    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
725        let redacted_env: HashMap<&str, &str> = self
726            .env
727            .keys()
728            .map(|k| (k.as_str(), "[REDACTED]"))
729            .collect();
730        // Redact header values to avoid leaking tokens in logs.
731        let redacted_headers: HashMap<&str, &str> = self
732            .headers
733            .keys()
734            .map(|k| (k.as_str(), "[REDACTED]"))
735            .collect();
736        f.debug_struct("McpServerConfig")
737            .field("id", &self.id)
738            .field("command", &self.command)
739            .field("args", &self.args)
740            .field("env", &redacted_env)
741            .field("url", &self.url)
742            .field("timeout", &self.timeout)
743            .field("policy", &self.policy)
744            .field("headers", &redacted_headers)
745            .field("oauth", &self.oauth)
746            .field("trust_level", &self.trust_level)
747            .field("tool_allowlist", &self.tool_allowlist)
748            .field("expected_tools", &self.expected_tools)
749            .field("roots", &self.roots)
750            .field(
751                "tool_metadata_keys",
752                &self.tool_metadata.keys().collect::<Vec<_>>(),
753            )
754            .field("elicitation_enabled", &self.elicitation_enabled)
755            .field("env_isolation", &self.env_isolation)
756            .finish()
757    }
758}