mcp_execution_cli/runner.rs
1//! Command execution and runtime logic.
2//!
3//! Contains the main command execution loop and logging initialization.
4
5use std::io::{self, Write};
6
7use anyhow::Result;
8use mcp_execution_core::Error as CoreError;
9use mcp_execution_core::cli::{ExitCode, OutputFormat};
10use mcp_execution_files::FilesError;
11use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
12
13use crate::cli::Commands;
14use crate::commands;
15use crate::commands::common::ServerSource;
16use crate::formatters::escape_error_text;
17
18/// [`Write`] wrapper that redacts embedded secrets out of each buffer before forwarding it to the
19/// inner sink.
20///
21/// Exists because `rmcp`'s own `tracing` targets (e.g. `rmcp::transport::worker`'s `ERROR` line on
22/// a connection failure) format a `reqwest::Error` whose `Display` embeds the full request URL,
23/// query string included, and log it directly — bypassing every redacting `Debug` impl this
24/// project applies to its own types, since this project never constructs that line's text.
25/// `tracing-subscriber`'s fmt layer formats each event into a buffer and issues exactly one
26/// [`write_all`](Write::write_all) call per event (verified against `tracing-subscriber` 0.3.23's
27/// `fmt_layer` internals), so `write` here always receives one whole formatted event line, which
28/// [`mcp_execution_core::redact_urls_in_text`] can scan and redact as a unit.
29///
30/// Generic over the inner writer so tests can redirect to an in-memory buffer instead of the real
31/// `stderr` [`init_logging`] wraps it around.
32struct RedactingWriter<W>(W);
33
34impl<W: Write> Write for RedactingWriter<W> {
35 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
36 let text = String::from_utf8_lossy(buf);
37 self.0
38 .write_all(mcp_execution_core::redact_urls_in_text(&text).as_bytes())?;
39 // The whole input was consumed and forwarded (redaction only ever
40 // changes the byte count written *downstream*, not how much of
41 // `buf` this call accounts for), so report all of it as written.
42 Ok(buf.len())
43 }
44
45 fn flush(&mut self) -> io::Result<()> {
46 self.0.flush()
47 }
48}
49
50/// Initializes logging infrastructure.
51///
52/// Sets up tracing with appropriate log levels based on verbosity flag.
53/// Writes log messages to stderr, with any embedded URL's credentials/query string redacted (via
54/// a wrapping [`Write`] adapter around the fmt layer's writer) — this covers `rmcp` and any other
55/// dependency's log lines, not just this
56/// project's own, since a dependency's `tracing` output cannot go through this crate's
57/// `Debug`/[`escape_error_text`] redaction paths.
58///
59/// # Arguments
60///
61/// * `verbose` - If true, sets log level to DEBUG; otherwise uses INFO or
62/// environment variable override via `RUST_LOG`
63///
64/// # Errors
65///
66/// This function cannot fail—it always returns `Ok(())`. Multiple calls
67/// in the same process will panic rather than returning an error, but this
68/// is not a recoverable condition and indicates a programming error.
69///
70/// # Examples
71///
72/// ```no_run
73/// use mcp_execution_cli::runner;
74///
75/// // `no_run`: this installs a process-global tracing subscriber, which
76/// // panics if called more than once in the same process.
77/// runner::init_logging(false)?;
78/// # Ok::<(), anyhow::Error>(())
79/// ```
80pub fn init_logging(verbose: bool) -> Result<()> {
81 let filter = if verbose {
82 EnvFilter::new("debug")
83 } else {
84 EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
85 };
86
87 tracing_subscriber::registry()
88 .with(filter)
89 .with(tracing_subscriber::fmt::layer().with_writer(|| RedactingWriter(io::stderr())))
90 .init();
91
92 Ok(())
93}
94
95/// Executes the specified CLI command.
96///
97/// Routes commands to their respective handlers. On success, returns the exit
98/// code reported by the handler. If the handler fails, the error is printed
99/// to stderr and classified into a semantic [`ExitCode`] via
100/// `classify_exit_code` rather than propagated — this lets `main` always
101/// turn the result into a process exit code without falling back to anyhow's
102/// default behavior of collapsing every `Err` to exit code 1.
103///
104/// # Arguments
105///
106/// * `command` - The parsed CLI command to execute
107/// * `output_format` - Output format preference (JSON, text, or pretty)
108///
109/// # Errors
110///
111/// This function does not propagate command execution failures as `Err` —
112/// see above. It is fallible in signature to match this crate's convention
113/// of using `Result` consistently across command handlers.
114///
115/// # Examples
116///
117/// ```no_run
118/// use mcp_execution_cli::cli::Commands;
119/// use mcp_execution_cli::runner;
120/// use mcp_execution_core::cli::OutputFormat;
121///
122/// # async fn example() -> anyhow::Result<()> {
123/// let exit_code = runner::execute_command(
124/// Commands::Setup,
125/// OutputFormat::Pretty,
126/// ).await?;
127/// # Ok(())
128/// # }
129/// ```
130pub async fn execute_command(command: Commands, output_format: OutputFormat) -> Result<ExitCode> {
131 Ok(match dispatch(command, output_format).await {
132 Ok(code) => code,
133 Err(err) => report_and_classify(&err),
134 })
135}
136
137/// Routes `command` to its handler and returns the handler's result unclassified.
138///
139/// # Errors
140///
141/// Returns whatever error the dispatched command handler produces.
142async fn dispatch(command: Commands, output_format: OutputFormat) -> Result<ExitCode> {
143 match command {
144 Commands::Introspect { flags, detailed } => {
145 let source = ServerSource::try_from(flags)?;
146 commands::introspect::run(source, detailed, output_format).await
147 }
148 Commands::Skill {
149 server,
150 servers_dir,
151 output,
152 skill_name,
153 hints,
154 overwrite,
155 } => {
156 commands::skill::run(
157 server,
158 servers_dir,
159 output,
160 skill_name,
161 hints,
162 overwrite,
163 output_format,
164 )
165 .await
166 }
167 Commands::Generate {
168 flags,
169 name,
170 progressive_output,
171 dry_run,
172 } => {
173 let source = ServerSource::try_from(flags)?;
174 commands::generate::run(source, name, progressive_output, dry_run, output_format).await
175 }
176 Commands::Server { action } => commands::server::run(action, output_format).await,
177 Commands::Setup => commands::setup::run(output_format).await,
178 Commands::Completions { shell } => run_completions(shell).await,
179 }
180}
181
182/// Runs the `completions` subcommand: builds the clap command tree and generates the shell
183/// completion script for it.
184async fn run_completions(shell: clap_complete::Shell) -> Result<ExitCode> {
185 use crate::cli::Cli;
186 use clap::CommandFactory;
187 let mut cmd = Cli::command();
188 commands::completions::run(shell, &mut cmd).await
189}
190
191/// Prints `err` to stderr, then classifies it into a semantic [`ExitCode`].
192///
193/// Structurally matches anyhow's default `main`-error format (a summary line, then a numbered
194/// "Caused by:" section for any further causes), but with each cause's own text — not anyhow's
195/// surrounding structure — passed through [`escape_error_text`] before printing. Classification is
196/// via `classify_exit_code`.
197///
198/// Shared by [`execute_command`] (command-handler failures) and `main`
199/// (pre-dispatch failures, e.g. an invalid `--format` value), so every
200/// failure this CLI can produce is reported and exits the same way. An
201/// error's cause chain can embed content from an untrusted MCP server (e.g.
202/// a JSON-RPC error `message`), and both `anyhow::Error`'s `Debug` rendering
203/// and the `thiserror`-derived `Display` impls it walks interpolate that
204/// content verbatim — so `err`'s formatted report is sanitized via
205/// `sanitized_error_report` before printing.
206///
207/// # Examples
208///
209/// ```
210/// use mcp_execution_cli::runner;
211/// use mcp_execution_core::Error as CoreError;
212/// use mcp_execution_core::cli::ExitCode;
213///
214/// let err = anyhow::Error::from(CoreError::InvalidArgument(
215/// "invalid output format: 'xml' (expected: json, text, or pretty)".to_string(),
216/// ));
217/// assert_eq!(runner::report_and_classify(&err), ExitCode::INVALID_INPUT);
218/// ```
219#[must_use]
220pub fn report_and_classify(err: &anyhow::Error) -> ExitCode {
221 eprintln!("Error: {}", sanitized_error_report(err));
222 classify_exit_code(err)
223}
224
225/// Renders `err`'s cause chain — and, if captured, its backtrace — exactly as
226/// [`report_and_classify`] prints them: a summary line, then (if there are further causes) a
227/// "Caused by:" section listing each one, numbered from 0, then (if `RUST_BACKTRACE`/
228/// `RUST_LIB_BACKTRACE` caused one to be captured) a "Stack backtrace:" section. Each cause's own
229/// rendered text is sanitized individually via [`escape_error_text`] (capped to 4000 chars each);
230/// the backtrace is not, and is not length-capped either.
231///
232/// An earlier version of this function sanitized anyhow's fully-rendered `{err:?}` report as one
233/// blob. That could not tell anyhow's own trusted structural newlines/indentation (between `Caused
234/// by:` frames, and throughout a backtrace) apart from a `\n` embedded in one cause's own
235/// untrusted `Display` text (e.g. a hostile MCP server's JSON-RPC error `message`, which
236/// `anyhow`/`thiserror` interpolate verbatim) — so it neutralized both alike, collapsing a
237/// legitimate multi-cause chain, and any backtrace, onto one line and truncating the result well
238/// short of a typical backtrace's length, for no security benefit. Building the report from
239/// [`anyhow::Error::chain`] instead sanitizes only each cause's own text and rejoins with
240/// `\n\nCaused by:\n{n:>5}: ` separators this function itself writes, so a hostile cause cannot
241/// forge those separators (any `\n` in *its* text is still neutralized) while a chain with only
242/// trusted causes keeps its real multi-line structure. [`anyhow::Error::backtrace`] is not part of
243/// `chain()` — it is captured once from the local call stack at the point `err` was constructed —
244/// so it carries nothing an external MCP server could have influenced, and is appended verbatim.
245///
246/// Deliberately does not reproduce one thing anyhow's own `{err:?}` output has: it always numbers
247/// every cause, where anyhow omits the number when there is exactly one. That's structural/local
248/// formatting with nothing untrusted in it, so it isn't a correctness concern for this function's
249/// purpose — a simplification to avoid depending on anyhow's private formatting internals, not the
250/// reason this exists. The backtrace section's own layout mirrors anyhow's
251/// (`anyhow-1.0.104/src/fmt.rs`'s `ErrorImpl::debug`) via the same public
252/// [`anyhow::Error::backtrace`] accessor it uses internally.
253///
254/// Factored out of [`report_and_classify`] (rather than inlined) so tests can assert on precisely
255/// the string that reaches stderr by calling this directly, instead of recomputing the same
256/// pipeline independently and asserting against that — which would silently drift from the real
257/// code path if either implementation changed without the other.
258fn sanitized_error_report(err: &anyhow::Error) -> String {
259 use std::backtrace::BacktraceStatus;
260 use std::fmt::Write as _;
261
262 let mut links = err.chain();
263
264 let mut report = links
265 .next()
266 .map_or_else(String::new, |top| escape_error_text(&top.to_string()));
267
268 let causes: Vec<_> = links.collect();
269 if !causes.is_empty() {
270 report.push_str("\n\nCaused by:");
271 for (n, cause) in causes.into_iter().enumerate() {
272 // `write!` into a `String` is infallible.
273 let _ = write!(
274 report,
275 "\n{n:>5}: {}",
276 escape_error_text(&cause.to_string())
277 );
278 }
279 }
280
281 let backtrace = err.backtrace();
282 if backtrace.status() == BacktraceStatus::Captured {
283 // Trusted, locally-generated content (source file paths, function names from this
284 // binary's own stack) — deliberately not sanitized or length-capped, unlike the chain
285 // links above. Mirrors anyhow's own `ErrorImpl::debug` header handling: some Rust/backtrace
286 // versions' `Backtrace::to_string()` already starts with a lowercase "stack backtrace:"
287 // header, others don't.
288 let mut backtrace_text = backtrace.to_string();
289 report.push_str("\n\n");
290 if backtrace_text.starts_with("stack backtrace:") {
291 backtrace_text.replace_range(0..1, "S");
292 } else {
293 report.push_str("Stack backtrace:\n");
294 }
295 backtrace_text.truncate(backtrace_text.trim_end().len());
296 report.push_str(&backtrace_text);
297 }
298
299 report
300}
301
302/// Classifies an [`anyhow::Error`] returned by a command handler into a
303/// semantic [`ExitCode`].
304///
305/// Walks the error's cause chain looking for a [`CoreError`] — the concrete
306/// type every command handler ultimately produces via `?` — and delegates to
307/// [`classify_core_error`] for the variant-to-exit-code mapping. Falls back to checking for a
308/// [`FilesError`] (the `generate` command's `export_to_filesystem` errors are wrapped via
309/// `anyhow::Context` rather than converted to `CoreError`, so they would otherwise never match
310/// the first check and always fall through to the generic [`ExitCode::ERROR`] — issue #198 M6).
311/// Errors that match neither (e.g. CLI argument parsing, serialization) fall back to
312/// [`ExitCode::ERROR`].
313fn classify_exit_code(error: &anyhow::Error) -> ExitCode {
314 if let Some(core_error) = error
315 .chain()
316 .find_map(|cause| cause.downcast_ref::<CoreError>())
317 {
318 return classify_core_error(core_error);
319 }
320
321 if let Some(files_error) = error
322 .chain()
323 .find_map(|cause| cause.downcast_ref::<FilesError>())
324 {
325 return match files_error {
326 // Same "the server is at fault" classification as `CoreError::ResourceLimitExceeded`
327 // above — the export this bounds is sized by what the (possibly hostile or
328 // misbehaving) introspected server returned, not by CLI-caller-supplied input.
329 FilesError::ResourceLimitExceeded { .. } => ExitCode::SERVER_ERROR,
330 FilesError::FileNotFound { .. }
331 | FilesError::NotADirectory { .. }
332 | FilesError::InvalidPath { .. }
333 | FilesError::PathNotAbsolute { .. }
334 | FilesError::InvalidPathComponent { .. }
335 | FilesError::PathEscapesBase { .. }
336 | FilesError::IoError { .. } => ExitCode::ERROR,
337 };
338 }
339
340 ExitCode::ERROR
341}
342
343/// Classifies a single [`CoreError`] variant.
344///
345/// [`CoreError::ScriptGenerationError`] wraps an arbitrary underlying failure (schema
346/// extraction, template rendering, output tracking) behind one variant so a codegen error can
347/// always be attributed to the tool that caused it; that wrapping must not also collapse the
348/// wrapped cause's own exit-code classification (e.g. a wrapped
349/// [`CoreError::ResourceLimitExceeded`] should still report [`ExitCode::SERVER_ERROR`], not the
350/// generic code every other `ScriptGenerationError` gets). Recursing into `source` when it
351/// downcasts to another `CoreError` preserves that.
352fn classify_core_error(core_error: &CoreError) -> ExitCode {
353 match core_error {
354 CoreError::Timeout { .. } => ExitCode::TIMEOUT,
355 // A resource limit is exceeded by data the remote MCP server returned (tool
356 // count, schema size, etc.), not by the CLI caller's own arguments — same
357 // "the server is at fault" classification as `ConnectionFailed`.
358 CoreError::ConnectionFailed { .. } | CoreError::ResourceLimitExceeded { .. } => {
359 ExitCode::SERVER_ERROR
360 }
361 CoreError::ValidationError { .. }
362 | CoreError::SecurityViolation { .. }
363 | CoreError::InvalidArgument(_) => ExitCode::INVALID_INPUT,
364 // A duplicate generated-file path indicates a codegen invariant was violated (e.g. a
365 // reserved output filename not seeded into name-collision resolution), not something
366 // caused by the remote server's data or the CLI caller's own arguments.
367 CoreError::SerializationError { .. } | CoreError::DuplicateGeneratedFilePath { .. } => {
368 ExitCode::ERROR
369 }
370 CoreError::ScriptGenerationError { source, .. } => source
371 .as_deref()
372 .and_then(|source| source.downcast_ref::<CoreError>())
373 .map_or(ExitCode::ERROR, classify_core_error),
374 }
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use mcp_execution_core::ResourceKind;
381 use mcp_execution_core::ServerId;
382 use mcp_execution_files::FilesResourceKind;
383
384 fn wrap(core_error: CoreError) -> anyhow::Error {
385 anyhow::Error::new(core_error)
386 }
387
388 #[test]
389 fn test_classify_exit_code_timeout() {
390 let err = wrap(CoreError::Timeout {
391 operation: "discover".to_string(),
392 duration_secs: 30,
393 });
394 assert_eq!(classify_exit_code(&err), ExitCode::TIMEOUT);
395 }
396
397 #[test]
398 fn test_classify_exit_code_connection_failed() {
399 let err = wrap(CoreError::ConnectionFailed {
400 server: "test".to_string(),
401 source: "refused".into(),
402 });
403 assert_eq!(classify_exit_code(&err), ExitCode::SERVER_ERROR);
404 }
405
406 #[test]
407 fn test_classify_exit_code_resource_limit_exceeded() {
408 let err = wrap(CoreError::ResourceLimitExceeded {
409 resource: ResourceKind::ToolCount {
410 server_id: ServerId::new("github").unwrap(),
411 },
412 actual: 1500,
413 limit: 1000,
414 });
415 assert_eq!(classify_exit_code(&err), ExitCode::SERVER_ERROR);
416 }
417
418 /// #198 M6 — `FilesError` (e.g. from `generate`'s `export_to_filesystem`, wrapped via
419 /// `anyhow::Context` rather than converted to `CoreError`) must be classified too, not
420 /// fall through to the generic `ExitCode::ERROR` unconditionally.
421 #[test]
422 fn test_classify_exit_code_files_error_resource_limit_exceeded() {
423 let files_error = FilesError::ResourceLimitExceeded {
424 resource: FilesResourceKind::ExportFileCount,
425 actual: 3000,
426 limit: 2000,
427 };
428 // Mirrors how `commands::generate::run` actually wraps this error.
429 let err: anyhow::Error =
430 anyhow::Error::new(files_error).context("failed to export files to filesystem");
431
432 assert_eq!(classify_exit_code(&err), ExitCode::SERVER_ERROR);
433 }
434
435 #[test]
436 fn test_classify_exit_code_files_error_other_variant_falls_back_to_error() {
437 let err = anyhow::Error::new(FilesError::FileNotFound {
438 path: "/missing".to_string(),
439 });
440 assert_eq!(classify_exit_code(&err), ExitCode::ERROR);
441 }
442
443 #[test]
444 fn test_classify_exit_code_validation_error() {
445 let err = wrap(CoreError::ValidationError {
446 field: "connect_timeout".to_string(),
447 reason: "must be greater than zero".to_string(),
448 });
449 assert_eq!(classify_exit_code(&err), ExitCode::INVALID_INPUT);
450 }
451
452 #[test]
453 fn test_classify_exit_code_security_violation() {
454 let err = wrap(CoreError::SecurityViolation {
455 reason: "forbidden env var".to_string(),
456 });
457 assert_eq!(classify_exit_code(&err), ExitCode::INVALID_INPUT);
458 }
459
460 #[test]
461 fn test_classify_exit_code_invalid_argument() {
462 let err = wrap(CoreError::InvalidArgument("bad flag".to_string()));
463 assert_eq!(classify_exit_code(&err), ExitCode::INVALID_INPUT);
464 }
465
466 #[test]
467 fn test_classify_exit_code_other_core_errors_fall_back_to_error() {
468 let err = wrap(CoreError::SerializationError {
469 message: "bad json".to_string(),
470 source: None,
471 });
472 assert_eq!(classify_exit_code(&err), ExitCode::ERROR);
473
474 let err = wrap(CoreError::ScriptGenerationError {
475 tool: "example_tool".to_string(),
476 message: "template rendering failed".to_string(),
477 source: None,
478 });
479 assert_eq!(classify_exit_code(&err), ExitCode::ERROR);
480 }
481
482 /// `ScriptGenerationError` wraps its cause via `source` (see
483 /// `ProgressiveGenerator::wrap_tool_generation_error`) precisely so this recursion can
484 /// still find the original classification instead of collapsing every wrapped cause to the
485 /// generic exit code.
486 #[test]
487 fn test_classify_exit_code_script_generation_error_recurses_into_wrapped_source() {
488 let err = wrap(CoreError::ScriptGenerationError {
489 tool: "example_tool".to_string(),
490 message: "failed to track generated tool file".to_string(),
491 source: Some(Box::new(CoreError::ResourceLimitExceeded {
492 resource: ResourceKind::GeneratedOutputSize,
493 actual: 10,
494 limit: 5,
495 })),
496 });
497 assert_eq!(classify_exit_code(&err), ExitCode::SERVER_ERROR);
498 }
499
500 #[test]
501 fn test_classify_exit_code_non_core_error_falls_back_to_error() {
502 let err = anyhow::anyhow!("plain CLI-layer failure");
503 assert_eq!(classify_exit_code(&err), ExitCode::ERROR);
504 }
505
506 #[test]
507 fn test_classify_exit_code_finds_core_error_through_context_chain() {
508 // The command handlers wrap `mcp_execution_core::Error` with
509 // `.with_context(...)` before it reaches `execute_command` — the
510 // classifier must find it through that wrapping, not just at the top.
511 let err = wrap(CoreError::Timeout {
512 operation: "connect".to_string(),
513 duration_secs: 5,
514 })
515 .context("failed to connect to server 'test' - ensure the server is installed");
516 assert_eq!(classify_exit_code(&err), ExitCode::TIMEOUT);
517 }
518
519 #[tokio::test]
520 async fn test_execute_command_converts_failure_into_classified_exit_code_not_err() {
521 // Regression test for #195: a failing command must surface as
522 // `Ok(non_success_exit_code)`, never as `Err`, so `main` can always
523 // reach `std::process::exit` with the classified code instead of
524 // falling back to anyhow's default exit-code-1 handling. Asserting
525 // the exact `SERVER_ERROR` value (not just `!is_success()`) so a
526 // regression to the generic `ExitCode::ERROR` fallback is caught.
527 //
528 // Built via real clap parsing (rather than a `Commands::Introspect`
529 // literal): `ServerFlags`'s fields are private outside `cli.rs` by
530 // design, so this is the only way an external module can produce one.
531 use clap::Parser as _;
532 let cli = crate::cli::Cli::parse_from([
533 "mcp-execution-cli",
534 "introspect",
535 "nonexistent-server-for-exit-code-test",
536 ]);
537 let result = execute_command(cli.command, OutputFormat::Json).await;
538
539 let exit_code = result.expect("execute_command must not propagate Err");
540 assert_eq!(exit_code, ExitCode::SERVER_ERROR);
541 }
542
543 #[test]
544 fn test_report_and_classify_prints_and_classifies() {
545 // Regression test for #195/S2: `main` routes pre-dispatch failures
546 // (e.g. an invalid `--format` value) through this same function, not
547 // just command-handler failures via `execute_command`.
548 let err = anyhow::Error::from(CoreError::InvalidArgument(
549 "invalid output format: 'xml' (expected: json, text, or pretty)".to_string(),
550 ));
551 assert_eq!(report_and_classify(&err), ExitCode::INVALID_INPUT);
552 }
553
554 #[test]
555 fn test_report_and_classify_escapes_control_chars_in_error_chain() {
556 // Regression test for #308: a malicious/compromised MCP server can embed raw ANSI/control
557 // escape sequences in a JSON-RPC error message, which end up in the `Display` string of a
558 // `CoreError::ConnectionFailed`'s wrapped `source` — a distinct link in `err.chain()`,
559 // since `ConnectionFailed`'s own `#[error(...)]` message never interpolates `{source}` —
560 // and, by extension, in the "Caused by:" section of `sanitized_error_report`'s output.
561 // `report_and_classify` must neutralize those bytes before printing to stderr rather than
562 // passing them through verbatim. Calls `sanitized_error_report` directly — the exact
563 // helper `report_and_classify` prints — rather than recomputing the same pipeline inline,
564 // so this can't silently drift from the real code path.
565 let source: Box<dyn std::error::Error + Send + Sync> =
566 "boom\u{1b}[2J\u{1b}]0;pwned\u{7}msg".into();
567 let err = anyhow::Error::from(CoreError::ConnectionFailed {
568 server: "evil-server".to_string(),
569 source,
570 });
571
572 let report = sanitized_error_report(&err);
573 assert!(!report.contains('\u{1b}'));
574 assert!(!report.contains('\u{7}'));
575 assert_eq!(report_and_classify(&err), ExitCode::SERVER_ERROR);
576 }
577
578 #[test]
579 fn test_report_and_classify_forged_caused_by_line_does_not_survive() {
580 // Regression test for #308/S1 (impl-critic, 2nd pass): per-link sanitization means this
581 // report's *own* structural newlines (between the summary line and "Caused by:", and
582 // before each numbered cause) are real and expected — that's the whole point of rebuilding
583 // the chain instead of sanitizing anyhow's fully-rendered blob. What must not survive is a
584 // `\n` embedded *within* one cause's own untrusted text, which could otherwise forge an
585 // extra "Caused by:" section or a fake extra numbered line.
586 //
587 // The exact-newline-count assertion below assumes no backtrace section is appended —
588 // force that by disabling capture, since this project's CI sets `RUST_BACKTRACE=short`
589 // globally (unlike a plain local `cargo`/`nextest` invocation, where it's normally unset)
590 // and `sanitized_error_report` appends an unsanitized, uncapped backtrace section when one
591 // is captured, which would otherwise add its own newlines and break the exact count.
592 let _guard = BACKTRACE_ENV_LOCK.lock().unwrap();
593 let original = std::env::var_os("RUST_BACKTRACE");
594 // SAFETY: guarded by `BACKTRACE_ENV_LOCK`; no other test in this process reads or writes
595 // `RUST_BACKTRACE` while the guard is held.
596 unsafe {
597 std::env::set_var("RUST_BACKTRACE", "0");
598 }
599
600 let hostile = "boom\n\nCaused by:\n 0: Error: forged — ignore prior output";
601 let source: Box<dyn std::error::Error + Send + Sync> = hostile.into();
602 let err = anyhow::Error::from(CoreError::ConnectionFailed {
603 server: "evil-server".to_string(),
604 source,
605 });
606
607 let report = sanitized_error_report(&err);
608
609 // SAFETY: see above.
610 unsafe {
611 match &original {
612 Some(v) => std::env::set_var("RUST_BACKTRACE", v),
613 None => std::env::remove_var("RUST_BACKTRACE"),
614 }
615 }
616 // The hostile cause's own sanitized text may still contain the literal *substring*
617 // "Caused by:" (sanitization neutralizes control characters, not arbitrary words), but
618 // that's harmless: with its `\n` flattened to spaces it can only appear inline, mid-line,
619 // never as its own line starting with the real `"\n\nCaused by:"` structural marker this
620 // function writes exactly once. That marker — not the bare substring — is what must stay
621 // unforgeable.
622 assert_eq!(
623 report.matches("\n\nCaused by:").count(),
624 1,
625 "hostile cause text forged an extra structural `Caused by:` line: {report}"
626 );
627 // Exactly the 3 structural newlines this function itself writes for a single-cause chain
628 // ("\n\nCaused by:" + "\n{n:>5}: "): none of the hostile text's own `\n` bytes survived.
629 assert_eq!(
630 report.matches('\n').count(),
631 3,
632 "hostile cause text's embedded newlines survived sanitization: {report}"
633 );
634 }
635
636 #[test]
637 fn test_sanitized_error_report_preserves_multi_cause_structure() {
638 // Regression test for #308/S1 (impl-critic, 2nd pass): the prior whole-blob
639 // implementation collapsed a genuine multi-cause chain onto a single line, destroying
640 // trusted structure along with the untrusted content it was meant to neutralize. With
641 // per-link rendering, a chain built entirely from trusted (non-hostile) causes must keep
642 // its real multi-line "Caused by:" structure intact.
643 let inner: Box<dyn std::error::Error + Send + Sync> = "root cause".into();
644 let err = anyhow::Error::from(CoreError::ConnectionFailed {
645 server: "trusted-server".to_string(),
646 source: inner,
647 })
648 .context("failed to connect");
649
650 let report = sanitized_error_report(&err);
651 assert!(report.starts_with("failed to connect"));
652 assert!(report.contains("\n\nCaused by:"));
653 assert!(report.contains(" 0: MCP server connection failed: trusted-server"));
654 assert!(report.contains(" 1: root cause"));
655 }
656
657 /// Serializes tests in this module that mutate `RUST_BACKTRACE`, mirroring the
658 /// `HOME_ENV_LOCK` pattern `commands::common`/`commands::server`'s tests already use for
659 /// env-var mutation: a safety net for plain `cargo test` (which shares one process across a
660 /// crate's tests), not required by the mandated `cargo nextest run` (which isolates every
661 /// test in its own process).
662 static BACKTRACE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
663
664 #[test]
665 fn test_sanitized_error_report_preserves_backtrace_when_captured() {
666 // Regression test for #308/S1 (impl-critic, 3rd pass): a backtrace anyhow captures under
667 // `RUST_BACKTRACE=1` is fully local, trusted content (source paths, function names from
668 // this binary's own stack) with nothing an external MCP server could have influenced, so
669 // it must survive `sanitized_error_report` untouched rather than being dropped or
670 // sanitized/truncated like a chain link. `RUST_BACKTRACE` must be set *before* the error
671 // is constructed — anyhow captures the backtrace (if any) at that point, not lazily at
672 // format time.
673 let _guard = BACKTRACE_ENV_LOCK.lock().unwrap();
674 let original = std::env::var_os("RUST_BACKTRACE");
675 // SAFETY: guarded by `BACKTRACE_ENV_LOCK`; no other test in this process reads or writes
676 // `RUST_BACKTRACE` while the guard is held.
677 unsafe {
678 std::env::set_var("RUST_BACKTRACE", "1");
679 }
680
681 let err = anyhow::Error::msg("boom");
682 let captured = err.backtrace().status() == std::backtrace::BacktraceStatus::Captured;
683
684 // SAFETY: see above.
685 unsafe {
686 match &original {
687 Some(v) => std::env::set_var("RUST_BACKTRACE", v),
688 None => std::env::remove_var("RUST_BACKTRACE"),
689 }
690 }
691
692 // Best-effort: some environments (e.g. certain sandboxes, targets without frame-pointer
693 // unwind info) leave backtrace capture `Disabled`/`Unsupported` even with the env var set
694 // — nothing this function controls, so only assert the positive case when it applies.
695 if captured {
696 let report = sanitized_error_report(&err);
697 assert!(
698 report.contains("tack backtrace:"),
699 "captured backtrace did not survive: {report}"
700 );
701 }
702 }
703
704 /// Leak B regression: `CoreError::ConnectionFailed`'s boxed `source` is an opaque
705 /// `Box<dyn Error + Send + Sync>` that, for an http/sse transport, is really `rmcp`'s wrapped
706 /// `reqwest::Error` — whose `Display` embeds the full request URL, query string included. This
707 /// simulates that exact shape (the security audit's captured `rmcp` output) without a live
708 /// network connection, and asserts the secret never reaches the printed report.
709 #[test]
710 fn test_sanitized_error_report_redacts_connection_failed_source_url_secret() {
711 let source: Box<dyn std::error::Error + Send + Sync> = concat!(
712 "Client error: error sending request for url ",
713 "(http://127.0.0.1:1/mcp?token=REFUSEDSECRET), when send initialize request"
714 )
715 .into();
716 let err = wrap(CoreError::ConnectionFailed {
717 server: "test".to_string(),
718 source,
719 });
720
721 let report = sanitized_error_report(&err);
722 assert!(!report.contains("REFUSEDSECRET"), "secret leaked: {report}");
723 assert!(report.contains("http://127.0.0.1:1/mcp?<redacted>"));
724 assert!(report.contains("MCP server connection failed: test"));
725 }
726
727 /// C2 regression at this leak's real entry point: an IPv6-literal authority must not defeat
728 /// redaction here either. Mirrors the critic's live repro
729 /// (`introspect --http "http://[::1]:1/mcp?token=..."`), which printed the secret in this
730 /// exact report on the unfixed version.
731 #[test]
732 fn test_sanitized_error_report_redacts_connection_failed_source_ipv6_url_secret() {
733 let source: Box<dyn std::error::Error + Send + Sync> = concat!(
734 "Client error: error sending request for url ",
735 "(http://[::1]:1/mcp?token=IPV6LEAKTEST), when send initialize request"
736 )
737 .into();
738 let err = wrap(CoreError::ConnectionFailed {
739 server: "test".to_string(),
740 source,
741 });
742
743 let report = sanitized_error_report(&err);
744 assert!(!report.contains("IPV6LEAKTEST"), "secret leaked: {report}");
745 assert!(report.contains("http://[::1]:1/mcp?<redacted>"));
746 }
747
748 #[test]
749 fn test_redacting_writer_redacts_url_secret_before_forwarding() {
750 let mut sink = Vec::new();
751 {
752 let mut writer = RedactingWriter(&mut sink);
753 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";
754 let n = writer.write(line.as_bytes()).unwrap();
755 assert_eq!(n, line.len());
756 }
757 let written = String::from_utf8(sink).unwrap();
758 assert!(!written.contains("hunter2secret"));
759 assert!(written.contains("https://api.example.invalid/mcp?<redacted>"));
760 assert!(written.contains("worker quit with fatal"));
761 }
762
763 #[test]
764 fn test_redacting_writer_passes_through_text_without_urls() {
765 let mut sink = Vec::new();
766 RedactingWriter(&mut sink)
767 .write_all(b"INFO some ordinary log line\n")
768 .unwrap();
769 assert_eq!(sink, b"INFO some ordinary log line\n");
770 }
771
772 /// Pins the assumption `RedactingWriter`'s doc comment relies on but the two tests above
773 /// don't exercise: that `tracing-subscriber`'s fmt layer issues exactly one `write_all` per
774 /// event, so `RedactingWriter::write` always sees a whole formatted line. Wires the real
775 /// `fmt::layer()` (not a direct `RedactingWriter::write` call) through a scoped subscriber
776 /// into a shared buffer, so a future `tracing-subscriber` upgrade that splits an event across
777 /// multiple writes -- which would let a URL straddling the split leak unredacted -- fails this
778 /// test instead of failing silently in production.
779 #[test]
780 fn test_redacting_writer_wired_into_real_fmt_layer_redacts_full_event() {
781 use std::sync::{Arc, Mutex};
782
783 #[derive(Clone)]
784 struct SharedBuf(Arc<Mutex<Vec<u8>>>);
785
786 impl Write for SharedBuf {
787 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
788 self.0.lock().unwrap().write(buf)
789 }
790
791 fn flush(&mut self) -> io::Result<()> {
792 self.0.lock().unwrap().flush()
793 }
794 }
795
796 let buf = Arc::new(Mutex::new(Vec::new()));
797 let make_writer = {
798 let buf = buf.clone();
799 move || RedactingWriter(SharedBuf(buf.clone()))
800 };
801
802 let subscriber = tracing_subscriber::registry().with(
803 tracing_subscriber::fmt::layer()
804 .with_writer(make_writer)
805 .with_ansi(false),
806 );
807
808 tracing::subscriber::with_default(subscriber, || {
809 tracing::error!(
810 "error sending request for url (https://api.example.invalid/mcp?token=hunter2secret), when send initialize request"
811 );
812 });
813
814 let written = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
815 assert!(
816 !written.contains("hunter2secret"),
817 "secret leaked: {written}"
818 );
819 assert!(written.contains("https://api.example.invalid/mcp?<redacted>"));
820 assert!(written.contains("when send initialize request"));
821 }
822}