From 3fa0f5c4281fecd33168e0e05ac91f6f443ebf77 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Wed, 12 Aug 2026 17:55:33 -0600 Subject: [PATCH] feat: Support managing MCP servers from the CLI directly --- src/cli/mod.rs | 165 ++++++++++++++++- src/main.rs | 12 ++ src/mcp/manage.rs | 443 ++++++++++++++++++++++++++++++++++++++++++++++ src/mcp/mod.rs | 3 + 4 files changed, 622 insertions(+), 1 deletion(-) create mode 100644 src/mcp/manage.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c054552..b57859b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -13,6 +13,20 @@ use is_terminal::IsTerminal; use std::collections::HashSet; use std::io::{Read, stdin}; +#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpTransportArg { + Stdio, + Http, + Sse, +} + +#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum McpScopeArg { + #[default] + User, + Workspace, +} + #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] #[command( @@ -41,10 +55,15 @@ use std::io::{Read, stdin}; "list_skills", "skill", "tail_logs", "completions", "update", ]) ), + group( + ArgGroup::new("mcp-action") + .args(["mcp_add", "mcp_remove", "mcp_list", "mcp_get"]) + .multiple(false) + ), )] pub struct Cli { /// Input text - #[arg(trailing_var_arg = true)] + #[arg(allow_hyphen_values = true)] text: Vec, /// Select a LLM model @@ -224,6 +243,57 @@ pub struct Cli { #[arg(long, exclusive = true, value_name = "SERVER_NAME", help_heading = "Authentication", add = ArgValueCompleter::new(mcp_server_completer))] pub auth_mcp: Option, + /// Add an MCP server. Use `-- [args...]` for stdio, or `--url ` for http/sse. + #[arg(long, value_name = "NAME", help_heading = "MCP Servers")] + pub mcp_add: Option, + /// Remove an MCP server by name + #[arg(long, value_name = "NAME", help_heading = "MCP Servers", add = ArgValueCompleter::new(mcp_server_completer))] + pub mcp_remove: Option, + /// List all configured MCP servers (user + workspace scopes) + #[arg(long, help_heading = "MCP Servers")] + pub mcp_list: bool, + /// Show the JSON config for one MCP server + #[arg(long, value_name = "NAME", help_heading = "MCP Servers", add = ArgValueCompleter::new(mcp_server_completer))] + pub mcp_get: Option, + /// Transport for --mcp-add: stdio (default when `--` present), http, or sse + #[arg( + long, + value_enum, + value_name = "TRANSPORT", + help_heading = "MCP Servers" + )] + pub transport: Option, + /// URL for http/sse MCP server (used with --mcp-add) + #[arg(long, value_name = "URL", help_heading = "MCP Servers")] + pub url: Option, + /// Scope for MCP config: user (~/.config/coyote/functions/mcp.json) or workspace (./.coyote/mcp.json). Default: user + #[arg(long, value_enum, value_name = "SCOPE", help_heading = "MCP Servers")] + pub scope: Option, + /// Environment variable for stdio MCP server (repeatable): --env KEY=VALUE + #[arg(long, value_name = "KEY=VALUE", help_heading = "MCP Servers")] + pub env: Vec, + /// HTTP header for http/sse MCP server (repeatable): --header "Name: Value" + #[arg(long, value_name = "HEADER", help_heading = "MCP Servers")] + pub header: Vec, + /// Working directory for stdio MCP server + #[arg(long, value_name = "PATH", value_hint = ValueHint::AnyPath, help_heading = "MCP Servers")] + pub cwd: Option, + /// OAuth client ID for http/sse MCP server + #[arg(long, value_name = "ID", help_heading = "MCP Servers")] + pub client_id: Option, + /// OAuth client secret for http/sse MCP server (use {{NAME}} to reference a vault secret) + #[arg(long, value_name = "SECRET", help_heading = "MCP Servers")] + pub client_secret: Option, + /// OAuth callback port for http/sse MCP server + #[arg(long, value_name = "PORT", help_heading = "MCP Servers")] + pub callback_port: Option, + /// OAuth redirect host for http/sse MCP server + #[arg(long, value_name = "HOST", help_heading = "MCP Servers")] + pub redirect_host: Option, + /// Overwrite an existing MCP server (with --mcp-add) or skip confirmation (with --mcp-remove) + #[arg(long, help_heading = "MCP Servers")] + pub mcp_force: bool, + /// Launch Coyote inside a Docker sandbox (via `sbx`); name defaults to current directory basename #[arg(long, value_name = "NAME", help_heading = "Sandbox")] pub sandbox: Option>, @@ -254,6 +324,15 @@ pub struct Cli { /// Generate static shell completion scripts #[arg(long, value_name = "SHELL", value_enum, help_heading = "Shell")] pub completions: Option, + + /// Stdio command for --mcp-add: everything after `--` is passed to the server verbatim + #[arg( + last = true, + allow_hyphen_values = true, + value_name = "CMD", + help_heading = "MCP Servers" + )] + pub mcp_command: Vec, } impl Cli { @@ -633,4 +712,88 @@ mod tests { fn parse_sandbox_is_exclusive() { assert!(Cli::try_parse_from(["coyote", "--sandbox", "--agent", "foo"]).is_err()); } + + #[test] + fn parse_mcp_add_stdio_with_trailing_command() { + let cli = parse(&[ + "--mcp-add", + "myserver", + "--", + "npx", + "some-server", + "--flag", + "arg1", + ]); + assert_eq!(cli.mcp_add, Some("myserver".to_string())); + assert_eq!( + cli.mcp_command, + vec!["npx", "some-server", "--flag", "arg1"] + ); + assert!(cli.text.is_empty()); + } + + #[test] + fn parse_mcp_add_stdio_with_env_and_command() { + let cli = parse(&[ + "--mcp-add", + "s", + "--env", + "API_KEY={{API_KEY}}", + "--env", + "MODE=dev", + "--", + "npx", + "srv", + ]); + assert_eq!(cli.mcp_add, Some("s".to_string())); + assert_eq!(cli.env, vec!["API_KEY={{API_KEY}}", "MODE=dev"]); + assert_eq!(cli.mcp_command, vec!["npx", "srv"]); + } + + #[test] + fn parse_mcp_add_http_with_header() { + let cli = parse(&[ + "--mcp-add", + "notion", + "--transport", + "http", + "--url", + "https://mcp.notion.com/mcp", + "--header", + "Authorization: Bearer {{NOTION_TOKEN}}", + ]); + assert_eq!(cli.mcp_add, Some("notion".to_string())); + assert!(matches!(cli.transport, Some(McpTransportArg::Http))); + assert_eq!(cli.url, Some("https://mcp.notion.com/mcp".to_string())); + assert_eq!( + cli.header, + vec!["Authorization: Bearer {{NOTION_TOKEN}}"] + ); + assert!(cli.mcp_command.is_empty()); + } + + #[test] + fn parse_mcp_list_flag() { + let cli = parse(&["--mcp-list"]); + assert!(cli.mcp_list); + } + + #[test] + fn parse_mcp_scope_workspace() { + let cli = parse(&["--mcp-list", "--scope", "workspace"]); + assert!(cli.mcp_list); + assert!(matches!(cli.scope, Some(McpScopeArg::Workspace))); + } + + #[test] + fn parse_mcp_action_group_is_exclusive() { + assert!(Cli::try_parse_from(["coyote", "--mcp-list", "--mcp-get", "foo"]).is_err()); + } + + #[test] + fn parse_trailing_text_unchanged_without_dash_dash() { + let cli = parse(&["hello", "world"]); + assert_eq!(cli.text, vec!["hello", "world"]); + assert!(cli.mcp_command.is_empty()); + } } diff --git a/src/main.rs b/src/main.rs index e073f09..937d255 100644 --- a/src/main.rs +++ b/src/main.rs @@ -196,6 +196,18 @@ async fn main() -> Result<()> { return Ok(()); } + let mcp_action = + cli.mcp_list || cli.mcp_get.is_some() || cli.mcp_remove.is_some() || cli.mcp_add.is_some(); + if mcp_action { + let cfg = Config::load_with_interpolation(true).await?; + let app_config = AppConfig::from_config(cfg)?; + let vault = Vault::init(&app_config)?; + + mcp::manage::handle(&cli, &vault)?; + + return Ok(()); + } + if vault_flags { let cfg = Config::load_with_interpolation(true).await?; let app_config = AppConfig::from_config(cfg)?; diff --git a/src/mcp/manage.rs b/src/mcp/manage.rs new file mode 100644 index 0000000..c9d4a53 --- /dev/null +++ b/src/mcp/manage.rs @@ -0,0 +1,443 @@ +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, + }) +} + +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, + }) +} + +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(()) +} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index c7468ab..9cd7bad 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod manage; pub(crate) mod oauth; mod sse_transport; @@ -62,6 +63,8 @@ pub(crate) struct McpServersConfig { pub(crate) struct McpOAuthConfig { #[serde(rename = "clientId", skip_serializing_if = "Option::is_none")] pub client_id: Option, + #[serde(rename = "clientSecret", skip_serializing_if = "Option::is_none")] + pub client_secret: Option, #[serde(rename = "callbackPort", skip_serializing_if = "Option::is_none")] pub callback_port: Option, #[serde(rename = "redirectHost", skip_serializing_if = "Option::is_none")]