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.
1135 lines
38 KiB
Rust
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(¤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<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);
|
|
}
|
|
}
|