1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
//! Typed error enum for monarch-mcp.
#![allow(dead_code)] // Public API consumed by A4–A7 tool implementations
//!
//! All domain errors funnel through here so that MCP error responses carry
//! consistent, human-readable messages. The most important variant is
//! `SessionExpired`: every tool handler maps HTTP 401 from Monarch to this
//! variant and surfaces the re-authentication message the BDD scenarios assert.
use rmcp::ErrorData as McpError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MonarchError {
/// Monarch returned HTTP 401 or signalled an expired session.
/// Tools surface this as an auth error so Claude knows to tell the user
/// to re-run `monarch-mcp login`.
#[error("Monarch session expired — please re-run `monarch-mcp login` to re-authenticate")]
SessionExpired,
/// The `MONARCH_GOALS_FILE` was missing, unreadable, or contained invalid TOML.
#[error("Goals file error: {0}")]
GoalsFile(String),
/// A GraphQL response contained an `errors` array.
#[error("Monarch GraphQL error: {0}")]
GraphQL(String),
/// An HTTP transport or serialization error.
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
/// Caller-supplied input was malformed or logically invalid (e.g. a
/// non-ISO date string, or start_date after end_date). Surfaces as a soft
/// error payload so the advisor receives a clear message rather than empty
/// months or a hard MCP fault.
#[error("Invalid input: {0}")]
InvalidInput(String),
/// Any other unexpected error.
#[error("Internal error: {0}")]
Internal(String),
}
impl MonarchError {
/// Convert to an MCP error response suitable for returning from a tool handler.
pub fn into_mcp_error(self) -> McpError {
// Use MCP's internal_error code for all variants — the message is what matters.
McpError::internal_error(self.to_string(), None)
}
/// Return true when the error indicates the session needs re-authentication.
pub fn is_auth_error(&self) -> bool {
matches!(self, MonarchError::SessionExpired)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_expired_message_contains_reauth_hint() {
let err = MonarchError::SessionExpired;
let msg = err.to_string();
assert!(
msg.contains("re-authenticate") || msg.contains("login"),
"expected re-auth hint in: {msg}"
);
}
#[test]
fn session_expired_is_detected_as_auth_error() {
assert!(MonarchError::SessionExpired.is_auth_error());
}
#[test]
fn other_errors_are_not_auth_errors() {
assert!(!MonarchError::Internal("boom".into()).is_auth_error());
assert!(!MonarchError::GraphQL("bad query".into()).is_auth_error());
}
#[test]
fn into_mcp_error_preserves_message() {
let err = MonarchError::SessionExpired;
let msg = err.to_string();
let mcp = MonarchError::SessionExpired.into_mcp_error();
assert!(mcp.message.contains("re-authenticate") || mcp.message.contains(&msg[..20]));
}
}