1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
13#[serde(rename_all = "snake_case")]
14pub enum ToolErrorCode {
15 OutdatedSkill,
16 SkillMissing,
18 InvalidArguments,
19 NotFound,
20 Forbidden,
22 TemporarilyUnavailable,
23 StagedStateChanged,
24 InsufficientScope,
25 InternalError,
26 PublishFailed,
28 PublishConflict,
30 AdminOnlyExerciseServices,
32 ChangesetChanged,
34 DiscardConflict,
36 RevertConflict,
38 PagesNotReady,
40}
41
42#[derive(Debug, thiserror::Error)]
44pub enum ToolError {
45 #[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 #[error("{message}")]
56 Forbidden { message: String },
57 #[error("{message}")]
59 Retryable {
60 message: String,
61 retry_after_seconds: u64,
62 },
63 #[error("{message}")]
66 StagedStateChanged {
67 message: String,
68 entities: Vec<Value>,
71 },
72 #[error("{message}")]
75 Refused {
76 code: ToolErrorCode,
77 message: String,
78 details: Map<String, Value>,
79 },
80 #[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 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 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 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 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 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 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}