use std::{
collections::HashMap,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use polyc_crypto::sensitive::Sensitive;
use rmcp::service::{RoleClient, RunningService};
use crate::mcp_client::{McpClientError, dial_service};
pub(crate) const IDLE_TTL: Duration = Duration::from_mins(15);
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct ConnectionKey {
connector: String,
principal: String,
}
impl ConnectionKey {
pub(crate) fn new(connector: impl Into<String>, principal: impl Into<String>) -> Self {
Self {
connector: connector.into(),
principal: principal.into(),
}
}
}
struct CachedSession {
service: Arc<RunningService<RoleClient, ()>>,
auth_header: Option<String>,
caller: Option<String>,
last_used: Instant,
}
#[derive(Clone)]
pub struct ConnectionPool {
inner: Arc<Mutex<HashMap<ConnectionKey, CachedSession>>>,
idle_ttl: Duration,
}
impl Default for ConnectionPool {
fn default() -> Self {
Self {
inner: Arc::default(),
idle_ttl: IDLE_TTL,
}
}
}
impl std::fmt::Debug for ConnectionPool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let live = self.inner.lock().map_or(0, |m| m.len());
f.debug_struct("ConnectionPool")
.field("live", &live)
.field("idle_ttl", &self.idle_ttl)
.finish()
}
}
impl ConnectionPool {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[cfg(test)]
pub(crate) fn with_idle_ttl(idle_ttl: Duration) -> Self {
Self {
inner: Arc::default(),
idle_ttl,
}
}
pub(crate) async fn acquire(
&self,
key: &ConnectionKey,
uri: &Arc<str>,
auth_header: Option<&str>,
caller: Option<&str>,
label: &str,
timeout: Option<Duration>,
) -> Result<Arc<RunningService<RoleClient, ()>>, McpClientError> {
if let Some(service) = self.reuse(key, auth_header, caller) {
return Ok(service);
}
let dial = dial_service(
Arc::clone(uri),
auth_header.map(str::to_owned),
caller.map(str::to_owned),
None,
);
let service = match timeout {
Some(budget) => tokio::time::timeout(budget, dial)
.await
.map_err(|_elapsed| McpClientError::Timeout(budget))??,
None => dial.await?,
};
let service = Arc::new(service);
tracing::info!(label = %label, uri = %uri, "connected dynamic tool source");
self.inner
.lock()
.expect("connection pool mutex poisoned")
.insert(
key.clone(),
CachedSession {
service: Arc::clone(&service),
auth_header: auth_header.map(str::to_owned),
caller: caller.map(str::to_owned),
last_used: Instant::now(),
},
);
Ok(service)
}
fn reuse(
&self,
key: &ConnectionKey,
auth_header: Option<&str>,
caller: Option<&str>,
) -> Option<Arc<RunningService<RoleClient, ()>>> {
let mut map = self.inner.lock().expect("connection pool mutex poisoned");
let now = Instant::now();
map.retain(|_, entry| {
if now.duration_since(entry.last_used) < self.idle_ttl {
true
} else {
shutdown_if_orphaned(&entry.service);
false
}
});
let reused = map
.get_mut(key)
.filter(|entry| {
entry.auth_header.as_deref() == auth_header && entry.caller.as_deref() == caller
})
.map(|entry| {
entry.last_used = now;
Arc::clone(&entry.service)
});
drop(map);
reused
}
pub(crate) fn evict(&self, key: &ConnectionKey) {
let evicted = self
.inner
.lock()
.expect("connection pool mutex poisoned")
.remove(key);
if let Some(session) = evicted {
shutdown_if_orphaned(&session.service);
}
}
}
fn shutdown_if_orphaned(service: &Arc<RunningService<RoleClient, ()>>) {
if Arc::strong_count(service) == 1 {
service.cancellation_token().cancel();
}
}
pub(crate) enum SessionHandle {
Direct(Arc<RunningService<RoleClient, ()>>),
Pooled(PooledSession),
}
pub(crate) struct PooledSession {
pub(crate) pool: ConnectionPool,
pub(crate) key: ConnectionKey,
pub(crate) uri: Arc<str>,
pub(crate) auth_header: Option<Sensitive<String>>,
pub(crate) caller: Option<String>,
pub(crate) connect_timeout: Option<Duration>,
pub(crate) label: String,
}
impl SessionHandle {
pub(crate) async fn acquire(
&self,
) -> Result<Arc<RunningService<RoleClient, ()>>, McpClientError> {
match self {
Self::Direct(service) => Ok(Arc::clone(service)),
Self::Pooled(p) => {
p.pool
.acquire(
&p.key,
&p.uri,
p.auth_header.as_ref().map(|h| h.expose().as_str()),
p.caller.as_deref(),
&p.label,
p.connect_timeout,
)
.await
}
}
}
pub(crate) fn evict(&self) {
if let Self::Pooled(p) = self {
p.pool.evict(&p.key);
}
}
pub(crate) fn shutdown(&self) {
if let Self::Direct(service) = self {
service.cancellation_token().cancel();
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use std::{borrow::Cow, net::SocketAddr};
use rmcp::{
ErrorData as McpError, ServerHandler,
handler::server::{
router::tool::ToolRouter,
tool::{ToolCallContext, ToolRoute},
},
model::{
CallToolRequestParams, CallToolResponse, CallToolResult, Implementation,
InitializeResult, ListToolsResult, PaginatedRequestParams, ServerCapabilities, Tool,
},
service::{RequestContext, RoleServer},
transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
},
};
use serde_json::json;
use tokio_util::sync::CancellationToken;
use super::*;
#[derive(Clone)]
struct EchoServer {
router: Arc<ToolRouter<Self>>,
}
impl EchoServer {
fn new() -> Self {
let mut router: ToolRouter<Self> = ToolRouter::new();
let schema = json!({ "type": "object", "properties": {} });
let echo = Tool::new(
Cow::Borrowed("echo"),
Cow::Borrowed("Echo."),
schema.as_object().cloned().unwrap_or_default(),
);
router.add_route(ToolRoute::new_dyn(echo, |_ctx: ToolCallContext<Self>| {
Box::pin(
async move { Ok(CallToolResult::structured(json!({ "echo": true })).into()) },
)
}));
Self {
router: Arc::new(router),
}
}
}
impl std::fmt::Debug for EchoServer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EchoServer").finish_non_exhaustive()
}
}
impl ServerHandler for EchoServer {
fn get_info(&self) -> rmcp::model::ServerInfo {
InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("echo", env!("CARGO_PKG_VERSION")))
}
fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
let tools = self.router.list_all();
async move { Ok(ListToolsResult::with_all_items(tools)) }
}
fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<CallToolResponse, McpError>> + Send + '_ {
let router = self.router.clone();
async move {
router
.call(ToolCallContext::new(self, request, context))
.await
}
}
}
async fn spawn_server() -> (SocketAddr, CancellationToken, tokio::task::JoinHandle<()>) {
let mut config = StreamableHttpServerConfig::default();
config.legacy_session_mode = true;
config.sse_keep_alive = None;
let config = config.disable_allowed_hosts().disable_allowed_origins();
let service = StreamableHttpService::new(
|| Ok(EchoServer::new()),
Arc::new(LocalSessionManager::default()),
config,
);
let router = axum::Router::new().nest_service("/mcp", service);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let ct = CancellationToken::new();
let server_ct = ct.clone();
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, router)
.with_graceful_shutdown(async move { server_ct.cancelled_owned().await })
.await;
});
tokio::time::sleep(Duration::from_millis(50)).await;
(addr, ct, handle)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn idle_session_is_swept_on_next_acquire() {
let (addr, ct, handle) = spawn_server().await;
let uri: Arc<str> = Arc::from(format!("http://{addr}/mcp").as_str());
let key = ConnectionKey::new("http://conn/mcp", "conv-1");
let warm = ConnectionPool::new();
let a = warm
.acquire(&key, &uri, None, None, "notes", None)
.await
.unwrap();
let b = warm
.acquire(&key, &uri, None, None, "notes", None)
.await
.unwrap();
assert!(
Arc::ptr_eq(&a, &b),
"a still-fresh session must be reused, not re-dialed"
);
let pool = ConnectionPool::with_idle_ttl(Duration::from_millis(1));
let first = pool
.acquire(&key, &uri, None, None, "notes", None)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
let second = pool
.acquire(&key, &uri, None, None, "notes", None)
.await
.unwrap();
assert!(
!Arc::ptr_eq(&first, &second),
"an entry idle past the TTL must be swept and re-dialed, not reused"
);
drop((a, b, first, second));
ct.cancel();
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
}
}