Skip to main content

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}