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("ed_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}