use serde_json::Value;
use thiserror::Error;
pub type McpClientResult<T> = Result<T, McpClientError>;
#[derive(Error, Debug)]
pub enum McpClientError {
#[error("Transport error: {0}")]
Transport(#[from] TransportError),
#[error("Protocol error: {0}")]
Protocol(#[from] ProtocolError),
#[error("Session error: {0}")]
Session(#[from] SessionError),
#[error("Authentication error: {0}")]
Auth(String),
#[error("Configuration error: {0}")]
Config(String),
#[error("Connection error: {0}")]
Connection(#[from] reqwest::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Input required: the server needs client input before completing")]
InputRequired {
input_requests: Option<serde_json::Value>,
request_state: Option<String>,
},
#[error("Operation timed out")]
Timeout,
#[error("Server error (code {code}): {message}")]
ServerError {
code: i32,
message: String,
data: Option<Value>,
},
#[error("Error: {message}")]
Generic { message: String },
}
#[derive(Error, Debug)]
pub enum TransportError {
#[error("HTTP transport error: {0}")]
Http(String),
#[error("SSE transport error: {0}")]
Sse(String),
#[error("Stdio transport error: {0}")]
Stdio(String),
#[error("Unsupported transport: {0}")]
Unsupported(String),
#[error("Connection failed: {0}")]
ConnectionFailed(String),
#[error("HTTP {status}: {message}")]
HttpStatus { status: u16, message: String },
#[error("Transport closed unexpectedly")]
Closed,
}
#[derive(Error, Debug)]
pub enum ProtocolError {
#[error("Invalid JSON-RPC request: {0}")]
InvalidRequest(String),
#[error("Invalid JSON-RPC response: {0}")]
InvalidResponse(String),
#[error("Unsupported protocol version: {0}")]
UnsupportedVersion(String),
#[error("Method not found: {0}")]
MethodNotFound(String),
#[error("Invalid parameters: {0}")]
InvalidParams(String),
#[error("Protocol negotiation failed: {0}")]
NegotiationFailed(String),
#[error("Capability mismatch: {0}")]
CapabilityMismatch(String),
}
#[derive(Error, Debug)]
pub enum SessionError {
#[error("Session not initialized")]
NotInitialized,
#[error("Session already initialized")]
AlreadyInitialized,
#[error("Session expired")]
Expired,
#[error("Session terminated")]
Terminated,
#[error("Invalid session state: expected {expected}, found {actual}")]
InvalidState { expected: String, actual: String },
#[error("Session recovery failed: {0}")]
RecoveryFailed(String),
}
impl McpClientError {
pub fn generic(message: impl Into<String>) -> Self {
Self::Generic {
message: message.into(),
}
}
pub fn config(message: impl Into<String>) -> Self {
Self::Config(message.into())
}
pub fn auth(message: impl Into<String>) -> Self {
Self::Auth(message.into())
}
pub fn server_error(code: i32, message: impl Into<String>, data: Option<Value>) -> Self {
Self::ServerError {
code,
message: message.into(),
data,
}
}
pub fn is_resource_not_found(&self, version: crate::version::McpVersion) -> bool {
use crate::version::McpVersion;
matches!(
(self, version),
(Self::ServerError { code: -32602, .. }, _)
| (
Self::ServerError { code: -32002, .. },
McpVersion::V2025_11_25
)
)
}
pub fn is_retryable(&self) -> bool {
match self {
Self::Transport(TransportError::ConnectionFailed(_)) => true,
Self::Transport(TransportError::Closed) => true,
Self::Connection(_) => true,
Self::Timeout => true,
Self::ServerError { code, .. } => {
matches!(code, -32099..=-32000) }
_ => false,
}
}
pub fn is_session_expired(&self) -> bool {
matches!(
self,
Self::Transport(TransportError::HttpStatus { status: 404, .. })
)
}
pub fn is_session_not_initialized(&self) -> bool {
match self {
Self::ServerError { code, message, .. } => {
*code == -32031 || message.contains("Session not initialized")
}
_ => false,
}
}
pub fn is_protocol_error(&self) -> bool {
matches!(self, Self::Protocol(_))
}
pub fn is_session_error(&self) -> bool {
matches!(self, Self::Session(_))
}
pub fn error_code(&self) -> Option<i32> {
match self {
Self::ServerError { code, .. } => Some(*code),
_ => None,
}
}
}
#[macro_export]
macro_rules! client_error {
($($arg:tt)*) => {
$crate::error::McpClientError::generic(format!($($arg)*))
};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_404_is_session_expired() {
let err = McpClientError::Transport(TransportError::HttpStatus {
status: 404,
message: "Not Found".to_string(),
});
assert!(err.is_session_expired());
}
#[test]
fn test_500_is_not_session_expired() {
let err = McpClientError::Transport(TransportError::HttpStatus {
status: 500,
message: "Internal".to_string(),
});
assert!(!err.is_session_expired());
}
#[test]
fn test_http_status_error_display() {
let err = TransportError::HttpStatus {
status: 404,
message: "Not Found".to_string(),
};
assert_eq!(err.to_string(), "HTTP 404: Not Found");
}
#[test]
fn test_session_not_initialized_by_code() {
let err = McpClientError::server_error(-32031, "Session error: something", None);
assert!(err.is_session_not_initialized());
}
#[test]
fn test_session_not_initialized_by_message() {
let err = McpClientError::server_error(
-32000,
"Session not initialized - client must send notifications/initialized first",
None,
);
assert!(err.is_session_not_initialized());
}
#[test]
fn test_unrelated_error_is_not_session_not_initialized() {
let err = McpClientError::server_error(-32602, "Invalid params", None);
assert!(!err.is_session_not_initialized());
}
#[test]
fn test_transport_error_is_not_session_not_initialized() {
let err = McpClientError::Transport(TransportError::HttpStatus {
status: 500,
message: "Internal".to_string(),
});
assert!(!err.is_session_not_initialized());
}
}
#[cfg(test)]
mod resource_not_found_tests {
use super::*;
use crate::version::McpVersion;
#[test]
fn resource_not_found_accepts_both_codes_on_2025_11_25() {
assert!(
McpClientError::server_error(-32602, "no such resource", None)
.is_resource_not_found(McpVersion::V2025_11_25)
);
assert!(
McpClientError::server_error(-32002, "no such resource", None)
.is_resource_not_found(McpVersion::V2025_11_25)
);
assert!(
!McpClientError::server_error(-32601, "no such method", None)
.is_resource_not_found(McpVersion::V2025_11_25)
);
assert!(!McpClientError::Timeout.is_resource_not_found(McpVersion::V2025_11_25));
}
#[test]
fn resource_not_found_rejects_legacy_code_on_2026_07_28() {
assert!(
McpClientError::server_error(-32602, "no such resource", None)
.is_resource_not_found(McpVersion::V2026_07_28)
);
assert!(
!McpClientError::server_error(-32002, "no such resource", None)
.is_resource_not_found(McpVersion::V2026_07_28)
);
assert!(
!McpClientError::server_error(-32601, "no such method", None)
.is_resource_not_found(McpVersion::V2026_07_28)
);
assert!(!McpClientError::Timeout.is_resource_not_found(McpVersion::V2026_07_28));
}
}