use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::defaults::default_true;
use crate::providers::ProviderName;
pub use zeph_mcp::{McpTrustLevel, tool::ToolSecurityMeta};
fn default_skill_allowlist() -> Vec<String> {
vec!["*".into()]
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ChannelSkillsConfig {
#[serde(default = "default_skill_allowlist")]
pub allowed: Vec<String>,
}
impl Default for ChannelSkillsConfig {
fn default() -> Self {
Self {
allowed: default_skill_allowlist(),
}
}
}
#[must_use]
pub fn is_skill_allowed(name: &str, config: &ChannelSkillsConfig) -> bool {
config.allowed.iter().any(|p| glob_match(p, name))
}
fn glob_match(pattern: &str, name: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix('*') {
if prefix.is_empty() {
return true;
}
name.starts_with(prefix)
} else {
pattern == name
}
}
#[cfg(test)]
mod tests {
use super::*;
fn allow(patterns: &[&str]) -> ChannelSkillsConfig {
ChannelSkillsConfig {
allowed: patterns.iter().map(ToString::to_string).collect(),
}
}
#[test]
fn wildcard_star_allows_any_skill() {
let cfg = allow(&["*"]);
assert!(is_skill_allowed("anything", &cfg));
assert!(is_skill_allowed("web-search", &cfg));
}
#[test]
fn empty_allowlist_denies_all() {
let cfg = allow(&[]);
assert!(!is_skill_allowed("web-search", &cfg));
assert!(!is_skill_allowed("shell", &cfg));
}
#[test]
fn exact_match_allows_only_that_skill() {
let cfg = allow(&["web-search"]);
assert!(is_skill_allowed("web-search", &cfg));
assert!(!is_skill_allowed("shell", &cfg));
assert!(!is_skill_allowed("web-search-extra", &cfg));
}
#[test]
fn prefix_wildcard_allows_matching_skills() {
let cfg = allow(&["web-*"]);
assert!(is_skill_allowed("web-search", &cfg));
assert!(is_skill_allowed("web-fetch", &cfg));
assert!(!is_skill_allowed("shell", &cfg));
assert!(!is_skill_allowed("awesome-web-thing", &cfg));
}
#[test]
fn multiple_patterns_or_logic() {
let cfg = allow(&["shell", "web-*"]);
assert!(is_skill_allowed("shell", &cfg));
assert!(is_skill_allowed("web-search", &cfg));
assert!(!is_skill_allowed("memory", &cfg));
}
#[test]
fn default_config_allows_all() {
let cfg = ChannelSkillsConfig::default();
assert!(is_skill_allowed("any-skill", &cfg));
}
#[test]
fn prefix_wildcard_does_not_match_empty_suffix() {
let cfg = allow(&["web-*"]);
assert!(is_skill_allowed("web-", &cfg));
}
#[test]
fn matching_is_case_sensitive() {
let cfg = allow(&["Web-Search"]);
assert!(!is_skill_allowed("web-search", &cfg));
assert!(is_skill_allowed("Web-Search", &cfg));
}
}
fn default_slack_port() -> u16 {
3000
}
fn default_slack_webhook_host() -> String {
"127.0.0.1".into()
}
fn default_a2a_host() -> String {
"0.0.0.0".into()
}
fn default_a2a_port() -> u16 {
8080
}
fn default_a2a_rate_limit() -> u32 {
60
}
fn default_a2a_max_body() -> usize {
1_048_576
}
fn default_drain_timeout_ms() -> u64 {
30_000
}
fn default_max_dynamic_servers() -> usize {
10
}
fn default_mcp_timeout() -> u64 {
30
}
fn default_oauth_callback_port() -> u16 {
18766
}
fn default_oauth_client_name() -> String {
"Zeph".into()
}
#[derive(Clone, Deserialize, Serialize)]
pub struct TelegramConfig {
pub token: Option<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
#[serde(default)]
pub skills: ChannelSkillsConfig,
}
impl std::fmt::Debug for TelegramConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TelegramConfig")
.field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
.field("allowed_users", &self.allowed_users)
.field("skills", &self.skills)
.finish()
}
}
#[derive(Clone, Deserialize, Serialize)]
pub struct DiscordConfig {
pub token: Option<String>,
pub application_id: Option<String>,
#[serde(default)]
pub allowed_user_ids: Vec<String>,
#[serde(default)]
pub allowed_role_ids: Vec<String>,
#[serde(default)]
pub allowed_channel_ids: Vec<String>,
#[serde(default)]
pub skills: ChannelSkillsConfig,
}
impl std::fmt::Debug for DiscordConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DiscordConfig")
.field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
.field("application_id", &self.application_id)
.field("allowed_user_ids", &self.allowed_user_ids)
.field("allowed_role_ids", &self.allowed_role_ids)
.field("allowed_channel_ids", &self.allowed_channel_ids)
.field("skills", &self.skills)
.finish()
}
}
#[derive(Clone, Deserialize, Serialize)]
pub struct SlackConfig {
pub bot_token: Option<String>,
pub signing_secret: Option<String>,
#[serde(default = "default_slack_webhook_host")]
pub webhook_host: String,
#[serde(default = "default_slack_port")]
pub port: u16,
#[serde(default)]
pub allowed_user_ids: Vec<String>,
#[serde(default)]
pub allowed_channel_ids: Vec<String>,
#[serde(default)]
pub skills: ChannelSkillsConfig,
}
impl std::fmt::Debug for SlackConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SlackConfig")
.field("bot_token", &self.bot_token.as_ref().map(|_| "[REDACTED]"))
.field(
"signing_secret",
&self.signing_secret.as_ref().map(|_| "[REDACTED]"), )
.field("webhook_host", &self.webhook_host)
.field("port", &self.port)
.field("allowed_user_ids", &self.allowed_user_ids)
.field("allowed_channel_ids", &self.allowed_channel_ids)
.field("skills", &self.skills)
.finish()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct IbctKeyConfig {
pub key_id: String,
pub key_hex: String,
}
fn default_ibct_ttl() -> u64 {
300
}
#[derive(Deserialize, Serialize)]
#[allow(clippy::struct_excessive_bools)] pub struct A2aServerConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_a2a_host")]
pub host: String,
#[serde(default = "default_a2a_port")]
pub port: u16,
#[serde(default)]
pub public_url: String,
#[serde(default)]
pub auth_token: Option<String>,
#[serde(default = "default_a2a_rate_limit")]
pub rate_limit: u32,
#[serde(default = "default_true")]
pub require_tls: bool,
#[serde(default = "default_true")]
pub ssrf_protection: bool,
#[serde(default = "default_a2a_max_body")]
pub max_body_size: usize,
#[serde(default = "default_drain_timeout_ms")]
pub drain_timeout_ms: u64,
#[serde(default)]
pub require_auth: bool,
#[serde(default)]
pub ibct_keys: Vec<IbctKeyConfig>,
#[serde(default)]
pub ibct_signing_key_vault_ref: Option<String>,
#[serde(default = "default_ibct_ttl")]
pub ibct_ttl_secs: u64,
}
impl std::fmt::Debug for A2aServerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("A2aServerConfig")
.field("enabled", &self.enabled)
.field("host", &self.host)
.field("port", &self.port)
.field("public_url", &self.public_url)
.field(
"auth_token",
&self.auth_token.as_ref().map(|_| "[REDACTED]"),
)
.field("rate_limit", &self.rate_limit)
.field("require_tls", &self.require_tls)
.field("ssrf_protection", &self.ssrf_protection)
.field("max_body_size", &self.max_body_size)
.field("drain_timeout_ms", &self.drain_timeout_ms)
.field("require_auth", &self.require_auth)
.field("ibct_keys_count", &self.ibct_keys.len())
.field(
"ibct_signing_key_vault_ref",
&self.ibct_signing_key_vault_ref,
)
.field("ibct_ttl_secs", &self.ibct_ttl_secs)
.finish()
}
}
impl Default for A2aServerConfig {
fn default() -> Self {
Self {
enabled: false,
host: default_a2a_host(),
port: default_a2a_port(),
public_url: String::new(),
auth_token: None,
rate_limit: default_a2a_rate_limit(),
require_tls: true,
ssrf_protection: true,
max_body_size: default_a2a_max_body(),
drain_timeout_ms: default_drain_timeout_ms(),
require_auth: false,
ibct_keys: Vec::new(),
ibct_signing_key_vault_ref: None,
ibct_ttl_secs: default_ibct_ttl(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ToolPruningConfig {
pub enabled: bool,
pub max_tools: usize,
pub pruning_provider: ProviderName,
pub min_tools_to_prune: usize,
pub always_include: Vec<String>,
}
impl Default for ToolPruningConfig {
fn default() -> Self {
Self {
enabled: false,
max_tools: 15,
pruning_provider: ProviderName::default(),
min_tools_to_prune: 10,
always_include: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ToolDiscoveryStrategyConfig {
Embedding,
Llm,
#[default]
None,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct ToolDiscoveryConfig {
pub strategy: ToolDiscoveryStrategyConfig,
pub top_k: usize,
pub min_similarity: f32,
pub embedding_provider: ProviderName,
pub always_include: Vec<String>,
pub min_tools_to_filter: usize,
pub strict: bool,
}
impl Default for ToolDiscoveryConfig {
fn default() -> Self {
Self {
strategy: ToolDiscoveryStrategyConfig::None,
top_k: 10,
min_similarity: 0.2,
embedding_provider: ProviderName::default(),
always_include: Vec::new(),
min_tools_to_filter: 10,
strict: false,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct TrustCalibrationConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_true")]
pub probe_on_connect: bool,
#[serde(default = "default_true")]
pub monitor_invocations: bool,
#[serde(default = "default_true")]
pub persist_scores: bool,
#[serde(default = "default_decay_rate")]
pub decay_rate_per_day: f64,
#[serde(default = "default_injection_penalty")]
pub injection_penalty: f64,
#[serde(default)]
pub verifier_provider: ProviderName,
}
fn default_decay_rate() -> f64 {
0.01
}
fn default_injection_penalty() -> f64 {
0.25
}
impl Default for TrustCalibrationConfig {
fn default() -> Self {
Self {
enabled: false,
probe_on_connect: true,
monitor_invocations: true,
persist_scores: true,
decay_rate_per_day: default_decay_rate(),
injection_penalty: default_injection_penalty(),
verifier_provider: ProviderName::default(),
}
}
}
fn default_max_description_bytes() -> usize {
2048
}
fn default_max_instructions_bytes() -> usize {
2048
}
fn default_elicitation_timeout() -> u64 {
120
}
fn default_elicitation_queue_capacity() -> usize {
16
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpConfig {
#[serde(default)]
pub servers: Vec<McpServerConfig>,
#[serde(default)]
pub allowed_commands: Vec<String>,
#[serde(default = "default_max_dynamic_servers")]
pub max_dynamic_servers: usize,
#[serde(default)]
pub pruning: ToolPruningConfig,
#[serde(default)]
pub trust_calibration: TrustCalibrationConfig,
#[serde(default)]
pub tool_discovery: ToolDiscoveryConfig,
#[serde(default = "default_max_description_bytes")]
pub max_description_bytes: usize,
#[serde(default = "default_max_instructions_bytes")]
pub max_instructions_bytes: usize,
#[serde(default)]
pub elicitation_enabled: bool,
#[serde(default = "default_elicitation_timeout")]
pub elicitation_timeout: u64,
#[serde(default = "default_elicitation_queue_capacity")]
pub elicitation_queue_capacity: usize,
#[serde(default = "default_true")]
pub elicitation_warn_sensitive_fields: bool,
#[serde(default)]
pub lock_tool_list: bool,
#[serde(default)]
pub default_env_isolation: bool,
}
impl Default for McpConfig {
fn default() -> Self {
Self {
servers: Vec::new(),
allowed_commands: Vec::new(),
max_dynamic_servers: default_max_dynamic_servers(),
pruning: ToolPruningConfig::default(),
trust_calibration: TrustCalibrationConfig::default(),
tool_discovery: ToolDiscoveryConfig::default(),
max_description_bytes: default_max_description_bytes(),
max_instructions_bytes: default_max_instructions_bytes(),
elicitation_enabled: false,
elicitation_timeout: default_elicitation_timeout(),
elicitation_queue_capacity: default_elicitation_queue_capacity(),
elicitation_warn_sensitive_fields: true,
lock_tool_list: false,
default_env_isolation: false,
}
}
}
#[derive(Clone, Deserialize, Serialize)]
pub struct McpServerConfig {
pub id: String,
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
pub url: Option<String>,
#[serde(default = "default_mcp_timeout")]
pub timeout: u64,
#[serde(default)]
pub policy: zeph_mcp::McpPolicy,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub oauth: Option<McpOAuthConfig>,
#[serde(default)]
pub trust_level: McpTrustLevel,
#[serde(default)]
pub tool_allowlist: Option<Vec<String>>,
#[serde(default)]
pub expected_tools: Vec<String>,
#[serde(default)]
pub roots: Vec<McpRootEntry>,
#[serde(default)]
pub tool_metadata: HashMap<String, ToolSecurityMeta>,
#[serde(default)]
pub elicitation_enabled: Option<bool>,
#[serde(default)]
pub env_isolation: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpRootEntry {
pub uri: String,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct McpOAuthConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub token_storage: OAuthTokenStorage,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default = "default_oauth_callback_port")]
pub callback_port: u16,
#[serde(default = "default_oauth_client_name")]
pub client_name: String,
}
impl Default for McpOAuthConfig {
fn default() -> Self {
Self {
enabled: false,
token_storage: OAuthTokenStorage::default(),
scopes: Vec::new(),
callback_port: default_oauth_callback_port(),
client_name: default_oauth_client_name(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum OAuthTokenStorage {
#[default]
Vault,
Memory,
}
impl std::fmt::Debug for McpServerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let redacted_env: HashMap<&str, &str> = self
.env
.keys()
.map(|k| (k.as_str(), "[REDACTED]"))
.collect();
let redacted_headers: HashMap<&str, &str> = self
.headers
.keys()
.map(|k| (k.as_str(), "[REDACTED]"))
.collect();
f.debug_struct("McpServerConfig")
.field("id", &self.id)
.field("command", &self.command)
.field("args", &self.args)
.field("env", &redacted_env)
.field("url", &self.url)
.field("timeout", &self.timeout)
.field("policy", &self.policy)
.field("headers", &redacted_headers)
.field("oauth", &self.oauth)
.field("trust_level", &self.trust_level)
.field("tool_allowlist", &self.tool_allowlist)
.field("expected_tools", &self.expected_tools)
.field("roots", &self.roots)
.field(
"tool_metadata_keys",
&self.tool_metadata.keys().collect::<Vec<_>>(),
)
.field("elicitation_enabled", &self.elicitation_enabled)
.field("env_isolation", &self.env_isolation)
.finish()
}
}