grok-build-upstream-mirror/crates/codegen/xai-grok-shell/src/util/config/mcp.rs
grokkybara[bot] a5727c5960 Synced from monorepo
Changes:
- Non-blocking coding-data sharing upsell banner
- Consolidate remediation in Doctor
- Auto mode defers fail-closed gate asks to the classifier
- Coalesce marketplace list fetches
- Allow removing a marketplace source by name
- Contain hung git marketplace sources (timeouts, non-blocking refresh, unbrick modal)
- Label failed workspace RPCs with error_kind
- Drop redundant explicit tonic/prost deps from xai-grok-shell
- Report real exit codes for completed background shells
- Narrow the date-rollover reminder to date-bearing templates
- Wire toolOverrides through the session and agent
- Security: Bash(git:*) allowlist matches whole command chain by prefix
- Split prompt-trigger telemetry and record classifier provenance
- Raise connectors-manager timeout to 60s
- Auto classifier honors recorded approvals for repeat actions
- Apply doctor fixes in the TUI
- Auto-mode classifier timeouts prompt instead of silently denying
- Scope subagent completion drains to the owning session
- Add the toolOverrides wire types
- Set client_identifier=grok-agent-sdk
- Accept both spellings of the workspace-teleport kill switch
- Persist one-shot occurrence journal
- Stop turns that poll the exact same tool call 16x in a row
- Copy compaction checkpoint files when forking sessions
- Auto-focus permission prompt from scrollback
- Esc cancels the running turn in non-vim and minimal modes
- List Ctrl+Z undo and redo in keyboard shortcuts
- Out-of-process macOS mic capture
- Show active auth mode on session-info
- Install the npm binary under $GROK_HOME
- Remove hover/click dead zones between dashboard items
- Route startup warnings to doctor
- Document [feedback.user] author identity config
- Extend bang command timeout
- Close combine-queued edit-hold race
- Integrate relocation recovery
- Expose privacy notice rollout flag
- Break harness discovery ref cycle so connections can idle-evict
- Shift/Alt+Enter inserts newline when editing a queued prompt
- Gate project Claude permissions on folder trust
- Echo response.create.event_id on response.created
- Toast when session creation fails from disk full
- Add shared test process lifecycle
- Enable dynamic workflows by default
- Add relocation transaction state machine
- Add shared test sandbox
- Surface auth failures on model-switch compact
- Persist durable scheduler expiry
- Confirm before removing extensions-modal items
- Re-run compact and prompt after login when compact hit expired auth
- Recap sends hosted tools under backend search
2026-07-22 19:22:27 +01:00

2048 lines
73 KiB
Rust

use agent_client_protocol as acp;
use anyhow::Result;
use indexmap::IndexMap;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
use toml::Value as TomlValue;
use toml::map::Map as TomlMap;
use xai_grok_agent::prompt::skills::SkillsConfig;
use xai_grok_tools::types::compat::{CompatConfig, CompatConfigToml};
pub use xai_grok_mcp::oauth_config::{McpOAuthConfig, McpOAuthConfigMap};
// MCP server config value types extracted to `xai-grok-config-types` (config
// dependency inversion); re-exported so `crate::util::config::*` paths keep working.
pub use xai_grok_config_types::{
McpJsonOAuthBlock, McpPreferenceSource, McpPreferencesFile, McpServerConfig,
McpServerPreferences, McpServerTransportConfig, McpSetupConfig, McpSetupDerivedValue,
McpSetupField, McpSetupFieldType, McpSetupOption, McpSetupResolution,
};
// Permission-policy value types likewise extracted; re-exported to keep paths stable.
pub use xai_grok_config_types::{
PatternMode, PermissionConfig, PermissionRule, RuleAction, ToolFilter,
};
// Relay-sync + MCP-config value types extracted; re-exported to keep paths stable.
pub use xai_grok_config_types::{McpConfig, RelaySyncConfig};
// Worktree-pool config value type extracted; re-exported to keep paths stable.
pub use xai_grok_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<String>,
/// Permission policy rules loaded from `[permission]` section in config.toml.
pub permission: Option<PermissionConfig>,
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,
/// `[privacy]` — local banner ack (not auth-metadata).
pub privacy: PrivacyConfig,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct PrivacyConfig {
/// Last banner dismiss (Accept/Customize), RFC 3339 UTC. None/0 remote
/// `privacy_banner_reshow_days` = never re-show once set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub privacy_banner_acked: Option<String>,
}
pub fn get_mcp_server_config(name: &str) -> Option<McpServerConfig> {
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 `.grok/config.toml` at each level.
/// Project-scoped `.grok/config.toml` entries override global `~/.grok/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<McpServerConfig> {
// 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
/// `.grok/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<acp::McpServer>, McpOAuthConfigMap) {
let global_config =
crate::config::load_from_disk().unwrap_or_else(|_| TomlValue::Table(toml::map::Map::new()));
let mut servers_map: IndexMap<String, McpServerConfig> = 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);
}
let mut oauth_configs = McpOAuthConfigMap::new();
let mut acp_servers = Vec::new();
let preferences = load_mcp_preferences().file();
let sub = &crate::config::expand_env_vars_in_string;
for (name, config) in servers_map {
let mut config = match config.resolve_setup(preferences.servers.get(&name)) {
McpSetupResolution::Resolved(config) => config,
McpSetupResolution::Required(_) => continue,
McpSetupResolution::Invalid(reason) => {
tracing::warn!(server = %name, error = %reason, "MCP setup config is invalid");
continue;
}
};
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::<PoolConfig>()
.unwrap_or_default()
} else {
PoolConfig::default()
}
}
/// Load MCP servers with project-scoped overrides from `.grok/config.toml`.
///
/// Merge strategy:
/// 1. Load MCP servers from global `~/.grok/config.toml`
/// 2. Walk from git repo root down to `cwd`, loading `.grok/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<acp::McpServer> {
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<acp::McpServer> {
let preferences = load_mcp_preferences().file();
let sub = &crate::config::expand_env_vars_in_string;
load_all_mcp_configs(cwd)
.into_iter()
.filter_map(|(name, config)| {
let mut config = match config.resolve_setup(preferences.servers.get(&name)) {
McpSetupResolution::Resolved(config) => config,
McpSetupResolution::Required(_) => return None,
McpSetupResolution::Invalid(reason) => {
tracing::warn!(server = %name, error = %reason, "MCP setup config is invalid");
return None;
}
};
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<acp::McpServer> {
let mut servers: IndexMap<String, McpServerConfig> = 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 .grok/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);
}
let preferences = load_mcp_preferences().file();
let sub = &crate::config::expand_env_vars_in_string;
servers
.into_iter()
.filter_map(|(name, config)| {
let mut config = match config.resolve_setup(preferences.servers.get(&name)) {
McpSetupResolution::Resolved(config) => config,
McpSetupResolution::Required(_) => return None,
McpSetupResolution::Invalid(reason) => {
tracing::warn!(server = %name, error = %reason, "MCP setup config is invalid");
return None;
}
};
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<acp::McpServer> {
// 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<String> {
load_all_mcp_configs(cwd).keys().cloned().collect()
}
pub fn mcp_preferences_path() -> PathBuf {
xai_grok_config::grok_home().join("mcp_preferences.json")
}
/// Result of loading prefs. Corrupt files are readable as empty for resolution
/// but must not be overwritten (would clobber other servers).
#[derive(Debug, Clone)]
pub enum McpPreferencesLoad {
Ok(McpPreferencesFile),
Missing,
Corrupt,
}
impl McpPreferencesLoad {
pub fn file(&self) -> McpPreferencesFile {
match self {
Self::Ok(f) => f.clone(),
Self::Missing | Self::Corrupt => McpPreferencesFile::default(),
}
}
pub fn is_writable(&self) -> bool {
!matches!(self, Self::Corrupt)
}
}
pub fn load_mcp_preferences() -> McpPreferencesLoad {
load_mcp_preferences_from(&mcp_preferences_path())
}
pub fn load_mcp_preferences_from(path: &std::path::Path) -> McpPreferencesLoad {
let content = match std::fs::read_to_string(path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return McpPreferencesLoad::Missing,
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "failed to read MCP preferences");
return McpPreferencesLoad::Corrupt;
}
};
match serde_json::from_str(&content) {
Ok(file) => McpPreferencesLoad::Ok(file),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "failed to parse MCP preferences");
McpPreferencesLoad::Corrupt
}
}
}
pub async fn save_mcp_preferences(prefs: &McpPreferencesFile) -> Result<()> {
save_mcp_preferences_to(&mcp_preferences_path(), prefs).await
}
pub async fn save_mcp_preferences_to(
path: &std::path::Path,
prefs: &McpPreferencesFile,
) -> Result<()> {
if matches!(load_mcp_preferences_from(path), McpPreferencesLoad::Corrupt) {
anyhow::bail!(
"refusing to overwrite unreadable MCP preferences at {}",
path.display()
);
}
let json = serde_json::to_string_pretty(prefs)?;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let tmp = path.with_extension(format!(
"json.tmp.{}{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
tokio::fs::write(&tmp, &json).await?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))
.await
.map_err(|e| anyhow::anyhow!("failed to set mcp preferences permissions: {e}"))?;
}
tokio::fs::rename(&tmp, path).await?;
Ok(())
}
/// Restore a single server key after a failed setup (best-effort).
pub async fn restore_mcp_preference_server(
server_name: &str,
previous: Option<McpServerPreferences>,
) -> Result<()> {
let load = load_mcp_preferences();
if !load.is_writable() {
return Ok(());
}
let mut prefs = load.file();
match previous {
Some(entry) => {
prefs.servers.insert(server_name.to_string(), entry);
}
None => {
prefs.servers.remove(server_name);
}
}
save_mcp_preferences(&prefs).await
}
/// Unresolved setup-bearing MCP config collected for `/mcps` list and auth.
#[derive(Debug, Clone)]
pub struct McpSetupServerEntry {
pub name: String,
pub config: McpServerConfig,
pub source: McpPreferenceSource,
}
/// Collect MCP configs that declare a `setup` schema from config and plugins.
/// Used to surface setup-required rows and drive `x.ai/mcp/setup`.
pub fn collect_mcp_setup_configs(
cwd: &std::path::Path,
plugin_registry: Option<&xai_grok_agent::plugins::PluginRegistry>,
compat: &CompatConfig,
) -> IndexMap<String, McpSetupServerEntry> {
let mut result = IndexMap::new();
for (name, (config, scope)) in load_mcp_server_configs_with_project(cwd) {
if !config.enabled || config.setup.is_none() {
continue;
}
result.insert(
name.clone(),
McpSetupServerEntry {
name,
config,
source: McpPreferenceSource {
kind: "config".to_string(),
plugin: None,
scope: Some(scope.to_string()),
},
},
);
}
if !crate::claude_import::is_claude_import_marked_with_log("collect_mcp_setup_configs") {
for (name, config) in load_claude_json_mcp_servers_as_configs(cwd, compat) {
if !config.enabled || config.setup.is_none() {
continue;
}
result.entry(name.clone()).or_insert(McpSetupServerEntry {
name,
config,
source: McpPreferenceSource {
kind: "config".to_string(),
plugin: None,
scope: Some(MCP_SCOPE_USER.to_string()),
},
});
}
for (name, config) in load_cursor_mcp_servers_as_configs(cwd, compat) {
if !config.enabled || config.setup.is_none() {
continue;
}
result.entry(name.clone()).or_insert(McpSetupServerEntry {
name,
config,
source: McpPreferenceSource {
kind: "config".to_string(),
plugin: None,
scope: Some(MCP_SCOPE_USER.to_string()),
},
});
}
for (name, config) in load_mcp_json_servers_as_configs(cwd) {
if !config.enabled || config.setup.is_none() {
continue;
}
result.entry(name.clone()).or_insert(McpSetupServerEntry {
name,
config,
source: McpPreferenceSource {
kind: "config".to_string(),
plugin: None,
scope: Some(MCP_SCOPE_PROJECT.to_string()),
},
});
}
}
if let Some(registry) = plugin_registry {
let toml_claimed_names = all_toml_mcp_server_names(cwd);
for plugin in registry.active_plugins() {
// File first, then inline; first-wins matches runtime plugin load.
let mut plugin_configs = IndexMap::new();
if let Some(ref mcp_path) = plugin.mcp_config_path
&& let Some(config) = read_mcp_json(mcp_path)
{
for (name, server) in config.mcp_servers {
plugin_configs.entry(name).or_insert(server);
}
}
if let Some(ref inline_value) = plugin.inline_mcp_servers {
let normalized =
xai_grok_agent::plugins::manifest::normalize_inline_mcp_servers(inline_value);
if let Ok(config) = serde_json::from_value::<McpConfig>(normalized) {
for (name, server) in config.mcp_servers {
plugin_configs.entry(name).or_insert(server);
}
}
}
for (name, config) in plugin_configs {
if toml_claimed_names.contains(&name) || !config.enabled || config.setup.is_none() {
continue;
}
result.entry(name.clone()).or_insert(McpSetupServerEntry {
name,
config,
source: McpPreferenceSource {
kind: "plugin".to_string(),
plugin: Some(plugin.name.clone()),
scope: None,
},
});
}
}
}
result
}
pub const MANAGED_GATEWAY_DISABLED_CONNECTORS_KEY: &str = "__managed_gateway_connectors";
/// 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 for managed servers.
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::fs::rename(&tmp, &path).await?;
Ok(())
}
/// Persist the enabled/disabled state for a single MCP server.
///
/// Uses the top-level `disabled_mcp_servers` array in `~/.grok/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<String> = 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::fs::rename(&tmp, &path).await?;
Ok(())
}
/// Upsert an MCP server entry in `~/.grok/config.toml`.
///
/// Creates or replaces `[mcp_servers.<name>]` 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 `.grok/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?;
tokio::fs::rename(&tmp, &path).await?;
Ok(())
}
/// Delete an MCP server entry from `~/.grok/config.toml`.
///
/// Removes `[mcp_servers.<name>]`, cleans up `disabled_mcp_servers` and
/// `[disabled_mcp_tools.<name>]` entries. Returns `true` if the entry existed.
pub async fn delete_mcp_server_config(server_name: &str) -> Result<bool> {
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 `.grok/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<bool> {
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?;
tokio::fs::rename(&tmp, &path).await?;
// Clean up OAuth credentials for the deleted server.
if let Ok(mut cred_store) = xai_grok_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<String, std::collections::HashSet<String>> {
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<String> = 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 `.grok/config.toml` files.
pub fn load_mcp_server_configs() -> IndexMap<String, McpServerConfig> {
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<String, McpServerConfig> {
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::<McpServerConfig>(value.clone()) {
result.insert(name.clone(), config);
}
}
result
}
// ── .mcp.json support ────────────────────────────────────────────────
// `.mcp.json` discovery moved to `xai-grok-workspace` (client-side, shared with
// the folder-trust gate); re-exported so `crate::util::config::*` paths keep working.
pub use xai_grok_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<acp::McpServer> {
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<String, McpServerConfig> {
// 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<String, McpServerConfig> {
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<acp::McpServer> {
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<acp::McpServer>, McpOAuthConfigMap) {
let preferences = load_mcp_preferences().file();
let mut servers = Vec::new();
let mut oauth_configs = McpOAuthConfigMap::new();
for (name, server_config) in &config.mcp_servers {
let mut server_config = match server_config.resolve_setup(preferences.servers.get(name)) {
McpSetupResolution::Resolved(config) => config,
McpSetupResolution::Required(_) => continue,
McpSetupResolution::Invalid(reason) => {
tracing::warn!(
source = source_label,
server = %name,
error = %reason,
"MCP setup config is invalid"
);
continue;
}
};
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.<cwd>.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<acp::McpServer> {
// 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<String, McpServerConfig> {
// 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<String, McpServerConfig> {
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<String, McpServerConfig> {
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 `<cwd>/.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<acp::McpServer> {
// 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 `<cwd>/.cursor/mcp.json` first, then global.
pub(crate) fn load_cursor_mcp_servers_as_configs(
cwd: &std::path::Path,
compat: &CompatConfig,
) -> IndexMap<String, McpServerConfig> {
// 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<String, McpConfig>,
}
/// 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<acp::McpServer> {
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).
pub(crate) fn read_mcp_json(path: &std::path::Path) -> Option<McpConfig> {
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<String, McpServerConfig> {
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 `.grok/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<String, (McpServerConfig, &'static str)> {
let global_config = crate::config::load_effective_config()
.unwrap_or_else(|_| TomlValue::Table(toml::map::Map::new()));
let mut servers: IndexMap<String, (McpServerConfig, &'static str)> =
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<String> {
let mut disabled: std::collections::HashSet<String> = 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::grok_home::grok_home().join("config.toml")
}
/// Path to the user-level config file (`~/.grok/config.toml`).
pub fn user_config_path() -> PathBuf {
config_path()
}
/// Path to a project-level config file (`<dir>/.grok/config.toml`).
pub fn project_config_path(dir: &std::path::Path) -> PathBuf {
dir.join(".grok").join("config.toml")
}
/// True when the config file at `path` defines `[mcp_servers.<name>]`.
///
/// 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<String> {
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<String> {
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<String> {
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<bool> {
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<bool> {
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
}
}
/// Overrides `[cli] session_registry`; usable before `~/.grok/config.toml` exists.
pub const SESSION_REGISTRY_ENV_VAR: &str = "GROK_SESSION_REGISTRY";
pub fn session_registry_from_env_opt() -> Option<bool> {
xai_grok_config::env_bool(SESSION_REGISTRY_ENV_VAR)
}
/// Where a local session-registry override came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegistrySource {
/// [`SESSION_REGISTRY_ENV_VAR`].
Env,
/// `[cli] session_registry` in config.toml.
ConfigToml,
}
impl RegistrySource {
/// The user-facing name of this source, for diagnostics.
pub const fn label(self) -> &'static str {
match self {
RegistrySource::Env => SESSION_REGISTRY_ENV_VAR,
RegistrySource::ConfigToml => "[cli] session_registry",
}
}
}
/// Env var, then `[cli] session_registry`; `None` defers to remote settings.
pub fn session_registry_local_override_sourced(
root: Option<&TomlValue>,
) -> Option<(bool, RegistrySource)> {
if let Some(v) = session_registry_from_env_opt() {
return Some((v, RegistrySource::Env));
}
root.and_then(session_registry_from_toml_opt)
.map(|v| (v, RegistrySource::ConfigToml))
}
pub fn session_registry_local_override(root: Option<&TomlValue>) -> Option<bool> {
session_registry_local_override_sourced(root).map(|(v, _)| v)
}
#[cfg(test)]
mod tests {
use super::*;
use toml::Value as TomlValue;
/// Env beats config.toml; unrecognized env defers; both absent defers to remote.
#[test]
#[serial_test::serial]
fn session_registry_local_override_precedence() {
let toml_true: TomlValue = toml::from_str("[cli]\nsession_registry = true").unwrap();
{
let _g = xai_grok_test_support::EnvGuard::set(SESSION_REGISTRY_ENV_VAR, "false");
assert_eq!(
session_registry_local_override_sourced(Some(&toml_true)),
Some((false, RegistrySource::Env)),
"env wins and reports itself as the source"
);
}
{
let _g = xai_grok_test_support::EnvGuard::set(SESSION_REGISTRY_ENV_VAR, "bogus");
assert_eq!(
session_registry_local_override_sourced(Some(&toml_true)),
Some((true, RegistrySource::ConfigToml)),
"unrecognized env values defer to config.toml"
);
}
{
let _g = xai_grok_test_support::EnvGuard::unset(SESSION_REGISTRY_ENV_VAR);
assert_eq!(session_registry_local_override_sourced(None), None);
}
}
#[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::<TomlValue>("").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::<TomlValue>(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 = "grok-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 = "grok-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::<TomlValue>(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::<TomlValue>(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<String, McpServerConfig> = 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::<TomlValue>(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::<TomlValue>(project_toml).unwrap();
let project_servers = parse_mcp_servers_from_toml(&project_root);
// Merge: project adds new server
let mut merged: IndexMap<String, McpServerConfig> = 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::<TomlValue>(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::<TomlValue>(project_toml).unwrap();
let project_servers = parse_mcp_servers_from_toml(&project_root);
let mut merged: IndexMap<String, McpServerConfig> = 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::<TomlValue>(
r#"
[skills]
paths = ["~/.grok/skills", "~/.grok/skills/special/SKILL.md"]
ignore = ["~/.grok/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::<SkillsConfig>().ok())
.unwrap_or_default();
assert_eq!(
cfg.paths,
vec!["~/.grok/skills", "~/.grok/skills/special/SKILL.md"]
);
assert_eq!(cfg.ignore, vec!["~/.grok/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::<TomlValue>(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::<TomlValue>(project_toml).unwrap();
let project_servers = parse_mcp_servers_from_toml(&project_root);
let mut merged: IndexMap<String, McpServerConfig> = 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::<TomlValue>(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::<TomlValue>(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::<TomlValue>(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": "${GROK_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 grok_dir = tmp.path().join(".grok");
std::fs::create_dir_all(&grok_dir).unwrap();
std::fs::write(
grok_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"),
]
);
}
#[tokio::test]
async fn mcp_preferences_missing_malformed_and_save_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mcp_preferences.json");
assert!(matches!(
load_mcp_preferences_from(&path),
McpPreferencesLoad::Missing
));
assert!(load_mcp_preferences_from(&path).file().servers.is_empty());
std::fs::write(&path, "not json").unwrap();
assert!(matches!(
load_mcp_preferences_from(&path),
McpPreferencesLoad::Corrupt
));
let prefs = McpPreferencesFile {
version: 1,
servers: HashMap::from([(
"acme".to_string(),
McpServerPreferences {
values: HashMap::from([("site".to_string(), "us5".to_string())]),
source: Some(McpPreferenceSource {
kind: "plugin".to_string(),
plugin: Some("acme".to_string()),
scope: None,
}),
updated_at: Some("2026-06-19T00:00:00Z".to_string()),
},
)]),
};
assert!(save_mcp_preferences_to(&path, &prefs).await.is_err());
std::fs::remove_file(&path).unwrap();
save_mcp_preferences_to(&path, &prefs).await.unwrap();
let loaded = load_mcp_preferences_from(&path).file();
assert_eq!(loaded.servers["acme"].values["site"], "us5");
assert_eq!(
loaded.servers["acme"]
.source
.as_ref()
.unwrap()
.plugin
.as_deref(),
Some("acme")
);
}
// === merge_section tests ===
}