Skip to main content

flymcprust_core/
error.rs

1//! Protocol-level (JSON-RPC) errors.
2//!
3//! Messages are written for the client and never contain internal details.
4//! Codes follow the 2026-07-28 allocation policy: standard JSON-RPC codes plus
5//! the MCP-reserved `-32020..=-32022`; the legacy `-32000..=-32019` range is
6//! never used.
7
8use std::borrow::Cow;
9
10use serde_json::{Value, json};
11
12use crate::spec::{self, error_codes};
13
14/// Maximum length of client-provided text echoed back in error messages.
15const MAX_ECHO_CHARS: usize = 128;
16
17/// How an error relates to the request, independent of transport. HTTP uses
18/// it to pick a status code (400, 404, 200 or 500).
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum ErrorKind {
22    /// The request is malformed or cannot be accepted as sent.
23    BadRequest,
24    /// The method does not exist.
25    MethodNotFound,
26    /// A well-formed request that could not be completed (e.g. unknown tool).
27    Failed,
28    /// A server-side failure.
29    Internal,
30    /// The access token lacks scopes the request needs (HTTP 403 with an
31    /// `insufficient_scope` challenge).
32    Forbidden,
33}
34
35/// A JSON-RPC error returned to the client.
36#[derive(Debug, Clone, PartialEq)]
37pub struct McpError {
38    code: i64,
39    kind: ErrorKind,
40    message: Cow<'static, str>,
41    // Boxed to keep `McpError` (and every `Result` carrying it) small.
42    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    /// `-32700`: the message is not valid JSON.
56    #[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    /// `-32600`: the message is not a valid JSON-RPC request.
66    #[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    /// `-32601`: the method does not exist.
76    #[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    /// `-32601` with a custom, client-safe explanation.
86    #[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    /// `-32602`: the parameters are malformed (including missing `_meta`).
96    #[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    /// `-32602`: the requested tool does not exist.
106    #[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    /// `-32602`: a well-formed request the handler refused, with a
116    /// client-safe message (for example a `ToolError::user` raised while
117    /// reading a resource or getting a prompt).
118    #[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    /// `-32602` with `data.uri`: the resource does not exist (the
128    /// 2026-07-28 code for resource-not-found).
129    #[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    /// `-32602`: the requested prompt does not exist.
141    #[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    /// `-32603` with a client-safe message (no internal details).
151    #[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    /// `-32603`: a generic internal error. Details must be logged, not sent.
157    #[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    /// `-32600` with `data.requiredScopes`: the access token does not grant
167    /// every scope the handler needs. Over HTTP this is a `403` whose
168    /// `WWW-Authenticate` challenge lists the scopes.
169    #[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    /// The scopes listed by [`insufficient_scope`](Self::insufficient_scope).
181    #[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    /// `-32020`: HTTP headers do not match the request body.
192    #[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    /// `-32021`: the request needs client capabilities the client did not declare.
202    #[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    /// `-32022`: the requested protocol version is not supported.
214    #[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    /// The same error with another code (used to adapt errors to older
228    /// protocol versions).
229    #[must_use]
230    pub(crate) fn with_code(mut self, code: i64) -> Self {
231        self.code = code;
232        self
233    }
234
235    /// The JSON-RPC error code.
236    #[must_use]
237    pub fn code(&self) -> i64 {
238        self.code
239    }
240
241    /// How the error relates to the request.
242    #[must_use]
243    pub fn kind(&self) -> ErrorKind {
244        self.kind
245    }
246
247    /// The client-facing message.
248    #[must_use]
249    pub fn message(&self) -> &str {
250        &self.message
251    }
252
253    /// The optional `data` member.
254    #[must_use]
255    pub fn data(&self) -> Option<&Value> {
256        self.data.as_deref()
257    }
258
259    /// Converts into the wire representation.
260    #[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
278/// Maximum length of an error message built from client input.
279const MAX_MESSAGE_CHARS: usize = 512;
280
281/// Maximum length of a quoted fragment kept in a sanitized message.
282const MAX_QUOTED_CHARS: usize = 32;
283
284/// Makes a message that may quote client input safe to send back: quoted
285/// fragments longer than a few words are shortened (deserializer messages
286/// quote the offending value), and the whole message is capped, so a large
287/// request never produces a large error.
288pub(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
326/// Shortens client-provided text before echoing it in an error message.
327pub(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}