Skip to main content

yapi_core/mcp/
jsonrpc.rs

1//! JSON-RPC 2.0 messages as MCP exchanges them, and the client's errors. Port
2//! of `protocol/jsonrpc.ts` in pi-mcp `v1.0.0`.
3
4use serde_json::{Map, Value};
5
6/// The request was not valid JSON-RPC.
7pub const INVALID_REQUEST: i64 = -32600;
8/// The method does not exist.
9pub const METHOD_NOT_FOUND: i64 = -32601;
10/// The handler failed.
11pub const INTERNAL_ERROR: i64 = -32603;
12
13/// Why an MCP request or connection failed. Messages match pi-mcp's.
14#[derive(Clone, Debug, PartialEq, thiserror::Error)]
15pub enum McpError {
16    /// The server answered with a JSON-RPC error, or sent something invalid.
17    #[error("{message}")]
18    Rpc {
19        /// JSON-RPC error code.
20        code: i64,
21        /// The server's message.
22        message: String,
23        /// Extra data the server attached.
24        data: Option<Value>,
25    },
26    /// The connection is closed or not open.
27    #[error("{0}")]
28    Closed(String),
29    /// No response within the timeout.
30    #[error("MCP request timed out after {0}ms")]
31    Timeout(u64),
32    /// The caller cancelled the request.
33    #[error("MCP request aborted")]
34    Aborted,
35    /// An HTTP request failed with a status.
36    #[error("{message}")]
37    Http {
38        /// HTTP status.
39        status: u16,
40        /// Description with the start of the body.
41        message: String,
42    },
43    /// The server answered 401.
44    #[error("MCP server requires authentication")]
45    AuthRequired {
46        /// The `WWW-Authenticate` challenge.
47        challenge: Option<String>,
48    },
49    /// The server no longer knows the session (404 with a session id).
50    #[error("MCP session expired")]
51    SessionExpired,
52    /// The stored OAuth credentials cannot be used or refreshed, so the user
53    /// has to sign in (again).
54    #[error("MCP OAuth authorization requires user interaction")]
55    SignInRequired,
56    /// The request could not reach the server.
57    #[error("{0}")]
58    Network(String),
59    /// Anything else, such as a server process that could not start.
60    #[error("{0}")]
61    Other(String),
62}
63
64impl McpError {
65    /// pi's `McpConnectionClosedError` with its default message.
66    pub fn closed() -> McpError {
67        McpError::Closed("MCP connection closed".into())
68    }
69
70    /// An invalid-request error with `message`.
71    pub fn invalid(message: impl Into<String>) -> McpError {
72        McpError::Rpc {
73            code: INVALID_REQUEST,
74            message: message.into(),
75            data: None,
76        }
77    }
78
79    /// The HTTP status, for HTTP failures.
80    pub fn status(&self) -> Option<u16> {
81        match self {
82            McpError::Http { status, .. } => Some(*status),
83            McpError::AuthRequired { .. } => Some(401),
84            McpError::SessionExpired => Some(404),
85            _ => None,
86        }
87    }
88
89    /// Network failures and overloaded or restarting servers, worth another
90    /// attempt.
91    pub fn is_transient(&self) -> bool {
92        match self {
93            McpError::Network(_) => true,
94            McpError::Http { status, .. } => {
95                *status == 408 || *status == 429 || (*status >= 500 && *status != 501)
96            }
97            _ => false,
98        }
99    }
100}
101
102/// A message as received, classified.
103#[derive(Debug, PartialEq)]
104pub enum Incoming<'a> {
105    /// A request from the server.
106    Request {
107        /// Its id.
108        id: &'a Value,
109        /// Its method.
110        method: &'a str,
111        /// Its parameters.
112        params: Option<&'a Value>,
113    },
114    /// A notification from the server.
115    Notification {
116        /// Its method.
117        method: &'a str,
118        /// Its parameters.
119        params: Option<&'a Value>,
120    },
121    /// A response to one of the client's requests.
122    Response {
123        /// The request's id.
124        id: &'a Value,
125        /// The result, or the error.
126        outcome: Result<&'a Value, McpError>,
127    },
128}
129
130/// Whether `value` can be a JSON-RPC id: a string or a finite number.
131pub fn is_id(value: &Value) -> bool {
132    value.is_string() || value.as_f64().is_some_and(f64::is_finite)
133}
134
135/// Classifies a message, or `None` when it is not valid JSON-RPC.
136pub fn classify(value: &Value) -> Option<Incoming<'_>> {
137    let object = value.as_object()?;
138    if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
139        return None;
140    }
141    let method = object.get("method").and_then(Value::as_str);
142    match (object.get("id"), method) {
143        (Some(id), Some(method)) if is_id(id) => Some(Incoming::Request {
144            id,
145            method,
146            params: object.get("params"),
147        }),
148        (None, Some(method)) => Some(Incoming::Notification {
149            method,
150            params: object.get("params"),
151        }),
152        (Some(id), None) if is_id(id) => response(id, object),
153        _ => None,
154    }
155}
156
157fn response<'a>(id: &'a Value, object: &'a Map<String, Value>) -> Option<Incoming<'a>> {
158    if let Some(result) = object.get("result") {
159        return (!object.contains_key("error")).then_some(Incoming::Response {
160            id,
161            outcome: Ok(result),
162        });
163    }
164    let error = object.get("error")?.as_object()?;
165    let code = error.get("code")?.as_f64()?;
166    let message = error.get("message")?.as_str()?;
167    Some(Incoming::Response {
168        id,
169        outcome: Err(McpError::Rpc {
170            code: code as i64,
171            message: message.to_owned(),
172            data: error.get("data").cloned(),
173        }),
174    })
175}
176
177/// A key for an id, so string `"1"` and number `1` stay distinct.
178pub fn id_key(id: &Value) -> String {
179    id.to_string()
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use serde_json::json;
186
187    #[test]
188    fn classifies_like_pi() {
189        let request = json!({"jsonrpc": "2.0", "id": 1, "method": "ping"});
190        assert!(matches!(
191            classify(&request),
192            Some(Incoming::Request { method: "ping", .. })
193        ));
194        let notification =
195            json!({"jsonrpc": "2.0", "method": "notifications/progress", "params": {}});
196        assert!(matches!(
197            classify(&notification),
198            Some(Incoming::Notification { .. })
199        ));
200        let failure =
201            json!({"jsonrpc": "2.0", "id": "a", "error": {"code": -32601, "message": "nope"}});
202        assert_eq!(
203            classify(&failure),
204            Some(Incoming::Response {
205                id: &json!("a"),
206                outcome: Err(McpError::Rpc {
207                    code: -32601,
208                    message: "nope".into(),
209                    data: None
210                })
211            })
212        );
213        assert_eq!(
214            classify(&json!({"jsonrpc": "2.0", "id": 1, "result": 1, "error": {}})),
215            None
216        );
217        assert_eq!(classify(&json!({"jsonrpc": "1.0", "method": "x"})), None);
218        assert_ne!(id_key(&json!(1)), id_key(&json!("1")));
219    }
220}