use super::serde_defaults;
#[allow(clippy::wildcard_imports)]
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ShadowConfig {
pub enabled: bool,
pub report_interval: usize,
pub report_dir: String,
}
impl Default for ShadowConfig {
fn default() -> Self {
Self {
enabled: false,
report_interval: 10,
report_dir: String::new(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct OclaConfig {
pub delivery: DeliveryConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DeliveryConfig {
pub enabled: bool,
pub delivery_for_subagents: bool,
pub max_entries: usize,
pub ttl_minutes: u64,
}
impl Default for DeliveryConfig {
fn default() -> Self {
Self {
enabled: true,
delivery_for_subagents: true,
max_entries: 4096,
ttl_minutes: 30,
}
}
}
impl OclaConfig {
pub fn delivery_enabled(&self) -> bool {
self.delivery.enabled
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CacheConfig {
pub l1_ttl_secs: u64,
pub l2_max_entries: usize,
pub l2_ttl_secs: u64,
pub l3_max_bytes: u64,
pub compose_cache_enabled: bool,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
l1_ttl_secs: 300,
l2_max_entries: 10_000,
l2_ttl_secs: 3_600,
l3_max_bytes: 500_000_000,
compose_cache_enabled: true,
}
}
}
#[cfg(test)]
mod cache_config_tests {
use super::CacheConfig;
#[test]
fn cache_defaults_match_delivery_budget() {
assert_eq!(CacheConfig::default().l3_max_bytes, 500_000_000);
assert!(CacheConfig::default().compose_cache_enabled);
}
#[test]
fn cache_config_deserializes_partial_overrides() {
let parsed: CacheConfig = serde_json::from_str(
r#"{"l1_max_entries": 12, "l3_gc_threshold": 0.5, "shell_cache_enabled": true, "l2_ttl_secs": 60}"#,
)
.unwrap();
assert_eq!(parsed.l2_ttl_secs, 60);
assert_eq!(parsed.l2_max_entries, 10_000);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AgentsConfig {
pub gc_interval_minutes: u64,
pub presence_ttl_hours: u64,
pub scratchpad_default_ttl_hours: u64,
pub logical_session_ttl_seconds: u64,
pub active_worker_lease_seconds: u64,
pub serialize_build_commands: bool,
pub cargo_build_jobs: usize,
pub shared_cargo_target: bool,
pub max_scratchpad_entries: usize,
}
impl Default for AgentsConfig {
fn default() -> Self {
Self {
gc_interval_minutes: 10,
presence_ttl_hours: 24,
scratchpad_default_ttl_hours: 12,
logical_session_ttl_seconds: 180,
active_worker_lease_seconds: 120,
serialize_build_commands: false,
cargo_build_jobs: 0,
shared_cargo_target: false,
max_scratchpad_entries: 200,
}
}
}
#[cfg(test)]
mod agents_config_tests {
use super::AgentsConfig;
#[test]
fn default_values_are_sane() {
let cfg = AgentsConfig::default();
assert_eq!(cfg.gc_interval_minutes, 10);
assert_eq!(cfg.presence_ttl_hours, 24);
assert_eq!(cfg.scratchpad_default_ttl_hours, 12);
assert_eq!(cfg.logical_session_ttl_seconds, 180);
assert_eq!(cfg.active_worker_lease_seconds, 120);
assert!(!cfg.serialize_build_commands);
assert_eq!(cfg.cargo_build_jobs, 0);
assert!(!cfg.shared_cargo_target);
assert_eq!(cfg.max_scratchpad_entries, 200);
}
#[test]
fn deserializes_with_missing_fields() {
let json = r"{}";
let cfg: AgentsConfig = serde_json::from_str(json).expect("empty object → defaults");
assert_eq!(cfg.gc_interval_minutes, 10);
}
#[test]
fn partial_override() {
let json = r#"{"gc_interval_minutes": 5, "presence_ttl_hours": 12}"#;
let cfg: AgentsConfig = serde_json::from_str(json).expect("partial");
assert_eq!(cfg.gc_interval_minutes, 5);
assert_eq!(cfg.presence_ttl_hours, 12);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SecretDetectionConfig {
pub enabled: bool,
pub redact: bool,
pub custom_patterns: Vec<String>,
pub exclude_patterns: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SetupConfig {
pub auto_inject_rules: Option<bool>,
pub auto_inject_skills: Option<bool>,
#[serde(default = "serde_defaults::default_true")]
pub auto_update_mcp: bool,
#[serde(default = "serde_defaults::default_true")]
pub manage_hooks: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub agents: Option<Vec<String>>,
}
impl Default for SetupConfig {
fn default() -> Self {
Self {
auto_inject_rules: None,
auto_inject_skills: None,
auto_update_mcp: true,
manage_hooks: true,
agents: None,
}
}
}
impl SetupConfig {
pub fn should_inject_rules(&self) -> bool {
match self.auto_inject_rules {
Some(v) => v,
None => Self::rules_already_present(),
}
}
pub fn should_inject_skills(&self) -> bool {
match self.auto_inject_skills {
Some(v) => v,
None => Self::rules_already_present(),
}
}
pub fn should_update_mcp(&self) -> bool {
self.auto_update_mcp
}
fn rules_already_present() -> bool {
let Some(home) = dirs::home_dir() else {
return false;
};
if crate::rules_inject::any_rules_marker_present(&home) {
return true;
}
let legacy_paths = [
crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md"),
crate::core::editor_registry::codebuddy_rules_dir(&home).join("lean-ctx.md"),
];
legacy_paths.iter().any(|p| {
std::fs::read_to_string(p)
.is_ok_and(|c| c.contains(crate::core::rules_canonical::START_MARK))
})
}
}
impl Default for SecretDetectionConfig {
fn default() -> Self {
Self {
enabled: true,
redact: true,
custom_patterns: Vec::new(),
exclude_patterns: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ArchiveConfig {
pub enabled: bool,
pub threshold_chars: usize,
pub max_age_hours: u64,
pub max_disk_mb: u64,
pub ephemeral: bool,
pub ephemeral_min_tokens: usize,
pub inline_max_bytes: usize,
pub raw_commands: Vec<String>,
pub verbatim_max_tokens: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ConversationConfig {
pub compression_enabled: bool,
pub preserve_last_n_turns: usize,
pub compression_threshold_tokens: usize,
pub min_score_to_preserve: f64,
pub drop_score_below: f64,
}
impl Default for ConversationConfig {
fn default() -> Self {
Self {
compression_enabled: false,
preserve_last_n_turns: 10,
compression_threshold_tokens: 50_000,
min_score_to_preserve: 0.5,
drop_score_below: 0.2,
}
}
}
impl Default for ArchiveConfig {
fn default() -> Self {
Self {
enabled: true,
threshold_chars: 800,
max_age_hours: 48,
max_disk_mb: 500,
ephemeral: true,
ephemeral_min_tokens: 2000,
inline_max_bytes: 32 * 1024,
raw_commands: crate::core::firewall::DEFAULT_RAW_COMMANDS
.iter()
.map(|s| (*s).to_string())
.collect(),
verbatim_max_tokens: 100_000,
}
}
}
impl ArchiveConfig {
pub fn ephemeral_effective(&self) -> bool {
if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL") {
return !matches!(v.trim(), "0" | "false" | "off");
}
self.ephemeral && self.enabled
}
pub fn ephemeral_min_tokens_effective(&self) -> usize {
if let Ok(v) = std::env::var("LEAN_CTX_EPHEMERAL_MIN_TOKENS")
&& let Ok(n) = v.trim().parse::<usize>()
{
return n;
}
self.ephemeral_min_tokens
}
pub fn verbatim_max_tokens_effective(&self) -> usize {
if let Ok(v) = std::env::var("LEAN_CTX_VERBATIM_MAX_TOKENS")
&& let Ok(n) = v.trim().parse::<usize>()
{
return n;
}
self.verbatim_max_tokens
}
pub fn inline_max_bytes_effective(&self) -> usize {
if let Ok(v) = std::env::var("LEAN_CTX_INLINE_MAX_BYTES")
&& let Ok(n) = v.trim().parse::<usize>()
{
return n;
}
self.inline_max_bytes
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ProvidersConfig {
pub enabled: bool,
pub github: ProviderEntryConfig,
pub gitlab: ProviderEntryConfig,
pub auto_index: bool,
pub cache_ttl_secs: u64,
#[serde(default)]
pub mcp_bridges: std::collections::HashMap<String, McpBridgeEntry>,
}
impl Default for ProvidersConfig {
fn default() -> Self {
Self {
enabled: true,
github: ProviderEntryConfig::default(),
gitlab: ProviderEntryConfig::default(),
auto_index: true,
cache_ttl_secs: 120,
mcp_bridges: std::collections::HashMap::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct KnowledgeRoutingConfig {
pub enabled: bool,
pub max_references: usize,
pub hint_prefix: String,
}
impl Default for KnowledgeRoutingConfig {
fn default() -> Self {
Self {
enabled: true,
max_references: 5,
hint_prefix: "--- Knowledge Context ---".to_string(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpBridgeEntry {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub auth_env: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ProviderEntryConfig {
pub enabled: bool,
pub token: Option<String>,
pub api_url: Option<String>,
pub project: Option<String>,
}
impl Default for ProviderEntryConfig {
fn default() -> Self {
Self {
enabled: true,
token: None,
api_url: None,
project: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct AutonomyConfig {
pub enabled: bool,
pub auto_consolidate: bool,
pub consolidate_every_calls: u32,
pub consolidate_cooldown_secs: u64,
pub cognition_loop_enabled: bool,
#[serde(default = "serde_defaults::default_cognition_loop_interval")]
pub cognition_loop_interval_secs: u64,
#[serde(default = "serde_defaults::default_cognition_loop_max_steps")]
pub cognition_loop_max_steps: u8,
#[serde(default = "serde_defaults::default_cognition_synthesis_min_cluster")]
pub cognition_synthesis_min_cluster: usize,
}
impl Default for AutonomyConfig {
fn default() -> Self {
Self {
enabled: true,
auto_consolidate: true,
consolidate_every_calls: 25,
consolidate_cooldown_secs: 120,
cognition_loop_enabled: false,
cognition_loop_interval_secs: 3600,
cognition_loop_max_steps: 9,
cognition_synthesis_min_cluster: 3,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct UpdatesConfig {
pub auto_update: bool,
pub check_interval_hours: u64,
pub notify_only: bool,
#[serde(default)]
pub pinned_version: Option<String>,
}
impl Default for UpdatesConfig {
fn default() -> Self {
Self {
auto_update: false,
check_interval_hours: 6,
notify_only: false,
pinned_version: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ContextConfig {
pub budget_tokens: usize,
pub proactive_expansion: bool,
pub proactive_expansion_budget_tokens: usize,
pub proactive_expansion_threshold: f64,
pub proactive_expansion_max_age_secs: u64,
}
impl Default for ContextConfig {
fn default() -> Self {
Self {
budget_tokens: 8000,
proactive_expansion: true,
proactive_expansion_budget_tokens: 2000,
proactive_expansion_threshold: 0.6,
proactive_expansion_max_age_secs: 3600,
}
}
}
impl UpdatesConfig {
pub fn from_env() -> Self {
let mut cfg = Self::default();
if let Ok(v) = std::env::var("LEAN_CTX_AUTO_UPDATE") {
cfg.auto_update = v == "1" || v.eq_ignore_ascii_case("true");
}
if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_INTERVAL_HOURS")
&& let Ok(h) = v.parse::<u64>()
{
cfg.check_interval_hours = h.clamp(1, 168);
}
if let Ok(v) = std::env::var("LEAN_CTX_UPDATE_NOTIFY_ONLY") {
cfg.notify_only = v == "1" || v.eq_ignore_ascii_case("true");
}
cfg
}
}
impl AutonomyConfig {
pub fn from_env() -> Self {
let mut cfg = Self::default();
if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY")
&& (v == "false" || v == "0")
{
cfg.enabled = false;
}
if let Ok(v) = std::env::var("LEAN_CTX_AUTO_CONSOLIDATE") {
cfg.auto_consolidate = v != "false" && v != "0";
}
if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_EVERY_CALLS")
&& let Ok(n) = v.parse()
{
cfg.consolidate_every_calls = n;
}
if let Ok(v) = std::env::var("LEAN_CTX_CONSOLIDATE_COOLDOWN_SECS")
&& let Ok(n) = v.parse()
{
cfg.consolidate_cooldown_secs = n;
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
cfg.cognition_loop_enabled = v != "false" && v != "0";
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS")
&& let Ok(n) = v.parse()
{
cfg.cognition_loop_interval_secs = n;
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS")
&& let Ok(n) = v.parse()
{
cfg.cognition_loop_max_steps = n;
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_SYNTHESIS_MIN_CLUSTER")
&& let Ok(n) = v.parse()
{
cfg.cognition_synthesis_min_cluster = n;
}
cfg
}
pub fn load() -> Self {
let file_cfg = Config::load().autonomy;
let mut cfg = file_cfg;
if let Ok(v) = std::env::var("LEAN_CTX_AUTONOMY")
&& (v == "false" || v == "0")
{
cfg.enabled = false;
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_ENABLED") {
cfg.cognition_loop_enabled = v != "false" && v != "0";
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_INTERVAL_SECS")
&& let Ok(n) = v.parse()
{
cfg.cognition_loop_interval_secs = n;
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_LOOP_MAX_STEPS")
&& let Ok(n) = v.parse()
{
cfg.cognition_loop_max_steps = n;
}
if let Ok(v) = std::env::var("LEAN_CTX_COGNITION_SYNTHESIS_MIN_CLUSTER")
&& let Ok(n) = v.parse()
{
cfg.cognition_synthesis_min_cluster = n;
}
cfg
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TelemetryPreference {
#[default]
DefaultOn,
ExplicitlyEnabled,
ExplicitlyDisabled,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct TelemetryConfig {
pub enabled: bool,
pub preference: TelemetryPreference,
pub last_heartbeat: Option<String>,
}
impl Default for TelemetryConfig {
fn default() -> Self {
Self {
enabled: true,
preference: TelemetryPreference::DefaultOn,
last_heartbeat: None,
}
}
}
impl TelemetryConfig {
#[must_use]
pub fn environment_disables(do_not_track: Option<&str>, env_override: Option<&str>) -> bool {
do_not_track.is_some_and(|value| value.trim() == "1")
|| env_override.is_some_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"off" | "false" | "0" | "no"
)
})
}
#[must_use]
pub fn explicitly_disabled(&self) -> bool {
!self.enabled || self.preference == TelemetryPreference::ExplicitlyDisabled
}
#[must_use]
pub fn send_eligible(&self, do_not_track: Option<&str>, env_override: Option<&str>) -> bool {
self.enabled
&& self.preference != TelemetryPreference::ExplicitlyDisabled
&& !Self::environment_disables(do_not_track, env_override)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct LegacyCloudConfig {
pub contribute_enabled: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct CostConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_model: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub models: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub prices: HashMap<String, PriceOverride>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ProtectionConfig {
pub file_patterns: Vec<String>,
pub tags: bool,
}
impl Default for ProtectionConfig {
fn default() -> Self {
Self {
file_patterns: Vec::new(),
tags: true,
}
}
}
impl ProtectionConfig {
pub fn path_is_protected(&self, path: &str) -> bool {
!path.is_empty()
&& self
.file_patterns
.iter()
.filter_map(|p| glob::Pattern::new(p).ok())
.any(|pattern| pattern.matches(path))
}
}
#[cfg(test)]
mod protection_tests {
use super::ProtectionConfig;
#[test]
fn protected_globs_match_paths_and_ignore_invalid_patterns() {
let cfg = ProtectionConfig {
file_patterns: vec![
"docs/audits/**".into(),
"*.secret.md".into(),
"[invalid".into(),
],
tags: true,
};
assert!(cfg.path_is_protected("docs/audits/q3/blockers.md"));
assert!(cfg.path_is_protected("notes.secret.md"));
assert!(!cfg.path_is_protected("src/main.rs"));
assert!(!cfg.path_is_protected(""));
let empty = ProtectionConfig::default();
assert!(!empty.path_is_protected("docs/audits/q3/blockers.md"));
assert!(empty.tags, "protect tags are honored by default");
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DecisionLoopConfig {
pub enabled: bool,
#[serde(default)]
pub max_filter_level: u8,
}
impl Default for DecisionLoopConfig {
fn default() -> Self {
Self {
enabled: true,
max_filter_level: 0,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct PriceOverride {
pub input_per_m: Option<f64>,
pub output_per_m: Option<f64>,
pub cache_write_per_m: Option<f64>,
pub cache_read_per_m: Option<f64>,
}
impl CostConfig {
pub fn model_for_client(&self, client: &str) -> Option<String> {
self.models
.get(client)
.or(self.default_model.as_ref())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CodeHealthConfig {
pub cognitive_threshold: u32,
pub gate: String,
pub annotate_reads: bool,
pub naming: bool,
#[serde(default)]
pub inject_context: bool,
}
impl Default for CodeHealthConfig {
fn default() -> Self {
Self {
cognitive_threshold: 15,
gate: "warn".to_string(),
annotate_reads: true,
naming: true,
inject_context: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct IndexConfig {
pub respect_gitignore: bool,
pub exclude: Vec<String>,
pub include: Vec<String>,
}
impl Default for IndexConfig {
fn default() -> Self {
Self {
respect_gitignore: true,
exclude: Vec::new(),
include: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SkillifyConfig {
pub enabled: bool,
pub scope: String,
pub min_confidence: f32,
pub min_recurrence: u32,
}
impl Default for SkillifyConfig {
fn default() -> Self {
Self {
enabled: true,
scope: "project".to_string(),
min_confidence: 0.7,
min_recurrence: 2,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SummariesConfig {
pub enabled: bool,
pub every_n_turns: u32,
pub max_kept: u32,
}
impl Default for SummariesConfig {
fn default() -> Self {
Self {
enabled: true,
every_n_turns: 25,
max_kept: 100,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AliasEntry {
pub command: String,
pub alias: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LoopDetectionConfig {
pub normal_threshold: u32,
pub reduced_threshold: u32,
pub blocked_threshold: u32,
pub window_secs: u64,
pub search_group_limit: u32,
pub tool_total_limits: HashMap<String, u32>,
}
impl Default for LoopDetectionConfig {
fn default() -> Self {
let mut tool_total_limits = HashMap::new();
tool_total_limits.insert("ctx_read".to_string(), 100);
tool_total_limits.insert("ctx_search".to_string(), 80);
tool_total_limits.insert("ctx_shell".to_string(), 50);
Self {
normal_threshold: 2,
reduced_threshold: 4,
blocked_threshold: 0,
window_secs: 300,
search_group_limit: 10,
tool_total_limits,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct GatewayServerConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seats: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub org_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pseudonymize_persons: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_servers: Vec<McpServerEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct McpServerEntry {
pub id: String,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_env: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedMcpServer {
pub id: String,
pub url: String,
pub auth_env: Option<String>,
}
impl GatewayServerConfig {
#[must_use]
pub fn resolve_mcp_servers(&self, allow_insecure_http: bool) -> Vec<ResolvedMcpServer> {
let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
let mut out = Vec::new();
for entry in &self.mcp_servers {
if !entry.enabled.unwrap_or(true) {
continue;
}
let id = entry.id.trim();
if !is_valid_mcp_server_id(id) {
tracing::warn!(
"[gateway_server.mcp_servers] invalid id '{id}' \
(lowercase alnum/-/_ only) — entry skipped"
);
continue;
}
if !seen.insert(id) {
tracing::warn!(
"[gateway_server.mcp_servers] duplicate id '{id}' — keeping first entry"
);
continue;
}
match validate_mcp_upstream_url(&entry.url, allow_insecure_http) {
Ok(url) => out.push(ResolvedMcpServer {
id: id.to_string(),
url,
auth_env: entry
.auth_env
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string),
}),
Err(e) => {
tracing::warn!(
"[gateway_server.mcp_servers] '{id}' has invalid url — skipped: {e}"
);
}
}
}
out
}
}
fn is_valid_mcp_server_id(id: &str) -> bool {
!id.is_empty()
&& id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
fn validate_mcp_upstream_url(url: &str, allow_insecure_http: bool) -> Result<String, String> {
let trimmed = url.trim().trim_end_matches('/');
if trimmed.is_empty() {
return Err("empty url".into());
}
if crate::core::config::is_local_proxy_url(trimmed) {
return Ok(trimmed.to_string());
}
if trimmed.starts_with("http://") {
if allow_insecure_http {
return Ok(trimmed.to_string());
}
return Err(format!(
"MCP upstream must use HTTPS: {trimmed} (for a trusted local-network HTTP \
upstream opt in with `[proxy] allow_insecure_http_upstream = true`)"
));
}
if trimmed.starts_with("https://") {
return Ok(trimmed.to_string());
}
Err(format!(
"MCP upstream must start with http:// or https://: {trimmed}"
))
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct EmbeddingConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dimensions: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_download: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deterministic: Option<bool>,
}
#[cfg(test)]
mod gateway_server_tests {
use super::*;
fn mcp_entry(id: &str, url: &str) -> McpServerEntry {
McpServerEntry {
id: id.into(),
url: url.into(),
auth_env: None,
enabled: None,
}
}
#[test]
fn mcp_registry_validates_ids_urls_and_duplicates() {
let cfg = GatewayServerConfig {
mcp_servers: vec![
mcp_entry("github", "https://mcp.example.com/mcp/"),
mcp_entry("GitHub", "https://mcp.example.com/mcp"),
mcp_entry("github", "https://other.example.com/mcp"),
mcp_entry("plain", "http://mcp.example.com/mcp"),
mcp_entry("local", "http://127.0.0.1:9200/mcp"),
McpServerEntry {
enabled: Some(false),
..mcp_entry("disabled", "https://mcp.example.com/mcp")
},
McpServerEntry {
auth_env: Some(" GITHUB_MCP_PAT ".into()),
..mcp_entry("authed", "https://api.githubcopilot.com/mcp")
},
],
..Default::default()
};
let resolved = cfg.resolve_mcp_servers(false);
let ids: Vec<&str> = resolved.iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids, ["github", "local", "authed"]);
assert_eq!(resolved[0].url, "https://mcp.example.com/mcp");
assert_eq!(resolved[2].auth_env.as_deref(), Some("GITHUB_MCP_PAT"));
let with_optin = cfg.resolve_mcp_servers(true);
assert!(with_optin.iter().any(|s| s.id == "plain"));
}
#[test]
fn mcp_upstream_url_rules_match_the_proxy_posture() {
assert!(validate_mcp_upstream_url("https://mcp.example.com/mcp", false).is_ok());
assert!(validate_mcp_upstream_url("http://localhost:9200/mcp", false).is_ok());
assert!(validate_mcp_upstream_url("http://mcp.example.com/mcp", false).is_err());
assert!(validate_mcp_upstream_url("http://mcp.example.com/mcp", true).is_ok());
assert!(validate_mcp_upstream_url("ftp://mcp.example.com", false).is_err());
assert!(validate_mcp_upstream_url(" ", false).is_err());
}
}
#[cfg(test)]
mod ocla_tests {
use super::OclaConfig;
use serde::Deserialize;
#[derive(Deserialize)]
struct ConfigFile {
ocla: OclaConfig,
}
#[test]
fn removed_listener_sections_are_ignored() {
let config: ConfigFile = toml::from_str(
r#"
[ocla.sidecar]
bind_addr = "127.0.0.1:9000"
enabled = true
[ocla.grpc]
enabled = true
listen = "127.0.0.1:60051"
[ocla.delivery]
max_entries = 7
"#,
)
.expect("legacy OCLA config");
assert_eq!(config.ocla.delivery.max_entries, 7);
assert!(config.ocla.delivery.enabled);
}
}
#[cfg(test)]
mod telemetry_tests {
use super::*;
#[test]
fn telemetry_config_defaults_on_and_send_eligible() {
let cfg = TelemetryConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.preference, TelemetryPreference::DefaultOn);
assert!(cfg.send_eligible(None, None));
assert!(cfg.last_heartbeat.is_none());
}
#[test]
fn telemetry_config_serde_roundtrip() {
let toml_str = r#"
[telemetry]
enabled = true
last_heartbeat = "2026-07-30"
"#;
#[derive(serde::Deserialize)]
struct Wrap {
telemetry: TelemetryConfig,
}
let wrap: Wrap = toml::from_str(toml_str).expect("parse telemetry config");
assert!(wrap.telemetry.enabled);
assert_eq!(wrap.telemetry.preference, TelemetryPreference::DefaultOn);
assert_eq!(wrap.telemetry.last_heartbeat.as_deref(), Some("2026-07-30"));
}
#[test]
fn a_retired_notice_key_in_an_existing_config_still_parses() {
let cfg: TelemetryConfig =
toml::from_str("enabled = true\nnotice_shown = false").expect("pre-removal config");
assert!(cfg.send_eligible(None, None));
}
#[test]
fn telemetry_config_missing_section_uses_defaults() {
let toml_str = "";
#[derive(serde::Deserialize, Default)]
#[serde(default)]
struct Wrap {
telemetry: TelemetryConfig,
}
let wrap: Wrap = toml::from_str(toml_str).expect("parse empty config");
assert!(wrap.telemetry.enabled);
assert_eq!(wrap.telemetry.preference, TelemetryPreference::DefaultOn);
assert!(wrap.telemetry.last_heartbeat.is_none());
}
#[test]
fn legacy_false_remains_an_explicit_opt_out() {
let cfg: TelemetryConfig = toml::from_str("enabled = false").expect("legacy config");
assert!(cfg.explicitly_disabled());
}
#[test]
fn send_honors_the_explicit_and_both_environment_opt_outs() {
let mut cfg = TelemetryConfig::default();
assert!(cfg.send_eligible(None, None));
assert!(!cfg.send_eligible(Some("1"), None));
assert!(!cfg.send_eligible(None, Some("OFF")));
cfg.preference = TelemetryPreference::ExplicitlyDisabled;
assert!(!cfg.send_eligible(None, None));
}
}