use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, RwLock};
use tower_mcp::client::McpClient;
pub type Connector = Arc<
dyn Fn() -> Pin<Box<dyn Future<Output = Result<McpClient, tower_mcp::Error>> + Send>>
+ Send
+ Sync,
>;
pub struct Session {
client: RwLock<Option<Arc<McpClient>>>,
connector: RwLock<Option<Connector>>,
reconnecting: tokio::sync::Mutex<()>,
generation: AtomicU64,
generation_tx: tokio::sync::watch::Sender<u64>,
}
impl Session {
pub fn new(client: McpClient, connector: Option<Connector>) -> Self {
let (generation_tx, _) = tokio::sync::watch::channel(0);
Self {
client: RwLock::new(Some(Arc::new(client))),
connector: RwLock::new(connector),
reconnecting: tokio::sync::Mutex::new(()),
generation: AtomicU64::new(0),
generation_tx,
}
}
pub fn disconnected() -> Self {
let (generation_tx, _) = tokio::sync::watch::channel(0);
Self {
client: RwLock::new(None),
connector: RwLock::new(None),
reconnecting: tokio::sync::Mutex::new(()),
generation: AtomicU64::new(0),
generation_tx,
}
}
pub fn client(&self) -> Arc<McpClient> {
self.try_client().expect("MCP session is not connected")
}
pub fn try_client(&self) -> Option<Arc<McpClient>> {
self.client.read().unwrap().clone()
}
pub fn is_connected(&self) -> bool {
self.client.read().unwrap().is_some()
}
pub fn can_reconnect(&self) -> bool {
self.connector.read().unwrap().is_some()
}
pub fn generation(&self) -> u64 {
self.generation.load(Ordering::Acquire)
}
pub fn subscribe_generation(&self) -> tokio::sync::watch::Receiver<u64> {
self.generation_tx.subscribe()
}
pub async fn shutdown(self) -> Result<(), tower_mcp::Error> {
let Some(client) = self
.client
.into_inner()
.expect("session client lock poisoned")
else {
return Ok(());
};
let client = Arc::try_unwrap(client).map_err(|_| {
tower_mcp::Error::Transport(
"cannot shut down an MCP session while its client is still in use".to_string(),
)
})?;
client.shutdown().await
}
pub async fn reconnect(&self, seen: u64) -> Result<(), tower_mcp::Error> {
let Some(connector) = self.connector.read().unwrap().clone() else {
return Err(tower_mcp::Error::Transport(
"this transport cannot be reconnected".to_string(),
));
};
let _guard = self.reconnecting.lock().await;
if self.generation() != seen {
tracing::debug!(
seen,
current = self.generation(),
"another command already reconnected; reusing its client"
);
return Ok(());
}
tracing::debug!(generation = seen, "reconnecting");
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
let fresh = connector().await?;
*self.client.write().unwrap() = Some(Arc::new(fresh));
let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1;
self.generation_tx.send_replace(generation);
tracing::debug!(generation, "reconnected");
Ok(())
}
pub async fn replace(
&self,
client: McpClient,
connector: Option<Connector>,
) -> Option<Arc<McpClient>> {
let _guard = self.reconnecting.lock().await;
*self.connector.write().unwrap() = connector;
let previous = self.client.write().unwrap().replace(Arc::new(client));
let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1;
self.generation_tx.send_replace(generation);
tracing::debug!(generation, "replaced session connection");
previous
}
}
pub fn is_not_initialized(e: &tower_mcp::Error) -> bool {
matches!(
e,
tower_mcp::Error::JsonRpc(j)
if j.code == -32600 && j.message.contains("notifications/initialized")
)
}
pub fn is_session_lost(e: &tower_mcp::Error) -> bool {
if matches!(e, tower_mcp::Error::SessionExpired)
|| is_not_initialized(e)
|| is_reconnectable_http_error(e)
{
return true;
}
match e {
tower_mcp::Error::Transport(msg) => {
msg.contains("Transport closed") || msg.contains("Connection closed")
}
_ => false,
}
}
pub(crate) fn is_reconnectable_http_error(e: &tower_mcp::Error) -> bool {
match e {
tower_mcp::Error::Transport(message) => status_after(message, "HTTP ")
.is_some_and(|status| matches!(status, "410" | "502" | "503")),
tower_mcp::Error::JsonRpc(error) if error.code == -32000 => {
status_after(&error.message, "server returned HTTP ")
.is_some_and(|status| matches!(status, "404" | "410" | "502" | "503"))
}
_ => false,
}
}
fn status_after<'a>(message: &'a str, prefix: &str) -> Option<&'a str> {
message.strip_prefix(prefix)?.split_whitespace().next()
}
#[cfg(test)]
mod tests {
use super::*;
fn jsonrpc(code: i32, message: &str) -> tower_mcp::Error {
tower_mcp::Error::JsonRpc(tower_mcp::error::JsonRpcError {
code,
message: message.to_string(),
data: None,
})
}
#[test]
fn disconnected_session_has_no_client_or_reconnect_recipe() {
let session = Session::disconnected();
assert!(!session.is_connected());
assert!(session.try_client().is_none());
assert!(!session.can_reconnect());
assert_eq!(session.generation(), 0);
}
#[test]
fn session_loss_covers_the_documented_conditions() {
assert!(is_session_lost(&tower_mcp::Error::SessionExpired));
assert!(is_session_lost(&jsonrpc(
-32600,
"Client must send notifications/initialized before making requests"
)));
assert!(is_session_lost(&tower_mcp::Error::Transport(
"Transport closed".into()
)));
assert!(is_session_lost(&tower_mcp::Error::Transport(
"Connection closed".into()
)));
for status in ["HTTP 410 Gone", "HTTP 502 Bad Gateway", "HTTP 503"] {
assert!(
is_session_lost(&tower_mcp::Error::Transport(format!(
"{status} from server: "
))),
"{status} should count as session loss"
);
}
for status in [
"404 Not Found",
"410 Gone",
"502 Bad Gateway",
"503 Service Unavailable",
] {
assert!(
is_session_lost(&jsonrpc(-32000, &format!("server returned HTTP {status}"))),
"live HTTP {status} should count as session loss"
);
}
}
#[test]
fn session_loss_does_not_swallow_real_errors() {
assert!(!is_session_lost(&tower_mcp::Error::Transport(
"HTTP 401 Unauthorized from server: bad token".into()
)));
assert!(!is_session_lost(&tower_mcp::Error::Transport(
"HTTP 404 from http://x/mcp: MCP endpoint not found".into()
)));
assert!(!is_session_lost(&jsonrpc(-32602, "Invalid params")));
assert!(!is_session_lost(&jsonrpc(
-32000,
"tool reported HTTP 503 Service Unavailable"
)));
assert!(!is_session_lost(&jsonrpc(
-32603,
"server returned HTTP 503 Service Unavailable"
)));
for status in ["401 Unauthorized", "403 Forbidden"] {
assert!(!is_session_lost(&jsonrpc(
-32000,
&format!("server returned HTTP {status}")
)));
}
assert!(!is_session_lost(&tower_mcp::Error::tool("boom")));
}
#[test]
fn detects_not_initialized_startup_error() {
assert!(is_not_initialized(&jsonrpc(
-32600,
"Client must send notifications/initialized before making requests"
)));
}
#[test]
fn does_not_match_unrelated_errors() {
assert!(!is_not_initialized(&jsonrpc(
-32600,
"some other invalid request"
)));
assert!(!is_not_initialized(&jsonrpc(
-32602,
"notifications/initialized"
)));
assert!(!is_not_initialized(&tower_mcp::Error::Transport(
"boom".into()
)));
}
}