use agent_client_protocol as acp; use anyhow::Result; use indexmap::IndexMap; use kigi_agent::prompt::skills::SkillsConfig; use kigi_tools::types::compat::{CompatConfig, CompatConfigToml}; use serde::Deserialize; use std::collections::HashMap; use std::path::PathBuf; use toml::Value as TomlValue; use toml::map::Map as TomlMap; pub use kigi_mcp::oauth_config::{McpOAuthConfig, McpOAuthConfigMap}; // MCP server config value types extracted to `kigi-config-types` (config // dependency inversion); re-exported so `crate::util::config::*` paths keep working. pub use kigi_config_types::{McpJsonOAuthBlock, McpServerConfig, McpServerTransportConfig}; // Permission-policy value types likewise extracted; re-exported to keep paths stable. pub use kigi_config_types::{ PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter, }; // Relay-sync + MCP-config value types extracted; re-exported to keep paths stable. pub use kigi_config_types::McpConfig; // Worktree-pool config value type extracted; re-exported to keep paths stable. pub use kigi_config_types::PoolConfig; /// TUI/CLI settings. Composed from typed section configs defined in `agent::config`. #[derive(Debug, Clone, Default)] pub struct Config { pub cli: crate::agent::config::CliConfig, pub models: crate::agent::config::ModelsConfig, pub ui: crate::agent::config::UiConfig, pub harness: crate::agent::config::HarnessConfig, pub skills: SkillsConfig, /// `[compat]` vendor-compatibility config, round-tripped so the /// pager preserves per-vendor toggles when persisting other settings. pub compat: CompatConfigToml, /// Management API key from `[endpoints]`. pub management_api_key: Option, /// Permission policy rules loaded from `[permission]` section in config.toml. pub permission: Option, pub diagnostics: crate::agent::config::DiagnosticsConfig, /// `[session]` section — round-tripped through `merge_section` so /// pager setters can persist session fields (e.g. auto-compact threshold). pub session: crate::agent::config::SessionConfig, /// `[toolset.ask_user_question]` sub-table — the only `[toolset]` piece /// the settings modal writes; the rest of `[toolset]` never round-trips /// (it carries runtime-only structs whose defaults must not hit disk). pub ask_user_question: crate::tools::config::AskUserQuestionToolConfig, } pub fn get_mcp_server_config(name: &str) -> Option { let root: TomlValue = crate::config::load_effective_config().ok()?; let configs = parse_mcp_servers_from_toml(&root); configs.get(name).cloned() } /// Get MCP server config by name, checking project-scoped configs first. /// Walks from cwd up to the git repo root checking `.kigi/config.toml` at each level. /// Project-scoped `.kigi/config.toml` entries override global `~/.kigi/config.toml` /// entries entirely (no deep merge of individual fields). /// Closer directories (cwd) take priority over further ones (repo root). pub fn get_mcp_server_config_with_project( name: &str, cwd: &std::path::Path, ) -> Option { // Check project-scoped configs from cwd (highest priority) to repo root let project_configs = crate::config::find_project_configs(cwd); for config_path in project_configs.iter().rev() { if let Ok(root) = crate::config::load_config_file(config_path) { let configs = parse_mcp_servers_from_toml(&root); if let Some(config) = configs.get(name) { return Some(config.clone()); } } } // Fall back to global config get_mcp_server_config(name) } /// Scope tags for an MCP server definition. Single source of truth shared by /// the scope producers ([`mcp_server_scope`], /// [`load_mcp_server_configs_with_project`]) and the folder-trust gate that /// filters project-scoped names, so a retag can't silently desync the gate. /// `MCP_SCOPE_PROJECT` is `pub(crate)` for the gate consumer in `folder_trust`; /// `MCP_SCOPE_USER` stays private (only used here). pub(crate) const MCP_SCOPE_PROJECT: &str = "project"; const MCP_SCOPE_USER: &str = "user"; /// Scope an MCP server resolves at: project when defined in any project-scoped /// `.kigi/config.toml`, otherwise user (global config, `~/.claude.json`, /// `~/.cursor/mcp.json`, etc.). See [`MCP_SCOPE_PROJECT`] / `MCP_SCOPE_USER`. pub(crate) fn mcp_server_scope(name: &str, cwd: &std::path::Path) -> &'static str { for config_path in crate::config::find_project_configs(cwd) { if let Ok(root) = crate::config::load_config_file(&config_path) && parse_mcp_servers_from_toml(&root).contains_key(name) { return MCP_SCOPE_PROJECT; } } MCP_SCOPE_USER } /// Load MCP servers and their OAuth configurations from config.toml. /// /// Returns both the `acp::McpServer` list and a parallel [`McpOAuthConfigMap`]. pub fn load_mcp_servers_with_oauth( cwd: &std::path::Path, compat: &CompatConfig, ) -> (Vec, McpOAuthConfigMap) { let global_config = crate::config::load_from_disk().unwrap_or_else(|_| TomlValue::Table(toml::map::Map::new())); let mut servers_map: IndexMap = IndexMap::new(); for (name, config) in parse_mcp_servers_from_toml(&global_config) { servers_map.insert(name, config); } let project_configs = crate::config::find_project_configs(cwd); for config_path in &project_configs { if let Ok(root) = crate::config::load_config_file(config_path) { for (name, config) in parse_mcp_servers_from_toml(&root) { servers_map.insert(name, config); } } } // Also load from ~/.claude.json (lower priority than TOML) for (name, config) in load_claude_json_mcp_servers_as_configs(cwd, compat) { servers_map.entry(name).or_insert(config); } // Also load from ~/.cursor/mcp.json (lower priority than TOML and ~/.claude.json) for (name, config) in load_cursor_mcp_servers_as_configs(cwd, compat) { servers_map.entry(name).or_insert(config); } // Also load from .mcp.json files (lower priority than TOML, ~/.claude.json, and ~/.cursor) for (name, config) in load_mcp_json_servers_as_configs(cwd) { servers_map.entry(name).or_insert(config); } // `--mcp-config-file` entries override everything: an explicitly passed // file is the most specific intent (kimi-cli parity, F6). for (name, config) in load_cli_mcp_config_files_as_configs() { servers_map.insert(name, config); } let mut oauth_configs = McpOAuthConfigMap::new(); let mut acp_servers = Vec::new(); let sub = &crate::config::expand_env_vars_in_string; for (name, mut config) in servers_map { config.expand_strings(sub); if let Some(oauth) = config.oauth_config() { oauth_configs.insert(name.clone(), oauth); } if let Some(acp_server) = config.to_acp_mcp_server(name) { acp_servers.push(acp_server); } } (acp_servers, oauth_configs) } /// Load the worktree pool configuration from config.toml. /// Returns the default config if the section is missing. pub fn worktree_pool_from_toml(root: &TomlValue) -> PoolConfig { if let TomlValue::Table(table) = root && let Some(pool_val) = table.get("worktree_pool") { // Try to deserialize the section; fall back to defaults on error pool_val .clone() .try_into::() .unwrap_or_default() } else { PoolConfig::default() } } /// Load MCP servers with project-scoped overrides from `.kigi/config.toml`. /// /// Merge strategy: /// 1. Load MCP servers from global `~/.kigi/config.toml` /// 2. Walk from git repo root down to `cwd`, loading `.kigi/config.toml` at each level /// (matching the convention used by skills and AGENTS.md discovery) /// 3. Each level's entries replace entries with the same name entirely /// (no deep merge — omitted fields fall back to defaults) /// 4. Closer directories (cwd) take priority over further ones (repo root) pub fn load_mcp_servers(cwd: &std::path::Path, compat: &CompatConfig) -> Vec { let global_config = crate::config::load_effective_config() .unwrap_or_else(|_| TomlValue::Table(toml::map::Map::new())); reload_mcp_servers_merged(&global_config, cwd, compat) } /// Load MCP servers from config.toml only (global + project-scoped), without /// loading from `~/.claude.json`, `~/.cursor/mcp.json`, or /// `.mcp.json` sources. /// /// Used by [`crate::session::managed_mcp::merge_managed_mcp_servers_sourced`] /// which handles those non-TOML sources separately with proper `ConfigSource` /// tracking. Using [`load_mcp_servers`] there would cause all entries to be /// tagged as `ConfigSource::ConfigToml`, hiding the true origin. pub(crate) fn load_mcp_servers_toml_only(cwd: &std::path::Path) -> Vec { let sub = &crate::config::expand_env_vars_in_string; load_all_mcp_configs(cwd) .into_iter() .filter_map(|(name, mut config)| { config.expand_strings(sub); config.to_acp_mcp_server(name) }) .collect() } /// Merge MCP servers from a pre-parsed global config with project-scoped overrides. /// /// Same merge strategy as [`load_mcp_servers_with_project`] but takes the global /// config as a pre-parsed `toml::Value` instead of re-reading from disk. Project /// configs are still read from disk because the watcher signals paths, not content. pub(crate) fn reload_mcp_servers_merged( global_config: &TomlValue, cwd: &std::path::Path, compat: &CompatConfig, ) -> Vec { let mut servers: IndexMap = IndexMap::new(); for (name, config) in parse_mcp_servers_from_toml(global_config) { servers.insert(name, config); } let project_configs = crate::config::find_project_configs(cwd); for config_path in &project_configs { if let Ok(root) = crate::config::load_config_file(config_path) { let project_servers = parse_mcp_servers_from_toml(&root); if !project_servers.is_empty() { tracing::info!( count = project_servers.len(), path = %config_path.display(), "Loaded project-scoped MCP servers from .kigi/config.toml" ); for (name, config) in project_servers { servers.insert(name, config); } } } } // Also load from ~/.claude.json (lower priority than TOML) let claude_servers = load_claude_json_mcp_servers_as_configs(cwd, compat); tracing::info!( count = claude_servers.len(), "Loaded MCP servers from ~/.claude.json" ); for (name, config) in claude_servers { servers.entry(name).or_insert(config); } // Also load from ~/.cursor/mcp.json (lower priority than TOML and ~/.claude.json) let cursor_servers = load_cursor_mcp_servers_as_configs(cwd, compat); tracing::info!( count = cursor_servers.len(), "Loaded Cursor MCP servers from ~/.cursor/mcp.json" ); for (name, config) in cursor_servers { servers.entry(name).or_insert(config); } // Also load from .mcp.json files (lower priority than TOML) let mcp_json_servers = load_mcp_json_servers_as_configs(cwd); tracing::info!( count = mcp_json_servers.len(), "Loaded .mcp.json MCP servers" ); for (name, config) in mcp_json_servers { servers.entry(name).or_insert(config); } // `--mcp-config-file` entries override everything (kimi-cli parity, F6). for (name, config) in load_cli_mcp_config_files_as_configs() { servers.insert(name, config); } let sub = &crate::config::expand_env_vars_in_string; servers .into_iter() .filter_map(|(name, mut config)| { config.expand_strings(sub); config.to_acp_mcp_server(name) }) .collect() } /// Load `.mcp.json` servers from repo root to `cwd` (closest wins on name conflict). pub fn load_mcp_json_servers(cwd: &std::path::Path) -> Vec { // Phase 2 cutoff: if the user has imported, skip reading .mcp.json. if crate::claude_import::is_claude_import_marked_with_log("load_mcp_json_servers") { return vec![]; } let mcp_json_files = find_mcp_json_files(cwd); if mcp_json_files.is_empty() { return vec![]; } let mut result = Vec::new(); let mut seen_names = std::collections::HashSet::new(); // Reverse so cwd entries win on name conflict. for mcp_path in mcp_json_files.iter().rev() { let json_servers = load_mcp_json_file(mcp_path); for server in json_servers { let name = match &server { acp::McpServer::Http(acp::McpServerHttp { name, .. }) | acp::McpServer::Sse(acp::McpServerSse { name, .. }) | acp::McpServer::Stdio(acp::McpServerStdio { name, .. }) => name.clone(), // TODO(acp-0.10): `McpServer` is #[non_exhaustive]. _ => continue, }; if seen_names.insert(name) { result.push(server); } } } result } /// All server names from config.toml (including `enabled = false`). pub fn all_toml_mcp_server_names(cwd: &std::path::Path) -> std::collections::HashSet { load_all_mcp_configs(cwd).keys().cloned().collect() } /// Persist `disabled_tools` for a server under `[disabled_mcp_tools]` in config.toml. /// /// Uses a dedicated top-level section (not `[mcp_servers]`) to avoid creating /// incomplete server entries that fail to deserialize. pub async fn save_mcp_disabled_tools(server_name: &str, disabled_tools: &[String]) -> Result<()> { let path = config_path(); let mut root: TomlValue = match tokio::fs::read_to_string(&path).await { Ok(s) => toml::from_str(&s).unwrap_or(TomlValue::Table(TomlMap::new())), Err(_) => TomlValue::Table(TomlMap::new()), }; let table = root .as_table_mut() .ok_or_else(|| anyhow::anyhow!("config root is not a table"))?; let section = table .entry("disabled_mcp_tools") .or_insert_with(|| TomlValue::Table(TomlMap::new())) .as_table_mut() .ok_or_else(|| anyhow::anyhow!("disabled_mcp_tools is not a table"))?; if disabled_tools.is_empty() { section.remove(server_name); if section.is_empty() { table.remove("disabled_mcp_tools"); } } else { let arr = disabled_tools .iter() .map(|s| TomlValue::String(s.clone())) .collect(); section.insert(server_name.to_string(), TomlValue::Array(arr)); } let toml_str = toml::to_string_pretty(&root)?; let tmp = path.with_extension("toml.tmp"); if let Some(parent) = path.parent() { let _ = tokio::fs::create_dir_all(parent).await; } tokio::fs::write(&tmp, &toml_str).await?; tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path)) .await .map_err(std::io::Error::other)??; Ok(()) } /// Persist the enabled/disabled state for a single MCP server. /// /// Uses the top-level `disabled_mcp_servers` array in `~/.kigi/config.toml`. /// For local servers that have a `[mcp_servers.X]` entry, also sets/clears /// the `enabled` field so `to_acp_mcp_server()` respects it at load time. pub async fn save_mcp_server_enabled(server_name: &str, enabled: bool) -> Result<()> { let path = config_path(); let mut root: TomlValue = match tokio::fs::read_to_string(&path).await { Ok(s) => toml::from_str(&s).unwrap_or(TomlValue::Table(TomlMap::new())), Err(_) => TomlValue::Table(TomlMap::new()), }; let table = root .as_table_mut() .ok_or_else(|| anyhow::anyhow!("config root is not a table"))?; // Update the `disabled_mcp_servers` list (source of truth for all servers). let mut disabled_list: Vec = table .get("disabled_mcp_servers") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(String::from)) .collect() }) .unwrap_or_default(); if enabled { disabled_list.retain(|n| n != server_name); } else if !disabled_list.contains(&server_name.to_string()) { disabled_list.push(server_name.to_string()); } if disabled_list.is_empty() { table.remove("disabled_mcp_servers"); } else { let arr = disabled_list .iter() .map(|s| TomlValue::String(s.clone())) .collect(); table.insert("disabled_mcp_servers".to_string(), TomlValue::Array(arr)); } let toml_str = toml::to_string_pretty(&root)?; let tmp = path.with_extension("toml.tmp"); if let Some(parent) = path.parent() { let _ = tokio::fs::create_dir_all(parent).await; } tokio::fs::write(&tmp, &toml_str).await?; tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &path)) .await .map_err(std::io::Error::other)??; Ok(()) } /// Upsert an MCP server entry in `~/.kigi/config.toml`. /// /// Creates or replaces `[mcp_servers.]` with the given config. /// Also removes the server from `disabled_mcp_servers` if present (a newly /// defined server should start enabled). pub async fn save_mcp_server_config(server_name: &str, config: &McpServerConfig) -> Result<()> { save_mcp_server_config_at(&config_path(), server_name, config).await } /// Upsert an MCP server entry in the config file at `path`. /// /// Same semantics as [`save_mcp_server_config`] but targets an explicit /// config file, e.g. a project-scoped `.kigi/config.toml`. pub async fn save_mcp_server_config_at( path: &std::path::Path, server_name: &str, config: &McpServerConfig, ) -> Result<()> { let mut root: TomlValue = match tokio::fs::read_to_string(&path).await { Ok(s) => toml::from_str(&s).unwrap_or(TomlValue::Table(TomlMap::new())), Err(_) => TomlValue::Table(TomlMap::new()), }; let table = root .as_table_mut() .ok_or_else(|| anyhow::anyhow!("config root is not a table"))?; let servers = table .entry("mcp_servers") .or_insert_with(|| TomlValue::Table(TomlMap::new())) .as_table_mut() .ok_or_else(|| anyhow::anyhow!("mcp_servers is not a table"))?; let serialized = toml::Value::try_from(config) .map_err(|e| anyhow::anyhow!("failed to serialize MCP server config: {e}"))?; servers.insert(server_name.to_string(), serialized); // Ensure the server isn't in the disabled list. if let Some(arr) = table .get_mut("disabled_mcp_servers") .and_then(|v| v.as_array_mut()) { arr.retain(|v| v.as_str() != Some(server_name)); if arr.is_empty() { table.remove("disabled_mcp_servers"); } } let toml_str = toml::to_string_pretty(&root)?; let tmp = path.with_extension("toml.tmp"); if let Some(parent) = path.parent() { let _ = tokio::fs::create_dir_all(parent).await; } tokio::fs::write(&tmp, &toml_str).await?; let dest = path.to_path_buf(); tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest)) .await .map_err(std::io::Error::other)??; Ok(()) } /// Delete an MCP server entry from `~/.kigi/config.toml`. /// /// Removes `[mcp_servers.]`, cleans up `disabled_mcp_servers` and /// `[disabled_mcp_tools.]` entries. Returns `true` if the entry existed. pub async fn delete_mcp_server_config(server_name: &str) -> Result { delete_mcp_server_config_at(&config_path(), server_name).await } /// Delete an MCP server entry from the config file at `path`. /// /// Same semantics as [`delete_mcp_server_config`] but targets an explicit /// config file, e.g. a project-scoped `.kigi/config.toml`. OAuth credential /// cleanup is keyed by server name against the global credential store, so it /// also drops credentials a same-named server in another config file uses. pub async fn delete_mcp_server_config_at( path: &std::path::Path, server_name: &str, ) -> Result { let mut root: TomlValue = match tokio::fs::read_to_string(&path).await { Ok(s) => toml::from_str(&s).unwrap_or(TomlValue::Table(TomlMap::new())), Err(_) => return Ok(false), }; let table = root .as_table_mut() .ok_or_else(|| anyhow::anyhow!("config root is not a table"))?; let existed = table .get_mut("mcp_servers") .and_then(|v| v.as_table_mut()) .and_then(|servers| servers.remove(server_name)) .is_some(); if !existed { return Ok(false); } // Clean up empty mcp_servers table. if table .get("mcp_servers") .and_then(|v| v.as_table()) .is_some_and(|t| t.is_empty()) { table.remove("mcp_servers"); } // Remove from disabled_mcp_servers list. if let Some(arr) = table .get_mut("disabled_mcp_servers") .and_then(|v| v.as_array_mut()) { arr.retain(|v| v.as_str() != Some(server_name)); if arr.is_empty() { table.remove("disabled_mcp_servers"); } } // Remove disabled_mcp_tools entry. if let Some(section) = table .get_mut("disabled_mcp_tools") .and_then(|v| v.as_table_mut()) { section.remove(server_name); if section.is_empty() { table.remove("disabled_mcp_tools"); } } let toml_str = toml::to_string_pretty(&root)?; let tmp = path.with_extension("toml.tmp"); if let Some(parent) = path.parent() { let _ = tokio::fs::create_dir_all(parent).await; } tokio::fs::write(&tmp, &toml_str).await?; { let dest = path.to_path_buf(); tokio::task::spawn_blocking(move || crate::util::fs::replace_file(&tmp, &dest)) .await .map_err(std::io::Error::other)??; } // Clean up OAuth credentials for the deleted server. if let Ok(mut cred_store) = kigi_mcp::credentials::McpCredentialStore::load_default() { let removed = cred_store.remove_by_server_name(server_name); if removed > 0 { let _ = cred_store.save_default(); } } Ok(true) } /// Load disabled_tools for all MCP servers from `[disabled_mcp_tools]` in config.toml. pub fn get_all_mcp_disabled_tools( _cwd: &std::path::Path, ) -> std::collections::HashMap> { let root = match crate::config::load_effective_config() { Ok(r) => r, Err(_) => return std::collections::HashMap::new(), }; let Some(section) = root.get("disabled_mcp_tools").and_then(|v| v.as_table()) else { return std::collections::HashMap::new(); }; section .iter() .filter_map(|(server, val)| { let tools: std::collections::HashSet = val .as_array()? .iter() .filter_map(|v| v.as_str().map(String::from)) .collect(); if tools.is_empty() { None } else { Some((server.clone(), tools)) } }) .collect() } /// Load all configured MCP servers as `(name, config)` pairs. /// /// Reads from `load_effective_config()`, which merges the system-managed, /// managed, and user config layers only. Use /// [`load_mcp_server_configs_with_project`] for a view that also includes /// project-scoped `.kigi/config.toml` files. pub fn load_mcp_server_configs() -> IndexMap { let root = crate::config::load_effective_config().unwrap_or_else(|_| TomlValue::Table(TomlMap::new())); parse_mcp_servers_from_toml(&root) } fn parse_mcp_servers_from_toml(root: &TomlValue) -> IndexMap { let TomlValue::Table(table) = root else { return IndexMap::new(); }; let Some(TomlValue::Table(mcp_servers)) = table.get("mcp_servers") else { return IndexMap::new(); }; let mut result = IndexMap::new(); for (name, value) in mcp_servers { if let Ok(config) = toml::Value::try_into::(value.clone()) { result.insert(name.clone(), config); } } result } // ── .mcp.json support ──────────────────────────────────────────────── // `.mcp.json` discovery moved to `kigi-workspace` (client-side, shared with // the folder-trust gate); re-exported so `crate::util::config::*` paths keep working. pub use kigi_workspace::project_config::{ MCP_JSON_FILENAME, find_mcp_json_files, mcp_json_candidate_paths, }; pub fn load_mcp_json_file(path: &std::path::Path) -> Vec { if !path.is_file() { return vec![]; } let Some(value) = read_mcp_json(path) else { return vec![]; }; let label = path.display().to_string(); parse_mcp_config(&value, &label, &crate::config::expand_env_vars_in_string) } /// Load .mcp.json servers as McpServerConfig map (for merging into load_mcp_servers). pub(crate) fn load_mcp_json_servers_as_configs( cwd: &std::path::Path, ) -> IndexMap { // Phase 2 cutoff: if the user has imported, skip reading .mcp.json. if crate::claude_import::is_claude_import_marked_with_log("load_mcp_json_servers_as_configs") { return IndexMap::new(); } load_mcp_json_servers_as_configs_unfiltered(cwd) } /// Like [`load_mcp_json_servers_as_configs`] but bypasses the import-marker /// gate. Used by the `/import-claude` scanner so users can re-import items /// they previously skipped, even after the runtime cutoff is active. pub fn load_mcp_json_servers_as_configs_unfiltered( cwd: &std::path::Path, ) -> IndexMap { let mcp_json_files = find_mcp_json_files(cwd); if mcp_json_files.is_empty() { return IndexMap::new(); } let mut result = IndexMap::new(); // Reverse so cwd entries win on name conflict. for mcp_path in mcp_json_files.iter().rev() { if let Some(config) = read_mcp_json(mcp_path) { for (name, cfg) in config.mcp_servers { result.entry(name).or_insert(cfg); } } } result } pub(crate) fn parse_mcp_config( config: &McpConfig, source_label: &str, sub: &dyn Fn(&str) -> String, ) -> Vec { parse_mcp_config_with_oauth(config, source_label, sub).0 } pub(crate) fn parse_mcp_config_with_oauth( config: &McpConfig, source_label: &str, sub: &dyn Fn(&str) -> String, ) -> (Vec, McpOAuthConfigMap) { let mut servers = Vec::new(); let mut oauth_configs = McpOAuthConfigMap::new(); for (name, server_config) in &config.mcp_servers { let mut server_config = server_config.clone(); server_config.expand_strings(sub); if let Some(oauth) = server_config.oauth_config() { oauth_configs.insert(name.clone(), oauth); } if let Some(server) = server_config.to_acp_mcp_server(name.clone()) { servers.push(server); } else { tracing::warn!( source = source_label, server = name, "MCP server has no 'command' (stdio) or 'url' (http/sse); skipping" ); } } if !servers.is_empty() { tracing::info!( source = source_label, count = servers.len(), "loaded MCP servers" ); } (servers, oauth_configs) } /// Load MCP servers from `~/.claude.json`. /// /// User-level MCP servers live at the top-level `mcpServers` key, /// and per-project (local-scope) MCP servers under `projects..mcpServers`. /// /// Returns servers from both locations (project-specific first, then user-level). pub fn load_claude_json_mcp_servers( cwd: &std::path::Path, compat: &CompatConfig, ) -> Vec { // Compat gate: skip ~/.claude.json MCP loading when disabled. if !compat.claude.mcps { return vec![]; } // Phase 2 cutoff: if the user has imported, skip reading ~/.claude.json. if crate::claude_import::is_claude_import_marked_with_log("load_claude_json_mcp_servers") { return vec![]; } let Some(home) = dirs::home_dir() else { return vec![]; }; let claude_json_path = home.join(".claude.json"); load_claude_json_mcp_servers_from(&claude_json_path, cwd) } /// Load ~/.claude.json MCP servers as McpServerConfig map (for merging into load_mcp_servers). pub(crate) fn load_claude_json_mcp_servers_as_configs( cwd: &std::path::Path, compat: &CompatConfig, ) -> IndexMap { // Compat gate: skip ~/.claude.json MCP loading when disabled. if !compat.claude.mcps { return IndexMap::new(); } // Phase 2 cutoff: if the user has imported, skip reading ~/.claude.json. if crate::claude_import::is_claude_import_marked_with_log( "load_claude_json_mcp_servers_as_configs", ) { return IndexMap::new(); } load_claude_json_mcp_servers_as_configs_unfiltered(cwd) } /// Like [`load_claude_json_mcp_servers_as_configs`] but bypasses the /// import-marker gate. Used by the `/import-claude` scanner so users can /// re-import items they previously skipped, even after the runtime cutoff /// is active. pub fn load_claude_json_mcp_servers_as_configs_unfiltered( cwd: &std::path::Path, ) -> IndexMap { let Some(home) = dirs::home_dir() else { return IndexMap::new(); }; let claude_json_path = home.join(".claude.json"); load_claude_json_mcp_servers_from_as_configs(&claude_json_path, cwd) } fn load_claude_json_mcp_servers_from_as_configs( claude_json_path: &std::path::Path, cwd: &std::path::Path, ) -> IndexMap { let content = match std::fs::read_to_string(claude_json_path) { Ok(c) => c, Err(e) => { tracing::debug!( path = %claude_json_path.display(), error = %e, "failed to read ~/.claude.json" ); return IndexMap::new(); } }; let config: ClaudeJsonConfig = match serde_json::from_str(&content) { Ok(v) => v, Err(e) => { tracing::debug!( path = %claude_json_path.display(), error = %e, "failed to parse ~/.claude.json" ); return IndexMap::new(); } }; let mut result = IndexMap::new(); // Per-project MCP servers (local scope, higher priority) let cwd_key = cwd.to_string_lossy(); if let Some(project) = config.projects.get(cwd_key.as_ref()) { for (name, cfg) in &project.mcp_servers { result.insert(name.clone(), cfg.clone()); } } // User-level MCP servers (lower priority) for (name, cfg) in &config.user_mcp.mcp_servers { result.entry(name.clone()).or_insert(cfg.clone()); } tracing::info!( project_count = config .projects .get(cwd_key.as_ref()) .map(|p| p.mcp_servers.len()) .unwrap_or(0), user_level_count = config.user_mcp.mcp_servers.len(), total_count = result.len(), "MCP servers loaded from ~/.claude.json" ); result } /// Load MCP servers from editor MCP config files. /// /// Scans project-level `/.cursor/mcp.json` first (higher priority), /// then global `~/.cursor/mcp.json`. Both use the `{"mcpServers": {...}}` /// format identical to `.mcp.json`. Gated by `compat.cursor.mcps`. pub fn load_cursor_mcp_servers( cwd: &std::path::Path, compat: &CompatConfig, ) -> Vec { // Compat gate: skip Cursor MCP loading when disabled. if !compat.cursor.mcps { return vec![]; } let mut result = Vec::new(); let mut seen_names = std::collections::HashSet::new(); // Project-level (higher priority) let project_path = cwd.join(".cursor").join("mcp.json"); for server in load_mcp_json_file(&project_path) { let name = match &server { acp::McpServer::Http(acp::McpServerHttp { name, .. }) | acp::McpServer::Sse(acp::McpServerSse { name, .. }) | acp::McpServer::Stdio(acp::McpServerStdio { name, .. }) => name.clone(), // TODO(acp-0.10): `McpServer` is #[non_exhaustive]. _ => continue, }; if seen_names.insert(name) { result.push(server); } } // Global (lower priority) if let Some(home) = dirs::home_dir() { let global_path = home.join(".cursor").join("mcp.json"); for server in load_mcp_json_file(&global_path) { let name = match &server { acp::McpServer::Http(acp::McpServerHttp { name, .. }) | acp::McpServer::Sse(acp::McpServerSse { name, .. }) | acp::McpServer::Stdio(acp::McpServerStdio { name, .. }) => name.clone(), // TODO(acp-0.10): `McpServer` is #[non_exhaustive]. _ => continue, }; if seen_names.insert(name) { result.push(server); } } } result } /// Load Cursor MCP servers as McpServerConfig map (for merging into load_mcp_servers). /// /// Scans project-level `/.cursor/mcp.json` first, then global. pub(crate) fn load_cursor_mcp_servers_as_configs( cwd: &std::path::Path, compat: &CompatConfig, ) -> IndexMap { // Compat gate: skip Cursor MCP loading when disabled. if !compat.cursor.mcps { return IndexMap::new(); } let mut result = IndexMap::new(); // Project-level (higher priority) let project_path = cwd.join(".cursor").join("mcp.json"); if project_path.is_file() && let Some(config) = read_mcp_json(&project_path) { for (name, cfg) in config.mcp_servers { result.insert(name, cfg); } } // Global (lower priority — or_insert so project wins) if let Some(home) = dirs::home_dir() { let global_path = home.join(".cursor").join("mcp.json"); if global_path.is_file() && let Some(config) = read_mcp_json(&global_path) { for (name, cfg) in config.mcp_servers { result.entry(name).or_insert(cfg); } } } result } /// Subset of `~/.claude.json` we care about for MCP server discovery. /// /// Reuses `McpConfig` for both the top-level user MCP servers and per-project /// entries — the JSON shape (`{ "mcpServers": { ... } }`) is identical at both levels. #[derive(Default, Deserialize)] struct ClaudeJsonConfig { /// User-level MCP servers (top-level `mcpServers` key). #[serde(flatten)] user_mcp: McpConfig, /// Per-project entries, keyed by absolute project path. #[serde(default)] projects: HashMap, } /// Inner implementation that accepts the file path, making it testable. fn load_claude_json_mcp_servers_from( claude_json_path: &std::path::Path, cwd: &std::path::Path, ) -> Vec { let content = match std::fs::read_to_string(claude_json_path) { Ok(c) => c, Err(_) => return vec![], }; let config: ClaudeJsonConfig = match serde_json::from_str(&content) { Ok(v) => v, Err(e) => { tracing::debug!( path = %claude_json_path.display(), error = %e, "failed to parse claude.json" ); return vec![]; } }; let sub = &crate::config::expand_env_vars_in_string; let mut servers = Vec::new(); // Per-project MCP servers (local scope, higher priority) let cwd_key = cwd.to_string_lossy(); if let Some(project) = config.projects.get(cwd_key.as_ref()) { let label = format!("~/.claude.json projects[{}]", cwd_key); servers.extend(parse_mcp_config(project, &label, sub)); } // User-level MCP servers (lower priority) if !config.user_mcp.mcp_servers.is_empty() { servers.extend(parse_mcp_config(&config.user_mcp, "~/.claude.json", sub)); } servers } /// Read and parse a JSON file. Returns `None` on I/O or parse errors (logged). /// Env var carrying `--mcp-config-file` paths across the TUI → shell /// process boundary (joined with the platform path separator). kimi-cli /// parity (F6): each file uses the `.mcp.json` shape /// (`{"mcpServers": {...}}`) and its entries override every config-scope /// source — an explicitly passed file is the most specific intent. pub const MCP_CONFIG_FILES_ENV: &str = "KIGI_MCP_CONFIG_FILES"; /// Servers from `--mcp-config-file` paths (via [`MCP_CONFIG_FILES_ENV`]), /// later files winning on name conflicts. Unreadable/unparsable files fail /// loudly at CLI parse time; here they only warn (the env may outlive the /// file). pub(crate) fn load_cli_mcp_config_files_as_configs() -> IndexMap { let mut result = IndexMap::new(); let Some(paths) = std::env::var_os(MCP_CONFIG_FILES_ENV) else { return result; }; for path in std::env::split_paths(&paths) { if let Some(config) = read_mcp_json(&path) { for (name, cfg) in config.mcp_servers { result.insert(name, cfg); } } } result } pub(crate) fn read_mcp_json(path: &std::path::Path) -> Option { let content = std::fs::read_to_string(path) .map_err(|e| { tracing::warn!(error = %e, "failed to read MCP JSON"); }) .ok()?; serde_json::from_str(&content) .map_err(|e| { tracing::warn!(error = %e, "failed to parse MCP JSON"); }) .ok() } /// Like `load_mcp_servers_with_project` but returns raw configs without filtering by `enabled`. fn load_all_mcp_configs(cwd: &std::path::Path) -> IndexMap { load_mcp_server_configs_with_project(cwd) .into_iter() .map(|(name, (config, _))| (name, config)) .collect() } /// Load all configured MCP servers with the scope each definition came from /// (`"user"` or `"project"`). /// /// Overlays project-scoped `.kigi/config.toml` files from `cwd` up to the /// repo root onto the user-tier config, nearest definition winning — the same /// override semantics as [`get_mcp_server_config_with_project`]. pub fn load_mcp_server_configs_with_project( cwd: &std::path::Path, ) -> IndexMap { let global_config = crate::config::load_effective_config() .unwrap_or_else(|_| TomlValue::Table(toml::map::Map::new())); let mut servers: IndexMap = parse_mcp_servers_from_toml(&global_config) .into_iter() .map(|(name, config)| (name, (config, MCP_SCOPE_USER))) .collect(); // find_project_configs is repo-root-first, so nearer files overwrite. for config_path in crate::config::find_project_configs(cwd) { if let Ok(root) = crate::config::load_config_file(&config_path) { for (name, config) in parse_mcp_servers_from_toml(&root) { servers.insert(name, (config, MCP_SCOPE_PROJECT)); } } } servers } /// MCP server names with `enabled = false` in config.toml (including project overrides). pub fn disabled_mcp_server_names(cwd: &std::path::Path) -> std::collections::HashSet { let mut disabled: std::collections::HashSet = load_all_mcp_configs(cwd) .into_iter() .filter(|(_, cfg)| !cfg.enabled) .map(|(name, _)| name) .collect(); // Also check the `disabled_mcp_servers` array in config.toml. if let Ok(root) = crate::config::load_effective_config() && let Some(arr) = root.get("disabled_mcp_servers").and_then(|v| v.as_array()) { for val in arr { if let Some(name) = val.as_str() { disabled.insert(name.to_string()); } } } disabled } fn config_path() -> PathBuf { crate::util::kigi_home::kigi_home().join("config.toml") } /// Path to the user-level config file (`~/.kigi/config.toml`). pub fn user_config_path() -> PathBuf { config_path() } /// Path to a project-level config file (`/.kigi/config.toml`). pub fn project_config_path(dir: &std::path::Path) -> PathBuf { dir.join(".kigi").join("config.toml") } /// True when the config file at `path` defines `[mcp_servers.]`. /// /// Checks raw key presence rather than deserializing, so malformed entries /// (the ones users most need `mcp remove` for) are still reported. pub fn mcp_server_defined_at(path: &std::path::Path, server_name: &str) -> bool { let Ok(root) = crate::config::load_config_file(path) else { return false; }; root.get("mcp_servers") .and_then(|v| v.as_table()) .is_some_and(|servers| servers.contains_key(server_name)) } /// Synchronously load `[cli] npm_registry` from config.toml. pub fn load_npm_registry_sync() -> Option { let root: TomlValue = crate::config::load_effective_config().ok()?; if let TomlValue::Table(table) = root && let Some(TomlValue::Table(cli)) = table.get("cli") { cli.get("npm_registry") .and_then(|v| v.as_str()) .map(|s| s.to_string()) } else { None } } /// Synchronously load just the management_api_key from the config file. /// This is intended for use in contexts where async is not available. pub fn load_management_api_key_sync() -> Option { let root: TomlValue = crate::config::load_effective_config().ok()?; if let TomlValue::Table(table) = root && let Some(TomlValue::Table(endpoints)) = table.get("endpoints") { endpoints .get("management_api_key") .and_then(|v| v.as_str()) .map(|s| s.to_string()) } else { None } } /// Synchronously load the gcs_service_account_key from the config file. /// This is intended for use in contexts where async is not available. pub fn load_gcs_service_account_key_sync() -> Option { let root: TomlValue = crate::config::load_effective_config().ok()?; if let TomlValue::Table(table) = root && let Some(TomlValue::Table(endpoints)) = table.get("endpoints") { endpoints .get("gcs_service_account_key") .and_then(|v| v.as_str()) .map(|s| s.to_string()) } else { None } } /// Returns `None` when `[cli] use_leader` is not set in the config /// (allowing a remote settings fallback), or `Some(true/false)` when /// explicitly configured. This distinction lets callers fall through /// to a remote flag when the user hasn't expressed a local preference. pub fn use_leader_from_toml_opt(root: &TomlValue) -> Option { if let TomlValue::Table(table) = root && let Some(TomlValue::Table(cli)) = table.get("cli") { cli.get("use_leader").and_then(|v| v.as_bool()) } else { None } } /// Check if leader mode is enabled in the config. /// When true, the agent will connect to a shared leader process instead of /// running the agent directly. This allows multiple agent instances to share one backend. /// Defaults to false when not explicitly set. pub fn use_leader_from_toml(root: &TomlValue) -> bool { use_leader_from_toml_opt(root).unwrap_or(false) } /// Returns `Some(true/false)` when `[cli] session_registry` is set in config.toml, /// `None` when absent (allowing remote settings fallback). /// Local config takes precedence over remote settings. pub fn session_registry_from_toml_opt(root: &TomlValue) -> Option { if let TomlValue::Table(table) = root && let Some(TomlValue::Table(cli)) = table.get("cli") { cli.get("session_registry").and_then(|v| v.as_bool()) } else { None } } #[cfg(test)] mod tests { /// `--mcp-config-file` env plumbing: later files override earlier ones, /// and entries parse with the `.mcp.json` shape. Serialized on the env /// var via the kigi_env guard (process-global state). #[test] #[serial_test::serial(kigi_mcp_config_files_env)] fn cli_mcp_config_files_load_in_order_with_later_files_winning() { let dir = tempfile::tempdir().unwrap(); let first = dir.path().join("first.json"); let second = dir.path().join("second.json"); std::fs::write( &first, serde_json::json!({ "mcpServers": { "shared": { "command": "first-cmd" }, "only-first": { "command": "keep-me" } }}) .to_string(), ) .unwrap(); std::fs::write( &second, serde_json::json!({ "mcpServers": { "shared": { "command": "second-cmd" } }}) .to_string(), ) .unwrap(); let joined = std::env::join_paths([&first, &second]).unwrap(); let _guard = kigi_env::EnvVarGuard::set(super::MCP_CONFIG_FILES_ENV, joined.to_str().unwrap()); let command_of = |c: &McpServerConfig| match &c.transport { McpServerTransportConfig::Stdio { command, .. } => command.clone(), other => panic!("expected stdio transport, got {other:?}"), }; let configs = super::load_cli_mcp_config_files_as_configs(); assert_eq!(configs.len(), 2); assert_eq!( configs.get("shared").map(command_of).as_deref(), Some("second-cmd"), "the later file must win on name conflicts" ); assert_eq!( configs.get("only-first").map(command_of).as_deref(), Some("keep-me") ); } /// Without the env var the loader is inert. #[test] #[serial_test::serial(kigi_mcp_config_files_env)] fn cli_mcp_config_files_absent_env_is_empty() { let _guard = kigi_env::EnvVarGuard::remove(super::MCP_CONFIG_FILES_ENV); assert!(super::load_cli_mcp_config_files_as_configs().is_empty()); } use super::*; use toml::Value as TomlValue; #[test] fn mcp_server_defined_at_checks_raw_key_presence() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); // `urll` fails McpServerConfig deserialization; the raw key must // still be reported so `mcp remove` can delete broken entries. std::fs::write( &path, "[mcp_servers.broken]\nurll = \"https://x.example\"\n", ) .unwrap(); assert!(mcp_server_defined_at(&path, "broken")); assert!(!mcp_server_defined_at(&path, "other")); assert!(!mcp_server_defined_at( &dir.path().join("missing.toml"), "broken" )); } /// Covers all canonical wire values plus the unknown/corrupt fallback. #[test] fn test_parse_mcp_servers_empty() { let root = toml::from_str::("").unwrap(); let servers = parse_mcp_servers_from_toml(&root); assert!(servers.is_empty()); } #[test] fn test_parse_mcp_servers_stdio() { let toml_str = r#" [mcp_servers.test_server] command = "node" args = ["server.js"] "#; let root = toml::from_str::(toml_str).unwrap(); let servers = parse_mcp_servers_from_toml(&root); assert_eq!(servers.len(), 1); assert!(servers.contains_key("test_server")); let config = servers.get("test_server").unwrap(); assert!(config.enabled); match &config.transport { McpServerTransportConfig::Stdio { command, args, .. } => { assert_eq!(command, "node"); assert_eq!(args, &["server.js"]); } _ => panic!("Expected Stdio transport"), } } #[test] fn test_use_leader_parsing_true() { // Test that we can parse a config with use_leader = true let toml_str = r#" [cli] use_leader = true "#; let root: TomlValue = toml::from_str(toml_str).unwrap(); assert!(use_leader_from_toml(&root)); } #[test] fn test_use_leader_parsing_false() { // Test that we can parse a config with use_leader = false let toml_str = r#" [cli] use_leader = false "#; let root: TomlValue = toml::from_str(toml_str).unwrap(); assert!(!use_leader_from_toml(&root)); } #[test] fn test_use_leader_default_false() { // Test that missing use_leader defaults to false let toml_str = r#" [cli] auto_update = true "#; let root: TomlValue = toml::from_str(toml_str).unwrap(); assert!(!use_leader_from_toml(&root)); } #[test] fn test_use_leader_no_cli_section() { // Test with no cli section at all let toml_str = r#" [models] default = "kigi-code-fast-1" "#; let root: TomlValue = toml::from_str(toml_str).unwrap(); if let TomlValue::Table(ref table) = root { let has_cli = table.get("cli").is_some(); assert!(!has_cli); } // use_leader_from_toml() should default to false when no cli section assert!(!use_leader_from_toml(&root)); } #[test] fn test_use_leader_opt_returns_some_true() { let toml_str = r#" [cli] use_leader = true "#; let root: TomlValue = toml::from_str(toml_str).unwrap(); assert_eq!(use_leader_from_toml_opt(&root), Some(true)); } #[test] fn test_use_leader_opt_returns_some_false() { let toml_str = r#" [cli] use_leader = false "#; let root: TomlValue = toml::from_str(toml_str).unwrap(); assert_eq!(use_leader_from_toml_opt(&root), Some(false)); } #[test] fn test_use_leader_opt_returns_none_when_absent() { let toml_str = r#" [cli] auto_update = true "#; let root: TomlValue = toml::from_str(toml_str).unwrap(); assert_eq!(use_leader_from_toml_opt(&root), None); } #[test] fn test_use_leader_opt_returns_none_when_no_cli_section() { let toml_str = r#" [models] default = "kigi-code-fast-1" "#; let root: TomlValue = toml::from_str(toml_str).unwrap(); assert_eq!(use_leader_from_toml_opt(&root), None); } // WorktreeType tests #[test] fn test_project_scoped_mcp_override_replaces_entirely() { // Simulate global config with timeouts let global_toml = r#" [mcp_servers.linear] command = "npx" args = ["-y", "mcp-remote", "https://mcp.linear.app/mcp"] enabled = true startup_timeout_sec = 10 tool_timeout_sec = 60 "#; let global_root = toml::from_str::(global_toml).unwrap(); let global_servers = parse_mcp_servers_from_toml(&global_root); // Simulate project config WITHOUT timeouts let project_toml = r#" [mcp_servers.linear] command = "npx" args = ["-y", "mcp-remote", "https://mcp.linear.app/mcp"] enabled = true "#; let project_root = toml::from_str::(project_toml).unwrap(); let project_servers = parse_mcp_servers_from_toml(&project_root); // Global config should have timeouts let global_linear = global_servers.get("linear").unwrap(); assert_eq!(global_linear.startup_timeout_sec, Some(10)); assert_eq!(global_linear.tool_timeout_sec, Some(60)); // Project config should NOT have timeouts (defaults apply) let project_linear = project_servers.get("linear").unwrap(); assert_eq!(project_linear.startup_timeout_sec, None); assert_eq!(project_linear.tool_timeout_sec, None); // Merge: project overrides global entirely let mut merged: IndexMap = IndexMap::new(); for (name, config) in &global_servers { merged.insert(name.clone(), config.clone()); } for (name, config) in &project_servers { merged.insert(name.clone(), config.clone()); } // After merge, the project config should have replaced the global one entirely let merged_linear = merged.get("linear").unwrap(); assert_eq!(merged_linear.startup_timeout_sec, None); assert_eq!(merged_linear.tool_timeout_sec, None); } #[test] fn test_project_scoped_mcp_adds_new_servers() { let global_toml = r#" [mcp_servers.linear] command = "npx" args = ["-y", "mcp-remote", "https://mcp.linear.app/mcp"] enabled = true "#; let global_root = toml::from_str::(global_toml).unwrap(); let global_servers = parse_mcp_servers_from_toml(&global_root); let project_toml = r#" [mcp_servers.buildkite] command = "npx" args = ["-y", "mcp-remote", "https://mcp.buildkite.com/mcp"] enabled = true "#; let project_root = toml::from_str::(project_toml).unwrap(); let project_servers = parse_mcp_servers_from_toml(&project_root); // Merge: project adds new server let mut merged: IndexMap = IndexMap::new(); for (name, config) in &global_servers { merged.insert(name.clone(), config.clone()); } for (name, config) in &project_servers { merged.insert(name.clone(), config.clone()); } assert_eq!(merged.len(), 2); assert!(merged.contains_key("linear")); assert!(merged.contains_key("buildkite")); } #[test] fn test_project_scoped_mcp_can_disable_server() { let global_toml = r#" [mcp_servers.linear] command = "npx" args = ["-y", "mcp-remote", "https://mcp.linear.app/mcp"] enabled = true "#; let global_root = toml::from_str::(global_toml).unwrap(); let global_servers = parse_mcp_servers_from_toml(&global_root); assert!(global_servers.get("linear").unwrap().enabled); // Project config disables the server let project_toml = r#" [mcp_servers.linear] command = "npx" args = ["-y", "mcp-remote", "https://mcp.linear.app/mcp"] enabled = false "#; let project_root = toml::from_str::(project_toml).unwrap(); let project_servers = parse_mcp_servers_from_toml(&project_root); let mut merged: IndexMap = IndexMap::new(); for (name, config) in &global_servers { merged.insert(name.clone(), config.clone()); } for (name, config) in &project_servers { merged.insert(name.clone(), config.clone()); } // After merge, the server should be disabled by project config assert!(!merged.get("linear").unwrap().enabled); } #[test] fn skills_config_default_is_empty() { let cfg = SkillsConfig::default(); assert!(cfg.paths.is_empty()); assert!(cfg.ignore.is_empty()); } #[test] fn skills_config_parses_paths_and_ignore() { let root = toml::from_str::( r#" [skills] paths = ["~/.kigi/skills", "~/.kigi/skills/special/SKILL.md"] ignore = ["~/.kigi/skills/noisy/SKILL.md"] "#, ) .unwrap(); let TomlValue::Table(ref table) = root else { panic!() }; let cfg = table .get("skills") .and_then(|v| v.clone().try_into::().ok()) .unwrap_or_default(); assert_eq!( cfg.paths, vec!["~/.kigi/skills", "~/.kigi/skills/special/SKILL.md"] ); assert_eq!(cfg.ignore, vec!["~/.kigi/skills/noisy/SKILL.md"]); } #[test] fn test_project_scoped_mcp_preserves_unrelated_global_servers() { let global_toml = r#" [mcp_servers.linear] command = "npx" args = ["-y", "mcp-remote", "https://mcp.linear.app/mcp"] enabled = true [mcp_servers.buildkite] command = "npx" args = ["-y", "mcp-remote", "https://mcp.buildkite.com/mcp"] enabled = true "#; let global_root = toml::from_str::(global_toml).unwrap(); let global_servers = parse_mcp_servers_from_toml(&global_root); // Project only overrides linear let project_toml = r#" [mcp_servers.linear] command = "npx" args = ["-y", "mcp-remote", "https://mcp.linear.app/mcp"] enabled = false "#; let project_root = toml::from_str::(project_toml).unwrap(); let project_servers = parse_mcp_servers_from_toml(&project_root); let mut merged: IndexMap = IndexMap::new(); for (name, config) in &global_servers { merged.insert(name.clone(), config.clone()); } for (name, config) in &project_servers { merged.insert(name.clone(), config.clone()); } // buildkite should be preserved from global config assert_eq!(merged.len(), 2); assert!(merged.get("buildkite").unwrap().enabled); assert!(!merged.get("linear").unwrap().enabled); } #[test] fn test_mcp_server_config_parses_tool_timeouts() { let toml_str = r#" [mcp_servers.github] command = "npx" args = ["-y", "@modelcontextprotocol/server-github"] tool_timeout_sec = 60 tool_timeouts = { create_issue = 120, search_repositories = 30 } "#; let root = toml::from_str::(toml_str).unwrap(); let servers = parse_mcp_servers_from_toml(&root); let github = servers.get("github").unwrap(); assert_eq!(github.tool_timeout_sec, Some(60)); let tt = github.tool_timeouts.as_ref().unwrap(); assert_eq!(tt.get("create_issue"), Some(&120)); assert_eq!(tt.get("search_repositories"), Some(&30)); assert_eq!(tt.get("nonexistent"), None); } #[test] fn test_mcp_server_config_tool_timeouts_defaults_to_none() { let toml_str = r#" [mcp_servers.filesystem] command = "npx" args = ["-y", "@modelcontextprotocol/server-filesystem"] "#; let root = toml::from_str::(toml_str).unwrap(); let servers = parse_mcp_servers_from_toml(&root); let fs = servers.get("filesystem").unwrap(); assert!(fs.tool_timeouts.is_none()); assert!(fs.tool_timeout_sec.is_none()); assert!(fs.expose_image_base64.is_none()); } #[test] fn test_mcp_server_config_parses_expose_image_base64() { let toml_str = r#" [mcp_servers.grafana] url = "https://grafana.example/mcp" expose_image_base64 = true "#; let root = toml::from_str::(toml_str).unwrap(); let servers = parse_mcp_servers_from_toml(&root); let grafana = servers.get("grafana").unwrap(); assert_eq!(grafana.expose_image_base64, Some(true)); } #[test] fn mcp_json_oauth_block_parsed_into_oauth_config() { let json = r#"{ "mcpServers": { "slack": { "type": "http", "url": "https://mcp.slack.example/mcp", "oauth": { "clientId": "slack-byo-client", "callbackPort": 3118 } } } }"#; let config: McpConfig = serde_json::from_str(json).expect("parse .mcp.json"); let slack = config.mcp_servers.get("slack").expect("slack server"); let block = slack.oauth.as_ref().expect("oauth block parsed"); assert_eq!(block.client_id.as_deref(), Some("slack-byo-client")); assert_eq!(block.callback_port, Some(3118)); let oauth = slack.oauth_config().expect("oauth_config from block"); assert_eq!(oauth.client_id.as_deref(), Some("slack-byo-client")); assert_eq!(oauth.callback_port, Some(3118)); } #[test] fn load_cursor_mcp_servers_as_configs_parses_cursor_mcp_json() { // NOTE: This test cannot override HOME (dirs::home_dir is not // controlled by an env var on all platforms), so we test the // underlying read_mcp_json + McpConfig round-trip instead. let dir = tempfile::tempdir().unwrap(); let mcp_json_path = dir.path().join("mcp.json"); std::fs::write( &mcp_json_path, r#"{ "mcpServers": { "test_server": { "command": "node", "args": ["server.js"] } } }"#, ) .unwrap(); let config = read_mcp_json(&mcp_json_path).expect("should parse cursor mcp.json"); assert_eq!(config.mcp_servers.len(), 1); assert!(config.mcp_servers.contains_key("test_server")); } #[test] fn load_cursor_mcp_servers_as_configs_returns_empty_for_missing_file() { let dir = tempfile::tempdir().unwrap(); let mcp_json_path = dir.path().join("mcp.json"); // File does not exist — should not panic, just return None. assert!(read_mcp_json(&mcp_json_path).is_none()); } #[test] fn mcp_json_env_var_default_value() { let tmp = tempfile::tempdir().unwrap(); let mcp_path = tmp.path().join(".mcp.json"); std::fs::write( &mcp_path, r#"{ "mcpServers": { "api": { "url": "${KIGI_TEST_MCP_UNSET_VAR_12345:-https://fallback.example.com}/mcp" } } }"#, ) .unwrap(); let servers = load_mcp_json_file(&mcp_path); assert_eq!(servers.len(), 1); match &servers[0] { acp::McpServer::Http(acp::McpServerHttp { url, .. }) => { assert_eq!(url, "https://fallback.example.com/mcp"); } other => panic!("expected Http, got {:?}", other), } } #[test] fn mcp_json_all_toml_names_includes_disabled() { let tmp = tempfile::tempdir().unwrap(); let kigi_dir = tmp.path().join(".kigi"); std::fs::create_dir_all(&kigi_dir).unwrap(); std::fs::write( kigi_dir.join("config.toml"), r#" [mcp_servers.enabled_one] url = "https://example.com" [mcp_servers.disabled_one] command = "/ignored" enabled = false "#, ) .unwrap(); git2::Repository::init(tmp.path()).unwrap(); let names = all_toml_mcp_server_names(tmp.path()); assert!(names.contains("enabled_one")); assert!(names.contains("disabled_one")); } #[test] fn mcp_json_candidate_paths_include_missing_files() { let tmp = tempfile::tempdir().unwrap(); let nested = tmp.path().join("a").join("b"); std::fs::create_dir_all(&nested).unwrap(); git2::Repository::init(tmp.path()).unwrap(); let paths = mcp_json_candidate_paths(&nested); assert_eq!( paths, vec![ tmp.path().join(".mcp.json"), tmp.path().join("a").join(".mcp.json"), nested.join(".mcp.json"), ] ); } // === merge_section tests === }