use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use serde_json::{Map, Value};
use crate::cli::McpTransportArg;
use trusty_mpm::core::mcp_config::{self, McpTransport, build_remote_entry, build_stdio_entry};
fn resolve_config_dir(root: Option<&str>) -> Result<PathBuf> {
match root {
Some(_) => Ok(super::managed_root::resolve_managed_paths(root)?.claude_config_dir),
None => trusty_mpm::core::trusty_tools_config::managed_claude_config_dir()
.context("cannot resolve home directory for the managed claude config dir"),
}
}
fn parse_env(pairs: &[String]) -> Result<Map<String, Value>> {
let mut map = Map::new();
for p in pairs {
let (k, v) = p
.split_once('=')
.with_context(|| format!("invalid --env '{p}': expected KEY=VALUE"))?;
map.insert(k.to_string(), Value::String(v.to_string()));
}
Ok(map)
}
fn parse_headers(items: &[String]) -> Result<Map<String, Value>> {
let mut map = Map::new();
for h in items {
let (k, v) = h
.split_once(':')
.with_context(|| format!("invalid --header '{h}': expected 'Name: Value'"))?;
map.insert(k.trim().to_string(), Value::String(v.trim().to_string()));
}
Ok(map)
}
pub(crate) fn add_cmd(
root: Option<&str>,
name: &str,
transport: McpTransportArg,
env: &[String],
header: &[String],
command_and_args: &[String],
) -> Result<()> {
let config_dir = resolve_config_dir(root)?;
let command_or_url = command_and_args.first().map(String::as_str);
let args = command_and_args.get(1..).unwrap_or_default();
let entry = match transport {
McpTransportArg::Stdio => {
if !header.is_empty() {
bail!("--header is only valid for http/sse transports, not stdio");
}
let command = command_or_url
.context("stdio transport requires a <command> positional argument")?;
if trusty_common::bin_resolve::resolve_binary(command).is_none() {
tracing::warn!(
"command '{command}' for MCP server '{name}' was not found on PATH; \
adding anyway (it must resolve at session launch)"
);
eprintln!("warning: '{command}' not found on PATH — adding '{name}' anyway");
}
let env = parse_env(env)?;
build_stdio_entry(command, args, &env)
}
McpTransportArg::Http | McpTransportArg::Sse => {
if !env.is_empty() {
bail!("--env is only valid for the stdio transport, not http/sse");
}
if !args.is_empty() {
bail!("subprocess args (after `--`) are only valid for the stdio transport");
}
let url = command_or_url
.context("http/sse transport requires a <url> positional argument")?;
let headers = parse_headers(header)?;
let t = if matches!(transport, McpTransportArg::Http) {
McpTransport::Http
} else {
McpTransport::Sse
};
build_remote_entry(t, url, &headers)
}
};
let changed = mcp_config::add_server(&config_dir, name, entry)?;
if changed {
println!(
"Added MCP server '{name}' to {}",
config_dir.join(".claude.json").display()
);
} else {
println!("MCP server '{name}' already present (no change)");
}
Ok(())
}
pub(crate) fn remove_cmd(root: Option<&str>, name: &str) -> Result<()> {
let config_dir = resolve_config_dir(root)?;
if mcp_config::remove_server(&config_dir, name)? {
println!("Removed MCP server '{name}'");
} else {
println!("MCP server '{name}' not found (no change)");
}
Ok(())
}
pub(crate) fn list_cmd(root: Option<&str>, json: bool) -> Result<()> {
let config_dir = resolve_config_dir(root)?;
let servers = mcp_config::list_servers(&config_dir)?;
if json {
println!("{}", serde_json::to_string_pretty(&servers)?);
return Ok(());
}
if servers.is_empty() {
println!(
"No user-scope MCP servers registered in {}",
config_dir.display()
);
println!(" Add one with: tm mcp add <name> -- <command> [args...]");
return Ok(());
}
let name_w = servers.keys().map(String::len).max().unwrap_or(4).max(4);
println!(
"MCP servers in {} ({}):",
config_dir.display(),
servers.len()
);
println!(" {:<name_w$} TYPE TARGET", "NAME");
for (name, entry) in &servers {
println!(
" {:<name_w$} {:<5} {}",
name,
entry_type(entry),
entry_target(entry)
);
}
Ok(())
}
pub(crate) fn get_cmd(root: Option<&str>, name: &str, json: bool) -> Result<()> {
let config_dir = resolve_config_dir(root)?;
let Some(entry) = mcp_config::get_server(&config_dir, name)? else {
bail!("MCP server '{name}' not found in {}", config_dir.display());
};
if json {
println!("{}", serde_json::to_string_pretty(&entry)?);
} else {
println!("{name}:");
println!(" Type: {}", entry_type(&entry));
println!(" Target: {}", entry_target(&entry));
println!(" Scope: User config (available in all managed sessions)");
}
Ok(())
}
fn entry_type(entry: &Value) -> &str {
entry.get("type").and_then(Value::as_str).unwrap_or("stdio")
}
fn entry_target(entry: &Value) -> String {
if let Some(url) = entry.get("url").and_then(Value::as_str) {
return url.to_string();
}
let cmd = entry.get("command").and_then(Value::as_str).unwrap_or("");
let args: Vec<&str> = entry
.get("args")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
if args.is_empty() {
cmd.to_string()
} else {
format!("{cmd} {}", args.join(" "))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_kv_env_splits_on_first_equals() {
let m = parse_env(&["A=1".into(), "B=x=y".into()]).unwrap();
assert_eq!(m["A"], "1");
assert_eq!(m["B"], "x=y", "only the first '=' splits");
}
#[test]
fn parse_env_rejects_missing_equals() {
assert!(parse_env(&["NOEQ".into()]).is_err());
}
#[test]
fn parse_headers_splits_on_first_colon() {
let m = parse_headers(&["Authorization: Bearer a:b".into()]).unwrap();
assert_eq!(
m["Authorization"], "Bearer a:b",
"value trimmed, first ':' splits"
);
}
#[test]
fn parse_headers_rejects_missing_colon() {
assert!(parse_headers(&["NoColon".into()]).is_err());
}
#[test]
fn entry_target_formats_command_and_url() {
let stdio = serde_json::json!({"type":"stdio","command":"echo","args":["hi","there"]});
assert_eq!(entry_target(&stdio), "echo hi there");
let http = serde_json::json!({"type":"http","url":"https://x/mcp"});
assert_eq!(entry_target(&http), "https://x/mcp");
}
}