pub mod recovery;
use std::fmt;
use thiserror::Error;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Error, Debug)]
pub enum Error {
#[error("Protocol error: {code} - {message}")]
Protocol {
code: ErrorCode,
message: String,
data: Option<serde_json::Value>,
},
#[error("Transport error: {0}")]
Transport(#[from] TransportError),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Validation error: {0}")]
Validation(String),
#[error("Authentication error: {0}")]
Authentication(String),
#[error("Request timed out after {0}ms")]
Timeout(u64),
#[error("Capability not supported: {0}")]
UnsupportedCapability(String),
#[error("Internal error: {0}")]
Internal(String),
#[error("Resource not found: {0}")]
NotFound(String),
#[error("Invalid state: {0}")]
InvalidState(String),
#[error("Operation cancelled")]
Cancelled,
#[error("Rate limit exceeded")]
RateLimited,
#[error("Circuit breaker is open")]
CircuitBreakerOpen,
#[error("{message}")]
ToolRejected {
message: String,
details: Option<serde_json::Value>,
},
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub const MRTR_ROUND_LIMIT_MARKER: &str = "MrtrRoundLimitExceeded";
pub const MRTR_INPUT_REQUIRED_MARKER: &str = "InputRequiredUnfulfilled";
pub const RETIRED_ON_V2_MARKER: &str = "RetiredOnV2";
pub const ISS_MISMATCH_MARKER: &str = "IssMismatch";
pub const STATE_MISMATCH_MARKER: &str = "StateMismatch";
pub const REAUTH_REQUIRED_MARKER: &str = "ReauthRequired";
const ISS_EXPECTED_KEY: &str = "expectedIssuer";
const ISS_ACTUAL_KEY: &str = "actualIssuer";
const REAUTH_ISSUER_KEY: &str = "issuer";
const PMCP_ERROR_KEY: &str = "pmcpError";
const RETIRED_METHOD_KEY: &str = "method";
const RETIRED_REPLACEMENT_KEY: &str = "replacement";
const MRTR_LIMIT_KEY: &str = "limit";
const MRTR_RESULT_KEY: &str = "result";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ErrorCode(pub i32);
impl ErrorCode {
pub const PARSE_ERROR: Self = Self(crate::types::protocol::error_codes::PARSE_ERROR);
pub const INVALID_REQUEST: Self = Self(crate::types::protocol::error_codes::INVALID_REQUEST);
pub const METHOD_NOT_FOUND: Self = Self(crate::types::protocol::error_codes::METHOD_NOT_FOUND);
pub const INVALID_PARAMS: Self = Self(crate::types::protocol::error_codes::INVALID_PARAMS);
pub const INTERNAL_ERROR: Self = Self(crate::types::protocol::error_codes::INTERNAL_ERROR);
pub const REQUEST_TIMEOUT: Self = Self(crate::types::protocol::error_codes::REQUEST_TIMEOUT);
pub const UNSUPPORTED_CAPABILITY: Self =
Self(crate::types::protocol::error_codes::UNSUPPORTED_CAPABILITY);
pub const AUTHENTICATION_REQUIRED: Self =
Self(crate::types::protocol::error_codes::AUTHENTICATION_REQUIRED);
pub const PERMISSION_DENIED: Self =
Self(crate::types::protocol::error_codes::PERMISSION_DENIED);
pub const RATE_LIMITED: Self = Self(crate::types::protocol::error_codes::RATE_LIMITED);
pub const CIRCUIT_BREAKER_OPEN: Self =
Self(crate::types::protocol::error_codes::CIRCUIT_BREAKER_OPEN);
pub const fn other(code: i32) -> Self {
Self(code)
}
pub fn as_i32(&self) -> i32 {
self.0
}
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::hash::Hash for ErrorCode {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.0.hash(state);
}
}
#[derive(Error, Debug)]
pub enum TransportError {
#[error("IO error: {0}")]
Io(String),
#[error("Connection closed")]
ConnectionClosed,
#[error("Invalid message format: {0}")]
InvalidMessage(String),
#[error("Serialization error: {0}")]
Serialization(String),
#[error("Deserialization error: {0}")]
Deserialization(String),
#[error("Request error: {0}")]
Request(String),
#[error("Send error: {0}")]
Send(String),
#[cfg(feature = "websocket")]
#[error("WebSocket error: {0}")]
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
#[cfg(feature = "http")]
#[error("HTTP error: {0}")]
Http(String),
}
impl From<std::io::Error> for TransportError {
fn from(err: std::io::Error) -> Self {
Self::Io(err.to_string())
}
}
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
Self::Transport(TransportError::Io(err.to_string()))
}
}
impl Error {
pub fn internal(message: impl Into<String>) -> Self {
Self::Internal(message.into())
}
pub fn protocol(code: ErrorCode, message: impl Into<String>) -> Self {
Self::Protocol {
code,
message: message.into(),
data: None,
}
}
pub fn error_code(&self) -> Option<ErrorCode> {
match self {
Self::Protocol { code, .. } => Some(*code),
Self::Timeout(_) => Some(ErrorCode::REQUEST_TIMEOUT),
Self::Authentication(_) => Some(ErrorCode::AUTHENTICATION_REQUIRED),
Self::RateLimited => Some(ErrorCode::RATE_LIMITED),
Self::CircuitBreakerOpen => Some(ErrorCode::CIRCUIT_BREAKER_OPEN),
_ => None,
}
}
pub fn validation(message: impl Into<String>) -> Self {
Self::Validation(message.into())
}
pub fn parse(message: impl Into<String>) -> Self {
Self::Protocol {
code: ErrorCode::PARSE_ERROR,
message: message.into(),
data: None,
}
}
pub fn authentication(message: impl Into<String>) -> Self {
Self::Authentication(message.into())
}
pub fn timeout(duration_ms: u64) -> Self {
Self::Timeout(duration_ms)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::NotFound(message.into())
}
pub fn tool_rejected(message: impl Into<String>, details: Option<serde_json::Value>) -> Self {
Self::ToolRejected {
message: message.into(),
details,
}
}
pub fn unsupported_capability(capability: impl Into<String>) -> Self {
Self::UnsupportedCapability(capability.into())
}
pub fn from_jsonrpc_error(error: crate::types::jsonrpc::JSONRPCError) -> Self {
Self::Protocol {
code: ErrorCode(error.code),
message: error.message,
data: error.data,
}
}
pub fn protocol_msg(message: impl Into<String>) -> Self {
Self::Protocol {
code: ErrorCode::INTERNAL_ERROR,
message: message.into(),
data: None,
}
}
pub fn is_error_code(&self, code: ErrorCode) -> bool {
matches!(self.error_code(), Some(c) if c == code)
}
pub fn capability(message: impl Into<String>) -> Self {
Self::UnsupportedCapability(message.into())
}
pub fn invalid_state(message: impl Into<String>) -> Self {
Self::InvalidState(message.into())
}
pub fn cancelled() -> Self {
Self::Cancelled
}
pub fn invalid_params(message: impl Into<String>) -> Self {
Self::Protocol {
code: ErrorCode::INVALID_PARAMS,
message: message.into(),
data: None,
}
}
pub fn method_not_found(method: impl Into<String>) -> Self {
Self::Protocol {
code: ErrorCode::METHOD_NOT_FOUND,
message: format!("Method not found: {}", method.into()),
data: None,
}
}
#[must_use]
pub fn mrtr_round_limit_exceeded(limit: usize) -> Self {
Self::Protocol {
code: ErrorCode::INTERNAL_ERROR,
message: format!(
"MRTR round limit exceeded: gave up after {limit} rounds without a complete result"
),
data: Some(serde_json::json!({
PMCP_ERROR_KEY: MRTR_ROUND_LIMIT_MARKER,
MRTR_LIMIT_KEY: limit,
})),
}
}
#[must_use]
pub fn is_mrtr_round_limit_exceeded(&self) -> bool {
self.pmcp_error_marker() == Some(MRTR_ROUND_LIMIT_MARKER)
}
#[must_use]
pub fn mrtr_round_limit(&self) -> Option<usize> {
if !self.is_mrtr_round_limit_exceeded() {
return None;
}
let limit = self.protocol_data()?.get(MRTR_LIMIT_KEY)?.as_u64()?;
usize::try_from(limit).ok()
}
#[must_use]
pub fn input_required_unfulfilled(result: crate::types::mrtr::InputRequiredResult) -> Self {
let payload = if result.raw.is_object() {
result.raw
} else {
serde_json::to_value(&result).unwrap_or(serde_json::Value::Null)
};
Self::Protocol {
code: ErrorCode::INTERNAL_ERROR,
message: "the server requires more input, and no registered handler could supply it — \
see Error::input_required_result() or the *_mrtr client methods"
.to_string(),
data: Some(serde_json::json!({
PMCP_ERROR_KEY: MRTR_INPUT_REQUIRED_MARKER,
MRTR_RESULT_KEY: payload,
})),
}
}
#[must_use]
pub fn is_input_required_unfulfilled(&self) -> bool {
self.pmcp_error_marker() == Some(MRTR_INPUT_REQUIRED_MARKER)
}
#[must_use]
pub fn input_required_result(&self) -> Option<crate::types::mrtr::InputRequiredResult> {
if !self.is_input_required_unfulfilled() {
return None;
}
let payload = self.protocol_data()?.get(MRTR_RESULT_KEY)?;
serde_json::from_value(payload.clone()).ok()
}
#[must_use]
pub fn retired_on_v2(method: &str, replacement: &str) -> Self {
Self::Protocol {
code: ErrorCode::METHOD_NOT_FOUND,
message: format!(
"{method} was removed in MCP 2026-07-28 and this connection speaks that version; \
use {replacement} instead"
),
data: Some(serde_json::json!({
PMCP_ERROR_KEY: RETIRED_ON_V2_MARKER,
RETIRED_METHOD_KEY: method,
RETIRED_REPLACEMENT_KEY: replacement,
})),
}
}
#[must_use]
pub fn is_retired_on_v2(&self) -> bool {
self.pmcp_error_marker() == Some(RETIRED_ON_V2_MARKER)
}
#[must_use]
pub fn retired_method(&self) -> Option<&str> {
self.retired_field(RETIRED_METHOD_KEY)
}
#[must_use]
pub fn retired_replacement(&self) -> Option<&str> {
self.retired_field(RETIRED_REPLACEMENT_KEY)
}
fn retired_field(&self, key: &str) -> Option<&str> {
self.marker_field(RETIRED_ON_V2_MARKER, key)
}
fn marker_field(&self, marker: &str, key: &str) -> Option<&str> {
if self.pmcp_error_marker() != Some(marker) {
return None;
}
self.protocol_data()?.get(key)?.as_str()
}
#[must_use]
pub fn iss_mismatch(expected: &str, actual: Option<&str>) -> Self {
let message = match actual {
Some(actual) => format!(
"authorization response `iss` mismatch: recorded issuer {expected}, response \
carried {actual} — rejecting per RFC 9207 §2.4"
),
None => format!(
"authorization response is missing the `iss` parameter, but {expected} advertises \
authorization_response_iss_parameter_supported — rejecting per RFC 9207 §2.4"
),
};
Self::Protocol {
code: ErrorCode::INVALID_REQUEST,
message,
data: Some(serde_json::json!({
PMCP_ERROR_KEY: ISS_MISMATCH_MARKER,
ISS_EXPECTED_KEY: expected,
ISS_ACTUAL_KEY: actual,
})),
}
}
#[must_use]
pub fn is_iss_mismatch(&self) -> bool {
self.pmcp_error_marker() == Some(ISS_MISMATCH_MARKER)
}
#[must_use]
pub fn iss_expected(&self) -> Option<&str> {
self.iss_field(ISS_EXPECTED_KEY)
}
#[must_use]
pub fn iss_actual(&self) -> Option<&str> {
self.iss_field(ISS_ACTUAL_KEY)
}
fn iss_field(&self, key: &str) -> Option<&str> {
self.marker_field(ISS_MISMATCH_MARKER, key)
}
#[must_use]
pub fn state_mismatch() -> Self {
Self::Protocol {
code: ErrorCode::INVALID_REQUEST,
message: "authorization response `state` did not match the value recorded for this \
authorization request; neither value is reproduced here because the \
expected one is a CSRF secret and the received one is attacker-controlled"
.to_string(),
data: Some(serde_json::json!({
PMCP_ERROR_KEY: STATE_MISMATCH_MARKER,
})),
}
}
#[must_use]
pub fn is_state_mismatch(&self) -> bool {
self.pmcp_error_marker() == Some(STATE_MISMATCH_MARKER)
}
#[must_use]
pub fn reauth_required(issuer: &str, reason: &str) -> Self {
Self::Protocol {
code: ErrorCode::INVALID_REQUEST,
message: format!("re-authorization with {issuer} is required: {reason}"),
data: Some(serde_json::json!({
PMCP_ERROR_KEY: REAUTH_REQUIRED_MARKER,
REAUTH_ISSUER_KEY: issuer,
})),
}
}
#[must_use]
pub fn is_reauth_required(&self) -> bool {
self.pmcp_error_marker() == Some(REAUTH_REQUIRED_MARKER)
}
#[must_use]
pub fn reauth_issuer(&self) -> Option<&str> {
self.marker_field(REAUTH_REQUIRED_MARKER, REAUTH_ISSUER_KEY)
}
fn protocol_data(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
match self {
Self::Protocol { data, .. } => data.as_ref()?.as_object(),
_ => None,
}
}
fn pmcp_error_marker(&self) -> Option<&str> {
self.protocol_data()?.get(PMCP_ERROR_KEY)?.as_str()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_creation() {
let err = Error::internal("test error");
assert!(matches!(err, Error::Internal(_)));
let err = Error::protocol(ErrorCode::INVALID_REQUEST, "bad request");
assert!(matches!(err, Error::Protocol { .. }));
}
#[test]
fn test_error_codes() {
assert_eq!(ErrorCode::PARSE_ERROR.as_i32(), -32700);
assert_eq!(ErrorCode::RATE_LIMITED.as_i32(), -32005);
assert_eq!(ErrorCode::CIRCUIT_BREAKER_OPEN.as_i32(), -32006);
}
mod mrtr {
use super::*;
use crate::types::mrtr::InputRequiredResult;
use serde_json::json;
fn input_required(raw: serde_json::Value) -> InputRequiredResult {
serde_json::from_value(raw).expect("the fixture is a valid input_required result")
}
#[test]
fn round_limit_error_is_distinguishable() {
let err = Error::mrtr_round_limit_exceeded(8);
assert!(err.is_mrtr_round_limit_exceeded());
assert!(!err.is_input_required_unfulfilled());
}
#[test]
fn an_unrelated_error_is_not_the_round_limit() {
assert!(!Error::internal("x").is_mrtr_round_limit_exceeded());
assert!(!Error::internal("x").is_input_required_unfulfilled());
assert!(!Error::protocol(ErrorCode::INTERNAL_ERROR, "x").is_mrtr_round_limit_exceeded());
}
#[test]
fn round_limit_error_carries_the_limit() {
assert_eq!(
Error::mrtr_round_limit_exceeded(8).mrtr_round_limit(),
Some(8)
);
assert_eq!(Error::internal("x").mrtr_round_limit(), None);
}
#[test]
fn round_limit_display_names_the_bound() {
let rendered = Error::mrtr_round_limit_exceeded(8).to_string();
assert!(
rendered.contains('8'),
"the limit must be visible: {rendered}"
);
assert!(
rendered.contains("round limit"),
"the reason must be visible: {rendered}"
);
}
#[test]
fn input_required_error_is_distinguishable() {
let err = Error::input_required_unfulfilled(input_required(
json!({ "resultType": "input_required", "requestState": "opaque" }),
));
assert!(err.is_input_required_unfulfilled());
assert!(!err.is_mrtr_round_limit_exceeded());
}
#[test]
fn input_required_error_round_trips_the_result() {
let raw = json!({
"resultType": "input_required",
"requestState": "opaque-token",
"inputRequests": {
"user_name": {
"method": "elicitation/create",
"params": { "message": "who?", "requestedSchema": {} }
}
},
"_meta": { "vendor/key": 1 },
"somethingUnmodelled": true
});
let err = Error::input_required_unfulfilled(input_required(raw.clone()));
let recovered = err.input_required_result().expect("the payload survives");
assert_eq!(recovered.request_state.as_deref(), Some("opaque-token"));
assert_eq!(recovered.result_type, "input_required");
let requests = recovered.input_requests.expect("inputRequests survive");
assert_eq!(requests.len(), 1);
assert!(requests.contains_key("user_name"));
assert_eq!(
recovered.raw, raw,
"the VERBATIM result object must survive, unmodelled keys included"
);
}
#[test]
fn input_required_result_is_none_for_other_errors() {
assert!(Error::internal("x").input_required_result().is_none());
assert!(Error::mrtr_round_limit_exceeded(8)
.input_required_result()
.is_none());
}
#[test]
fn both_errors_carry_a_wrapped_error_code() {
for err in [
Error::mrtr_round_limit_exceeded(3),
Error::input_required_unfulfilled(input_required(
json!({ "resultType": "input_required" }),
)),
] {
assert!(matches!(err, Error::Protocol { .. }));
assert_eq!(err.error_code(), Some(ErrorCode::INTERNAL_ERROR));
}
}
#[test]
fn markers_are_stable_strings() {
assert_eq!(MRTR_ROUND_LIMIT_MARKER, "MrtrRoundLimitExceeded");
assert_eq!(MRTR_INPUT_REQUIRED_MARKER, "InputRequiredUnfulfilled");
assert_eq!(RETIRED_ON_V2_MARKER, "RetiredOnV2");
}
}
mod retired_on_v2 {
use super::*;
#[test]
fn it_is_identifiable_and_carries_both_names() {
let err = Error::retired_on_v2("resources/subscribe", "subscriptions/listen");
assert!(err.is_retired_on_v2());
assert_eq!(err.retired_method(), Some("resources/subscribe"));
assert_eq!(err.retired_replacement(), Some("subscriptions/listen"));
}
#[test]
fn the_message_names_the_replacement() {
let err = Error::retired_on_v2("resources/unsubscribe", "subscriptions/listen");
let message = err.to_string();
assert!(message.contains("subscriptions/listen"), "{message}");
assert!(message.contains("resources/unsubscribe"), "{message}");
}
#[test]
fn it_rides_the_protocol_variant_with_method_not_found() {
let err = Error::retired_on_v2("resources/subscribe", "subscriptions/listen");
assert!(matches!(err, Error::Protocol { .. }));
assert_eq!(err.error_code(), Some(ErrorCode::METHOD_NOT_FOUND));
}
#[test]
fn other_errors_are_not_mistaken_for_it() {
for err in [
Error::internal("nope"),
Error::protocol(ErrorCode::METHOD_NOT_FOUND, "Method not found: whatever"),
Error::mrtr_round_limit_exceeded(3),
] {
assert!(!err.is_retired_on_v2(), "{err}");
assert!(err.retired_method().is_none());
assert!(err.retired_replacement().is_none());
}
}
}
mod auth_markers {
use super::*;
#[test]
fn iss_mismatch_is_identifiable_and_carries_both_issuers() {
let err = Error::iss_mismatch("https://as.example", Some("https://evil.example"));
assert!(err.is_iss_mismatch());
assert_eq!(err.iss_expected(), Some("https://as.example"));
assert_eq!(err.iss_actual(), Some("https://evil.example"));
}
#[test]
fn iss_mismatch_with_an_absent_iss_reports_no_actual_issuer() {
let err = Error::iss_mismatch("https://as.example", None);
assert!(err.is_iss_mismatch());
assert_eq!(err.iss_expected(), Some("https://as.example"));
assert_eq!(err.iss_actual(), None);
}
#[test]
fn iss_mismatch_messages_distinguish_a_wrong_iss_from_an_absent_one() {
let wrong =
Error::iss_mismatch("https://as.example", Some("https://evil.example")).to_string();
let absent = Error::iss_mismatch("https://as.example", None).to_string();
assert_ne!(wrong, absent, "the two rows must not read identically");
assert!(wrong.contains("https://evil.example"), "{wrong}");
assert!(
absent.contains("missing") || absent.contains("absent"),
"the row-2 message must say the parameter was not sent: {absent}"
);
}
#[test]
fn state_mismatch_discloses_neither_the_expected_nor_the_received_value() {
let err = Error::state_mismatch();
assert!(err.is_state_mismatch());
let rendered = err.to_string();
for secret in [
"Ml3n4L0PxQ-expected-csrf-secret",
"attacker-supplied-state-value",
] {
assert!(
!rendered.contains(secret),
"the refusal must not reproduce a state value: {rendered}"
);
}
assert!(
rendered.contains("state"),
"the reason must still be legible: {rendered}"
);
}
#[test]
fn reauth_required_names_its_issuer_and_its_reason() {
let err = Error::reauth_required("https://as.example", "refresh failed");
assert!(err.is_reauth_required());
assert_eq!(err.reauth_issuer(), Some("https://as.example"));
let rendered = err.to_string();
assert!(rendered.contains("refresh failed"), "{rendered}");
assert!(rendered.contains("https://as.example"), "{rendered}");
}
#[test]
fn iss_mismatch_state_mismatch_and_reauth_required_ride_the_protocol_variant() {
for err in [
Error::iss_mismatch("https://as.example", Some("https://evil.example")),
Error::iss_mismatch("https://as.example", None),
Error::state_mismatch(),
Error::reauth_required("https://as.example", "refresh failed"),
] {
assert!(matches!(err, Error::Protocol { .. }), "{err}");
assert_eq!(err.error_code(), Some(ErrorCode::INVALID_REQUEST));
}
}
#[test]
fn unrelated_errors_are_neither_iss_mismatch_state_mismatch_nor_reauth_required() {
for err in [
Error::internal("nope"),
Error::protocol(ErrorCode::INVALID_REQUEST, "bad request"),
Error::retired_on_v2("resources/subscribe", "subscriptions/listen"),
Error::mrtr_round_limit_exceeded(3),
] {
assert!(!err.is_iss_mismatch(), "{err}");
assert!(!err.is_state_mismatch(), "{err}");
assert!(!err.is_reauth_required(), "{err}");
assert!(err.iss_expected().is_none());
assert!(err.iss_actual().is_none());
assert!(err.reauth_issuer().is_none());
}
let iss = Error::iss_mismatch("https://as.example", None);
assert!(!iss.is_state_mismatch());
assert!(!iss.is_reauth_required());
assert!(iss.reauth_issuer().is_none());
let state = Error::state_mismatch();
assert!(!state.is_iss_mismatch());
assert!(!state.is_reauth_required());
let reauth = Error::reauth_required("https://as.example", "x");
assert!(!reauth.is_iss_mismatch());
assert!(!reauth.is_state_mismatch());
assert!(reauth.iss_expected().is_none());
}
#[test]
fn the_authentication_variant_cannot_carry_an_iss_mismatch_marker() {
let err = Error::Authentication(format!(
"{{\"pmcpError\":\"{ISS_MISMATCH_MARKER}\"}} looks like a marker but is a String"
));
assert!(!err.is_iss_mismatch());
assert!(!err.is_state_mismatch());
assert!(!err.is_reauth_required());
assert!(err.iss_expected().is_none());
}
#[test]
fn iss_mismatch_state_mismatch_and_reauth_required_markers_are_stable_strings() {
assert_eq!(ISS_MISMATCH_MARKER, "IssMismatch");
assert_eq!(STATE_MISMATCH_MARKER, "StateMismatch");
assert_eq!(REAUTH_REQUIRED_MARKER, "ReauthRequired");
}
}
}