pub(super) fn parse_mcp_flags(tokens: &[String]) -> (Vec<String>, Vec<(String, String)>) {
let mut positional = Vec::new();
let mut flags: Vec<(String, String)> = Vec::new();
let mut i = 0;
while i < tokens.len() {
let tok = &tokens[i];
if let Some(rest) = tok.strip_prefix("--") {
if let Some((key, val)) = rest.split_once('=') {
flags.push((key.to_string(), val.to_string()));
} else if i + 1 < tokens.len()
&& !tokens[i + 1].starts_with("--")
&& !tokens[i + 1].starts_with('-')
{
flags.push((rest.to_string(), tokens[i + 1].clone()));
i += 1;
} else {
flags.push((rest.to_string(), "true".to_string()));
}
} else if tok == "-g" {
flags.push(("global".to_string(), "true".to_string()));
} else if tok.starts_with('-') && tok.len() == 2 {
let key = match &tok[1..] {
"n" => "name",
"d" => "description",
"e" => "env",
"a" => "args",
"h" => "headers",
_ => {
positional.push(tok.clone());
i += 1;
continue;
}
};
if i + 1 < tokens.len() {
flags.push((key.to_string(), tokens[i + 1].clone()));
i += 1;
}
} else {
positional.push(tok.clone());
}
i += 1;
}
(positional, flags)
}
pub(super) fn mcp_json_path(working_dir: &str, global: bool) -> std::path::PathBuf {
if global {
crate::config::collet_home(None).join("mcp.json")
} else {
std::path::Path::new(working_dir)
.join(".collet")
.join("mcp.json")
}
}
pub(super) fn read_mcp_json(path: &std::path::Path) -> Result<serde_json::Value, String> {
if let Ok(raw) = std::fs::read_to_string(path) {
serde_json::from_str(&raw).map_err(|e| {
format!(
"Failed to parse `{}`: {e}\nRefusing to overwrite — fix the JSON manually.",
path.display()
)
})
} else {
Ok(serde_json::json!({ "version": 1, "mcpServers": {} }))
}
}
pub(super) fn write_mcp_json(
path: &std::path::Path,
value: &serde_json::Value,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let pretty = serde_json::to_string_pretty(value).map_err(|e| e.to_string())?;
std::fs::write(path, pretty).map_err(|e| e.to_string())
}
pub(super) fn entry_from_source_inner(source: &str) -> serde_json::Value {
if let Some(pkg) = source.strip_prefix("npm:") {
let versioned = if source_has_explicit_version(pkg) {
pkg.to_string()
} else {
format!("{pkg}@latest")
};
serde_json::json!({
"source": source,
"command": "npx",
"args": ["-y", versioned],
"env": {},
"enabled": true
})
} else if let Some(bin) = source.strip_prefix("local:") {
serde_json::json!({
"source": source,
"command": bin,
"args": [],
"env": {},
"enabled": true
})
} else if let Some(url) = source.strip_prefix("http:") {
serde_json::json!({
"source": source,
"url": url,
"headers": {},
"enabled": true
})
} else if let Some(repo) = source.strip_prefix("github:") {
serde_json::json!({
"source": source,
"command": "npx",
"args": ["-y", format!("github:{repo}")],
"env": {},
"enabled": true
})
} else {
let versioned = if source_has_explicit_version(source) {
source.to_string()
} else {
format!("{source}@latest")
};
serde_json::json!({
"source": format!("npm:{source}"),
"command": "npx",
"args": ["-y", versioned],
"env": {},
"enabled": true
})
}
}
pub(super) fn source_has_explicit_version(pkg: &str) -> bool {
if let Some(stripped) = pkg.strip_prefix('@') {
stripped.contains('@')
} else {
pkg.contains('@')
}
}
pub(super) fn mcp_add(args: &[String], working_dir: &str) -> String {
let (positional, flags) = parse_mcp_flags(args);
let (name, source) = match (positional.first(), positional.get(1)) {
(Some(n), Some(s)) => (n.clone(), s.clone()),
_ => {
return "Usage: /mcp add <name> <source> [-g] [-a arg] [-e K=V] [-h K=V] [-d desc]\n\n\
Sources:\n npm:@playwright/mcp npm package (npx -y)\n local:alcove local binary\n https://example.com/mcp HTTP endpoint (auto-detected)\n http://example.com/mcp HTTP endpoint (auto-detected)\n github:owner/repo@tag GitHub repo\n bare-name treated as npm package\n\n\
Flags:\n -g, --global Save to ~/.collet/mcp.json (default: project)\n -a, --args <val> Extra argument (repeatable)\n -e, --env K=V Environment variable (repeatable)\n -h, --headers K=V HTTP header (repeatable)\n -d, --description Description text\n\n\
Examples:\n /mcp add playwright npm:@playwright/mcp -g\n /mcp add alcove local:alcove -g -e DOC_ROOT=/path\n /mcp add context7 https://mcp.context7.com/mcp -h Authorization=\"Bearer $KEY\"\n /mcp rm context7 -g".to_string();
}
};
let source = if source.starts_with("https://") || source.starts_with("http://") {
format!("http:{source}")
} else {
source
};
let global = flags.iter().any(|(k, _)| k == "global");
let path = mcp_json_path(working_dir, global);
let mut entry = entry_from_source_inner(&source);
if let Some((_, desc)) = flags.iter().find(|(k, _)| k == "description") {
entry["description"] = serde_json::Value::String(desc.clone());
}
let extra_args: Vec<&str> = flags
.iter()
.filter(|(k, _)| k.as_str() == "args")
.map(|(_, v)| v.as_str())
.collect();
if !extra_args.is_empty() {
let arr = entry.get_mut("args").and_then(|v| v.as_array_mut());
if let Some(arr) = arr {
for a in &extra_args {
arr.push(serde_json::Value::String(a.to_string()));
}
} else {
entry["args"] = serde_json::json!(extra_args);
}
}
for (_k, v) in flags.iter().filter(|(k, _)| k.as_str() == "env") {
if let Some((ek, ev)) = v.split_once('=') {
entry["env"][ek] = serde_json::Value::String(ev.to_string());
}
}
for (_k, v) in flags
.iter()
.filter(|(k, _)| k.as_str() == "header" || k.as_str() == "headers")
{
if let Some((hk, hv)) = v.split_once('=') {
if entry.get("headers").is_none() {
entry["headers"] = serde_json::json!({});
}
entry["headers"][hk] = serde_json::Value::String(hv.to_string());
}
}
let mut doc = match read_mcp_json(&path) {
Ok(v) => v,
Err(e) => return e,
};
if doc.get("mcpServers").is_none() {
doc["mcpServers"] = serde_json::json!({});
}
let existed = doc["mcpServers"].get(&name).is_some();
doc["mcpServers"][&name] = entry;
match write_mcp_json(&path, &doc) {
Ok(()) => {
let verb = if existed { "Updated" } else { "Added" };
let scope = if global { "global" } else { "project" };
format!(
"{verb} `{name}` to {scope} MCP config (`{}`).",
path.display()
)
}
Err(e) => format!("Failed to write `{}`: {e}", path.display()),
}
}
pub(super) fn mcp_remove(args: &[String], working_dir: &str) -> String {
let (positional, flags) = parse_mcp_flags(args);
let name = match positional.first() {
Some(n) => n.clone(),
None => return "Usage: `/mcp remove|rm <name> [-g|--global]`".to_string(),
};
let global = flags.iter().any(|(k, _)| k == "global");
let path = mcp_json_path(working_dir, global);
let mut doc = match read_mcp_json(&path) {
Ok(v) => v,
Err(e) => return e,
};
if doc["mcpServers"].get(&name).is_none() {
return format!("`{name}` not found in `{}`.", path.display());
}
doc["mcpServers"].as_object_mut().unwrap().remove(&name);
match write_mcp_json(&path, &doc) {
Ok(()) => format!("Removed `{name}` from `{}`.", path.display()),
Err(e) => format!("Failed to write `{}`: {e}", path.display()),
}
}
pub(super) fn mcp_set_enabled(args: &[String], working_dir: &str, enabled: bool) -> String {
let (positional, flags) = parse_mcp_flags(args);
let name = match positional.first() {
Some(n) => n.clone(),
None => {
let sub = if enabled { "enable" } else { "disable" };
return format!("Usage: `/mcp {sub} <name> [--global]`");
}
};
let global = flags.iter().any(|(k, _)| k == "global");
let path = mcp_json_path(working_dir, global);
let mut doc = match read_mcp_json(&path) {
Ok(v) => v,
Err(e) => return e,
};
if doc["mcpServers"].get(&name).is_none() {
return format!("`{name}` not found in `{}`.", path.display());
}
doc["mcpServers"][&name]["enabled"] = serde_json::Value::Bool(enabled);
match write_mcp_json(&path, &doc) {
Ok(()) => {
let state = if enabled { "enabled" } else { "disabled" };
format!("`{name}` {state} in `{}`.", path.display())
}
Err(e) => format!("Failed to write `{}`: {e}", path.display()),
}
}
pub(crate) fn handle_mcp_command(args: &[String], working_dir: &str) -> String {
let sub = args.first().map(|s| s.as_str()).unwrap_or("");
let rest = if args.len() > 1 {
&args[1..]
} else {
&[] as &[String]
};
match sub {
"add" => mcp_add(rest, working_dir),
"remove" | "rm" => mcp_remove(rest, working_dir),
"enable" => mcp_set_enabled(rest, working_dir, true),
"disable" => mcp_set_enabled(rest, working_dir, false),
_ => mcp_text(working_dir),
}
}
pub(super) fn mcp_text(working_dir: &str) -> String {
let project_cfg = std::path::Path::new(working_dir)
.join(".collet")
.join("mcp.json");
let user_cfg = Some(crate::config::collet_home(None).join("mcp.json"));
let mut lines = vec!["## MCP Servers\n".to_string()];
let mut found = false;
for path in [Some(project_cfg), user_cfg].into_iter().flatten() {
if path.exists() {
found = true;
let label = if path.starts_with(working_dir) {
"project"
} else {
"user"
};
lines.push(format!("**Config** (`{label}`): `{}`\n", path.display()));
match std::fs::read_to_string(&path) {
Ok(contents) => lines.push(format!("```json\n{}\n```", contents.trim())),
Err(e) => lines.push(format!("_(error reading file: {e})_")),
}
}
}
if !found {
lines.push("No MCP configuration found.".to_string());
lines.push(
"\nCreate `.collet/mcp.json` in your project or `~/.collet/mcp.json` globally to configure MCP servers.".to_string(),
);
}
lines.join("\n")
}