use std::path::{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, strip_arg_separator,
};
use trusty_mpm::core::mcp_test::{self, McpTestResult};
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 = strip_arg_separator(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");
println!(
" Bridges into managed/fleet sessions too, unless this is a remote \
(http/sse) server with `headers` set (not bridged — no safe delivery channel \
for header secrets into .mcp.json yet)."
);
}
Ok(())
}
pub(crate) async fn test_cmd(root: Option<&str>, name: Option<&str>, json: bool) -> Result<()> {
let config_dir = resolve_config_dir(root)?;
let servers = mcp_config::list_servers(&config_dir)?;
let targets = mcp_test::select_targets(&servers, name)?;
let results = mcp_test::run_all(targets, mcp_test::DEFAULT_PROBE_TIMEOUT).await;
if json {
let arr: Vec<Value> = results.iter().map(McpTestResult::to_json).collect();
println!("{}", serde_json::to_string_pretty(&Value::Array(arr))?);
} else {
print_test_table(&results, &config_dir);
}
if mcp_test::any_failed(&results) {
std::process::exit(1);
}
Ok(())
}
fn print_test_table(results: &[McpTestResult], config_dir: &Path) {
if results.is_empty() {
println!("No MCP servers to test in {}", config_dir.display());
return;
}
println!(
"Testing {} MCP server(s) from {}:",
results.len(),
config_dir.display()
);
let name_w = results
.iter()
.map(|r| r.name.len())
.max()
.unwrap_or(4)
.max(4);
println!(" {:<name_w$} TRANSPORT RESULT DETAIL", "NAME");
for r in results {
let (label, detail) = r.render();
println!(
" {:<name_w$} {:<9} {:<6} {}",
r.name,
r.transport.as_str(),
label,
detail
);
}
let passed = results.iter().filter(|r| r.passed()).count();
println!("\n{passed}/{} passed", results.len());
}
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 mut 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.first() == Some(&"--") {
args.remove(0);
}
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");
}
#[test]
fn entry_target_shows_effective_argv_dropping_legacy_separator() {
let legacy = serde_json::json!({
"type":"stdio","command":"npx",
"args":["--","-y","@modelcontextprotocol/server-github"]
});
assert_eq!(
entry_target(&legacy),
"npx -y @modelcontextprotocol/server-github"
);
}
#[test]
fn add_strips_leading_separator_before_persisting() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_str().unwrap();
let command_and_args = vec![
"npx".to_string(),
"--".to_string(),
"-y".to_string(),
"@modelcontextprotocol/server-github".to_string(),
];
add_cmd(
Some(root),
"github",
McpTransportArg::Stdio,
&[],
&[],
&command_and_args,
)
.unwrap();
let cfg = std::path::Path::new(root).join("claude-config");
let entry = mcp_config::get_server(&cfg, "github")
.unwrap()
.expect("server persisted");
assert_eq!(entry["command"], "npx");
assert_eq!(
entry["args"],
serde_json::json!(["-y", "@modelcontextprotocol/server-github"]),
"the `--` sentinel must not be persisted into stored args"
);
}
#[test]
fn add_preserves_deeper_separator() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_str().unwrap();
let command_and_args = vec![
"uv".to_string(),
"--".to_string(),
"run".to_string(),
"--".to_string(),
"tool".to_string(),
];
add_cmd(
Some(root),
"x",
McpTransportArg::Stdio,
&[],
&[],
&command_and_args,
)
.unwrap();
let cfg = std::path::Path::new(root).join("claude-config");
let entry = mcp_config::get_server(&cfg, "x").unwrap().unwrap();
assert_eq!(entry["command"], "uv");
assert_eq!(
entry["args"],
serde_json::json!(["run", "--", "tool"]),
"leading `--` stripped once; the deeper `--` is preserved"
);
}
}