use std::time::Duration;
use rmcp::{
model::CallToolResult, service::ServiceError,
transport::streamable_http_client::StreamableHttpError,
};
use serde_json::json;
use crate::mcp_client::McpClientError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectorErrorKind {
Transport,
Auth,
Application,
Config,
}
impl ConnectorErrorKind {
#[must_use]
pub const fn retryable(self) -> bool {
matches!(self, Self::Transport)
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Transport => "transport_unreachable",
Self::Auth => "auth_rejected",
Self::Application => "application_error",
Self::Config => "config_error",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct CallRetryPolicy {
pub budget: Duration,
pub base_delay: Duration,
pub max_delay: Duration,
pub call_timeout: Duration,
}
impl CallRetryPolicy {
pub const DEFAULT: Self = Self {
budget: Duration::from_secs(2),
base_delay: Duration::from_millis(100),
max_delay: Duration::from_millis(500),
call_timeout: Duration::from_secs(30),
};
}
impl Default for CallRetryPolicy {
fn default() -> Self {
Self::DEFAULT
}
}
pub(crate) fn classify_call_error(err: &ServiceError) -> ConnectorErrorKind {
match err {
ServiceError::McpError(_) | ServiceError::InputRequiredRoundsExceeded { .. } => {
ConnectorErrorKind::Application
}
ServiceError::TransportSend(dyn_err) => {
if transport_source_is_auth(dyn_err.error.as_ref()) {
ConnectorErrorKind::Auth
} else {
ConnectorErrorKind::Transport
}
}
_ => ConnectorErrorKind::Transport,
}
}
pub(crate) fn transport_source_is_auth(err: &(dyn std::error::Error + 'static)) -> bool {
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
while let Some(e) = current {
if let Some(se) = e.downcast_ref::<StreamableHttpError<reqwest::Error>>() {
match se {
StreamableHttpError::AuthRequired(_)
| StreamableHttpError::InsufficientScope(_) => {
return true;
}
StreamableHttpError::Client(re) if reqwest_is_auth(re) => return true,
_ => {}
}
}
if let Some(re) = e.downcast_ref::<reqwest::Error>()
&& reqwest_is_auth(re)
{
return true;
}
current = e.source();
}
false
}
fn reqwest_is_auth(err: &reqwest::Error) -> bool {
err.status().is_some_and(|s| {
s == reqwest::StatusCode::UNAUTHORIZED || s == reqwest::StatusCode::FORBIDDEN
})
}
pub(crate) fn dial_error<E: std::error::Error + 'static>(err: E) -> McpClientError {
if transport_source_is_auth(&err) {
McpClientError::AuthRejected(err.to_string())
} else {
McpClientError::Init(err.to_string())
}
}
pub(crate) fn success_json(result: CallToolResult) -> String {
if let Some(structured) = result.structured_content {
structured.to_string()
} else {
let text: String = result
.content
.iter()
.filter_map(|c| c.as_text().map(|t| t.text.clone()))
.collect();
if text.is_empty() {
json!({ "ok": true }).to_string()
} else {
serde_json::from_str::<serde_json::Value>(&text)
.map_or_else(|_| json!({ "result": text }).to_string(), |v| v.to_string())
}
}
}
pub(crate) fn result_message(result: &CallToolResult) -> String {
if let Some(structured) = &result.structured_content {
return structured.to_string();
}
let text: String = result
.content
.iter()
.filter_map(|c| c.as_text().map(|t| t.text.clone()))
.collect();
if text.is_empty() {
"The tool reported an error.".to_owned()
} else {
text
}
}
#[must_use]
pub const fn transport_failure_message() -> &'static str {
"The tool is temporarily unavailable: its connector could not be reached. \
Try again in a moment."
}
pub(crate) fn call_error_message(kind: ConnectorErrorKind, err: &ServiceError) -> String {
match kind {
ConnectorErrorKind::Transport => transport_failure_message().to_owned(),
ConnectorErrorKind::Auth => {
"The tool's connector rejected the request. Its access needs to be renewed.".to_owned()
}
ConnectorErrorKind::Config => {
"The tool's connector is misconfigured. Its setup needs to be corrected.".to_owned()
}
ConnectorErrorKind::Application => match err {
ServiceError::McpError(data) => data.message.to_string(),
other => other.to_string(),
},
}
}
#[must_use]
pub fn dial_failure_message(kind: ConnectorErrorKind) -> String {
match kind {
ConnectorErrorKind::Auth => {
"The tool's connector rejected the request. Its access needs to be renewed.".to_owned()
}
ConnectorErrorKind::Config => {
"The tool's connector is misconfigured. Its setup needs to be corrected.".to_owned()
}
ConnectorErrorKind::Transport | ConnectorErrorKind::Application => {
transport_failure_message().to_owned()
}
}
}
#[must_use]
pub fn failure_json(kind: ConnectorErrorKind, message: &str) -> String {
json!({ "error": message, "kind": kind.as_str() }).to_string()
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
#[test]
fn only_transport_is_retryable() {
assert!(ConnectorErrorKind::Transport.retryable());
assert!(!ConnectorErrorKind::Auth.retryable());
assert!(!ConnectorErrorKind::Application.retryable());
assert!(!ConnectorErrorKind::Config.retryable());
}
#[test]
fn kind_tokens_are_stable() {
assert_eq!(
ConnectorErrorKind::Transport.as_str(),
"transport_unreachable"
);
assert_eq!(ConnectorErrorKind::Auth.as_str(), "auth_rejected");
assert_eq!(
ConnectorErrorKind::Application.as_str(),
"application_error"
);
assert_eq!(ConnectorErrorKind::Config.as_str(), "config_error");
}
#[test]
fn default_budget_stays_well_under_the_turn_deadline() {
let p = CallRetryPolicy::default();
assert!(p.budget <= Duration::from_secs(60));
assert!(p.base_delay <= p.max_delay);
assert!(p.max_delay <= p.budget);
}
#[test]
fn default_call_timeout_stays_a_fraction_of_the_control_plane_turn_deadline() {
let p = CallRetryPolicy::default();
assert!(p.call_timeout >= Duration::from_secs(10));
assert!(p.call_timeout <= Duration::from_secs(60));
}
}