//! `kigi mcp` — manage MCP server configurations from the command line. use std::collections::HashMap; use std::path::{Path, PathBuf}; use anyhow::{Result, bail}; use clap::{Subcommand, ValueEnum}; use kigi_shell::util::config::{McpServerConfig, McpServerTransportConfig}; use crate::util::display_user_kigi_path; const ADD_AFTER_HELP: &str = "\ Examples: # Add a stdio server (everything after -- is the server command) kigi mcp add xcode -- xcrun mcpbridge # Add a stdio server with environment variables kigi mcp add postgres -e DATABASE_URL=postgres://localhost/mydb -- npx -y @modelcontextprotocol/server-postgres # Add a remote HTTP server kigi mcp add --transport http sentry https://mcp.sentry.dev/mcp # Add a remote server with an authentication header kigi mcp add --transport http api https://mcp.example.com/mcp --header \"Authorization: Bearer YOUR_TOKEN\" # Add to the project config (./.kigi/config.toml) instead of ~/.kigi/config.toml kigi mcp add --scope project github -- npx -y @modelcontextprotocol/server-github"; #[derive(Debug, clap::Args, Clone)] pub struct McpArgs { #[command(subcommand)] pub command: McpCommand, } /// Transport used to communicate with an MCP server. #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum McpTransport { /// Launch a local process and communicate over stdin/stdout Stdio, /// Connect to a remote server over streamable HTTP Http, /// Connect to a remote server over Server-Sent Events Sse, } /// Which config file an MCP server definition is written to. #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum McpScope { /// `~/.kigi/config.toml`, available in all your projects User, /// `./.kigi/config.toml`, shared with everyone working in this directory Project, } impl McpScope { fn label(self) -> &'static str { match self { McpScope::User => "user", McpScope::Project => "project", } } } #[derive(Debug, Subcommand, Clone)] pub enum McpCommand { /// List configured MCP servers List { /// Emit machine-readable JSON output #[arg(long)] json: bool, }, /// Add or update an MCP server Add(AddArgs), /// Remove an MCP server Remove { /// Server name to remove name: String, /// Config to remove from. When omitted, all scopes are searched. #[arg(short = 's', long, value_enum)] scope: Option, }, /// Authorize with an OAuth-enabled remote MCP server Auth { /// Name of the MCP server to authorize name: String, }, /// Diagnose MCP server configuration and connectivity Doctor { /// Emit machine-readable JSON output #[arg(long)] json: bool, /// Server name to check name: Option, }, } // Everything `mcp add` accepts, before validation; `resolve_add` turns it // into a transport config. #[derive(Debug, clap::Args, Clone)] #[command(after_help = ADD_AFTER_HELP)] pub struct AddArgs { /// Server name name: String, /// Command to launch (stdio) or URL to connect to (http, sse) #[arg(value_name = "COMMAND_OR_URL", group = "source")] command_or_url: Option, /// Arguments passed to the server command. Place them after `--` so /// flags such as `-y` are passed to the server instead of kigi. #[arg(value_name = "ARGS")] args: Vec, /// Transport type. Defaults to stdio. #[arg(short = 't', long, value_enum)] transport: Option, /// Config to write to: user (~/.kigi/config.toml) or project (./.kigi/config.toml) #[arg(short = 's', long, value_enum, default_value = "user")] scope: McpScope, /// Environment variable for the server process (repeatable) #[arg(short = 'e', long = "env", value_name = "KEY=value")] env: Vec, /// HTTP header for remote servers (repeatable) #[arg(short = 'H', long = "header", value_name = "NAME: VALUE")] header: Vec, /// Legacy alias for the positional command argument #[arg(long, hide = true, group = "source")] command: Option, /// Legacy companion to --command #[arg(long = "args", hide = true, num_args = 1.., requires = "command")] legacy_args: Vec, /// Legacy alias for adding a remote server by URL #[arg(long, hide = true, group = "source")] url: Option, /// Legacy transport type for --url servers #[arg(long = "type", hide = true)] transport_type: Option, } pub async fn run(mcp_args: McpArgs) -> Result<()> { match mcp_args.command { McpCommand::List { json } => run_list(json), McpCommand::Add(args) => run_add(args).await, McpCommand::Remove { name, scope } => run_remove(&name, scope).await, McpCommand::Auth { name } => run_auth(&name).await, McpCommand::Doctor { json, name } => run_doctor(json, name).await, } } /// kimi-cli `mcp auth` parity: start the named remote server with the /// interactive OAuth flow and report the tool count on success. async fn run_auth(name: &str) -> Result<()> { let cwd = current_dir_or_exit(); println!("Authorizing with '{name}'..."); println!("A browser window will open if authorization is required."); match kigi_shell::mcp_doctor::run_auth(&cwd, name).await { Ok(tool_count) => { println!("Successfully authorized with '{name}'."); println!("Available tools: {tool_count}"); Ok(()) } Err(e) => { eprintln!("Authorization failed: {e}"); std::process::exit(1); } } } fn run_list(json: bool) -> Result<()> { // Include project-scoped servers (nearest definition wins), matching what // a session started in this directory would load from config.toml files. let cwd = current_dir_or_exit(); let servers = kigi_shell::util::config::load_mcp_server_configs_with_project(&cwd); if json { let payload: serde_json::Value = servers .iter() .map(|(name, (config, scope))| { let mut entry = serde_json::to_value(config).unwrap_or_default(); if let Some(obj) = entry.as_object_mut() { obj.insert("name".into(), serde_json::Value::String(name.clone())); obj.insert("scope".into(), serde_json::Value::String(scope.to_string())); } entry }) .collect(); println!("{}", serde_json::to_string_pretty(&payload)?); } else if servers.is_empty() { println!("No MCP servers configured. Run `kigi mcp add --help` to get started."); } else { for (name, (config, scope)) in &servers { let transport = match &config.transport { McpServerTransportConfig::Stdio { command, args, .. } => { if args.is_empty() { command.clone() } else { format!("{} {}", command, args.join(" ")) } } McpServerTransportConfig::StreamableHttp { url, .. } => url.clone(), }; let status = if config.enabled { "" } else { " (disabled)" }; let scope_note = if *scope == "project" { " (project)" } else { "" }; println!(" {name}: {transport}{status}{scope_note}"); } } Ok(()) } #[derive(Debug)] struct ResolvedAdd { /// Transport the request resolved to; drives the summary wording. kind: McpTransport, transport: McpServerTransportConfig, warnings: Vec, } async fn run_add(args: AddArgs) -> Result<()> { let resolved = resolve_add(&args)?; for warning in &resolved.warnings { eprintln!("{warning}"); } let name = &args.name; let kind = match resolved.kind { McpTransport::Stdio => "stdio", McpTransport::Http => "HTTP", McpTransport::Sse => "SSE", }; let summary = match &resolved.transport { McpServerTransportConfig::Stdio { command, args: cmd_args, .. } => { let mut rendered = command.clone(); if !cmd_args.is_empty() { rendered.push(' '); rendered.push_str(&cmd_args.join(" ")); } format!("{kind} MCP server '{name}' with command: {rendered}") } McpServerTransportConfig::StreamableHttp { url, .. } => { format!("{kind} MCP server '{name}' with URL: {url}") } }; let config = McpServerConfig { transport: resolved.transport, enabled: true, oauth: None, startup_timeout_sec: None, tool_timeout_sec: None, tool_timeouts: None, expose_image_base64: None, }; let path = scope_target(args.scope); kigi_shell::util::config::save_mcp_server_config_at(&path, name, &config).await?; println!("Added {summary} to {} config", args.scope.label()); println!("File modified: {}", scope_display(args.scope, &path)); Ok(()) } /// Validate an `mcp add` request and build the transport config. /// /// The transport flag fully determines how `command_or_url` is interpreted; /// URL-looking commands only produce a warning, never a behavior change. fn resolve_add(args: &AddArgs) -> Result { validate_server_name(&args.name)?; let transport = match args.transport { Some(t) => t, // The legacy --url form defaults to HTTP and honors the legacy --type flag. None if args.url.is_some() => match args.transport_type.as_deref() { Some(t) if t.eq_ignore_ascii_case("sse") => McpTransport::Sse, _ => McpTransport::Http, }, None => McpTransport::Stdio, }; let explicit_transport = args.transport.is_some(); // Legacy-flag misroutes: --url always means a remote server, and --type // only modifies --url. if args.url.is_some() && transport == McpTransport::Stdio { bail!( "--url cannot be combined with --transport stdio. For a remote server, use --transport http or --transport sse." ); } if args.transport_type.is_some() && args.url.is_none() { bail!("--type is only valid together with --url. Use --transport to choose the transport."); } let server_args = if args.command.is_some() { &args.legacy_args } else { &args.args }; // Clap's "source" group guarantees at most one of these is set. let source = args .command_or_url .as_deref() .or(args.command.as_deref()) .or(args.url.as_deref()); match transport { McpTransport::Stdio => { let Some(command) = source else { bail!( "A command is required for stdio servers. Usage: kigi mcp add -- [args...]" ); }; if !args.header.is_empty() { bail!("--header can only be used with HTTP or SSE servers."); } // A KEY=value command means an env pair leaked out of -e, which // takes one pair per flag (the pre-parity --env was greedy). if looks_like_env_pair(command) { let pairs: Vec = args .env .iter() .map(String::as_str) .chain([command]) .map(|pair| format!("-e {pair}")) .collect(); bail!( "Invalid command '{command}': it looks like an environment variable. Pass each variable as its own flag: {}", pairs.join(" ") ); } let env = parse_env_vars(&args.env)?; let mut warnings = Vec::new(); if !explicit_transport && looks_like_url(command) { // Suggest a command that passes URL validation even when the // original lacks a scheme (e.g. localhost:3000). let suggested_url = if command.starts_with("http://") || command.starts_with("https://") { command.to_string() } else { format!("http://{command}") }; warnings.push(format!( "Warning: '{command}' looks like a URL, but it is being added as a stdio command because --transport was not specified.\nFor a remote server, use: kigi mcp add --transport http {} {suggested_url}", args.name )); } Ok(ResolvedAdd { kind: McpTransport::Stdio, transport: McpServerTransportConfig::Stdio { command: command.to_string(), args: server_args.clone(), env: (!env.is_empty()).then_some(env), cwd: None, }, warnings, }) } McpTransport::Http | McpTransport::Sse => { let label = if transport == McpTransport::Sse { "sse" } else { "http" }; let Some(url) = source else { bail!( "A URL is required for {label} servers. Usage: kigi mcp add --transport {label} " ); }; if !url.starts_with("http://") && !url.starts_with("https://") { bail!("Invalid URL '{url}'. Server URLs must start with http:// or https://."); } if !server_args.is_empty() { bail!( "Unexpected arguments after the URL: '{}'. HTTP and SSE servers take a single URL.", server_args.join(" ") ); } if !args.env.is_empty() { bail!("--env can only be used with stdio servers."); } let headers = parse_headers(&args.header)?; Ok(ResolvedAdd { kind: transport, transport: McpServerTransportConfig::StreamableHttp { url: url.to_string(), transport_type: (transport == McpTransport::Sse).then(|| "sse".to_string()), bearer_token_env_var: None, headers: (!headers.is_empty()).then_some(headers), oauth_client_id: None, oauth_client_secret_env_var: None, oauth_scopes: None, }, warnings: Vec::new(), }) } } } fn validate_server_name(name: &str) -> Result<()> { if name.is_empty() || !name .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { bail!( "Invalid name '{name}'. Names can only contain letters, numbers, hyphens, and underscores." ); } Ok(()) } fn parse_env_vars(pairs: &[String]) -> Result> { let mut env = HashMap::new(); for pair in pairs { match pair.split_once('=') { Some((key, value)) if !key.is_empty() => { env.insert(key.to_string(), value.to_string()); } _ => bail!( "Invalid environment variable format: '{pair}'. Environment variables should be added as: -e KEY1=value1 -e KEY2=value2" ), } } Ok(env) } fn parse_headers(headers: &[String]) -> Result> { let mut parsed = HashMap::new(); for header in headers { let Some((name, value)) = header.split_once(':') else { bail!("Invalid header format: '{header}'. Expected format: 'Name: value'"); }; let name = name.trim(); if name.is_empty() { bail!("Invalid header: '{header}'. Header name cannot be empty."); } parsed.insert(name.to_string(), value.trim().to_string()); } Ok(parsed) } fn looks_like_url(command: &str) -> bool { command.starts_with("http://") || command.starts_with("https://") || command.starts_with("localhost") } /// True for `KEY=value` shapes with a `[A-Za-z_][A-Za-z0-9_]*` key. fn looks_like_env_pair(s: &str) -> bool { let Some((key, _)) = s.split_once('=') else { return false; }; let mut chars = key.chars(); chars .next() .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') } /// Current working directory, exiting loudly when it cannot be determined. fn current_dir_or_exit() -> PathBuf { std::env::current_dir().unwrap_or_else(|e| { eprintln!("Cannot determine working directory: {e}"); std::process::exit(1); }) } /// Resolve the config file path for a scope. fn scope_target(scope: McpScope) -> PathBuf { match scope { McpScope::User => kigi_shell::util::config::user_config_path(), McpScope::Project => kigi_shell::util::config::project_config_path(¤t_dir_or_exit()), } } /// Display form of a scope's config file path. fn scope_display(scope: McpScope, path: &Path) -> String { match scope { McpScope::User => display_user_kigi_path("config.toml"), McpScope::Project => path.display().to_string(), } } /// Why `mcp remove` could not resolve a single config file to delete from. #[derive(Debug, PartialEq)] enum RemoveError { /// The name is not defined in any searched scope. NotFound, /// The name is defined in both scopes, so the user must pick one. Ambiguous { project_path: PathBuf }, } /// Pick the config file `mcp remove` deletes from, given which scopes define /// the name. Pure so the scope x presence matrix is unit-testable; printing /// and exit codes stay in `run_remove`. fn select_remove_site( user_defined: bool, project_site: Option, scope: Option, ) -> Result<(McpScope, PathBuf), RemoveError> { use kigi_shell::util::config::user_config_path; match scope { Some(McpScope::User) => user_defined .then(|| (McpScope::User, user_config_path())) .ok_or(RemoveError::NotFound), Some(McpScope::Project) => project_site .map(|path| (McpScope::Project, path)) .ok_or(RemoveError::NotFound), None => match (user_defined, project_site) { (true, Some(project_path)) => Err(RemoveError::Ambiguous { project_path }), (true, None) => Ok((McpScope::User, user_config_path())), (false, Some(path)) => Ok((McpScope::Project, path)), (false, None) => Err(RemoveError::NotFound), }, } } /// Where a name still resolves after a delete: project sites shadow user /// scope, so the nearest surviving definition wins. fn surviving_definition( user_defined: bool, project_site: Option, ) -> Option<(McpScope, PathBuf)> { project_site .map(|path| (McpScope::Project, path)) .or_else(|| { user_defined.then(|| (McpScope::User, kigi_shell::util::config::user_config_path())) }) } async fn run_remove(name: &str, requested_scope: Option) -> Result<()> { use kigi_shell::util::config::{ delete_mcp_server_config_at, mcp_server_defined_at, user_config_path, }; let cwd = current_dir_or_exit(); // Project configs from cwd up to the repo root, nearest first. let find_project_site = || { kigi_shell::config::find_project_configs(&cwd) .into_iter() .rev() .find(|path| mcp_server_defined_at(path, name)) }; let user_defined = mcp_server_defined_at(&user_config_path(), name); let (scope, path) = match select_remove_site(user_defined, find_project_site(), requested_scope) { Ok(site) => site, Err(RemoveError::NotFound) => { let searched = requested_scope.map_or("user or project", McpScope::label); eprintln!("No MCP server named '{name}' in {searched} config"); std::process::exit(1); } Err(RemoveError::Ambiguous { project_path }) => { eprintln!("MCP server '{name}' exists in multiple scopes:"); eprintln!(" user: {}", display_user_kigi_path("config.toml")); eprintln!(" project: {}", project_path.display()); eprintln!("Specify which one to remove, e.g.: kigi mcp remove {name} --scope project"); std::process::exit(1); } }; let existed = delete_mcp_server_config_at(&path, name).await?; if !existed { // Race guard: the entry vanished between the existence check and the delete. eprintln!("No MCP server named '{name}' in {} config", scope.label()); std::process::exit(1); } println!("Removed MCP server '{name}' from {} config", scope.label()); println!("File modified: {}", scope_display(scope, &path)); // A scoped delete can leave the name defined in the other scope or an // ancestor .kigi/config.toml, where it still resolves for sessions. let still_user_defined = mcp_server_defined_at(&user_config_path(), name); if let Some((survivor_scope, remaining)) = surviving_definition(still_user_defined, find_project_site()) { eprintln!( "note: '{name}' is still defined in {}", scope_display(survivor_scope, &remaining) ); } Ok(()) } async fn run_doctor(json: bool, name: Option) -> Result<()> { let cwd = current_dir_or_exit(); let report = kigi_shell::mcp_doctor::run_doctor(&cwd, name.as_deref()).await; if let Some(ref filter) = name && report.servers.is_empty() { eprintln!("MCP server '{}' not found.", filter); if !report.all_server_names.is_empty() { eprintln!("Available servers: {}", report.all_server_names.join(", ")); } std::process::exit(1); } if json { println!( "{}", serde_json::to_string_pretty(&report).unwrap_or_default() ); } else { kigi_shell::mcp_doctor::print_report(&report); } if report.failing_count > 0 { std::process::exit(1); } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::app::{Command, PagerArgs}; use clap::Parser as _; fn parse_add(argv: &[&str]) -> AddArgs { let args = PagerArgs::try_parse_from(argv).expect("args should parse"); match args.command { Some(Command::Mcp(McpArgs { command: McpCommand::Add(add), })) => add, other => panic!("expected mcp add, got {other:?}"), } } #[test] fn add_accepts_trailing_command_after_double_dash() { // The invocation from the original report: a stdio server whose // command follows `--`, with an explicit transport. let add = parse_add(&[ "kigi", "mcp", "add", "--transport", "stdio", "xcode", "--", "xcrun", "mcpbridge", ]); assert_eq!(add.name, "xcode"); assert_eq!(add.transport, Some(McpTransport::Stdio)); assert_eq!(add.scope, McpScope::User); let resolved = resolve_add(&add).expect("resolves to stdio"); match resolved.transport { McpServerTransportConfig::Stdio { command, args, .. } => { assert_eq!(command, "xcrun"); assert_eq!(args, vec!["mcpbridge".to_string()]); } other => panic!("expected stdio transport, got {other:?}"), } } #[test] fn add_passes_hyphen_flags_and_repeated_env_to_server() { let add = parse_add(&[ "kigi", "mcp", "add", "fs", "-e", "FOO=bar", "-e", "BAZ=qux=quux", "--", "npx", "-y", "@modelcontextprotocol/server-filesystem", "/allowed/dir", ]); let resolved = resolve_add(&add).expect("resolves to stdio"); match resolved.transport { McpServerTransportConfig::Stdio { command, args, env, .. } => { assert_eq!(command, "npx"); assert_eq!(args[0], "-y"); let env = env.expect("env should be set"); assert_eq!(env.get("FOO").map(String::as_str), Some("bar")); // Values may themselves contain '='. assert_eq!(env.get("BAZ").map(String::as_str), Some("qux=quux")); } other => panic!("expected stdio transport, got {other:?}"), } } #[test] fn add_hyphen_flag_without_double_dash_is_rejected() { let err = PagerArgs::try_parse_from(["kigi", "mcp", "add", "fs", "npx", "-y"]) .expect_err("hyphen args must be escaped with --"); assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); } #[test] fn add_http_with_headers() { let add = parse_add(&[ "kigi", "mcp", "add", "--transport", "http", "api", "https://mcp.example.com/mcp", "--header", "Authorization: Bearer tok", ]); let resolved = resolve_add(&add).expect("resolves to http"); match resolved.transport { McpServerTransportConfig::StreamableHttp { url, transport_type, headers, .. } => { assert_eq!(url, "https://mcp.example.com/mcp"); assert_eq!(transport_type, None); let headers = headers.expect("headers should be set"); assert_eq!( headers.get("Authorization").map(String::as_str), Some("Bearer tok") ); } other => panic!("expected http transport, got {other:?}"), } } #[test] fn add_sse_sets_transport_type() { let add = parse_add(&[ "kigi", "mcp", "add", "--transport", "sse", "linear", "https://mcp.linear.app/sse", ]); let resolved = resolve_add(&add).expect("resolves to sse"); assert_eq!(resolved.kind, McpTransport::Sse); match resolved.transport { McpServerTransportConfig::StreamableHttp { transport_type, .. } => { assert_eq!(transport_type.as_deref(), Some("sse")); } other => panic!("expected sse transport, got {other:?}"), } } #[test] fn add_http_transport_with_non_url_command_is_rejected() { // Previously this silently stored `xcrun` as an HTTP URL. let add = parse_add(&[ "kigi", "mcp", "add", "--transport", "http", "xcode", "--", "xcrun", "mcpbridge", ]); let err = resolve_add(&add).expect_err("non-URL with http transport must fail"); assert!(err.to_string().contains("Invalid URL"), "got: {err}"); } #[test] fn add_explicit_stdio_keeps_url_looking_command_as_stdio() { // Previously URL sniffing overrode an explicit stdio transport. let add = parse_add(&[ "kigi", "mcp", "add", "--transport", "stdio", "proxy", "--", "https://example.com/fetcher", ]); let resolved = resolve_add(&add).expect("explicit stdio must stay stdio"); assert!(resolved.warnings.is_empty()); assert!(matches!( resolved.transport, McpServerTransportConfig::Stdio { .. } )); } #[test] fn add_default_transport_warns_on_url_looking_command() { let add = parse_add(&["kigi", "mcp", "add", "api", "https://mcp.example.com/mcp"]); let resolved = resolve_add(&add).expect("defaults to stdio with a warning"); assert!(matches!( resolved.transport, McpServerTransportConfig::Stdio { .. } )); assert_eq!(resolved.warnings.len(), 1); assert!(resolved.warnings[0].contains("--transport http")); // Scheme-less commands get http:// prepended so the suggested // command passes URL validation verbatim. let add = parse_add(&["kigi", "mcp", "add", "local", "localhost:3000"]); let resolved = resolve_add(&add).expect("localhost command warns"); assert!( resolved.warnings[0].contains("--transport http local http://localhost:3000"), "got: {}", resolved.warnings[0] ); } #[test] fn add_scope_project_parses_and_invalid_scope_is_rejected() { let add = parse_add(&[ "kigi", "mcp", "add", "-s", "project", "fs", "--", "npx", "pkg", ]); assert_eq!(add.scope, McpScope::Project); let err = PagerArgs::try_parse_from([ "kigi", "mcp", "add", "-s", "local", "fs", "--", "npx", "pkg", ]) .expect_err("local is not a kigi scope"); assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue); } #[test] fn add_legacy_flag_forms_still_parse() { let add = parse_add(&[ "kigi", "mcp", "add", "oldfs", "--command", "npx", "--args", "@foo/bar", "/path", ]); let resolved = resolve_add(&add).expect("legacy stdio form resolves"); match resolved.transport { McpServerTransportConfig::Stdio { command, args, .. } => { assert_eq!(command, "npx"); assert_eq!(args, vec!["@foo/bar".to_string(), "/path".to_string()]); } other => panic!("expected stdio transport, got {other:?}"), } let add = parse_add(&[ "kigi", "mcp", "add", "remote", "--url", "https://mcp.example.com/sse", "--type", "sse", ]); let resolved = resolve_add(&add).expect("legacy url form resolves"); match resolved.transport { McpServerTransportConfig::StreamableHttp { url, transport_type, .. } => { assert_eq!(url, "https://mcp.example.com/sse"); assert_eq!(transport_type.as_deref(), Some("sse")); } other => panic!("expected http transport, got {other:?}"), } } #[test] fn add_legacy_command_conflicts_with_positional() { let err = PagerArgs::try_parse_from([ "kigi", "mcp", "add", "fs", "npx", "--command", "other-npx", ]) .expect_err("--command and a positional command are mutually exclusive"); assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); } #[test] fn add_legacy_multi_value_env_is_rejected() { // Pre-parity --env was greedy (`--env A=1 B=2`); with --command the // stray pair now lands in the positional and trips the source group. let err = PagerArgs::try_parse_from([ "kigi", "mcp", "add", "github", "--command", "npx", "--args", "@foo/bar", "--env", "A=1", "B=2", ]) .expect_err("greedy --env must no longer parse"); assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); // Without --command the stray pair used to be silently written as the // command; resolve_add must reject it with migration guidance. let add = parse_add(&[ "kigi", "mcp", "add", "pg", "--env", "A=1", "B=2", "--", "npx", "-y", "server", ]); let err = resolve_add(&add).expect_err("env-shaped command must fail"); assert!(err.to_string().contains("-e A=1 -e B=2"), "got: {err}"); } #[test] fn add_legacy_url_and_type_misuse_is_rejected() { // --url with an explicit stdio transport used to silently store the // URL as a stdio command. let add = parse_add(&[ "kigi", "mcp", "add", "foo", "--url", "https://mcp.example.com/mcp", "-t", "stdio", ]); let err = resolve_add(&add).expect_err("--url with stdio transport must fail"); assert!(err.to_string().contains("--url"), "got: {err}"); // --type without --url used to be silently ignored. let add = parse_add(&[ "kigi", "mcp", "add", "bar", "https://x.example/sse", "--type", "sse", ]); let err = resolve_add(&add).expect_err("--type without --url must fail"); assert!(err.to_string().contains("--transport"), "got: {err}"); } #[test] fn add_validates_name_env_and_headers() { let add = parse_add(&["kigi", "mcp", "add", "fs", "-e", "NOT_A_PAIR", "--", "npx"]); let err = resolve_add(&add).expect_err("malformed env must fail"); assert!( err.to_string() .contains("Invalid environment variable format"), "got: {err}" ); let add = parse_add(&[ "kigi", "mcp", "add", "--transport", "http", "api", "https://mcp.example.com", "-H", "NoColonHere", ]); let err = resolve_add(&add).expect_err("malformed header must fail"); assert!( err.to_string().contains("Invalid header format"), "got: {err}" ); let add = parse_add(&["kigi", "mcp", "add", "bad name!", "--", "npx"]); let err = resolve_add(&add).expect_err("invalid name must fail"); assert!(err.to_string().contains("Invalid name"), "got: {err}"); } #[test] fn add_rejects_mismatched_options_per_transport() { let add = parse_add(&["kigi", "mcp", "add", "fs", "-H", "X: y", "--", "npx"]); let err = resolve_add(&add).expect_err("--header is remote-only"); assert!(err.to_string().contains("--header"), "got: {err}"); let add = parse_add(&[ "kigi", "mcp", "add", "--transport", "http", "api", "https://mcp.example.com", "-e", "K=v", ]); let err = resolve_add(&add).expect_err("--env is stdio-only"); assert!(err.to_string().contains("--env"), "got: {err}"); let add = parse_add(&[ "kigi", "mcp", "add", "--transport", "http", "api", "--", "https://mcp.example.com", "extra", ]); let err = resolve_add(&add).expect_err("extra args after a URL must fail"); assert!( err.to_string().contains("Unexpected arguments"), "got: {err}" ); } #[test] fn add_requires_a_source_for_each_transport() { let add = parse_add(&["kigi", "mcp", "add", "fs"]); let err = resolve_add(&add).expect_err("stdio without a command must fail"); assert!( err.to_string().contains("command is required"), "got: {err}" ); let add = parse_add(&["kigi", "mcp", "add", "--transport", "http", "api"]); let err = resolve_add(&add).expect_err("http without a URL must fail"); assert!(err.to_string().contains("URL is required"), "got: {err}"); } #[test] fn remove_accepts_optional_scope() { let args = PagerArgs::try_parse_from(["kigi", "mcp", "remove", "fs", "-s", "project"]) .expect("remove with scope parses"); match args.command { Some(Command::Mcp(McpArgs { command: McpCommand::Remove { name, scope }, })) => { assert_eq!(name, "fs"); assert_eq!(scope, Some(McpScope::Project)); } other => panic!("expected mcp remove, got {other:?}"), } } #[test] fn select_remove_site_covers_scope_presence_matrix() { let user = kigi_shell::util::config::user_config_path(); let project = PathBuf::from("/repo/.kigi/config.toml"); // No scope: single hits resolve, both scopes is ambiguous, neither is // not found. assert_eq!( select_remove_site(true, None, None), Ok((McpScope::User, user.clone())) ); assert_eq!( select_remove_site(false, Some(project.clone()), None), Ok((McpScope::Project, project.clone())) ); assert_eq!( select_remove_site(true, Some(project.clone()), None), Err(RemoveError::Ambiguous { project_path: project.clone() }) ); assert_eq!( select_remove_site(false, None, None), Err(RemoveError::NotFound) ); // Explicit scope: only that scope is consulted. assert_eq!( select_remove_site(true, Some(project.clone()), Some(McpScope::User)), Ok((McpScope::User, user)) ); assert_eq!( select_remove_site(false, Some(project.clone()), Some(McpScope::Project)), Ok((McpScope::Project, project.clone())) ); assert_eq!( select_remove_site(false, Some(project), Some(McpScope::User)), Err(RemoveError::NotFound) ); assert_eq!( select_remove_site(true, None, Some(McpScope::Project)), Err(RemoveError::NotFound) ); } #[test] fn surviving_definition_prefers_project_then_user() { let user = kigi_shell::util::config::user_config_path(); let project = PathBuf::from("/repo/.kigi/config.toml"); // The mirror of the remove note: a user-scope delete with a project // survivor (and vice versa) must still report the remaining site. assert_eq!( surviving_definition(false, Some(project.clone())), Some((McpScope::Project, project.clone())) ); assert_eq!( surviving_definition(true, None), Some((McpScope::User, user)) ); // Project shadows user when both survive; nothing left is silent. assert_eq!( surviving_definition(true, Some(project.clone())), Some((McpScope::Project, project)) ); assert_eq!(surviving_definition(false, None), None); } }