1use std::borrow::Cow;
9
10use serde_json::{Value, json};
11
12use crate::spec::{self, error_codes};
13
14const MAX_ECHO_CHARS: usize = 128;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum ErrorKind {
22 BadRequest,
24 MethodNotFound,
26 Failed,
28 Internal,
30 Forbidden,
33}
34
35#[derive(Debug, Clone, PartialEq)]
37pub struct McpError {
38 code: i64,
39 kind: ErrorKind,
40 message: Cow<'static, str>,
41 data: Option<Box<Value>>,
43}
44
45impl McpError {
46 fn new(code: i64, kind: ErrorKind, message: impl Into<Cow<'static, str>>) -> Self {
47 Self {
48 code,
49 kind,
50 message: message.into(),
51 data: None,
52 }
53 }
54
55 #[must_use]
57 pub fn parse_error() -> Self {
58 Self::new(
59 error_codes::PARSE_ERROR,
60 ErrorKind::BadRequest,
61 "Parse error",
62 )
63 }
64
65 #[must_use]
67 pub fn invalid_request(reason: &'static str) -> Self {
68 Self::new(
69 error_codes::INVALID_REQUEST_ERROR,
70 ErrorKind::BadRequest,
71 reason,
72 )
73 }
74
75 #[must_use]
77 pub fn method_not_found(method: &str) -> Self {
78 Self::new(
79 error_codes::METHOD_NOT_FOUND_ERROR,
80 ErrorKind::MethodNotFound,
81 format!("Method not found: {}", truncate(method)),
82 )
83 }
84
85 #[must_use]
87 pub fn method_not_found_explained(explanation: impl Into<Cow<'static, str>>) -> Self {
88 Self::new(
89 error_codes::METHOD_NOT_FOUND_ERROR,
90 ErrorKind::MethodNotFound,
91 explanation,
92 )
93 }
94
95 #[must_use]
97 pub fn invalid_params(message: impl Into<Cow<'static, str>>) -> Self {
98 Self::new(
99 error_codes::INVALID_PARAMS_ERROR,
100 ErrorKind::BadRequest,
101 message,
102 )
103 }
104
105 #[must_use]
107 pub fn unknown_tool(name: &str) -> Self {
108 Self::new(
109 error_codes::INVALID_PARAMS_ERROR,
110 ErrorKind::Failed,
111 format!("Unknown tool: {}", truncate(name)),
112 )
113 }
114
115 #[must_use]
119 pub fn request_failed(message: impl Into<Cow<'static, str>>) -> Self {
120 Self::new(
121 error_codes::INVALID_PARAMS_ERROR,
122 ErrorKind::Failed,
123 message,
124 )
125 }
126
127 #[must_use]
130 pub fn resource_not_found(uri: &str) -> Self {
131 let mut err = Self::new(
132 error_codes::INVALID_PARAMS_ERROR,
133 ErrorKind::Failed,
134 format!("Resource not found: {}", truncate(uri)),
135 );
136 err.data = Some(Box::new(json!({ "uri": uri })));
137 err
138 }
139
140 #[must_use]
142 pub fn unknown_prompt(name: &str) -> Self {
143 Self::new(
144 error_codes::INVALID_PARAMS_ERROR,
145 ErrorKind::Failed,
146 format!("Unknown prompt: {}", truncate(name)),
147 )
148 }
149
150 #[must_use]
152 pub fn internal_with_message(message: &'static str) -> Self {
153 Self::new(error_codes::INTERNAL_ERROR, ErrorKind::Internal, message)
154 }
155
156 #[must_use]
158 pub fn internal() -> Self {
159 Self::new(
160 error_codes::INTERNAL_ERROR,
161 ErrorKind::Internal,
162 "Internal error",
163 )
164 }
165
166 #[must_use]
170 pub fn insufficient_scope(required: &[String]) -> Self {
171 let mut err = Self::new(
172 error_codes::INVALID_REQUEST_ERROR,
173 ErrorKind::Forbidden,
174 "Insufficient scope",
175 );
176 err.data = Some(Box::new(json!({ "requiredScopes": required })));
177 err
178 }
179
180 #[cfg_attr(not(feature = "http"), allow(dead_code))]
182 pub(crate) fn required_scopes(&self) -> Vec<&str> {
183 self.data
184 .as_deref()
185 .and_then(|d| d.get("requiredScopes"))
186 .and_then(Value::as_array)
187 .map(|scopes| scopes.iter().filter_map(Value::as_str).collect())
188 .unwrap_or_default()
189 }
190
191 #[must_use]
193 pub fn header_mismatch(message: impl Into<Cow<'static, str>>) -> Self {
194 Self::new(
195 error_codes::HEADER_MISMATCH_ERROR,
196 ErrorKind::BadRequest,
197 message,
198 )
199 }
200
201 #[must_use]
203 pub fn missing_client_capability(required: &spec::ClientCapabilities) -> Self {
204 let mut err = Self::new(
205 error_codes::MISSING_REQUIRED_CLIENT_CAPABILITY_ERROR,
206 ErrorKind::BadRequest,
207 "Missing required client capability",
208 );
209 err.data = Some(Box::new(json!({ "requiredCapabilities": required })));
210 err
211 }
212
213 #[must_use]
215 pub fn unsupported_protocol_version(requested: &str, supported: &[String]) -> Self {
216 let mut err = Self::new(
217 error_codes::UNSUPPORTED_PROTOCOL_VERSION_ERROR,
218 ErrorKind::BadRequest,
219 "Unsupported protocol version",
220 );
221 err.data = Some(Box::new(
222 json!({ "supported": supported, "requested": truncate(requested) }),
223 ));
224 err
225 }
226
227 #[must_use]
230 pub(crate) fn with_code(mut self, code: i64) -> Self {
231 self.code = code;
232 self
233 }
234
235 #[must_use]
237 pub fn code(&self) -> i64 {
238 self.code
239 }
240
241 #[must_use]
243 pub fn kind(&self) -> ErrorKind {
244 self.kind
245 }
246
247 #[must_use]
249 pub fn message(&self) -> &str {
250 &self.message
251 }
252
253 #[must_use]
255 pub fn data(&self) -> Option<&Value> {
256 self.data.as_deref()
257 }
258
259 #[must_use]
261 pub fn into_spec(self) -> spec::Error {
262 spec::Error {
263 code: self.code,
264 message: self.message.into_owned(),
265 data: self.data.map(|d| *d),
266 }
267 }
268}
269
270impl std::fmt::Display for McpError {
271 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272 write!(f, "{} ({})", self.message, self.code)
273 }
274}
275
276impl std::error::Error for McpError {}
277
278const MAX_MESSAGE_CHARS: usize = 512;
280
281const MAX_QUOTED_CHARS: usize = 32;
283
284pub(crate) fn sanitize(message: &str) -> String {
289 let mut out = String::with_capacity(message.len().min(MAX_MESSAGE_CHARS));
290 let mut in_quote = false;
291 let mut quoted = 0usize;
292 let mut escaped = false;
293 for c in message.chars() {
294 if in_quote {
295 if escaped {
296 escaped = false;
297 } else if c == '\\' {
298 escaped = true;
299 } else if c == '"' {
300 in_quote = false;
301 if quoted > MAX_QUOTED_CHARS {
302 out.push('…');
303 }
304 out.push('"');
305 continue;
306 }
307 quoted += 1;
308 if quoted <= MAX_QUOTED_CHARS {
309 out.push(c);
310 }
311 continue;
312 }
313 if c == '"' {
314 in_quote = true;
315 quoted = 0;
316 }
317 out.push(c);
318 if out.chars().count() >= MAX_MESSAGE_CHARS {
319 out.push('…');
320 break;
321 }
322 }
323 out
324}
325
326pub(crate) fn truncate(text: &str) -> Cow<'_, str> {
328 match text.char_indices().nth(MAX_ECHO_CHARS) {
329 Some((idx, _)) => Cow::Owned(format!("{}…", text.get(..idx).unwrap_or_default())),
330 None => Cow::Borrowed(text),
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn unsupported_version_carries_data() {
340 let err = McpError::unsupported_protocol_version("1900-01-01", &["2026-07-28".into()]);
341 assert_eq!(err.code(), -32022);
342 assert_eq!(
343 err.data(),
344 Some(&json!({ "supported": ["2026-07-28"], "requested": "1900-01-01" }))
345 );
346 }
347
348 #[test]
349 fn sanitize_shortens_quoted_input() {
350 let long = format!(
351 "invalid type: string \"{}\", expected f64 at line 1 column 9",
352 "A".repeat(10_000)
353 );
354 let clean = sanitize(&long);
355 assert!(clean.len() < 200, "{clean}");
356 assert!(clean.contains("expected f64"));
357 assert_eq!(sanitize("missing field `a`"), "missing field `a`");
358 assert!(sanitize(&"x".repeat(10_000)).chars().count() <= MAX_MESSAGE_CHARS + 1);
359 }
360
361 #[test]
362 fn echoed_text_is_truncated() {
363 let long = "x".repeat(1000);
364 let err = McpError::method_not_found(&long);
365 assert!(err.message().chars().count() < 200);
366 }
367}