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