Skip to main content

fastmcp_console/
diagnostics.rs

1//! Error/warning formatting
2
3use crate::console::FastMcpConsole;
4use crate::theme::FastMcpTheme;
5use fastmcp_core::{McpError, McpErrorCode};
6use rich_rust::prelude::*;
7
8/// Renders errors in a beautiful, informative format
9pub struct RichErrorRenderer {
10    show_suggestions: bool,
11    show_backtrace: bool,
12    show_error_code: bool,
13}
14
15impl Default for RichErrorRenderer {
16    fn default() -> Self {
17        Self {
18            show_suggestions: true,
19            show_backtrace: std::env::var("RUST_BACKTRACE").is_ok(),
20            show_error_code: true,
21        }
22    }
23}
24
25impl RichErrorRenderer {
26    pub fn new() -> Self {
27        Self::default()
28    }
29
30    /// Render an error with full context
31    pub fn render(&self, error: &McpError, console: &FastMcpConsole) {
32        if !console.is_rich() {
33            self.render_plain(error, console);
34            return;
35        }
36
37        let theme = console.theme();
38
39        // Error header
40        let category = self.categorize_error(error);
41        self.render_header(category, theme, console);
42
43        // Main error panel
44        self.render_error_panel(error, theme, console);
45
46        // Suggestions
47        if self.show_suggestions {
48            if let Some(suggestions) = self.get_suggestions(error) {
49                self.render_suggestions(&suggestions, theme, console);
50            }
51        }
52
53        // Context/backtrace
54        if self.show_backtrace {
55            // Fallback: reuse render_panic when McpError doesn't carry a backtrace.
56            self.render_panic(&error.message, None, console);
57        }
58    }
59
60    fn categorize_error(&self, error: &McpError) -> ErrorCategory {
61        match error.code {
62            McpErrorCode::ParseError => ErrorCategory::Protocol,
63            McpErrorCode::InvalidRequest => ErrorCategory::Protocol,
64            McpErrorCode::MethodNotFound => ErrorCategory::Protocol,
65            McpErrorCode::InvalidParams => ErrorCategory::Protocol,
66            McpErrorCode::InternalError => ErrorCategory::Internal,
67            McpErrorCode::ToolExecutionError => ErrorCategory::Handler,
68            McpErrorCode::ResourceNotFound => ErrorCategory::Handler,
69            McpErrorCode::ResourceForbidden => ErrorCategory::Handler,
70            McpErrorCode::PromptNotFound => ErrorCategory::Handler,
71            McpErrorCode::RequestCancelled => ErrorCategory::Cancelled,
72            McpErrorCode::Custom(_) => ErrorCategory::Unknown,
73        }
74    }
75
76    fn render_header(
77        &self,
78        category: ErrorCategory,
79        theme: &FastMcpTheme,
80        console: &FastMcpConsole,
81    ) {
82        let (icon, label, style) = match category {
83            ErrorCategory::Connection => ("🔌", "Connection Error", theme.error_style.clone()),
84            ErrorCategory::Protocol => ("📋", "Protocol Error", theme.error_style.clone()),
85            ErrorCategory::Handler => ("⚙️", "Handler Error", theme.warning_style.clone()),
86            ErrorCategory::Timeout => ("⏱️", "Timeout", theme.warning_style.clone()),
87            ErrorCategory::Cancelled => ("✋", "Cancelled", theme.info_style.clone()),
88            ErrorCategory::Internal => ("💥", "Internal Error", theme.error_style.clone()),
89            ErrorCategory::Unknown => ("❌", "Error", theme.error_style.clone()),
90        };
91
92        // Use Text::from to convert format! string to Text
93        let rule = Rule::with_title(Text::from(format!("{} {}", icon, label))).style(style);
94        console.render(&rule);
95    }
96
97    fn render_error_panel(&self, error: &McpError, theme: &FastMcpTheme, console: &FastMcpConsole) {
98        let message = &error.message;
99        let code = i32::from(error.code);
100
101        let content = if self.show_error_code {
102            format!("[bold]{}[/]\n\n{}", code, message)
103        } else {
104            message.clone()
105        };
106
107        // Add data context if present
108        let content = if let Some(data) = &error.data {
109            if let Ok(pretty) = serde_json::to_string_pretty(data) {
110                format!("{}\n\n[dim]Context:[/]\n{}", content, pretty)
111            } else {
112                content
113            }
114        } else {
115            content
116        };
117
118        let panel = Panel::from_text(&content)
119            .style(theme.border_style.clone()) // Use border style for panel
120            .padding(1);
121
122        console.render(&panel);
123    }
124
125    fn render_suggestions(
126        &self,
127        suggestions: &[String],
128        _theme: &FastMcpTheme,
129        console: &FastMcpConsole,
130    ) {
131        console.print("\n[bold cyan]💡 Suggestions:[/]");
132        for (i, suggestion) in suggestions.iter().enumerate() {
133            console.print(&format!("  [dim]{}.[/] {}", i + 1, suggestion));
134        }
135    }
136
137    fn get_suggestions(&self, error: &McpError) -> Option<Vec<String>> {
138        match error.code {
139            McpErrorCode::MethodNotFound => Some(vec![
140                "Verify the method name is correct".to_string(),
141                "Check that the handler is registered".to_string(),
142                "Run with RUST_LOG=debug for more details".to_string(),
143            ]),
144            McpErrorCode::ParseError => Some(vec![
145                "Validate the JSON structure".to_string(),
146                "Ensure text encoding is UTF-8".to_string(),
147            ]),
148            McpErrorCode::ResourceNotFound => Some(vec![
149                "Verify the resource URI".to_string(),
150                "Check if the resource provider is active".to_string(),
151            ]),
152            _ => None,
153        }
154    }
155
156    fn render_plain(&self, error: &McpError, console: &FastMcpConsole) {
157        console.print_plain(&format!(
158            "ERROR [{}]: {}",
159            i32::from(error.code),
160            error.message
161        ));
162        if let Some(data) = &error.data {
163            console.print_plain(&format!("Context: {:?}", data));
164        }
165    }
166
167    pub fn render_panic(&self, message: &str, backtrace: Option<&str>, console: &FastMcpConsole) {
168        let theme = console.theme();
169        if !console.is_rich() {
170            eprintln!("PANIC: {}", message);
171            if let Some(bt) = backtrace {
172                eprintln!("Backtrace:\n{}", bt);
173            }
174            return;
175        }
176
177        // Main error panel
178        let panel = Panel::from_text(message)
179            .title("[bold red]PANIC[/]")
180            .border_style(theme.error_style.clone())
181            .rounded();
182
183        console.render(&panel);
184
185        // Backtrace if available
186        if let Some(bt) = backtrace {
187            // Fix hex call
188            let label_color = theme
189                .label_style
190                .color
191                .as_ref()
192                .map(|c| c.triplet.unwrap_or_default().hex())
193                .unwrap_or_default();
194            console.print(&format!("\n[{}]Backtrace:[/]", label_color));
195
196            // Syntax-highlight the backtrace (if syntax feature enabled)
197            #[cfg(feature = "syntax")]
198            {
199                let syntax = Syntax::new(bt, "rust")
200                    .line_numbers(true)
201                    .theme("base16-ocean.dark");
202                console.render(&syntax);
203            }
204
205            #[cfg(not(feature = "syntax"))]
206            {
207                for line in bt.lines() {
208                    // Fix hex call
209                    let text_color = theme.text_dim.triplet.unwrap_or_default().hex();
210                    console.print(&format!("  [{}]{}[/]", text_color, line));
211                }
212            }
213        }
214    }
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218#[allow(dead_code)]
219enum ErrorCategory {
220    Connection,
221    Protocol,
222    Handler,
223    Timeout,
224    Cancelled,
225    Internal,
226    Unknown,
227}
228
229/// Render an MCP error with full context (legacy helper)
230pub fn render_error(error: &McpError, console: &FastMcpConsole) {
231    RichErrorRenderer::default().render(error, console);
232}
233
234/// Render a warning
235pub fn render_warning(message: &str, console: &FastMcpConsole) {
236    if console.is_rich() {
237        console.print(&format!(
238            "[{}]⚠[/] [{}]Warning:[/] {}",
239            console.theme().warning.triplet.unwrap_or_default().hex(),
240            console.theme().warning.triplet.unwrap_or_default().hex(),
241            message
242        ));
243    } else {
244        eprintln!("[WARN] {}", message);
245    }
246}
247
248/// Render an info message
249pub fn render_info(message: &str, console: &FastMcpConsole) {
250    if console.is_rich() {
251        console.print(&format!(
252            "[{}]ℹ[/] {}",
253            console.theme().info.triplet.unwrap_or_default().hex(),
254            message
255        ));
256    } else {
257        eprintln!("[INFO] {}", message);
258    }
259}
260
261/// Format a panic/error with stack trace
262pub fn render_panic(message: &str, backtrace: Option<&str>, console: &FastMcpConsole) {
263    RichErrorRenderer::default().render_panic(message, backtrace, console);
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use crate::testing::TestConsole;
270
271    /// Minimal writer for creating a non-rich (plain) console in tests.
272    struct PlainWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
273
274    impl std::io::Write for PlainWriter {
275        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
276            self.0.lock().unwrap().extend_from_slice(buf);
277            Ok(buf.len())
278        }
279        fn flush(&mut self) -> std::io::Result<()> {
280            Ok(())
281        }
282    }
283
284    #[test]
285    fn render_warning_includes_message() {
286        let tc = TestConsole::new();
287        render_warning("something happened", tc.console());
288        assert!(tc.contains("warning"));
289        assert!(tc.contains("something happened"));
290    }
291
292    #[test]
293    fn render_info_includes_message() {
294        let tc = TestConsole::new();
295        render_info("hello", tc.console());
296        assert!(tc.contains("hello"));
297    }
298
299    #[test]
300    fn rich_error_renderer_renders_error_message() {
301        let tc = TestConsole::new();
302        let err = McpError::new(McpErrorCode::MethodNotFound, "missing method");
303        RichErrorRenderer::default().render(&err, tc.console());
304        assert!(tc.contains("missing method"));
305    }
306
307    #[test]
308    fn categorize_error_maps_codes() {
309        let renderer = RichErrorRenderer::default();
310
311        let protocol = McpError::new(McpErrorCode::ParseError, "bad parse");
312        assert_eq!(
313            renderer.categorize_error(&protocol),
314            ErrorCategory::Protocol
315        );
316
317        let handler = McpError::new(McpErrorCode::ResourceNotFound, "missing");
318        assert_eq!(renderer.categorize_error(&handler), ErrorCategory::Handler);
319
320        let cancelled = McpError::new(McpErrorCode::RequestCancelled, "cancelled");
321        assert_eq!(
322            renderer.categorize_error(&cancelled),
323            ErrorCategory::Cancelled
324        );
325
326        let internal = McpError::new(McpErrorCode::InternalError, "boom");
327        assert_eq!(
328            renderer.categorize_error(&internal),
329            ErrorCategory::Internal
330        );
331
332        let unknown = McpError::new(McpErrorCode::Custom(42), "custom");
333        assert_eq!(renderer.categorize_error(&unknown), ErrorCategory::Unknown);
334    }
335
336    #[test]
337    fn suggestions_exist_for_selected_codes() {
338        let renderer = RichErrorRenderer::default();
339
340        let missing = McpError::new(McpErrorCode::MethodNotFound, "missing");
341        let method_suggestions = renderer.get_suggestions(&missing).unwrap_or_default();
342        assert!(method_suggestions.len() >= 2);
343
344        let parse = McpError::new(McpErrorCode::ParseError, "parse");
345        let parse_suggestions = renderer.get_suggestions(&parse).unwrap_or_default();
346        assert!(parse_suggestions.iter().any(|s| s.contains("JSON")));
347
348        let internal = McpError::new(McpErrorCode::InternalError, "internal");
349        assert!(renderer.get_suggestions(&internal).is_none());
350    }
351
352    #[test]
353    fn render_header_renders_all_categories() {
354        let tc = TestConsole::new();
355        let renderer = RichErrorRenderer::default();
356        let theme = tc.console().theme();
357
358        renderer.render_header(ErrorCategory::Connection, theme, tc.console());
359        assert!(tc.contains("Connection Error"));
360        tc.clear();
361
362        renderer.render_header(ErrorCategory::Timeout, theme, tc.console());
363        assert!(tc.contains("Timeout"));
364        tc.clear();
365
366        renderer.render_header(ErrorCategory::Cancelled, theme, tc.console());
367        assert!(tc.contains("Cancelled"));
368    }
369
370    #[test]
371    fn render_error_panel_and_suggestions_include_expected_text() {
372        let tc = TestConsole::new();
373        let renderer = RichErrorRenderer {
374            show_suggestions: true,
375            show_backtrace: false,
376            show_error_code: true,
377        };
378
379        let err = McpError::with_data(
380            McpErrorCode::MethodNotFound,
381            "missing method",
382            serde_json::json!({ "method": "tools/missing" }),
383        );
384        renderer.render_error_panel(&err, tc.console().theme(), tc.console());
385        assert!(tc.contains("missing method"));
386        assert!(tc.contains("-32601"));
387        assert!(tc.contains("tools/missing"));
388
389        tc.clear();
390        renderer.render_suggestions(
391            &["Check handler registration".to_string()],
392            tc.console().theme(),
393            tc.console(),
394        );
395        assert!(tc.contains("Suggestions"));
396        assert!(tc.contains("Check handler registration"));
397    }
398
399    #[test]
400    fn render_respects_show_error_code_flag() {
401        let tc = TestConsole::new();
402        let with_code = RichErrorRenderer {
403            show_suggestions: false,
404            show_backtrace: false,
405            show_error_code: true,
406        };
407        let without_code = RichErrorRenderer {
408            show_suggestions: false,
409            show_backtrace: false,
410            show_error_code: false,
411        };
412        let err = McpError::new(McpErrorCode::InvalidParams, "invalid params");
413
414        with_code.render(&err, tc.console());
415        assert!(tc.contains("-32602"));
416        tc.clear();
417
418        without_code.render(&err, tc.console());
419        assert!(!tc.contains("-32602"));
420        assert!(tc.contains("invalid params"));
421    }
422
423    #[test]
424    fn render_panic_with_backtrace_and_helper_wrapper() {
425        let tc = TestConsole::new();
426        let renderer = RichErrorRenderer::default();
427
428        renderer.render_panic("panic happened", Some("frame1\nframe2"), tc.console());
429        assert!(tc.contains("PANIC"));
430        assert!(tc.contains("panic happened"));
431        assert!(tc.contains("Backtrace"));
432        assert!(tc.contains("frame1"));
433
434        tc.clear();
435        render_panic("wrapped panic", Some("trace"), tc.console());
436        assert!(tc.contains("wrapped panic"));
437    }
438
439    // =========================================================================
440    // Additional coverage tests (bd-2z7s)
441    // =========================================================================
442
443    #[test]
444    fn categorize_error_remaining_protocol_and_handler_codes() {
445        let renderer = RichErrorRenderer::new();
446
447        // Protocol codes
448        assert_eq!(
449            renderer.categorize_error(&McpError::new(McpErrorCode::InvalidRequest, "")),
450            ErrorCategory::Protocol
451        );
452        assert_eq!(
453            renderer.categorize_error(&McpError::new(McpErrorCode::MethodNotFound, "")),
454            ErrorCategory::Protocol
455        );
456        assert_eq!(
457            renderer.categorize_error(&McpError::new(McpErrorCode::InvalidParams, "")),
458            ErrorCategory::Protocol
459        );
460
461        // Handler codes
462        assert_eq!(
463            renderer.categorize_error(&McpError::new(McpErrorCode::ToolExecutionError, "")),
464            ErrorCategory::Handler
465        );
466        assert_eq!(
467            renderer.categorize_error(&McpError::new(McpErrorCode::ResourceForbidden, "")),
468            ErrorCategory::Handler
469        );
470        assert_eq!(
471            renderer.categorize_error(&McpError::new(McpErrorCode::PromptNotFound, "")),
472            ErrorCategory::Handler
473        );
474    }
475
476    #[test]
477    fn render_header_remaining_categories() {
478        let tc = TestConsole::new();
479        let renderer = RichErrorRenderer::new();
480        let theme = tc.console().theme();
481
482        renderer.render_header(ErrorCategory::Protocol, theme, tc.console());
483        assert!(tc.contains("Protocol Error"));
484        tc.clear();
485
486        renderer.render_header(ErrorCategory::Handler, theme, tc.console());
487        assert!(tc.contains("Handler Error"));
488        tc.clear();
489
490        renderer.render_header(ErrorCategory::Internal, theme, tc.console());
491        assert!(tc.contains("Internal Error"));
492        tc.clear();
493
494        renderer.render_header(ErrorCategory::Unknown, theme, tc.console());
495        assert!(tc.contains("Error"));
496    }
497
498    #[test]
499    fn get_suggestions_resource_not_found() {
500        let renderer = RichErrorRenderer::new();
501        let err = McpError::new(McpErrorCode::ResourceNotFound, "missing");
502        let suggestions = renderer.get_suggestions(&err).unwrap();
503        assert!(suggestions.iter().any(|s| s.contains("URI")));
504    }
505
506    #[test]
507    fn render_plain_error_without_data() {
508        let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
509        let console = FastMcpConsole::with_writer(PlainWriter(buf.clone()), false);
510        let err = McpError::new(McpErrorCode::InternalError, "something broke");
511        RichErrorRenderer::new().render(&err, &console);
512        let output = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
513        assert!(output.contains("ERROR"));
514        assert!(output.contains("something broke"));
515    }
516
517    #[test]
518    fn render_plain_error_with_data() {
519        let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
520        let console = FastMcpConsole::with_writer(PlainWriter(buf.clone()), false);
521        let err = McpError::with_data(
522            McpErrorCode::InvalidParams,
523            "bad params",
524            serde_json::json!({"field": "name"}),
525        );
526        RichErrorRenderer::new().render(&err, &console);
527        let output = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
528        assert!(output.contains("bad params"));
529        assert!(output.contains("Context"));
530    }
531
532    #[test]
533    fn render_panic_without_backtrace() {
534        let tc = TestConsole::new();
535        let renderer = RichErrorRenderer::new();
536        renderer.render_panic("oops", None, tc.console());
537        assert!(tc.contains("PANIC"));
538        assert!(tc.contains("oops"));
539        assert!(!tc.contains("Backtrace"));
540    }
541
542    #[test]
543    fn render_warning_and_info_plain_mode() {
544        // Warning and info in plain mode write to stderr via eprintln!, which
545        // we cannot capture through the writer. We just verify the non-rich
546        // branch doesn't panic by constructing a non-rich console.
547        let buf = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
548        let console = FastMcpConsole::with_writer(PlainWriter(buf.clone()), false);
549        assert!(!console.is_rich());
550        render_warning("disk full", &console);
551        render_info("started", &console);
552        // No panic = success; output goes to stderr, not our buffer
553    }
554
555    #[test]
556    fn error_category_debug_clone_copy() {
557        let cat = ErrorCategory::Protocol;
558        let debug = format!("{cat:?}");
559        assert!(debug.contains("Protocol"));
560
561        let cloned = cat;
562        assert_eq!(cloned, ErrorCategory::Protocol);
563    }
564
565    #[test]
566    fn render_error_panel_without_data() {
567        let tc = TestConsole::new();
568        let renderer = RichErrorRenderer {
569            show_suggestions: false,
570            show_backtrace: false,
571            show_error_code: true,
572        };
573        let err = McpError::new(McpErrorCode::ParseError, "bad json");
574        renderer.render_error_panel(&err, tc.console().theme(), tc.console());
575        assert!(tc.contains("bad json"));
576        assert!(tc.contains("-32700"));
577    }
578
579    #[test]
580    fn render_error_helper_function() {
581        let tc = TestConsole::new();
582        let err = McpError::new(McpErrorCode::InternalError, "boom");
583        render_error(&err, tc.console());
584        assert!(tc.contains("boom"));
585    }
586}