1use std::sync::atomic::{AtomicUsize, Ordering};
31
32use fastmcp_core::McpError;
33
34use crate::config::ConsoleConfig;
35use crate::console::{
36 DEFAULT_TERMINAL_FIELD_MAX_CHARS, FastMcpConsole, bounded_redacted_rich_fragment,
37 bounded_redacted_terminal_text,
38};
39use crate::diagnostics::RichErrorRenderer;
40
41pub struct ErrorBoundary<'a> {
58 console: &'a FastMcpConsole,
59 renderer: RichErrorRenderer,
60 exit_on_error: bool,
61 error_count: AtomicUsize,
62}
63
64impl<'a> ErrorBoundary<'a> {
65 #[must_use]
78 pub fn new(console: &'a FastMcpConsole) -> Self {
79 Self {
80 console,
81 renderer: RichErrorRenderer::new(),
82 exit_on_error: false,
83 error_count: AtomicUsize::new(0),
84 }
85 }
86
87 #[must_use]
92 pub fn from_config(console: &'a FastMcpConsole, config: &ConsoleConfig) -> Self {
93 Self {
94 console,
95 renderer: RichErrorRenderer::from_config(config),
96 exit_on_error: false,
97 error_count: AtomicUsize::new(0),
98 }
99 }
100
101 #[must_use]
117 pub fn with_exit_on_error(mut self, exit: bool) -> Self {
118 self.exit_on_error = exit;
119 self
120 }
121
122 pub fn wrap<T, E>(&self, result: Result<T, E>) -> Option<T>
142 where
143 E: Into<McpError>,
144 {
145 match result {
146 Ok(value) => Some(value),
147 Err(e) => {
148 let error = e.into();
149 self.handle_error(&error);
150 None
151 }
152 }
153 }
154
155 pub fn wrap_with_context<T, E>(&self, result: Result<T, E>, context: &str) -> Option<T>
171 where
172 E: Into<McpError>,
173 {
174 match result {
175 Ok(value) => Some(value),
176 Err(e) => {
177 let error = e.into();
178 self.render_context(context);
179 self.handle_error(&error);
180 None
181 }
182 }
183 }
184
185 pub fn wrap_result<T, E>(&self, result: Result<T, E>) -> Result<T, McpError>
202 where
203 E: Into<McpError>,
204 {
205 match result {
206 Ok(value) => Ok(value),
207 Err(e) => {
208 let error = e.into();
209 self.handle_error(&error);
210 Err(error)
211 }
212 }
213 }
214
215 pub fn wrap_result_with_context<T, E>(
221 &self,
222 result: Result<T, E>,
223 context: &str,
224 ) -> Result<T, McpError>
225 where
226 E: Into<McpError>,
227 {
228 match result {
229 Ok(value) => Ok(value),
230 Err(e) => {
231 let error = e.into();
232 self.render_context(context);
233 self.handle_error(&error);
234 Err(error)
235 }
236 }
237 }
238
239 pub fn display_error(&self, error: &McpError) {
252 self.handle_error(error);
253 }
254
255 #[must_use]
260 pub fn error_count(&self) -> usize {
261 self.error_count.load(Ordering::Relaxed)
262 }
263
264 #[must_use]
269 pub fn has_errors(&self) -> bool {
270 self.error_count() > 0
271 }
272
273 pub fn reset_count(&self) {
278 self.error_count.store(0, Ordering::Relaxed);
279 }
280
281 fn render_context(&self, context: &str) {
282 if self.console.is_rich() {
283 let context = bounded_redacted_rich_fragment(context, DEFAULT_TERMINAL_FIELD_MAX_CHARS);
284 self.console.print(&format!("[dim]Context: {context}[/]"));
285 } else {
286 let context = bounded_redacted_terminal_text(context, DEFAULT_TERMINAL_FIELD_MAX_CHARS);
287 self.console.print_plain(&format!("Context: {context}"));
288 }
289 }
290
291 fn handle_error(&self, error: &McpError) {
293 self.error_count.fetch_add(1, Ordering::Relaxed);
294 self.renderer.render(error, self.console);
295
296 if self.exit_on_error {
297 std::process::exit(1);
298 }
299 }
300}
301
302#[macro_export]
319macro_rules! try_display {
320 ($boundary:expr, $expr:expr) => {
321 match $boundary.wrap($expr) {
322 Some(v) => v,
323 None => return,
324 }
325 };
326 ($boundary:expr, $expr:expr, $ctx:expr) => {
327 match $boundary.wrap_with_context($expr, $ctx) {
328 Some(v) => v,
329 None => return,
330 }
331 };
332}
333
334#[macro_export]
350macro_rules! try_display_result {
351 ($boundary:expr, $expr:expr) => {
352 match $boundary.wrap_result($expr) {
353 Ok(v) => v,
354 Err(e) => return Err(e),
355 }
356 };
357 ($boundary:expr, $expr:expr, $ctx:expr) => {
358 match $boundary.wrap_result_with_context($expr, $ctx) {
359 Ok(v) => v,
360 Err(e) => return Err(e),
361 }
362 };
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::testing::TestConsole;
369 use fastmcp_core::McpErrorCode;
370
371 struct CaptureWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
372
373 impl std::io::Write for CaptureWriter {
374 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
375 self.0.lock().unwrap().extend_from_slice(buf);
376 Ok(buf.len())
377 }
378
379 fn flush(&mut self) -> std::io::Result<()> {
380 Ok(())
381 }
382 }
383
384 fn test_console() -> FastMcpConsole {
385 FastMcpConsole::with_enabled(false)
387 }
388
389 #[test]
390 fn test_error_boundary_wrap_success() {
391 let console = test_console();
392 let boundary = ErrorBoundary::new(&console);
393
394 let result: Result<i32, McpError> = Ok(42);
395 assert_eq!(boundary.wrap(result), Some(42));
396 assert_eq!(boundary.error_count(), 0);
397 assert!(!boundary.has_errors());
398 }
399
400 #[test]
401 fn test_error_boundary_wrap_error() {
402 let console = test_console();
403 let boundary = ErrorBoundary::new(&console);
404
405 let result: Result<i32, McpError> = Err(McpError::internal_error("test error"));
406 assert_eq!(boundary.wrap(result), None);
407 assert_eq!(boundary.error_count(), 1);
408 assert!(boundary.has_errors());
409 }
410
411 #[test]
412 fn from_config_propagates_error_code_and_suggestion_policy() {
413 let output = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
414 let console = FastMcpConsole::with_writer(CaptureWriter(output.clone()), false);
415 let mut config = ConsoleConfig::new().without_suggestions();
416 config.show_error_codes = false;
417 let boundary = ErrorBoundary::from_config(&console, &config);
418
419 boundary.display_error(&McpError::new(
420 McpErrorCode::MethodNotFound,
421 "missing method",
422 ));
423 let output = String::from_utf8(output.lock().unwrap().clone()).unwrap();
424 assert!(output.contains("ERROR: missing method"), "{output}");
425 assert!(!output.contains("-32601"), "{output}");
426 assert!(!output.contains("Suggestions"), "{output}");
427 assert_eq!(boundary.error_count(), 1);
428 assert!(!boundary.exit_on_error);
429 }
430
431 #[test]
432 fn test_error_boundary_wrap_with_context() {
433 let console = test_console();
434 let boundary = ErrorBoundary::new(&console);
435
436 let result: Result<i32, McpError> = Err(McpError::internal_error("test"));
437 assert_eq!(boundary.wrap_with_context(result, "Loading config"), None);
438 assert_eq!(boundary.error_count(), 1);
439 }
440
441 #[test]
442 fn test_error_boundary_wrap_result_success() {
443 let console = test_console();
444 let boundary = ErrorBoundary::new(&console);
445
446 let result: Result<i32, McpError> = Ok(42);
447 let wrapped = boundary.wrap_result(result);
448 assert!(wrapped.is_ok());
449 assert_eq!(wrapped.unwrap(), 42);
450 assert_eq!(boundary.error_count(), 0);
451 }
452
453 #[test]
454 fn test_error_boundary_wrap_result_error() {
455 let console = test_console();
456 let boundary = ErrorBoundary::new(&console);
457
458 let result: Result<i32, McpError> = Err(McpError::internal_error("test"));
459 let wrapped = boundary.wrap_result(result);
460 assert!(wrapped.is_err());
461 assert_eq!(wrapped.unwrap_err().code, McpErrorCode::InternalError);
462 assert_eq!(boundary.error_count(), 1);
463 }
464
465 #[test]
466 fn test_error_boundary_multiple_errors() {
467 let console = test_console();
468 let boundary = ErrorBoundary::new(&console);
469
470 let err1: Result<i32, McpError> = Err(McpError::internal_error("error 1"));
471 let err2: Result<i32, McpError> = Err(McpError::parse_error("error 2"));
472 let err3: Result<i32, McpError> = Err(McpError::method_not_found("test"));
473
474 boundary.wrap(err1);
475 boundary.wrap(err2);
476 boundary.wrap(err3);
477
478 assert_eq!(boundary.error_count(), 3);
479 }
480
481 #[test]
482 fn test_error_boundary_reset_count() {
483 let console = test_console();
484 let boundary = ErrorBoundary::new(&console);
485
486 let err: Result<i32, McpError> = Err(McpError::internal_error("test"));
487 boundary.wrap(err);
488 assert_eq!(boundary.error_count(), 1);
489
490 boundary.reset_count();
491 assert_eq!(boundary.error_count(), 0);
492 assert!(!boundary.has_errors());
493 }
494
495 #[test]
496 fn test_error_boundary_display_error() {
497 let console = test_console();
498 let boundary = ErrorBoundary::new(&console);
499
500 let error = McpError::internal_error("direct display");
501 boundary.display_error(&error);
502
503 assert_eq!(boundary.error_count(), 1);
504 }
505
506 #[test]
507 fn test_error_boundary_mixed_results() {
508 let console = test_console();
509 let boundary = ErrorBoundary::new(&console);
510
511 let ok1: Result<i32, McpError> = Ok(1);
513 let ok2: Result<i32, McpError> = Ok(2);
514
515 let err1: Result<i32, McpError> = Err(McpError::internal_error("e1"));
517 let err2: Result<i32, McpError> = Err(McpError::internal_error("e2"));
518
519 assert_eq!(boundary.wrap(ok1), Some(1));
520 assert_eq!(boundary.wrap(err1), None);
521 assert_eq!(boundary.wrap(ok2), Some(2));
522 assert_eq!(boundary.wrap(err2), None);
523
524 assert_eq!(boundary.error_count(), 2);
526 }
527
528 #[test]
529 fn test_error_boundary_from_other_error_types() {
530 let console = test_console();
531 let boundary = ErrorBoundary::new(&console);
532
533 let json_result: Result<serde_json::Value, serde_json::Error> =
535 serde_json::from_str("invalid json");
536
537 let mcp_result = json_result.map_err(McpError::from);
539 assert_eq!(boundary.wrap(mcp_result), None);
540 assert_eq!(boundary.error_count(), 1);
541 }
542
543 #[test]
544 fn test_with_exit_on_error_builder_flag() {
545 let console = test_console();
546
547 let boundary = ErrorBoundary::new(&console).with_exit_on_error(false);
549 let result: Result<i32, McpError> = Ok(7);
550 assert_eq!(boundary.wrap(result), Some(7));
551 assert_eq!(boundary.error_count(), 0);
552 }
553
554 #[test]
555 fn test_wrap_with_context_success_path() {
556 let console = test_console();
557 let boundary = ErrorBoundary::new(&console);
558
559 let result: Result<&str, McpError> = Ok("ok");
560 assert_eq!(
561 boundary.wrap_with_context(result, "unused context"),
562 Some("ok")
563 );
564 assert_eq!(boundary.error_count(), 0);
565 }
566
567 #[test]
568 fn test_wrap_result_with_context_success_and_error_paths() {
569 let console = test_console();
570 let boundary = ErrorBoundary::new(&console);
571
572 let ok: Result<i32, McpError> = Ok(123);
573 let wrapped_ok = boundary.wrap_result_with_context(ok, "computing value");
574 assert!(wrapped_ok.is_ok());
575 assert_eq!(wrapped_ok.unwrap(), 123);
576 assert_eq!(boundary.error_count(), 0);
577
578 let err: Result<i32, McpError> = Err(McpError::internal_error("boom"));
579 let wrapped = boundary.wrap_result_with_context(err, "computing value");
580 assert!(wrapped.is_err());
581 assert_eq!(wrapped.unwrap_err().code, McpErrorCode::InternalError);
582 assert_eq!(boundary.error_count(), 1);
583 }
584
585 #[test]
586 fn plain_context_is_bounded_redacted_and_terminal_safe() {
587 let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
588 let console = FastMcpConsole::with_writer(CaptureWriter(buffer.clone()), false);
589 let boundary = ErrorBoundary::new(&console);
590 let context = format!(
591 "\u{1b}\u{202e}[bold red]owned[/] access_token=context-secret-canary {}",
592 "x".repeat(20_000)
593 );
594 let result: Result<(), McpError> = Err(McpError::internal_error("failure"));
595
596 assert_eq!(boundary.wrap_with_context(result, &context), None);
597 let output = String::from_utf8(buffer.lock().unwrap().clone()).unwrap();
598 assert!(output.contains("Context:"), "{output}");
599 assert!(output.contains("[REDACTED]"), "{output}");
600 assert!(!output.contains("context-secret-canary"), "{output}");
601 assert!(!output.contains('\u{1b}'), "{output}");
602 assert!(!output.contains('\u{202e}'), "{output}");
603 assert!(output.contains("\\u{1b}"), "{output}");
604 assert!(
605 output.chars().count() <= 600,
606 "context output was unbounded"
607 );
608 }
609
610 #[test]
611 fn rich_context_escapes_markup_and_redacts_credentials() {
612 let console = TestConsole::new_rich();
613 let boundary = ErrorBoundary::new(console.console());
614 let result: Result<(), McpError> = Err(McpError::internal_error("failure"));
615
616 assert!(
617 boundary
618 .wrap_result_with_context(
619 result,
620 "[bold red]owned[/] Authorization: Bearer rich-context-secret"
621 )
622 .is_err()
623 );
624 let output = console.output_string();
625 assert!(output.contains("[bold red]owned[/]"), "{output}");
626 assert!(output.contains("[REDACTED]"), "{output}");
627 assert!(!output.contains("rich-context-secret"), "{output}");
628 }
629}