use crate::output::sanitize_display_text;
use crate::{
config::{
McPaths, McpServerConfig, McpServersSettings, read_settings, validate_mcp_server_name,
},
mcp::{
McpClient,
manager::{McpManager, McpServerStatus},
oauth,
},
};
use anyhow::Result;
#[cfg(not(windows))]
use std::process::Command;
use std::{
io::Write,
sync::{Arc, atomic::AtomicBool},
thread,
time::Duration,
};
pub(crate) fn run_list(stdout: &mut impl Write, stderr: &mut impl Write) -> Result<()> {
let paths = McPaths::resolve()?;
let settings = read_settings(&paths)?;
writeln!(stdout, "MCP servers")?;
writeln!(
stdout,
" Definitions: CONFIG_DIR/.mcp.json or cwd/.mcp.json"
)?;
writeln!(
stdout,
" Disabled by default; enable with /mcp in Mission Control, then restart."
)?;
if settings.mcp_servers.is_empty() {
writeln!(stdout, " none configured")?;
return Ok(());
}
let manager = McpManager::from_settings_with_paths(&settings.mcp_servers, Some(&paths.root));
for (name, config) in &settings.mcp_servers {
let server_type = match config {
McpServerConfig::Stdio(_) => "stdio".to_string(),
McpServerConfig::Http(http) => format!("http {}", sanitized_url(&http.url)),
};
let auth = oauth_status_label(&paths, name, config, &mut *stderr);
if !config.enabled() {
writeln!(stdout, " {name} ({server_type}{auth}): disabled")?;
continue;
}
match manager.statuses().get(name) {
Some(McpServerStatus::Connected { tool_count, .. }) => {
writeln!(
stdout,
" {name} ({server_type}{auth}): connected ({tool_count} tools)"
)?;
}
Some(McpServerStatus::Failed { phase, error }) => {
writeln!(
stdout,
" {name} ({server_type}{auth}): failed during {phase}"
)?;
writeln!(stderr, "mcp {name}: {phase} failed: {error}")?;
}
None => {
writeln!(
stdout,
" {name} ({server_type}{auth}): failed during startup"
)?;
writeln!(stderr, "mcp {name}: startup failed without status")?;
}
}
}
Ok(())
}
pub(crate) fn run_login(
server: &str,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> Result<()> {
validate_mcp_server_name(server)?;
let paths = McPaths::resolve()?;
let settings = read_settings(&paths)?;
let config = settings
.mcp_servers
.get(server)
.ok_or_else(|| anyhow::anyhow!("MCP server '{server}' is not configured"))?;
let McpServerConfig::Http(http) = config else {
anyhow::bail!("MCP server '{server}' is not an HTTP server")
};
let oauth_config = http
.oauth
.as_ref()
.ok_or_else(|| anyhow::anyhow!("server does not have OAuth configured"))?;
let client = reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(
http.timeout
.unwrap_or(crate::config::DEFAULT_MCP_TIMEOUT_SECONDS),
))
.build()?;
let (listener, redirect_uri) =
oauth::bind_callback_listener().map_err(|e| anyhow::anyhow!(e))?;
let login = oauth::prepare_login(&http.url, oauth_config, &redirect_uri, &client)
.map_err(|e| anyhow::anyhow!(e))?;
writeln!(
stderr,
"Open this URL in your browser:\n{}",
login.authorization_url
)?;
let _ = open_browser(&login.authorization_url);
let cancel = Arc::new(AtomicBool::new(false));
let (tx, rx) = std::sync::mpsc::channel();
thread::spawn(move || {
let mut line = String::new();
if std::io::stdin().read_line(&mut line).is_ok() && !line.trim().is_empty() {
let _ = tx.send(line);
}
});
let code = oauth::capture_loopback_or_manual_code(
Some(listener),
&login.state,
oauth::LOGIN_WAIT_TIMEOUT,
&cancel,
Some(&rx),
)
.map_err(|e| anyhow::anyhow!(e))?;
let response = oauth::exchange_code(
&login.metadata.token_endpoint,
&code,
&login.redirect_uri,
&login.client_id,
&login.verifier,
&login.resource,
login.client_secret.as_deref(),
&client,
)
.map_err(|e| anyhow::anyhow!(e))?;
let token = oauth::stored_token_from_response(
response,
&login.client_id,
login.client_secret,
&http.url,
Some(login.authorization_server),
Some(login.metadata.issuer),
Some(login.metadata.token_endpoint),
Some(login.resource),
)
.map_err(|e| anyhow::anyhow!(e))?;
oauth::write_token(&paths.root, server, &token).map_err(|e| anyhow::anyhow!(e))?;
writeln!(stdout, "Authenticated with {server}. Token stored.")?;
Ok(())
}
pub(crate) fn run_logout(
server: &str,
stdout: &mut impl Write,
_stderr: &mut impl Write,
) -> Result<()> {
validate_mcp_server_name(server)?;
let paths = McPaths::resolve()?;
oauth::delete_token(&paths.root, server).map_err(|e| anyhow::anyhow!(e))?;
writeln!(stdout, "Logged out from {server}")?;
Ok(())
}
pub(crate) fn run_test(
server: &str,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> Result<()> {
validate_mcp_server_name(server)?;
let paths = McPaths::resolve()?;
let settings = read_settings(&paths)?;
let config = settings
.mcp_servers
.get(server)
.ok_or_else(|| anyhow::anyhow!("MCP server '{server}' is not configured"))?;
if !config.enabled() {
anyhow::bail!("MCP server '{server}' is disabled; enable it with /mcp in Mission Control");
}
writeln!(stdout, "Testing MCP server: {server}")?;
if let McpServerConfig::Http(http) = config {
writeln!(stdout, " endpoint: {}", sanitized_url(&http.url))?;
}
let mut client = connect_selected(server, &settings.mcp_servers, &paths, stderr)?;
let init = client.initialize().map_err(|error| {
let _ = writeln!(stderr, "mcp {server}: initialize failed: {error}");
anyhow::anyhow!(error)
})?;
let tools = client.list_tools().map_err(|error| {
let _ = writeln!(stderr, "mcp {server}: tools/list failed: {error}");
anyhow::anyhow!(error)
})?;
let result = write_connected_diagnostics(stdout, &init, &tools);
client.shutdown();
result
}
fn write_connected_diagnostics(
stdout: &mut impl Write,
init: &crate::mcp::protocol::InitializeResult,
tools: &[crate::mcp::protocol::Tool],
) -> Result<()> {
writeln!(stdout, "Connected")?;
writeln!(
stdout,
" server: {}",
sanitize_display_text(&init.server_info.name)
)?;
writeln!(
stdout,
" version: {}",
sanitize_display_text(&init.server_info.version)
)?;
writeln!(
stdout,
" protocol: {}",
sanitize_display_text(&init.protocol_version)
)?;
writeln!(stdout, " tools: {}", tools.len())?;
for tool in tools {
let description = sanitize_display_text(tool.description.as_deref().unwrap_or(""));
let name = sanitize_display_text(&tool.name);
if description.is_empty() {
writeln!(stdout, " - {name}")?;
} else {
writeln!(stdout, " - {name}: {description}")?;
}
}
Ok(())
}
fn connect_selected(
server: &str,
settings: &McpServersSettings,
paths: &McPaths,
stderr: &mut impl Write,
) -> Result<McpClient> {
validate_mcp_server_name(server)?;
let config = settings
.get(server)
.expect("server presence validated before connect");
McpClient::connect_named(Some(server), config, Some(&paths.root)).map_err(|error| {
let phase = match config {
McpServerConfig::Stdio(_) => "spawn",
McpServerConfig::Http(_) => "connect",
};
let _ = writeln!(stderr, "mcp {server}: {phase} failed: {error}");
anyhow::anyhow!(error)
})
}
fn oauth_status_label(
paths: &McPaths,
name: &str,
config: &McpServerConfig,
stderr: &mut impl Write,
) -> String {
let McpServerConfig::Http(http) = config else {
return String::new();
};
if http.oauth.is_none() {
return String::new();
}
match oauth::auth_status(&paths.root, name, &http.url) {
Ok(status) => format!(", auth: {status}"),
Err(error) => {
let _ = writeln!(stderr, "mcp {name}: auth status failed: {error}");
", auth: invalid (needs login)".to_string()
}
}
}
fn open_browser(url: &str) -> std::io::Result<()> {
let parsed = reqwest::Url::parse(url).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid browser URL")
})?;
if !matches!(parsed.scheme(), "http" | "https") || url.contains('\0') {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"browser URL must use HTTP or HTTPS",
));
}
#[cfg(target_os = "macos")]
{
Command::new("open").arg(url).spawn().map(|_| ())
}
#[cfg(target_os = "windows")]
{
use windows_sys::Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL};
let url: Vec<u16> = url.encode_utf16().chain(Some(0)).collect();
let operation: Vec<u16> = "open".encode_utf16().chain(Some(0)).collect();
let result = unsafe {
ShellExecuteW(
std::ptr::null_mut(),
operation.as_ptr(),
url.as_ptr(),
std::ptr::null(),
std::ptr::null(),
SW_SHOWNORMAL,
)
};
if result as isize <= 32 {
Err(std::io::Error::other("could not open browser"))
} else {
Ok(())
}
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
{
Command::new("xdg-open").arg(url).spawn().map(|_| ())
}
}
fn sanitized_url(url: &str) -> String {
match reqwest::Url::parse(url) {
Ok(parsed) => {
let host = parsed.host_str().unwrap_or("<unknown>");
let port = parsed
.port()
.map(|port| format!(":{port}"))
.unwrap_or_default();
format!("{}://{}{}{}", parsed.scheme(), host, port, parsed.path())
}
Err(_) => "<invalid-url>".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn http_list_output_redacts_header_values_and_url_query() {
let temp = tempfile::TempDir::new().unwrap();
let env = crate::test_support::env::env_lock();
env.set_var("MC_HOME", temp.path());
std::fs::write(
temp.path().join(".mcp.json"),
r#"{
"mcpServers": {
"remote": {
"type": "http",
"url": "http://127.0.0.1:9/mcp?token=url-secret#frag",
"headers": { "X-Team": "literal-secret-value" },
"timeout": 1
}
}
}"#,
)
.unwrap();
crate::config::set_mcp_server_enabled(&McPaths::resolve().unwrap(), "remote", true)
.unwrap();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
run_list(&mut stdout, &mut stderr).unwrap();
let output = format!(
"{}{}",
String::from_utf8(stdout).unwrap(),
String::from_utf8(stderr).unwrap()
);
assert!(output.contains("http http://127.0.0.1:9/mcp"), "{output}");
assert!(!output.contains("literal-secret-value"), "{output}");
assert!(!output.contains("url-secret"), "{output}");
assert!(!output.contains("token="), "{output}");
env.remove_var("MC_HOME");
}
#[test]
fn list_reports_oauth_auth_status_and_logout_deletes_token() {
let temp = tempfile::TempDir::new().unwrap();
let env = crate::test_support::env::env_lock();
env.set_var("MC_HOME", temp.path());
std::fs::write(
temp.path().join(".mcp.json"),
r#"{
"mcpServers": {
"remote": {
"type": "http",
"url": "http://127.0.0.1:9/mcp",
"oauth": {"client_id":"client-1"},
"timeout": 1
}
}
}"#,
)
.unwrap();
crate::mcp::oauth::write_token(
temp.path(),
"remote",
&crate::mcp::oauth::StoredToken {
client_id: "client-1".to_string(),
access_token: "access-token".to_string(),
refresh_token: Some("refresh-token".to_string()),
expires_at: Some(chrono::Utc::now().timestamp() + 3600),
granted_scopes: Vec::new(),
client_secret: None,
authorization_server: None,
issuer: None,
token_endpoint: None,
resource: Some("http://127.0.0.1:9/mcp".to_string()),
server_url: "http://127.0.0.1:9/mcp".to_string(),
token_received_at: chrono::Utc::now().timestamp(),
},
)
.unwrap();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
run_list(&mut stdout, &mut stderr).unwrap();
let output = String::from_utf8(stdout).unwrap();
assert!(output.contains("auth: authenticated"), "{output}");
assert!(!output.contains("access-token"), "{output}");
let mut logout_stdout = Vec::new();
run_logout("remote", &mut logout_stdout, &mut stderr).unwrap();
assert!(
String::from_utf8(logout_stdout)
.unwrap()
.contains("Logged out from remote")
);
assert!(
crate::mcp::oauth::read_token(temp.path(), "remote")
.unwrap()
.is_none()
);
env.remove_var("MC_HOME");
}
#[test]
fn unapproved_definition_is_listed_but_cannot_be_tested() {
let temp = tempfile::TempDir::new().unwrap();
let env = crate::test_support::env::env_lock();
env.set_var("MC_HOME", temp.path());
std::fs::write(
temp.path().join(".mcp.json"),
r#"{"mcpServers":{"unapproved":{"command":"nonexistent-mcp-server", "enabled":true}}}"#,
)
.unwrap();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
run_list(&mut stdout, &mut stderr).unwrap();
let output = String::from_utf8(stdout).unwrap();
assert!(output.contains("unapproved (stdio): disabled"), "{output}");
assert!(stderr.is_empty());
let mut stdout = Vec::new();
let error = run_test("unapproved", &mut stdout, &mut stderr)
.unwrap_err()
.to_string();
assert!(error.contains("is disabled"), "{error}");
assert!(error.contains("enable it with /mcp"), "{error}");
assert!(stdout.is_empty());
assert!(stderr.is_empty());
}
#[test]
fn list_with_empty_settings_is_human_readable() {
let temp = tempfile::TempDir::new().unwrap();
let env = crate::test_support::env::env_lock();
env.set_var("MC_HOME", temp.path());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
run_list(&mut stdout, &mut stderr).unwrap();
let output = String::from_utf8(stdout).unwrap();
assert!(output.contains("none configured"), "{output}");
assert!(output.contains("CONFIG_DIR/.mcp.json"), "{output}");
assert!(output.contains("cwd/.mcp.json"), "{output}");
assert!(output.contains("enable with /mcp"), "{output}");
assert!(stderr.is_empty());
env.remove_var("MC_HOME");
}
}
#[cfg(test)]
mod display_safety_tests {
use super::*;
#[test]
fn diagnostics_strip_csi_and_osc_from_all_remote_fields() {
let unsafe_text = "visible\x1b[2J\x1b]52;c;hidden\x07";
let init = serde_json::from_value(serde_json::json!({
"protocolVersion": unsafe_text,
"capabilities": {},
"serverInfo": {"name": unsafe_text, "version": unsafe_text}
}))
.unwrap();
let tool = serde_json::from_value(serde_json::json!({
"name": unsafe_text, "description": unsafe_text
}))
.unwrap();
let mut output = Vec::new();
write_connected_diagnostics(&mut output, &init, &[tool]).unwrap();
let output = String::from_utf8(output).unwrap();
assert_eq!(
output,
"Connected\n server: visible\n version: visible\n protocol: visible\n tools: 1\n - visible: visible\n"
);
}
#[test]
fn browser_rejects_non_web_targets_and_nul() {
for url in [
"file:///tmp/test",
"javascript:alert(1)",
"https://example.com/\0",
] {
assert_eq!(
open_browser(url).unwrap_err().kind(),
std::io::ErrorKind::InvalidInput
);
}
}
}