Skip to main content

headless_lms_mcp/tools/
error.rs

1//! The errors tools return, and how each reaches the agent.
2
3use serde::Serialize;
4use serde_json::{Map, Value, json};
5
6use super::{McpScope, ToolResult};
7use crate::engine::{EditError, EditErrorCode};
8use crate::hints::SkillRefusal;
9use crate::validator::ValidatorError;
10
11/// The `code` of an error result, other than an edit rejection's [`EditErrorCode`].
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum ToolErrorCode {
15    OutdatedSkill,
16    /// No `skill_version` was sent; the agent asks the teacher about installing the skill.
17    SkillMissing,
18    InvalidArguments,
19    NotFound,
20    /// The user's roles don't allow editing the course.
21    Forbidden,
22    TemporarilyUnavailable,
23    StagedStateChanged,
24    InsufficientScope,
25    InternalError,
26    /// Saving the staged changes failed, or the course would break a rule publishing relies on.
27    PublishFailed,
28    /// Staged entities changed live after they were staged.
29    PublishConflict,
30    /// The staged pages add exercises of services only administrators may add.
31    AdminOnlyExerciseServices,
32    /// The changeset isn't at the revision the call gave.
33    ChangesetChanged,
34    /// Without the entities a partial discard names, the rest couldn't be published.
35    DiscardConflict,
36    /// Reverting would undo or clash with later changes.
37    RevertConflict,
38    /// Staged pages lack a page title block or still hold template placeholder text.
39    PagesNotReady,
40}
41
42/// Why a tool call failed. Mapped to a tool result or a JSON-RPC error by the protocol layer.
43#[derive(Debug, thiserror::Error)]
44pub enum ToolError {
45    /// An edit the engines rejected; shown to the agent with its fix.
46    #[error(transparent)]
47    Edit(#[from] EditError),
48    #[error(transparent)]
49    Skill(#[from] SkillRefusal),
50    #[error("{message}")]
51    InvalidArguments { message: String },
52    #[error("{message}")]
53    NotFound { message: String },
54    /// The user's role doesn't allow this on that course or page.
55    #[error("{message}")]
56    Forbidden { message: String },
57    /// A dependency (validator, storage) is down or busy; the same call can be retried.
58    #[error("{message}")]
59    Retryable {
60        message: String,
61        retry_after_seconds: u64,
62    },
63    /// Another call changed the staged state this call read, so nothing was staged; the call can
64    /// be repeated after reading again.
65    #[error("{message}")]
66    StagedStateChanged {
67        message: String,
68        /// `{entity_type, entity_id}` of each entity that changed; empty when the whole changeset
69        /// closed.
70        entities: Vec<Value>,
71    },
72    /// The call changed nothing because it would break a rule of publishing or reverting; `code`
73    /// names the rule and `details` what to fix.
74    #[error("{message}")]
75    Refused {
76        code: ToolErrorCode,
77        message: String,
78        details: Map<String, Value>,
79    },
80    /// The token lacks a scope; answered with an HTTP 403 so the client can step up.
81    #[error("this needs the {} scope", required.as_str())]
82    InsufficientScope { required: McpScope },
83    #[error("internal error: {0}")]
84    Internal(String),
85}
86
87impl ToolError {
88    /// The user's roles don't allow editing the course.
89    pub fn no_edit_rights() -> Self {
90        ToolError::Forbidden {
91            message: "Your teacher account doesn't have edit rights on this course. To only read a course on courses.mooc.fi as reference, ask the teacher for its URL and use read_public_course.".to_string(),
92        }
93    }
94
95    /// A dependency is down or busy; the agent may repeat the same call after
96    /// `retry_after_seconds`.
97    pub fn retryable(message: impl Into<String>, retry_after_seconds: u64) -> Self {
98        ToolError::Retryable {
99            message: message.into(),
100            retry_after_seconds,
101        }
102    }
103
104    /// A refusal with `message` when `error` is one; an internal error when the check couldn't be
105    /// made.
106    pub fn forbidden_or_internal(
107        error: headless_lms_authorization::error::AuthorizationError,
108        message: impl Into<String>,
109    ) -> Self {
110        use headless_lms_authorization::error::AuthorizationErrorType;
111        use headless_lms_base::error::backend_error::BackendError;
112        match error.error_type() {
113            AuthorizationErrorType::Forbidden | AuthorizationErrorType::Unauthorized => {
114                ToolError::Forbidden {
115                    message: message.into(),
116                }
117            }
118            _ => ToolError::Internal(format!("{error:?}")),
119        }
120    }
121
122    /// A [`ToolError::Refused`] with the fields of `details`, a JSON object.
123    pub fn refused(code: ToolErrorCode, message: impl Into<String>, details: Value) -> Self {
124        ToolError::Refused {
125            code,
126            message: message.into(),
127            details: match details {
128                Value::Object(details) => details,
129                _ => Map::new(),
130            },
131        }
132    }
133
134    /// The `error` object of the result the agent gets. Dispatch answers insufficient scopes and
135    /// internal errors with protocol errors instead.
136    pub fn into_json(self) -> Value {
137        let (code, message) = match self {
138            ToolError::Edit(edit) => return serde_json::to_value(edit).unwrap_or_default(),
139            ToolError::Skill(SkillRefusal::Outdated(message)) => {
140                (ToolErrorCode::OutdatedSkill, message)
141            }
142            ToolError::Skill(SkillRefusal::Missing(message)) => {
143                (ToolErrorCode::SkillMissing, message)
144            }
145            ToolError::InvalidArguments { message } => (ToolErrorCode::InvalidArguments, message),
146            ToolError::NotFound { message } => (ToolErrorCode::NotFound, message),
147            ToolError::Forbidden { message } => (ToolErrorCode::Forbidden, message),
148            ToolError::Retryable {
149                message,
150                retry_after_seconds,
151            } => {
152                return json!({"code": ToolErrorCode::TemporarilyUnavailable, "message": message, "retryable": true, "retry_after_seconds": retry_after_seconds});
153            }
154            ToolError::StagedStateChanged { message, entities } => {
155                return json!({"code": ToolErrorCode::StagedStateChanged, "message": message, "retryable": true, "entities": entities});
156            }
157            ToolError::Refused {
158                code,
159                message,
160                mut details,
161            } => {
162                details.insert("code".into(), json!(code));
163                details.insert("message".into(), json!(message));
164                return Value::Object(details);
165            }
166            ToolError::InsufficientScope { required } => {
167                return json!({"code": ToolErrorCode::InsufficientScope, "required_scope": required.as_str()});
168            }
169            ToolError::Internal(_) => (
170                ToolErrorCode::InternalError,
171                "Internal error; the call changed nothing".to_string(),
172            ),
173        };
174        json!({"code": code, "message": message})
175    }
176
177    /// The `isError` result the agent gets; see [`ToolError::into_json`].
178    pub fn into_result(self) -> ToolResult {
179        ToolResult::error(self.into_json())
180    }
181}
182
183impl From<ValidatorError> for ToolError {
184    fn from(error: ValidatorError) -> Self {
185        match error {
186            ValidatorError::Unavailable { retry_after, .. } => ToolError::retryable(
187                "the content validator is unavailable, so nothing was staged; the same call can be retried",
188                retry_after.map_or(2, |d| d.as_secs()),
189            ),
190            ValidatorError::TooLarge { .. } => ToolError::InvalidArguments {
191                message: "Nothing was staged: the call writes too many or too large blocks for the validator to check at once. Write fewer or smaller blocks per call.".into(),
192            },
193            ValidatorError::FailedOnInput { message } => ToolError::Edit(
194                EditError::new(
195                    EditErrorCode::InvalidBlocks,
196                    format!("Nothing was staged: the validator failed on these blocks ({message})"),
197                )
198                .with_fix("check the written blocks against the guide's block reference, or write them in smaller calls to find the one that fails"),
199            ),
200            rejected @ ValidatorError::Rejected { .. } => ToolError::Internal(rejected.to_string()),
201        }
202    }
203}
204
205impl From<headless_lms_models::ModelError> for ToolError {
206    fn from(error: headless_lms_models::ModelError) -> Self {
207        use headless_lms_base::error::backend_error::BackendError;
208        use headless_lms_models::ModelErrorType;
209        match error.error_type() {
210            ModelErrorType::RecordNotFound | ModelErrorType::NotFound => ToolError::NotFound {
211                message: error.message().to_string(),
212            },
213            ModelErrorType::PreconditionFailed | ModelErrorType::InvalidRequest => {
214                ToolError::InvalidArguments {
215                    message: error.message().to_string(),
216                }
217            }
218            _ => ToolError::Internal(format!("{error:?}")),
219        }
220    }
221}
222
223impl From<headless_lms_authorization::error::AuthorizationError> for ToolError {
224    fn from(error: headless_lms_authorization::error::AuthorizationError) -> Self {
225        ToolError::forbidden_or_internal(error, "Your account isn't allowed to do this.")
226    }
227}