use crate::{
config::{McPaths, McpServerConfig, McpServersSettings, read_settings},
mcp::{
McpClient,
manager::{McpManager, McpServerStatus},
oauth,
},
};
use anyhow::Result;
use std::{
io::Write,
process::Command,
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")?;
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<()> {
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<()> {
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<()> {
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");
}
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)
})?;
writeln!(stdout, "Connected")?;
writeln!(stdout, " server: {}", init.server_info.name)?;
writeln!(stdout, " version: {}", init.server_info.version)?;
writeln!(stdout, " protocol: {}", init.protocol_version)?;
writeln!(stdout, " tools: {}", tools.len())?;
for tool in tools {
let description = tool.description.as_deref().unwrap_or("");
if description.is_empty() {
writeln!(stdout, " - {}", tool.name)?;
} else {
writeln!(stdout, " - {}: {description}", tool.name)?;
}
}
client.shutdown();
Ok(())
}
fn connect_selected(
server: &str,
settings: &McpServersSettings,
paths: &McPaths,
stderr: &mut impl Write,
) -> Result<McpClient> {
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<()> {
#[cfg(target_os = "macos")]
{
Command::new("open").arg(url).spawn().map(|_| ())
}
#[cfg(target_os = "windows")]
{
Command::new("cmd")
.args(["/C", "start", "", url])
.spawn()
.map(|_| ())
}
#[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("settings.json"),
r#"{
"mcp_servers": {
"remote": {
"type": "http",
"url": "http://127.0.0.1:9/mcp?token=url-secret#frag",
"headers": { "X-Team": "literal-secret-value" },
"enabled": true,
"timeout": 1
}
}
}"#,
)
.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("settings.json"),
r#"{
"mcp_servers": {
"remote": {
"type": "http",
"url": "http://127.0.0.1:9/mcp",
"oauth": {"client_id":"client-1"},
"enabled": true,
"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 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();
assert!(
String::from_utf8(stdout)
.unwrap()
.contains("none configured")
);
assert!(stderr.is_empty());
env.remove_var("MC_HOME");
}
}