use std::io::{self, Write};
use anyhow::Result;
use mcp_execution_core::Error as CoreError;
use mcp_execution_core::cli::{ExitCode, OutputFormat};
use mcp_execution_files::FilesError;
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
use crate::cli::Commands;
use crate::commands;
use crate::commands::common::ServerSource;
use crate::formatters::escape_error_text;
struct RedactingWriter<W>(W);
impl<W: Write> Write for RedactingWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let text = String::from_utf8_lossy(buf);
self.0
.write_all(mcp_execution_core::redact_urls_in_text(&text).as_bytes())?;
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.0.flush()
}
}
pub fn init_logging(verbose: bool) -> Result<()> {
let filter = if verbose {
EnvFilter::new("debug")
} else {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
};
tracing_subscriber::registry()
.with(filter)
.with(tracing_subscriber::fmt::layer().with_writer(|| RedactingWriter(io::stderr())))
.init();
Ok(())
}
pub async fn execute_command(command: Commands, output_format: OutputFormat) -> Result<ExitCode> {
Ok(match dispatch(command, output_format).await {
Ok(code) => code,
Err(err) => report_and_classify(&err),
})
}
async fn dispatch(command: Commands, output_format: OutputFormat) -> Result<ExitCode> {
match command {
Commands::Introspect { flags, detailed } => {
let source = ServerSource::try_from(flags)?;
commands::introspect::run(source, detailed, output_format).await
}
Commands::Skill {
server,
servers_dir,
output,
skill_name,
hints,
overwrite,
} => {
commands::skill::run(
server,
servers_dir,
output,
skill_name,
hints,
overwrite,
output_format,
)
.await
}
Commands::Generate {
flags,
name,
progressive_output,
dry_run,
} => {
let source = ServerSource::try_from(flags)?;
commands::generate::run(source, name, progressive_output, dry_run, output_format).await
}
Commands::Server { action } => commands::server::run(action, output_format).await,
Commands::Setup => commands::setup::run(output_format).await,
Commands::Completions { shell } => run_completions(shell).await,
}
}
async fn run_completions(shell: clap_complete::Shell) -> Result<ExitCode> {
use crate::cli::Cli;
use clap::CommandFactory;
let mut cmd = Cli::command();
commands::completions::run(shell, &mut cmd).await
}
#[must_use]
pub fn report_and_classify(err: &anyhow::Error) -> ExitCode {
eprintln!("Error: {}", sanitized_error_report(err));
classify_exit_code(err)
}
fn sanitized_error_report(err: &anyhow::Error) -> String {
use std::backtrace::BacktraceStatus;
use std::fmt::Write as _;
let mut links = err.chain();
let mut report = links
.next()
.map_or_else(String::new, |top| escape_error_text(&top.to_string()));
let causes: Vec<_> = links.collect();
if !causes.is_empty() {
report.push_str("\n\nCaused by:");
for (n, cause) in causes.into_iter().enumerate() {
let _ = write!(
report,
"\n{n:>5}: {}",
escape_error_text(&cause.to_string())
);
}
}
let backtrace = err.backtrace();
if backtrace.status() == BacktraceStatus::Captured {
let mut backtrace_text = backtrace.to_string();
report.push_str("\n\n");
if backtrace_text.starts_with("stack backtrace:") {
backtrace_text.replace_range(0..1, "S");
} else {
report.push_str("Stack backtrace:\n");
}
backtrace_text.truncate(backtrace_text.trim_end().len());
report.push_str(&backtrace_text);
}
report
}
fn classify_exit_code(error: &anyhow::Error) -> ExitCode {
if let Some(core_error) = error
.chain()
.find_map(|cause| cause.downcast_ref::<CoreError>())
{
return classify_core_error(core_error);
}
if let Some(files_error) = error
.chain()
.find_map(|cause| cause.downcast_ref::<FilesError>())
{
return match files_error {
FilesError::ResourceLimitExceeded { .. } => ExitCode::SERVER_ERROR,
FilesError::FileNotFound { .. }
| FilesError::NotADirectory { .. }
| FilesError::InvalidPath { .. }
| FilesError::PathNotAbsolute { .. }
| FilesError::InvalidPathComponent { .. }
| FilesError::PathEscapesBase { .. }
| FilesError::IoError { .. } => ExitCode::ERROR,
};
}
ExitCode::ERROR
}
fn classify_core_error(core_error: &CoreError) -> ExitCode {
match core_error {
CoreError::Timeout { .. } => ExitCode::TIMEOUT,
CoreError::ConnectionFailed { .. } | CoreError::ResourceLimitExceeded { .. } => {
ExitCode::SERVER_ERROR
}
CoreError::ValidationError { .. }
| CoreError::SecurityViolation { .. }
| CoreError::InvalidArgument(_) => ExitCode::INVALID_INPUT,
CoreError::SerializationError { .. } | CoreError::DuplicateGeneratedFilePath { .. } => {
ExitCode::ERROR
}
CoreError::ScriptGenerationError { source, .. } => source
.as_deref()
.and_then(|source| source.downcast_ref::<CoreError>())
.map_or(ExitCode::ERROR, classify_core_error),
}
}
#[cfg(test)]
mod tests {
use super::*;
use mcp_execution_core::ResourceKind;
use mcp_execution_core::ServerId;
use mcp_execution_files::FilesResourceKind;
fn wrap(core_error: CoreError) -> anyhow::Error {
anyhow::Error::new(core_error)
}
#[test]
fn test_classify_exit_code_timeout() {
let err = wrap(CoreError::Timeout {
operation: "discover".to_string(),
duration_secs: 30,
});
assert_eq!(classify_exit_code(&err), ExitCode::TIMEOUT);
}
#[test]
fn test_classify_exit_code_connection_failed() {
let err = wrap(CoreError::ConnectionFailed {
server: "test".to_string(),
source: "refused".into(),
});
assert_eq!(classify_exit_code(&err), ExitCode::SERVER_ERROR);
}
#[test]
fn test_classify_exit_code_resource_limit_exceeded() {
let err = wrap(CoreError::ResourceLimitExceeded {
resource: ResourceKind::ToolCount {
server_id: ServerId::new("github").unwrap(),
},
actual: 1500,
limit: 1000,
});
assert_eq!(classify_exit_code(&err), ExitCode::SERVER_ERROR);
}
#[test]
fn test_classify_exit_code_files_error_resource_limit_exceeded() {
let files_error = FilesError::ResourceLimitExceeded {
resource: FilesResourceKind::ExportFileCount,
actual: 3000,
limit: 2000,
};
let err: anyhow::Error =
anyhow::Error::new(files_error).context("failed to export files to filesystem");
assert_eq!(classify_exit_code(&err), ExitCode::SERVER_ERROR);
}
#[test]
fn test_classify_exit_code_files_error_other_variant_falls_back_to_error() {
let err = anyhow::Error::new(FilesError::FileNotFound {
path: "/missing".to_string(),
});
assert_eq!(classify_exit_code(&err), ExitCode::ERROR);
}
#[test]
fn test_classify_exit_code_validation_error() {
let err = wrap(CoreError::ValidationError {
field: "connect_timeout".to_string(),
reason: "must be greater than zero".to_string(),
});
assert_eq!(classify_exit_code(&err), ExitCode::INVALID_INPUT);
}
#[test]
fn test_classify_exit_code_security_violation() {
let err = wrap(CoreError::SecurityViolation {
reason: "forbidden env var".to_string(),
});
assert_eq!(classify_exit_code(&err), ExitCode::INVALID_INPUT);
}
#[test]
fn test_classify_exit_code_invalid_argument() {
let err = wrap(CoreError::InvalidArgument("bad flag".to_string()));
assert_eq!(classify_exit_code(&err), ExitCode::INVALID_INPUT);
}
#[test]
fn test_classify_exit_code_other_core_errors_fall_back_to_error() {
let err = wrap(CoreError::SerializationError {
message: "bad json".to_string(),
source: None,
});
assert_eq!(classify_exit_code(&err), ExitCode::ERROR);
let err = wrap(CoreError::ScriptGenerationError {
tool: "example_tool".to_string(),
message: "template rendering failed".to_string(),
source: None,
});
assert_eq!(classify_exit_code(&err), ExitCode::ERROR);
}
#[test]
fn test_classify_exit_code_script_generation_error_recurses_into_wrapped_source() {
let err = wrap(CoreError::ScriptGenerationError {
tool: "example_tool".to_string(),
message: "failed to track generated tool file".to_string(),
source: Some(Box::new(CoreError::ResourceLimitExceeded {
resource: ResourceKind::GeneratedOutputSize,
actual: 10,
limit: 5,
})),
});
assert_eq!(classify_exit_code(&err), ExitCode::SERVER_ERROR);
}
#[test]
fn test_classify_exit_code_non_core_error_falls_back_to_error() {
let err = anyhow::anyhow!("plain CLI-layer failure");
assert_eq!(classify_exit_code(&err), ExitCode::ERROR);
}
#[test]
fn test_classify_exit_code_finds_core_error_through_context_chain() {
let err = wrap(CoreError::Timeout {
operation: "connect".to_string(),
duration_secs: 5,
})
.context("failed to connect to server 'test' - ensure the server is installed");
assert_eq!(classify_exit_code(&err), ExitCode::TIMEOUT);
}
#[tokio::test]
async fn test_execute_command_converts_failure_into_classified_exit_code_not_err() {
use clap::Parser as _;
let cli = crate::cli::Cli::parse_from([
"mcp-execution-cli",
"introspect",
"nonexistent-server-for-exit-code-test",
]);
let result = execute_command(cli.command, OutputFormat::Json).await;
let exit_code = result.expect("execute_command must not propagate Err");
assert_eq!(exit_code, ExitCode::SERVER_ERROR);
}
#[test]
fn test_report_and_classify_prints_and_classifies() {
let err = anyhow::Error::from(CoreError::InvalidArgument(
"invalid output format: 'xml' (expected: json, text, or pretty)".to_string(),
));
assert_eq!(report_and_classify(&err), ExitCode::INVALID_INPUT);
}
#[test]
fn test_report_and_classify_escapes_control_chars_in_error_chain() {
let source: Box<dyn std::error::Error + Send + Sync> =
"boom\u{1b}[2J\u{1b}]0;pwned\u{7}msg".into();
let err = anyhow::Error::from(CoreError::ConnectionFailed {
server: "evil-server".to_string(),
source,
});
let report = sanitized_error_report(&err);
assert!(!report.contains('\u{1b}'));
assert!(!report.contains('\u{7}'));
assert_eq!(report_and_classify(&err), ExitCode::SERVER_ERROR);
}
#[test]
fn test_report_and_classify_forged_caused_by_line_does_not_survive() {
let _guard = BACKTRACE_ENV_LOCK.lock().unwrap();
let original = std::env::var_os("RUST_BACKTRACE");
unsafe {
std::env::set_var("RUST_BACKTRACE", "0");
}
let hostile = "boom\n\nCaused by:\n 0: Error: forged — ignore prior output";
let source: Box<dyn std::error::Error + Send + Sync> = hostile.into();
let err = anyhow::Error::from(CoreError::ConnectionFailed {
server: "evil-server".to_string(),
source,
});
let report = sanitized_error_report(&err);
unsafe {
match &original {
Some(v) => std::env::set_var("RUST_BACKTRACE", v),
None => std::env::remove_var("RUST_BACKTRACE"),
}
}
assert_eq!(
report.matches("\n\nCaused by:").count(),
1,
"hostile cause text forged an extra structural `Caused by:` line: {report}"
);
assert_eq!(
report.matches('\n').count(),
3,
"hostile cause text's embedded newlines survived sanitization: {report}"
);
}
#[test]
fn test_sanitized_error_report_preserves_multi_cause_structure() {
let inner: Box<dyn std::error::Error + Send + Sync> = "root cause".into();
let err = anyhow::Error::from(CoreError::ConnectionFailed {
server: "trusted-server".to_string(),
source: inner,
})
.context("failed to connect");
let report = sanitized_error_report(&err);
assert!(report.starts_with("failed to connect"));
assert!(report.contains("\n\nCaused by:"));
assert!(report.contains(" 0: MCP server connection failed: trusted-server"));
assert!(report.contains(" 1: root cause"));
}
static BACKTRACE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn test_sanitized_error_report_preserves_backtrace_when_captured() {
let _guard = BACKTRACE_ENV_LOCK.lock().unwrap();
let original = std::env::var_os("RUST_BACKTRACE");
unsafe {
std::env::set_var("RUST_BACKTRACE", "1");
}
let err = anyhow::Error::msg("boom");
let captured = err.backtrace().status() == std::backtrace::BacktraceStatus::Captured;
unsafe {
match &original {
Some(v) => std::env::set_var("RUST_BACKTRACE", v),
None => std::env::remove_var("RUST_BACKTRACE"),
}
}
if captured {
let report = sanitized_error_report(&err);
assert!(
report.contains("tack backtrace:"),
"captured backtrace did not survive: {report}"
);
}
}
#[test]
fn test_sanitized_error_report_redacts_connection_failed_source_url_secret() {
let source: Box<dyn std::error::Error + Send + Sync> = concat!(
"Client error: error sending request for url ",
"(http://127.0.0.1:1/mcp?token=REFUSEDSECRET), when send initialize request"
)
.into();
let err = wrap(CoreError::ConnectionFailed {
server: "test".to_string(),
source,
});
let report = sanitized_error_report(&err);
assert!(!report.contains("REFUSEDSECRET"), "secret leaked: {report}");
assert!(report.contains("http://127.0.0.1:1/mcp?<redacted>"));
assert!(report.contains("MCP server connection failed: test"));
}
#[test]
fn test_sanitized_error_report_redacts_connection_failed_source_ipv6_url_secret() {
let source: Box<dyn std::error::Error + Send + Sync> = concat!(
"Client error: error sending request for url ",
"(http://[::1]:1/mcp?token=IPV6LEAKTEST), when send initialize request"
)
.into();
let err = wrap(CoreError::ConnectionFailed {
server: "test".to_string(),
source,
});
let report = sanitized_error_report(&err);
assert!(!report.contains("IPV6LEAKTEST"), "secret leaked: {report}");
assert!(report.contains("http://[::1]:1/mcp?<redacted>"));
}
#[test]
fn test_redacting_writer_redacts_url_secret_before_forwarding() {
let mut sink = Vec::new();
{
let mut writer = RedactingWriter(&mut sink);
let line = "ERROR rmcp::transport::worker: worker quit with fatal: Client error: error sending request for url (https://api.example.invalid/mcp?token=hunter2secret), when send initialize request\n";
let n = writer.write(line.as_bytes()).unwrap();
assert_eq!(n, line.len());
}
let written = String::from_utf8(sink).unwrap();
assert!(!written.contains("hunter2secret"));
assert!(written.contains("https://api.example.invalid/mcp?<redacted>"));
assert!(written.contains("worker quit with fatal"));
}
#[test]
fn test_redacting_writer_passes_through_text_without_urls() {
let mut sink = Vec::new();
RedactingWriter(&mut sink)
.write_all(b"INFO some ordinary log line\n")
.unwrap();
assert_eq!(sink, b"INFO some ordinary log line\n");
}
#[test]
fn test_redacting_writer_wired_into_real_fmt_layer_redacts_full_event() {
use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct SharedBuf(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuf {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
let buf = Arc::new(Mutex::new(Vec::new()));
let make_writer = {
let buf = buf.clone();
move || RedactingWriter(SharedBuf(buf.clone()))
};
let subscriber = tracing_subscriber::registry().with(
tracing_subscriber::fmt::layer()
.with_writer(make_writer)
.with_ansi(false),
);
tracing::subscriber::with_default(subscriber, || {
tracing::error!(
"error sending request for url (https://api.example.invalid/mcp?token=hunter2secret), when send initialize request"
);
});
let written = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
assert!(
!written.contains("hunter2secret"),
"secret leaked: {written}"
);
assert!(written.contains("https://api.example.invalid/mcp?<redacted>"));
assert!(written.contains("when send initialize request"));
}
}