use super::{
format::uninstall_help,
install::{
build_mcp_descriptor, default_client_config_path, install_scope, remove_client_config,
supported_install_clients, supported_install_clients_with_all, write_text_atomic,
McpInstallOptions,
},
setup::{remove_instruction_text, GraphStatePaths},
util::{read_json_file, required_arg, resolve_repo_root},
};
use serde_json::json;
use std::{
fs,
io::Write,
path::{Path, PathBuf},
};
#[derive(Debug)]
pub(in crate::cli) struct UninstallOptions {
repo_root: Option<PathBuf>,
config: Option<PathBuf>,
mcp_client: String,
client_config_path: Option<PathBuf>,
dry_run: bool,
json: bool,
help: bool,
}
impl UninstallOptions {
fn parse(args: &[String]) -> Result<Self, String> {
let mut options = Self {
repo_root: None,
config: None,
mcp_client: "all".to_string(),
client_config_path: None,
dry_run: false,
json: false,
help: false,
};
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"-h" | "--help" => {
options.help = true;
index += 1;
}
"--repo-root" | "--source-root" => {
options.repo_root =
Some(PathBuf::from(required_arg(args, index, "--repo-root")?));
index += 2;
}
"--config" => {
options.config = Some(PathBuf::from(required_arg(args, index, "--config")?));
index += 2;
}
"--mcp-client" => {
let client = required_arg(args, index, "--mcp-client")?;
if client != "all" && !supported_install_clients().contains(&client) {
return Err(format!(
"--mcp-client must be one of {}",
supported_install_clients_with_all().join(", ")
));
}
options.mcp_client = client.to_string();
index += 2;
}
"--client-config-path" => {
options.client_config_path = Some(PathBuf::from(required_arg(
args,
index,
"--client-config-path",
)?));
index += 2;
}
"--dry-run" => {
options.dry_run = true;
index += 1;
}
"--json" => {
options.json = true;
index += 1;
}
other => {
return Err(format!(
"unknown uninstall option: {other}\n\n{}",
uninstall_help()
));
}
}
}
if options.client_config_path.is_some() && options.mcp_client == "all" {
return Err("--client-config-path requires --mcp-client <client>".to_string());
}
Ok(options)
}
}
pub(in crate::cli) fn run_uninstall<W: Write>(
args: &[String],
stdout: &mut W,
) -> Result<(), String> {
let options = UninstallOptions::parse(args)?;
if options.help {
writeln!(stdout, "{}", uninstall_help()).map_err(|error| error.to_string())?;
return Ok(());
}
let repo_root = resolve_repo_root(options.repo_root.as_deref())?;
let paths = GraphStatePaths::derive(&repo_root);
let config_path = options
.config
.clone()
.unwrap_or_else(|| paths.config_path.clone());
let server_name = uninstall_server_name(&repo_root, &config_path)?;
let state = uninstall_state_dir(&paths.state_dir, options.dry_run)?;
let instructions = uninstall_instruction_blocks(&repo_root, options.dry_run)?;
let mcp_clients = uninstall_mcp_clients(&options, &repo_root, &config_path, &server_name)?;
let output = json!({
"ok": true,
"repo_root": repo_root,
"config_path": config_path,
"server_name": server_name,
"dry_run": options.dry_run,
"state": state,
"instructions": instructions,
"mcp_clients": mcp_clients,
});
if options.json {
writeln!(
stdout,
"{}",
serde_json::to_string_pretty(&output).map_err(|error| error.to_string())?
)
.map_err(|error| error.to_string())?;
} else {
write!(stdout, "{}", serialize_uninstall_block(&output))
.map_err(|error| error.to_string())?;
}
Ok(())
}
fn uninstall_server_name(repo_root: &Path, config_path: &Path) -> Result<String, String> {
if config_path.exists() {
let config = read_json_file(config_path)?;
if let Some(name) = config
.pointer("/mcp/server_name")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
{
return Ok(name.to_string());
}
}
Ok(build_mcp_descriptor(&McpInstallOptions {
client: "generic".to_string(),
scope: "local".to_string(),
name: None,
config_path: Some(config_path.to_path_buf()),
client_config_path: None,
repo_root: Some(repo_root.to_path_buf()),
dry_run: true,
verify: false,
json: true,
help: false,
})?
.name)
}
fn uninstall_state_dir(path: &Path, dry_run: bool) -> Result<serde_json::Value, String> {
if !path.exists() {
return Ok(json!({"action": "unchanged", "path": path}));
}
if !dry_run {
fs::remove_dir_all(path).map_err(|error| {
format!(
"failed to remove state directory {}: {error}",
path.display()
)
})?;
}
Ok(json!({"action": if dry_run { "dry_run" } else { "removed" }, "path": path}))
}
fn uninstall_instruction_blocks(
repo_root: &Path,
dry_run: bool,
) -> Result<Vec<serde_json::Value>, String> {
["AGENTS.md", "CLAUDE.md"]
.into_iter()
.map(|file_name| uninstall_instruction_file(&repo_root.join(file_name), dry_run))
.collect()
}
fn uninstall_instruction_file(path: &Path, dry_run: bool) -> Result<serde_json::Value, String> {
let Ok(existing) = fs::read_to_string(path) else {
return Ok(json!({"action": "unchanged", "path": path}));
};
let (next, removed) = remove_instruction_text(&existing);
if !removed {
return Ok(json!({"action": "unchanged", "path": path}));
}
if !dry_run {
fs::write(path, next).map_err(|error| {
format!("failed to update instructions {}: {error}", path.display())
})?;
}
Ok(json!({"action": if dry_run { "dry_run" } else { "removed" }, "path": path}))
}
fn uninstall_mcp_clients(
options: &UninstallOptions,
repo_root: &Path,
config_path: &Path,
server_name: &str,
) -> Result<Vec<serde_json::Value>, String> {
let clients = if options.mcp_client == "all" {
supported_install_clients()
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>()
} else {
vec![options.mcp_client.clone()]
};
Ok(clients
.into_iter()
.map(|client| {
uninstall_mcp_client(&client, options, repo_root, config_path, server_name)
.unwrap_or_else(|error| {
json!({
"action": "failed",
"client": client,
"server_name": server_name,
"error": error,
})
})
})
.collect())
}
fn uninstall_mcp_client(
client: &str,
options: &UninstallOptions,
repo_root: &Path,
config_path: &Path,
server_name: &str,
) -> Result<serde_json::Value, String> {
if matches!(client, "copilot-studio" | "microsoft-copilot") {
return Ok(json!({
"action": "skipped",
"reason": "manual_metadata",
"client": client,
"server_name": server_name,
}));
}
let scope = if client == "claude-project" {
"project"
} else {
"local"
};
let descriptor = build_mcp_descriptor(&McpInstallOptions {
client: client.to_string(),
scope: scope.to_string(),
name: Some(server_name.to_string()),
config_path: Some(config_path.to_path_buf()),
client_config_path: options.client_config_path.clone(),
repo_root: Some(repo_root.to_path_buf()),
dry_run: true,
verify: false,
json: true,
help: false,
})?;
let scope = install_scope(client, scope);
let path = options
.client_config_path
.clone()
.unwrap_or_else(|| default_client_config_path(client, &scope, &descriptor));
let existing = fs::read_to_string(&path).ok();
let removed = remove_client_config(client, &scope, existing.as_deref(), server_name)?;
if removed.action == "removed" && !options.dry_run {
write_text_atomic(&path, &removed.text)?;
}
let action = if removed.action == "removed" && options.dry_run {
"dry_run".to_string()
} else {
removed.action
};
Ok(json!({
"action": action,
"client": client,
"scope": scope,
"server_name": server_name,
"path": path,
"previous": removed.previous,
"payload": removed.payload,
}))
}
fn serialize_uninstall_block(output: &serde_json::Value) -> String {
let mut lines = vec![format!(
"uninstall ok={} server_name={}",
output["ok"].as_bool().unwrap_or(false),
output["server_name"].as_str().unwrap_or_default()
)];
if let Some(state) = output["state"].as_object() {
lines.push(format!(
"state action={} path={}",
state
.get("action")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown"),
state
.get("path")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
));
}
for item in output["instructions"].as_array().into_iter().flatten() {
lines.push(format!(
"instructions action={} path={}",
item["action"].as_str().unwrap_or("unknown"),
item["path"].as_str().unwrap_or_default()
));
}
for item in output["mcp_clients"].as_array().into_iter().flatten() {
lines.push(format!(
"mcp client={} action={} path={}",
item["client"].as_str().unwrap_or("unknown"),
item["action"].as_str().unwrap_or("unknown"),
item["path"].as_str().unwrap_or_default()
));
}
lines.join("\n") + "\n"
}