Skip to main content

mcp_execution_cli/
formatters.rs

1//! Output formatters for CLI commands.
2//!
3//! Provides consistent formatting across all CLI commands for JSON, text, and pretty output modes.
4
5use anyhow::Result;
6use colored::Colorize;
7use mcp_execution_core::cli::{ExitCode, OutputFormat};
8use serde::Serialize;
9
10/// Format data according to the specified output format.
11///
12/// # Arguments
13///
14/// * `data` - The data to format (must be serializable)
15/// * `format` - The output format (Json, Text, Pretty)
16///
17/// # Errors
18///
19/// Returns an error if JSON serialization fails.
20///
21/// # Examples
22///
23/// ```
24/// use mcp_execution_cli::formatters::format_output;
25/// use mcp_execution_core::cli::OutputFormat;
26/// use serde::Serialize;
27///
28/// #[derive(Serialize)]
29/// struct ServerInfo {
30///     name: String,
31///     version: String,
32/// }
33///
34/// let info = ServerInfo {
35///     name: "test-server".to_string(),
36///     version: "1.0.0".to_string(),
37/// };
38///
39/// let output = format_output(&info, OutputFormat::Json)?;
40/// assert!(output.contains("\"name\""));
41/// # Ok::<(), anyhow::Error>(())
42/// ```
43pub fn format_output<T: Serialize>(data: &T, format: OutputFormat) -> Result<String> {
44    match format {
45        OutputFormat::Json => json::format(data),
46        OutputFormat::Text => text::format(data),
47        OutputFormat::Pretty => pretty::format(data),
48    }
49}
50
51/// Formats `data` per `format`, prints it to stdout, and returns `exit_code`.
52///
53/// Collapses the format/print/return-exit-code sequence repeated across every CLI command
54/// handler's branches into a single call site, so each branch only has to state which exit code
55/// it wants.
56///
57/// # Errors
58///
59/// Returns an error if formatting `data` fails (see [`format_output`]).
60///
61/// # Examples
62///
63/// ```
64/// use mcp_execution_cli::formatters::emit;
65/// use mcp_execution_core::cli::{ExitCode, OutputFormat};
66/// use serde::Serialize;
67///
68/// #[derive(Serialize)]
69/// struct Data {
70///     value: i32,
71/// }
72///
73/// let code = emit(&Data { value: 42 }, OutputFormat::Json, ExitCode::SUCCESS)?;
74/// assert_eq!(code, ExitCode::SUCCESS);
75/// # Ok::<(), anyhow::Error>(())
76/// ```
77pub fn emit<T: Serialize>(data: &T, format: OutputFormat, exit_code: ExitCode) -> Result<ExitCode> {
78    let formatted = format_output(data, format)?;
79    println!("{formatted}");
80    Ok(exit_code)
81}
82
83/// Escapes a string for safe interpolation into hand-crafted `Text`/`Pretty` output lines.
84///
85/// Commands that render whole structs through [`format_output`] get escaping for free, since
86/// every string value is serialized through `serde_json` before printing. Commands that instead
87/// build freeform lines (e.g. `"Server: {name} ({id})"`) must escape server-supplied strings
88/// themselves, or a malicious MCP server could inject raw ANSI/control escape sequences into the
89/// user's terminal via handshake or tool metadata fields. `pretty`'s internal value formatter
90/// delegates to this same function for its `String` values, so control characters (including
91/// ESC) are backslash-escaped instead of passed through verbatim, and both call sites share one
92/// implementation. The returned string is always JSON-quoted, even for input with no control
93/// characters, since callers need one consistent (and unambiguous) rendering rather than
94/// conditionally-quoted output.
95///
96/// # Examples
97///
98/// ```
99/// use mcp_execution_cli::formatters::escape_display;
100///
101/// assert_eq!(escape_display("hello"), "\"hello\"");
102/// assert_eq!(escape_display("esc\u{1b}[2J"), "\"esc\\u001b[2J\"");
103/// ```
104#[must_use]
105pub fn escape_display(s: &str) -> String {
106    // `Value::String`'s `Display` impl serializes through the same JSON string writer as
107    // `serde_json::to_string`, but is infallible (no `Result` to unwrap): formatting a `String`
108    // as a JSON string literal cannot fail.
109    serde_json::Value::String(s.to_owned()).to_string()
110}
111
112/// Cap, in `char`s, on a single value sanitized by [`escape_error_text`] — e.g. one
113/// `err.chain()` link's rendered text, or one `warn!` log argument, never a whole assembled
114/// multi-part report.
115///
116/// 4000 is generous for any one link/argument this crate actually passes through
117/// [`escape_error_text`] (`runner::sanitized_error_report`'s own chain-link text runs well under
118/// this in practice), while still bounding how much a single hostile MCP server response can
119/// force onto the terminal or into a log line. Does not bound a report's total length when it has
120/// several causes (each is capped independently) or a backtrace (never passed through
121/// [`escape_error_text`] at all — see `runner::sanitized_error_report`'s doc comment).
122const MAX_ERROR_TEXT_LEN: usize = 4000;
123
124/// Makes `s` safe to print to a terminal or log sink that does not itself escape untrusted content.
125///
126/// Neutralizes control characters (including line breaks), redacts any embedded URL's
127/// credentials/query string, and bounds the result to 4000 `char`s.
128///
129/// Redaction runs *before* truncation deliberately — truncating first could cut a redacted URL's
130/// marker off and leave a bare secret prefix as the last thing printed.
131///
132/// Delegates to two single-source-of-truth helpers rather than maintaining parallel logic here:
133/// [`mcp_execution_core::redact_urls_in_text`] finds and redacts every `scheme://…` token in `s`
134/// (a `reqwest`/`rmcp` transport error's `Display` routinely embeds the full request URL,
135/// including a `?token=…`-style query string, inline in prose — see `runner::sanitized_error_report`,
136/// whose whole reason for calling this function per-cause is to catch exactly that), and
137/// [`mcp_execution_core::untrusted::sanitize_untrusted_text`] then neutralizes every character
138/// `char::is_control` reports (the full C0 and C1 ranges, covering `\r`, `\n`, ESC, BEL, and
139/// friends) plus the Markdown/ECMAScript line separators U+2028/U+2029, replacing each with a
140/// space, and caps the result to 4000 `char`s (`MAX_ERROR_TEXT_LEN`, not itself public — its value
141/// is documented here since a reader of this function's public docs cannot otherwise resolve it).
142/// Used to sanitize command errors and log messages that may embed content from an untrusted MCP
143/// server, or a URL the user themselves supplied with a secret in its query string, before they
144/// reach the terminal.
145///
146/// `s` is treated as a single unit of untrusted text with no internal structure worth preserving
147/// — including any newline it contains, which this collapses like every other control character.
148/// This is why the name is `escape_error_text`, not e.g. `escape_report`: this function must
149/// never be called on an already-assembled multi-cause report (that would flatten anyhow's own
150/// trusted `Caused by:` structure along with the untrusted content it carries — see
151/// `runner::sanitized_error_report`'s doc comment for how that structure is instead rebuilt by
152/// calling this function once per cause and rejoining with separators the caller controls, rather
153/// than sanitizing the whole rendered report as one blob). Only ever call this on one piece of
154/// untrusted text at a time.
155///
156/// Sanitized here, at each `mcp-execution-cli` print/log call site — `runner::report_and_classify`
157/// (indirectly, via `sanitized_error_report`) and the `warn!` logging in `commands::server` —
158/// rather than where a server-supplied message first enters a [`mcp_execution_core::Error`] (e.g.
159/// `ConnectionFailed`'s boxed `source`) in
160/// `mcp-execution-core`/`mcp-execution-introspector`. `source` there is a generic `Box<dyn
161/// std::error::Error + Send + Sync>`, not specifically MCP-server text, and `mcp_execution_core::Error`
162/// is consumed by every crate in this workspace (this one, `mcp-execution-server`, and any future
163/// one), not just terminal/log output; sanitizing at that shared boundary would force one
164/// escaping policy — lossy, space-collapsing, terminal-oriented — onto every consumer, including
165/// ones that legitimately want the raw text (e.g. `mcp-execution-server`'s own untrusted-metadata
166/// handling in `mcp_execution_core::untrusted`, which has different escaping needs for an
167/// LLM-facing prompt than this crate has for a terminal). Scoping the fix to where untrusted text
168/// actually reaches a terminal/log sink — while still delegating the escaping logic itself to the
169/// one authoritative sanitizer — keeps the policy decision local to the output boundary that
170/// needs it, without widening this bug-fix change beyond `mcp-execution-cli`.
171///
172/// # Examples
173///
174/// ```
175/// use mcp_execution_cli::formatters::escape_error_text;
176///
177/// let cause = "boom\u{1b}[2Jname\nfake extra line";
178/// let escaped = escape_error_text(cause);
179/// assert!(!escaped.contains('\u{1b}'));
180/// assert!(!escaped.contains('\n'));
181/// assert!(escaped.contains("boom"));
182/// ```
183///
184/// A URL embedded in the text has its credentials/query string redacted too:
185///
186/// ```
187/// use mcp_execution_cli::formatters::escape_error_text;
188///
189/// let cause = "error sending request for url (https://api.example.com/mcp?token=hunter2secret)";
190/// let escaped = escape_error_text(cause);
191/// assert!(!escaped.contains("hunter2secret"));
192/// assert!(escaped.contains("https://api.example.com/mcp?<redacted>"));
193/// ```
194#[must_use]
195pub fn escape_error_text(s: &str) -> String {
196    let redacted = mcp_execution_core::redact_urls_in_text(s);
197    mcp_execution_core::untrusted::sanitize_untrusted_text(&redacted, MAX_ERROR_TEXT_LEN)
198}
199
200/// JSON output formatting.
201pub mod json {
202    use super::{Result, Serialize};
203
204    /// Format data as JSON.
205    ///
206    /// Uses pretty-printing with 2-space indentation.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if JSON serialization fails (e.g., if the data
211    /// contains non-serializable types or custom serialization fails).
212    ///
213    /// # Examples
214    ///
215    /// ```
216    /// use serde::Serialize;
217    /// use mcp_execution_cli::formatters::json;
218    ///
219    /// #[derive(Serialize)]
220    /// struct Data { value: i32 }
221    ///
222    /// let data = Data { value: 42 };
223    /// let json = json::format(&data).unwrap();
224    /// assert!(json.contains("42"));
225    /// ```
226    pub fn format<T: Serialize>(data: &T) -> Result<String> {
227        let json = serde_json::to_string_pretty(data)?;
228        Ok(json)
229    }
230
231    /// Format data as compact JSON (no formatting).
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if JSON serialization fails (e.g., if the data
236    /// contains non-serializable types or custom serialization fails).
237    ///
238    /// # Examples
239    ///
240    /// ```
241    /// use serde::Serialize;
242    /// use mcp_execution_cli::formatters::json;
243    ///
244    /// #[derive(Serialize)]
245    /// struct Data { value: i32 }
246    ///
247    /// let data = Data { value: 42 };
248    /// let json = json::format_compact(&data).unwrap();
249    /// assert!(!json.contains('\n'));
250    /// ```
251    pub fn format_compact<T: Serialize>(data: &T) -> Result<String> {
252        let json = serde_json::to_string(data)?;
253        Ok(json)
254    }
255}
256
257/// Plain text output formatting.
258pub mod text {
259    use super::{Result, Serialize, json};
260
261    /// Format data as plain text.
262    ///
263    /// Uses JSON representation but without colors or fancy formatting.
264    /// Suitable for piping to other commands or scripts.
265    ///
266    /// # Errors
267    ///
268    /// Returns an error if JSON serialization fails (propagated from the
269    /// underlying `json::format_compact` call).
270    ///
271    /// # Examples
272    ///
273    /// ```
274    /// use serde::Serialize;
275    /// use mcp_execution_cli::formatters::text;
276    ///
277    /// #[derive(Serialize)]
278    /// struct Data { value: i32 }
279    ///
280    /// let data = Data { value: 42 };
281    /// let text = text::format(&data).unwrap();
282    /// assert!(text.contains("42"));
283    /// ```
284    pub fn format<T: Serialize>(data: &T) -> Result<String> {
285        // For text mode, use JSON without pretty printing
286        json::format_compact(data)
287    }
288}
289
290/// Pretty (human-readable) output formatting.
291pub mod pretty {
292    use super::{Colorize, Result, Serialize, escape_display};
293
294    /// Format data as colorized, human-readable output.
295    ///
296    /// Uses colors and formatting for better terminal readability.
297    ///
298    /// # Errors
299    ///
300    /// Returns an error if JSON serialization fails (e.g., if the data
301    /// contains non-serializable types). Value formatting itself cannot fail.
302    ///
303    /// # Examples
304    ///
305    /// ```
306    /// use serde::Serialize;
307    /// use mcp_execution_cli::formatters::pretty;
308    ///
309    /// #[derive(Serialize)]
310    /// struct Data { value: i32 }
311    ///
312    /// let data = Data { value: 42 };
313    /// let output = pretty::format(&data).unwrap();
314    /// assert!(output.contains("42"));
315    /// ```
316    pub fn format<T: Serialize>(data: &T) -> Result<String> {
317        // Convert to JSON value first for inspection
318        let value = serde_json::to_value(data)?;
319
320        // Format with colors
321        format_value(&value, 0)
322    }
323
324    /// Recursively format a JSON value with colors and indentation.
325    fn format_value(value: &serde_json::Value, indent: usize) -> Result<String> {
326        use serde_json::Value;
327
328        let indent_str = "  ".repeat(indent);
329        let next_indent_str = "  ".repeat(indent + 1);
330
331        match value {
332            Value::Null => Ok("null".dimmed().to_string()),
333            Value::Bool(b) => Ok(b.to_string().yellow().to_string()),
334            Value::Number(n) => Ok(n.to_string().cyan().to_string()),
335            Value::String(s) => Ok(escape_display(s).green().to_string()),
336            Value::Array(arr) => {
337                if arr.is_empty() {
338                    return Ok("[]".to_string());
339                }
340
341                let mut result = "[\n".to_string();
342                for (i, item) in arr.iter().enumerate() {
343                    result.push_str(&next_indent_str);
344                    result.push_str(&format_value(item, indent + 1)?);
345                    if i < arr.len() - 1 {
346                        result.push(',');
347                    }
348                    result.push('\n');
349                }
350                result.push_str(&indent_str);
351                result.push(']');
352                Ok(result)
353            }
354            Value::Object(obj) => {
355                if obj.is_empty() {
356                    return Ok("{}".to_string());
357                }
358
359                let mut result = "{\n".to_string();
360                let entries: Vec<_> = obj.iter().collect();
361                for (i, (key, val)) in entries.iter().enumerate() {
362                    result.push_str(&next_indent_str);
363                    let quoted_key = serde_json::to_string(key)?;
364                    result.push_str(&quoted_key.blue().bold().to_string());
365                    result.push_str(": ");
366                    result.push_str(&format_value(val, indent + 1)?);
367                    if i < entries.len() - 1 {
368                        result.push(',');
369                    }
370                    result.push('\n');
371                }
372                result.push_str(&indent_str);
373                result.push('}');
374                Ok(result)
375            }
376        }
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use serde::Serialize;
384
385    #[derive(Serialize)]
386    struct TestData {
387        name: String,
388        count: i32,
389        enabled: bool,
390    }
391
392    #[test]
393    fn test_json_format() {
394        let data = TestData {
395            name: "test".to_string(),
396            count: 42,
397            enabled: true,
398        };
399
400        let output = json::format(&data).unwrap();
401        assert!(output.contains("\"name\""));
402        assert!(output.contains("\"test\""));
403        assert!(output.contains("\"count\""));
404        assert!(output.contains("42"));
405        assert!(output.contains("\"enabled\""));
406        assert!(output.contains("true"));
407    }
408
409    #[test]
410    fn test_json_format_compact() {
411        let data = TestData {
412            name: "test".to_string(),
413            count: 42,
414            enabled: true,
415        };
416
417        let output = json::format_compact(&data).unwrap();
418        // Compact format should not have newlines
419        assert!(!output.contains('\n'));
420        assert!(output.contains("\"name\":\"test\""));
421    }
422
423    #[test]
424    fn test_text_format() {
425        let data = TestData {
426            name: "test".to_string(),
427            count: 42,
428            enabled: true,
429        };
430
431        let output = text::format(&data).unwrap();
432        // Text format uses compact JSON
433        assert!(!output.contains('\n'));
434        assert!(output.contains("\"name\":\"test\""));
435    }
436
437    #[test]
438    fn test_pretty_format() {
439        let data = TestData {
440            name: "test".to_string(),
441            count: 42,
442            enabled: true,
443        };
444
445        let output = pretty::format(&data).unwrap();
446        // Pretty format should have structure
447        assert!(output.contains("name"));
448        assert!(output.contains("test"));
449        assert!(output.contains("count"));
450        assert!(output.contains("42"));
451    }
452
453    #[test]
454    fn test_format_output_json() {
455        let data = TestData {
456            name: "test".to_string(),
457            count: 42,
458            enabled: true,
459        };
460
461        let output = format_output(&data, OutputFormat::Json).unwrap();
462        assert!(output.contains("\"name\""));
463    }
464
465    #[test]
466    fn test_format_output_text() {
467        let data = TestData {
468            name: "test".to_string(),
469            count: 42,
470            enabled: true,
471        };
472
473        let output = format_output(&data, OutputFormat::Text).unwrap();
474        assert!(output.contains("\"name\""));
475    }
476
477    #[test]
478    fn test_pretty_format_escapes_quotes_and_newlines() {
479        // Regression test: strings containing embedded quotes, backslashes,
480        // or newlines must round-trip through valid JSON once ANSI color
481        // codes are stripped, not just be wrapped in literal quotes.
482        #[derive(Serialize)]
483        struct Message {
484            text: String,
485        }
486
487        let data = Message {
488            text: "line one\nline \"two\" with \\backslash\\".to_string(),
489        };
490
491        let output = pretty::format(&data).unwrap();
492        let stripped = strip_ansi(&output);
493
494        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
495        assert_eq!(parsed["text"], "line one\nline \"two\" with \\backslash\\");
496    }
497
498    #[test]
499    fn test_pretty_format_escapes_object_keys() {
500        // Regression test: object keys containing embedded quotes, backslashes,
501        // or newlines must also be escaped, not just values (the schema-derived
502        // property names rendered by `introspect --detailed` are attacker-controlled
503        // by the remote MCP server).
504        let mut data = std::collections::BTreeMap::new();
505        data.insert("line one\nline \"two\" with \\backslash\\".to_string(), 1);
506
507        let output = pretty::format(&data).unwrap();
508        let stripped = strip_ansi(&output);
509
510        let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
511        assert_eq!(
512            parsed["line one\nline \"two\" with \\backslash\\"],
513            serde_json::json!(1)
514        );
515    }
516
517    /// Strips ANSI color escape sequences emitted by the `colored` crate.
518    fn strip_ansi(s: &str) -> String {
519        let mut result = String::with_capacity(s.len());
520        let mut chars = s.chars();
521        while let Some(c) = chars.next() {
522            if c == '\u{1b}' {
523                for c in chars.by_ref() {
524                    if c == 'm' {
525                        break;
526                    }
527                }
528            } else {
529                result.push(c);
530            }
531        }
532        result
533    }
534
535    #[test]
536    fn test_escape_display_neutralizes_control_chars() {
537        let escaped = escape_display("evil\u{1b}[2Jname");
538        assert!(!escaped.contains('\u{1b}'));
539        assert!(escaped.contains("\\u001b"));
540    }
541
542    #[test]
543    fn test_escape_display_plain_string() {
544        assert_eq!(escape_display("hello"), "\"hello\"");
545    }
546
547    #[test]
548    fn test_escape_error_text_neutralizes_control_chars() {
549        let cause = "boom\u{1b}[2Jname, connection refused";
550        let escaped = escape_error_text(cause);
551        assert!(!escaped.contains('\u{1b}'));
552        assert!(escaped.contains("connection refused"));
553    }
554
555    #[test]
556    fn test_escape_error_text_plain_text_unaffected() {
557        let cause = "plain error, no control characters at all";
558        assert_eq!(escape_error_text(cause), cause);
559    }
560
561    /// Regression test for #308/S1 (impl-critic): a single cause's own text embedding a raw
562    /// newline must not survive as a real line break — the reason callers must never call this on
563    /// an already-assembled multi-cause report (see this function's doc comment), only on one
564    /// cause's text at a time, then rejoin with separators the caller controls (see
565    /// `runner::sanitized_error_report`).
566    #[test]
567    fn test_escape_error_text_newlines_do_not_survive() {
568        let hostile_cause = "boom\n\nCaused by:\n    0: Error: forged System component compromised";
569        let escaped = escape_error_text(hostile_cause);
570        assert!(!escaped.contains('\n'), "raw newline survived: {escaped}");
571    }
572
573    /// Regression test for #308/M3 (impl-critic): a lone `\r` (not part of a `\r\n` pair) must
574    /// also be neutralized — `str::lines()`-based splitting handled this inconsistently, which
575    /// is exactly why this delegates to `sanitize_untrusted_text`'s uniform char-by-char pass
576    /// instead.
577    #[test]
578    fn test_escape_error_text_lone_carriage_return_neutralized() {
579        let hostile = "before\rafter";
580        let escaped = escape_error_text(hostile);
581        assert!(!escaped.contains('\r'));
582    }
583
584    /// Leak B regression (see the security audit behind this fix): a
585    /// `reqwest`/`rmcp` transport error's `Display` text embeds the full
586    /// request URL, query string included, inline in prose. This must be
587    /// redacted, not merely control-char-escaped.
588    #[test]
589    fn test_escape_error_text_redacts_embedded_url_secret() {
590        let cause = "Client error: error sending request for url (http://127.0.0.1:1/mcp?token=REFUSEDSECRET), when send initialize request";
591        let escaped = escape_error_text(cause);
592        assert!(!escaped.contains("REFUSEDSECRET"));
593        assert!(escaped.contains("http://127.0.0.1:1/mcp?<redacted>"));
594        assert!(escaped.contains("when send initialize request"));
595    }
596
597    /// Redaction must run on the full text before the length cap is applied, so a secret
598    /// straddling the truncation boundary is still fully redacted rather than surviving as a
599    /// chopped-off prefix. `secret` is positioned so the `MAX_ERROR_TEXT_LEN`-char cut lands 5
600    /// characters into it: a truncate-first implementation would keep exactly `secret[..5]` (a
601    /// real prefix of the secret, not an unrelated substring) in its output.
602    #[test]
603    fn test_escape_error_text_redacts_secret_straddling_truncation_boundary() {
604        let secret = "verysecretvalue";
605        let url_prefix = "https://host.example.com/p?token=";
606        let chars_before_secret = MAX_ERROR_TEXT_LEN - 5;
607        let padding_len = chars_before_secret - 1 - url_prefix.chars().count();
608        let padding = "x".repeat(padding_len);
609        let cause = format!("{padding} {url_prefix}{secret}");
610        assert_eq!(
611            cause.chars().count(),
612            MAX_ERROR_TEXT_LEN - 5 + secret.chars().count()
613        );
614
615        let escaped = escape_error_text(&cause);
616        assert!(!escaped.contains(secret));
617        assert!(!escaped.contains(&secret[..5]));
618    }
619
620    #[test]
621    fn test_escape_error_text_caps_length() {
622        let long = "a".repeat(MAX_ERROR_TEXT_LEN + 500);
623        assert_eq!(escape_error_text(&long).chars().count(), MAX_ERROR_TEXT_LEN);
624    }
625
626    /// Pins the "4000" literal `escape_error_text`'s (necessarily public-facing, since the
627    /// constant itself is private) doc comment states inline — a drift-detector, not a design
628    /// constraint.
629    #[test]
630    fn test_max_error_text_len_matches_documented_value() {
631        assert_eq!(MAX_ERROR_TEXT_LEN, 4000);
632    }
633
634    #[test]
635    fn test_format_output_pretty() {
636        let data = TestData {
637            name: "test".to_string(),
638            count: 42,
639            enabled: true,
640        };
641
642        let output = format_output(&data, OutputFormat::Pretty).unwrap();
643        assert!(output.contains("name"));
644    }
645}