use serde_json::{Map, Value};
pub const INVALID_REQUEST: i64 = -32600;
pub const METHOD_NOT_FOUND: i64 = -32601;
pub const INTERNAL_ERROR: i64 = -32603;
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
pub enum McpError {
#[error("{message}")]
Rpc {
code: i64,
message: String,
data: Option<Value>,
},
#[error("{0}")]
Closed(String),
#[error("MCP request timed out after {0}ms")]
Timeout(u64),
#[error("MCP request aborted")]
Aborted,
#[error("{message}")]
Http {
status: u16,
message: String,
},
#[error("MCP server requires authentication")]
AuthRequired {
challenge: Option<String>,
},
#[error("MCP session expired")]
SessionExpired,
#[error("MCP OAuth authorization requires user interaction")]
SignInRequired,
#[error("{0}")]
Network(String),
#[error("{0}")]
Other(String),
}
impl McpError {
pub fn closed() -> McpError {
McpError::Closed("MCP connection closed".into())
}
pub fn invalid(message: impl Into<String>) -> McpError {
McpError::Rpc {
code: INVALID_REQUEST,
message: message.into(),
data: None,
}
}
pub fn status(&self) -> Option<u16> {
match self {
McpError::Http { status, .. } => Some(*status),
McpError::AuthRequired { .. } => Some(401),
McpError::SessionExpired => Some(404),
_ => None,
}
}
pub fn is_transient(&self) -> bool {
match self {
McpError::Network(_) => true,
McpError::Http { status, .. } => {
*status == 408 || *status == 429 || (*status >= 500 && *status != 501)
}
_ => false,
}
}
}
#[derive(Debug, PartialEq)]
pub enum Incoming<'a> {
Request {
id: &'a Value,
method: &'a str,
params: Option<&'a Value>,
},
Notification {
method: &'a str,
params: Option<&'a Value>,
},
Response {
id: &'a Value,
outcome: Result<&'a Value, McpError>,
},
}
pub fn is_id(value: &Value) -> bool {
value.is_string() || value.as_f64().is_some_and(f64::is_finite)
}
pub fn classify(value: &Value) -> Option<Incoming<'_>> {
let object = value.as_object()?;
if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
return None;
}
let method = object.get("method").and_then(Value::as_str);
match (object.get("id"), method) {
(Some(id), Some(method)) if is_id(id) => Some(Incoming::Request {
id,
method,
params: object.get("params"),
}),
(None, Some(method)) => Some(Incoming::Notification {
method,
params: object.get("params"),
}),
(Some(id), None) if is_id(id) => response(id, object),
_ => None,
}
}
fn response<'a>(id: &'a Value, object: &'a Map<String, Value>) -> Option<Incoming<'a>> {
if let Some(result) = object.get("result") {
return (!object.contains_key("error")).then_some(Incoming::Response {
id,
outcome: Ok(result),
});
}
let error = object.get("error")?.as_object()?;
let code = error.get("code")?.as_f64()?;
let message = error.get("message")?.as_str()?;
Some(Incoming::Response {
id,
outcome: Err(McpError::Rpc {
code: code as i64,
message: message.to_owned(),
data: error.get("data").cloned(),
}),
})
}
pub fn id_key(id: &Value) -> String {
id.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn classifies_like_pi() {
let request = json!({"jsonrpc": "2.0", "id": 1, "method": "ping"});
assert!(matches!(
classify(&request),
Some(Incoming::Request { method: "ping", .. })
));
let notification =
json!({"jsonrpc": "2.0", "method": "notifications/progress", "params": {}});
assert!(matches!(
classify(¬ification),
Some(Incoming::Notification { .. })
));
let failure =
json!({"jsonrpc": "2.0", "id": "a", "error": {"code": -32601, "message": "nope"}});
assert_eq!(
classify(&failure),
Some(Incoming::Response {
id: &json!("a"),
outcome: Err(McpError::Rpc {
code: -32601,
message: "nope".into(),
data: None
})
})
);
assert_eq!(
classify(&json!({"jsonrpc": "2.0", "id": 1, "result": 1, "error": {}})),
None
);
assert_eq!(classify(&json!({"jsonrpc": "1.0", "method": "x"})), None);
assert_ne!(id_key(&json!(1)), id_key(&json!("1")));
}
}