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