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