Files
Kigi-CLI/crates/codegen/kigi-tui/src/mcp_cmd.rs
T
ZacharyZhang-NY 6f31415ed6 §9 acceptance: grep-zero sweep — every internal x.ai/grok identifier renamed
The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok'
crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party
license archives, README provenance, and the required 'Based on Grok
Build Open Source' attribution, now sourced from version_attribution.txt).

Wire-visible renames (both sides in this repo, changed in lockstep):
- Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode).
- Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* /
  _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps
  a read-side alias for the legacy '_x.ai/session/update' method so
  existing updates.jsonl histories load; writes emit only the new name
  (both directions test-pinned).
- Agent types grok-build* → kigi* with a documented legacy-prefix alias
  at resolution time so persisted sessions keep resolving.
- ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case
  kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build
  implementation dirs renamed to kigi*.
- x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes
  grokday/groknight → kigiday/kiginight (old persisted values fall back
  to the default theme), web_fetch allowlist xAI hosts → kimi.com +
  moonshot platforms, changelog CDN → this repo, grok-build changelog
  archives deleted.
- BYOK default endpoint removed: [endpoints] api_base_url is now truly
  optional with NO default — consumers fail fast with the flag name when
  unset (no silent x.ai egress). Mock harnesses inject it explicitly.
- System-prompt identity fixed: 'released by xAI' → 'an unofficial
  community CLI for Kimi' (template + regenerated encrypted form).

Also repaired pre-existing grok-era test debt found by the sweep: the
stale trace_classify default-model pin, the grok-pager UA label test,
pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY
test could previously reach the real api.moonshot.cn), and the outdated
oauth fixture scope key.

Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings);
FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed;
deny advisories ok.
2026-07-18 02:48:46 -04:00

1135 lines
38 KiB
Rust

//! `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<McpScope>,
},
/// 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<String>,
},
}
// 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<String>,
/// 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<String>,
/// Transport type. Defaults to stdio.
#[arg(short = 't', long, value_enum)]
transport: Option<McpTransport>,
/// 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<String>,
/// HTTP header for remote servers (repeatable)
#[arg(short = 'H', long = "header", value_name = "NAME: VALUE")]
header: Vec<String>,
/// Legacy alias for the positional command argument
#[arg(long, hide = true, group = "source")]
command: Option<String>,
/// Legacy companion to --command
#[arg(long = "args", hide = true, num_args = 1.., requires = "command")]
legacy_args: Vec<String>,
/// Legacy alias for adding a remote server by URL
#[arg(long, hide = true, group = "source")]
url: Option<String>,
/// Legacy transport type for --url servers
#[arg(long = "type", hide = true)]
transport_type: Option<String>,
}
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<String>,
}
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<ResolvedAdd> {
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 <name> -- <command> [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<String> = 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} <name> <url>"
);
};
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<HashMap<String, String>> {
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<HashMap<String, String>> {
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(&current_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<PathBuf>,
scope: Option<McpScope>,
) -> 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<PathBuf>,
) -> 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<McpScope>) -> 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<String>) -> 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);
}
}