1use crate::console::FastMcpConsole;
4use crate::theme::FastMcpTheme;
5use fastmcp_core::{McpError, McpErrorCode};
6use rich_rust::prelude::*;
7
8pub 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 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 let category = self.categorize_error(error);
41 self.render_header(category, theme, console);
42
43 self.render_error_panel(error, theme, console);
45
46 if self.show_suggestions {
48 if let Some(suggestions) = self.get_suggestions(error) {
49 self.render_suggestions(&suggestions, theme, console);
50 }
51 }
52
53 if self.show_backtrace {
55 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 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 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()) .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 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 if let Some(bt) = backtrace {
187 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 #[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 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
229pub fn render_error(error: &McpError, console: &FastMcpConsole) {
231 RichErrorRenderer::default().render(error, console);
232}
233
234pub 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
248pub 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
261pub 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 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 #[test]
444 fn categorize_error_remaining_protocol_and_handler_codes() {
445 let renderer = RichErrorRenderer::new();
446
447 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 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 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 }
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}