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