use crate::cli::{Cli, McpScopeArg, McpTransportArg}; use crate::config::{ensure_parent_exists, paths}; use crate::mcp::{JsonField, McpOAuthConfig, McpServer, McpServersConfig, McpTransportType}; use crate::vault::{SECRET_RE, Vault}; use anyhow::{Context, Result, anyhow, bail}; use indexmap::{IndexMap, IndexSet}; use inquire::Confirm; use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; impl From for McpTransportType { fn from(value: McpTransportArg) -> Self { match value { McpTransportArg::Stdio => McpTransportType::Stdio, McpTransportArg::Http => McpTransportType::Http, McpTransportArg::Sse => McpTransportType::Sse, } } } pub fn handle(cli: &Cli, vault: &Vault) -> Result<()> { if cli.mcp_list { return handle_list(cli.scope); } if let Some(name) = &cli.mcp_get { return handle_get(name, cli.scope); } if let Some(name) = &cli.mcp_remove { return handle_remove(name, cli.scope, cli.mcp_force); } if let Some(name) = &cli.mcp_add { return handle_add(cli, name, vault); } Ok(()) } fn handle_list(scope: Option) -> Result<()> { let show_user = scope != Some(McpScopeArg::Workspace); let show_workspace = scope != Some(McpScopeArg::User); if show_user { let user_path = paths::mcp_config_file(); let user_cfg = load_config_raw(&user_path)?; println!("User ({})", user_path.display()); print_server_list(&user_cfg); } if show_workspace { match paths::workspace_mcp_config_file() { Some(ws_path) => { let ws_cfg = load_config_raw(&ws_path)?; if show_user { println!(); } println!("Workspace ({})", ws_path.display()); print_server_list(&ws_cfg); } None if scope == Some(McpScopeArg::Workspace) => { println!("Workspace: no mcp.json found in current directory"); } None => {} } } Ok(()) } fn print_server_list(cfg: &McpServersConfig) { if cfg.mcp_servers.is_empty() { println!(" (none)"); return; } let name_width = cfg.mcp_servers.keys().map(String::len).max().unwrap_or(0); for (name, spec) in &cfg.mcp_servers { let transport = match spec.transport_type { McpTransportType::Stdio => "stdio", McpTransportType::Http => "http", McpTransportType::Sse => "sse", }; let target = spec.url.clone().unwrap_or_else(|| { let cmd = spec.command.clone().unwrap_or_default(); let args = spec.args.as_ref().map(|a| a.join(" ")).unwrap_or_default(); if args.is_empty() { cmd } else { format!("{cmd} {args}") } }); println!( " {name:) -> Result<()> { let (path, cfg) = load_for_scope_or_search(name, scope)?; let spec = cfg .mcp_servers .get(name) .ok_or_else(|| anyhow!("MCP server '{name}' not found"))?; let pretty = serde_json::to_string_pretty(spec).context("failed to serialize MCP server config")?; println!("# {}", path.display()); println!("{pretty}"); Ok(()) } fn handle_remove(name: &str, scope: Option, force: bool) -> Result<()> { let (path, mut cfg) = load_for_scope_or_search(name, scope)?; if !force { let ok = Confirm::new(&format!( "Remove MCP server '{name}' from {}?", path.display() )) .with_default(false) .prompt()?; if !ok { println!("Aborted."); return Ok(()); } } cfg.mcp_servers.shift_remove(name); save_config(&path, &cfg)?; println!("✓ Removed MCP server '{name}' from {}", path.display()); Ok(()) } fn handle_add(cli: &Cli, name: &str, vault: &Vault) -> Result<()> { validate_name(name)?; let server = build_server(cli)?; server.validate(name)?; let scope = cli.scope.unwrap_or_default(); let path = write_path_for_scope(scope); let mut cfg = load_config_raw(&path)?; if cfg.mcp_servers.contains_key(name) && !cli.mcp_force { let ok = Confirm::new(&format!( "MCP server '{name}' already exists in {}. Overwrite?", path.display() )) .with_default(false) .prompt()?; if !ok { println!("Aborted. Use --mcp-force to overwrite without prompting."); return Ok(()); } } provision_secrets(cli, vault)?; cfg.mcp_servers.insert(name.to_string(), server); save_config(&path, &cfg)?; println!("✓ Added MCP server '{name}' to {}", path.display()); Ok(()) } fn validate_name(name: &str) -> Result<()> { if name.is_empty() { bail!("MCP server name cannot be empty"); } if !name .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { bail!("Invalid MCP server name '{name}': only letters, digits, '-', and '_' are allowed"); } Ok(()) } fn build_server(cli: &Cli) -> Result { let has_command = !cli.mcp_command.is_empty(); let has_url = cli.url.is_some(); let transport = cli .transport .map(McpTransportType::from) .unwrap_or_else(|| { if has_command { McpTransportType::Stdio } else { McpTransportType::Http } }); match transport { McpTransportType::Stdio => build_stdio(cli, has_url), McpTransportType::Http | McpTransportType::Sse => build_remote(cli, transport, has_command), } } fn build_stdio(cli: &Cli, has_url: bool) -> Result { if cli.mcp_command.is_empty() { bail!( "stdio MCP server requires a command. Pass it after `--`, e.g. \ `--mcp-add NAME -- npx some-server --flag`" ); } if has_url { bail!("stdio MCP server does not accept --url"); } if !cli.header.is_empty() { bail!("stdio MCP server does not accept --header"); } if cli.client_id.is_some() || cli.client_secret.is_some() || cli.callback_port.is_some() || cli.redirect_host.is_some() { bail!("stdio MCP server does not accept OAuth flags"); } let (cmd, args) = cli.mcp_command.split_first().unwrap(); let mut env: IndexMap = IndexMap::new(); for kv in &cli.env { let (k, v) = kv .split_once('=') .ok_or_else(|| anyhow!("invalid --env value '{kv}': expected KEY=VALUE"))?; if k.is_empty() { bail!("invalid --env value '{kv}': KEY cannot be empty"); } env.insert(k.to_string(), JsonField::Str(v.to_string())); } Ok(McpServer { transport_type: McpTransportType::Stdio, command: Some(cmd.clone()), args: (!args.is_empty()).then(|| args.to_vec()), env: (!env.is_empty()).then_some(env), cwd: cli.cwd.clone(), url: None, headers: None, oauth: None, allowed_tools: None, }) } fn build_remote(cli: &Cli, transport: McpTransportType, has_command: bool) -> Result { if has_command { bail!( "http/sse MCP server does not accept a trailing `-- `. Use `--url` \ to specify the endpoint." ); } let url = cli .url .clone() .ok_or_else(|| anyhow!("http/sse MCP server requires --url "))?; if !cli.env.is_empty() { bail!("http/sse MCP server does not accept --env; use --header instead"); } if cli.cwd.is_some() { bail!("http/sse MCP server does not accept --cwd"); } let mut headers: IndexMap = IndexMap::new(); for h in &cli.header { let (name, value) = h .split_once(':') .ok_or_else(|| anyhow!("invalid --header value '{h}': expected 'Name: Value'"))?; let name = name.trim(); let value = value.trim_start_matches(' '); if name.is_empty() { bail!("invalid --header value '{h}': header name cannot be empty"); } headers.insert(name.to_string(), value.to_string()); } let oauth = if cli.client_id.is_some() || cli.client_secret.is_some() || cli.callback_port.is_some() || cli.redirect_host.is_some() { Some(McpOAuthConfig { client_id: cli.client_id.clone(), client_secret: cli.client_secret.clone(), callback_port: cli.callback_port, redirect_host: cli.redirect_host.clone(), }) } else { None }; Ok(McpServer { transport_type: transport, command: None, args: None, env: None, cwd: None, url: Some(url), headers: (!headers.is_empty()).then_some(headers), oauth, allowed_tools: None, }) } fn provision_secrets(cli: &Cli, vault: &Vault) -> Result<()> { let mut sources: Vec<&str> = Vec::new(); if let Some(s) = cli.url.as_deref() { sources.push(s); } if let Some(s) = cli.client_secret.as_deref() { sources.push(s); } if let Some(s) = cli.client_id.as_deref() { sources.push(s); } if let Some(s) = cli.redirect_host.as_deref() { sources.push(s); } if let Some(s) = cli.cwd.as_deref() { sources.push(s); } sources.extend(cli.env.iter().map(String::as_str)); sources.extend(cli.header.iter().map(String::as_str)); let mut needed: IndexSet = IndexSet::new(); for value in sources { for caps in SECRET_RE.captures_iter(value).filter_map(Result::ok) { if let Some(m) = caps.get(1) { needed.insert(m.as_str().trim().to_string()); } } } if needed.is_empty() { return Ok(()); } let existing: HashSet = vault.list_secrets(false)?.into_iter().collect(); for name in needed { if existing.contains(&name) { continue; } eprintln!("Value references vault secret {{{{ {name} }}}} which is not stored yet."); let ok = Confirm::new(&format!("Add '{name}' to the vault now?")) .with_default(true) .prompt()?; if !ok { bail!( "Vault secret '{name}' is required by the config; aborting. \ Add it later with `coyote --add-secret {name}`." ); } vault.add_secret(&name)?; } Ok(()) } fn load_for_scope_or_search( name: &str, scope: Option, ) -> Result<(PathBuf, McpServersConfig)> { if let Some(s) = scope { let path = match s { McpScopeArg::User => paths::mcp_config_file(), McpScopeArg::Workspace => paths::workspace_mcp_config_file() .ok_or_else(|| anyhow!("no workspace mcp.json found in the current directory"))?, }; let cfg = load_config_raw(&path)?; if !cfg.mcp_servers.contains_key(name) { bail!( "MCP server '{name}' not found in {} scope ({})", scope_label(s), path.display() ); } return Ok((path, cfg)); } let user_path = paths::mcp_config_file(); let user_cfg = load_config_raw(&user_path)?; if user_cfg.mcp_servers.contains_key(name) { return Ok((user_path, user_cfg)); } if let Some(ws_path) = paths::workspace_mcp_config_file() { let ws_cfg = load_config_raw(&ws_path)?; if ws_cfg.mcp_servers.contains_key(name) { return Ok((ws_path, ws_cfg)); } } bail!("MCP server '{name}' not found in any scope"); } fn write_path_for_scope(scope: McpScopeArg) -> PathBuf { match scope { McpScopeArg::User => paths::mcp_config_file(), McpScopeArg::Workspace => paths::workspace_mcp_config_file() .unwrap_or_else(|| paths::workspace_config_dir().join("mcp.json")), } } fn scope_label(scope: McpScopeArg) -> &'static str { match scope { McpScopeArg::User => "user", McpScopeArg::Workspace => "workspace", } } fn load_config_raw(path: &Path) -> Result { if !path.exists() { return Ok(McpServersConfig { mcp_servers: IndexMap::new(), }); } let raw = fs::read_to_string(path) .with_context(|| format!("failed to read MCP config at {}", path.display()))?; if raw.trim().is_empty() { return Ok(McpServersConfig { mcp_servers: IndexMap::new(), }); } serde_json::from_str(&raw) .with_context(|| format!("failed to parse MCP config at {}", path.display())) } fn save_config(path: &Path, config: &McpServersConfig) -> Result<()> { ensure_parent_exists(path)?; let serialized = serde_json::to_string_pretty(config).context("failed to serialize MCP config")?; let tmp = path.with_extension("json.tmp"); fs::write(&tmp, &serialized) .with_context(|| format!("failed to write temporary MCP config at {}", tmp.display()))?; fs::rename(&tmp, path) .with_context(|| format!("failed to finalize MCP config at {}", path.display()))?; Ok(()) }