use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio_util::sync::CancellationToken;
use dashmap::DashMap;
use tokio::sync::mpsc;
use crate::client::{McpClient, ToolRefreshEvent};
use crate::error::McpError;
use super::{McpTransport, McpTrustLevel, ServerEntry, StatusTx};
pub(super) fn connect_retry_backoff(attempt: u8, base_ms: u64) -> Duration {
use rand::RngExt as _;
const CAP_MS: u64 = 8_000;
let exp = u32::from(attempt.saturating_sub(1));
let nominal = base_ms
.saturating_mul(2u64.saturating_pow(exp.min(20)))
.min(CAP_MS);
let low = nominal * 3 / 4;
let jittered = if low < nominal {
rand::rng().random_range(low..=nominal)
} else {
nominal
};
Duration::from_millis(jittered)
}
pub(super) fn is_retryable_connect_error(err: &McpError) -> bool {
match err {
McpError::Connection { .. } | McpError::Timeout { .. } => true,
McpError::ManagerShuttingDown { .. }
| McpError::CommandNotAllowed { .. }
| McpError::EnvVarBlocked { .. }
| McpError::SsrfBlocked { .. }
| McpError::InvalidUrl { .. }
| McpError::PolicyViolation(_)
| McpError::OAuthError { .. }
| McpError::OAuthCallbackTimeout { .. }
| McpError::ServerNotFound { .. }
| McpError::ServerAlreadyConnected { .. }
| McpError::ToolListLocked { .. }
| McpError::ToolCall { .. }
| McpError::ToolNotFound { .. }
| McpError::Qdrant(_)
| McpError::Json(_)
| McpError::IntConversion(_)
| McpError::Embedding(_)
| McpError::HttpAuth { .. } => false,
}
}
#[tracing::instrument(name = "mcp.manager.retry_loop", skip_all, fields(server_id = %server_id, max_attempts), err)]
pub(super) async fn retry_loop<F, Fut>(
server_id: &str,
max_attempts: u8,
retry_backoff_base_ms: u64,
status_tx: Option<&StatusTx>,
shutdown: &CancellationToken,
mut attempt_fn: F,
) -> Result<McpClient, McpError>
where
F: FnMut(u8) -> Fut,
Fut: std::future::Future<Output = Result<McpClient, McpError>>,
{
let mut last_err = McpError::ManagerShuttingDown {
server_id: server_id.to_owned(),
};
for attempt in 1..=max_attempts {
if shutdown.is_cancelled() {
return Err(McpError::ManagerShuttingDown {
server_id: server_id.to_owned(),
});
}
if let Some(stx) = status_tx {
let msg = if attempt == 1 {
format!("Connecting to MCP server {server_id}...")
} else {
format!(
"Reconnecting to MCP server {server_id} (attempt {attempt}/{max_attempts})..."
)
};
let _ = stx.send(msg);
}
match attempt_fn(attempt).await {
Ok(client) => return Ok(client),
Err(e) => {
let retryable = is_retryable_connect_error(&e);
tracing::warn!(
server_id,
attempt,
max_attempts,
retryable,
error = %e,
"MCP server connection attempt failed"
);
last_err = e;
if !retryable || attempt == max_attempts {
break;
}
let delay = connect_retry_backoff(attempt, retry_backoff_base_ms);
tokio::select! {
biased;
() = shutdown.cancelled() => {
return Err(McpError::ManagerShuttingDown {
server_id: server_id.to_owned(),
});
}
() = tokio::time::sleep(delay) => {}
}
}
}
}
Err(last_err)
}
#[allow(clippy::too_many_arguments)]
#[tracing::instrument(name = "mcp.manager.connect_with_retry", skip_all, fields(server_id = %entry.id), err)]
pub(super) async fn connect_with_retry(
entry: &ServerEntry,
allowed_commands: &[String],
suppress_stderr: bool,
tx: mpsc::Sender<ToolRefreshEvent>,
last_refresh: Arc<DashMap<String, Instant>>,
handler_cfg: &crate::client::HandlerConfig,
max_attempts: u8,
retry_backoff_base_ms: u64,
status_tx: Option<&StatusTx>,
shutdown: &CancellationToken,
) -> Result<McpClient, McpError> {
retry_loop(
entry.id.as_str(),
max_attempts,
retry_backoff_base_ms,
status_tx,
shutdown,
|_attempt| {
let tx = tx.clone();
let last_refresh = Arc::clone(&last_refresh);
async move {
connect_entry(
entry,
allowed_commands,
suppress_stderr,
tx,
last_refresh,
handler_cfg,
)
.await
}
},
)
.await
}
#[allow(clippy::too_many_arguments)]
#[tracing::instrument(name = "mcp.manager.connect_entry", skip_all, fields(server_id = %entry.id), err)]
pub(super) async fn connect_entry(
entry: &ServerEntry,
allowed_commands: &[String],
suppress_stderr: bool,
tx: mpsc::Sender<ToolRefreshEvent>,
last_refresh: Arc<DashMap<String, Instant>>,
handler_cfg: &crate::client::HandlerConfig,
) -> Result<McpClient, McpError> {
match &entry.transport {
McpTransport::Stdio { command, args, env } => {
McpClient::connect(
&entry.id,
command,
args,
env,
allowed_commands,
entry.timeout,
suppress_stderr,
entry.env_isolation,
tx,
last_refresh,
handler_cfg.clone(),
)
.await
}
McpTransport::Http { url, headers } => {
let trusted = matches!(entry.trust_level, McpTrustLevel::Trusted);
if headers.is_empty() {
McpClient::connect_url(
&entry.id,
url,
entry.timeout,
trusted,
tx,
last_refresh,
handler_cfg.clone(),
)
.await
} else {
McpClient::connect_url_with_headers(
&entry.id,
url,
headers,
entry.timeout,
trusted,
tx,
last_refresh,
handler_cfg.clone(),
)
.await
}
}
McpTransport::OAuth { .. } => {
Err(McpError::OAuthError {
server_id: entry.id.clone(),
message: "OAuth transport cannot be used via connect_entry".into(),
})
}
}
}