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