use anyhow::{Context, Result};
use mcp_common::McpClientConfig;
use rmcp::{
ServiceExt,
transport::{
common::client_side_sse::SseRetryPolicy,
streamable_http_client::{
StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
},
},
};
use std::sync::Arc;
use std::time::Duration;
use crate::backend_client::{BackendNotificationBridge, UpstreamPeerRegistry};
use crate::fallback::DiscoverySnapshot;
use crate::proxy_handler::{BackendRunningService, ProxyHandler};
use mcp_common::ToolFilter;
#[derive(Debug, Clone)]
pub struct CappedExponentialBackoff {
pub max_times: Option<usize>,
pub base_duration: Duration,
pub max_interval: Duration,
}
impl CappedExponentialBackoff {
pub fn new(max_times: Option<usize>, base_duration: Duration, max_interval: Duration) -> Self {
Self {
max_times,
base_duration,
max_interval,
}
}
}
impl Default for CappedExponentialBackoff {
fn default() -> Self {
Self {
max_times: None,
base_duration: Duration::from_secs(1),
max_interval: Duration::from_secs(60),
}
}
}
impl SseRetryPolicy for CappedExponentialBackoff {
fn retry(&self, current_times: usize) -> Option<Duration> {
if let Some(max_times) = self.max_times
&& current_times >= max_times
{
return None;
}
Some(mcp_common::capped_exponential_delay(
self.base_duration,
self.max_interval,
current_times,
))
}
}
pub struct StreamClientConnection {
inner: BackendRunningService,
}
impl StreamClientConnection {
pub async fn connect(config: McpClientConfig) -> Result<Self> {
Self::connect_with_peers(config, Arc::new(UpstreamPeerRegistry::new())).await
}
pub async fn connect_with_peers(
config: McpClientConfig,
upstream_peers: Arc<UpstreamPeerRegistry>,
) -> Result<Self> {
let http_client = build_http_client(&config)?;
let retry_policy = CappedExponentialBackoff::new(
None, Duration::from_secs(1), Duration::from_secs(60), );
let mut transport_config =
StreamableHttpClientTransportConfig::with_uri(config.url.clone());
transport_config.retry_config = Arc::new(retry_policy);
let transport = StreamableHttpClientTransport::with_client(http_client, transport_config);
let bridge = BackendNotificationBridge::with_default_info(upstream_peers);
let running = bridge
.serve(transport)
.await
.context("Failed to initialize MCP client")?;
Ok(Self { inner: running })
}
pub async fn list_tools(&self) -> Result<Vec<ToolInfo>> {
let result = self.inner.list_tools(None).await?;
Ok(result
.tools
.into_iter()
.map(|t| ToolInfo {
name: t.name.to_string(),
description: t.description.map(|d| d.to_string()),
})
.collect())
}
pub async fn fetch_discovery_snapshot(&self) -> Result<DiscoverySnapshot> {
use std::collections::HashSet;
const MAX_PAGES: usize = 10_000;
let server_info = self
.peer_info()
.map(|info| (*info).clone())
.context("Streamable HTTP upstream did not return initialize server info")?;
let mut tools = Vec::new();
let mut cursor: Option<String> = None;
let mut seen = HashSet::new();
let mut response_envelope = None;
for _ in 0..MAX_PAGES {
let request = cursor.clone().map(|cursor| {
rmcp::model::PaginatedRequestParams::default().with_cursor(Some(cursor))
});
let mut result = self
.inner
.list_tools(request)
.await
.context("failed to list Streamable HTTP upstream tools")?;
tools.append(&mut result.tools);
let next_cursor = result.next_cursor.take();
if response_envelope.is_none() {
response_envelope = Some(result);
}
match next_cursor {
Some(next) => {
if !seen.insert(next.clone()) {
anyhow::bail!(
"Streamable HTTP tools pagination returned a repeated cursor"
);
}
cursor = Some(next);
}
None => {
let mut complete = response_envelope
.context("Streamable HTTP tools pagination returned no response")?;
complete.tools = tools;
complete.next_cursor = None;
return Ok(DiscoverySnapshot {
server_info,
tools: complete,
});
}
}
}
anyhow::bail!("Streamable HTTP tools pagination exceeded {MAX_PAGES} pages")
}
pub fn is_closed(&self) -> bool {
use std::ops::Deref;
self.inner.deref().is_transport_closed()
}
pub fn peer_info(&self) -> Option<Arc<rmcp::model::ServerInfo>> {
self.inner
.peer_info()
.map(|p| Arc::new(crate::proxy_handler::peer_info_to_server_info((*p).clone())))
}
pub fn into_handler(self, mcp_id: String, tool_filter: ToolFilter) -> ProxyHandler {
ProxyHandler::with_tool_filter(self.inner, mcp_id, tool_filter)
}
pub fn into_running_service(self) -> BackendRunningService {
self.inner
}
}
#[derive(Clone, Debug)]
pub struct ToolInfo {
pub name: String,
pub description: Option<String>,
}
fn build_http_client(config: &McpClientConfig) -> Result<reqwest::Client> {
let mut headers = reqwest::header::HeaderMap::new();
for (key, value) in &config.headers {
let header_name = key
.parse::<reqwest::header::HeaderName>()
.with_context(|| format!("Invalid header name: {}", key))?;
let header_value = value
.parse()
.with_context(|| format!("Invalid header value for {key}"))?;
headers.insert(header_name, header_value);
}
let mut builder = reqwest::Client::builder().default_headers(headers);
if let Some(timeout) = config.connect_timeout {
builder = builder.connect_timeout(timeout);
}
if let Some(timeout) = config.read_timeout {
builder = builder.timeout(timeout);
}
builder.build().context("Failed to build HTTP client")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_info() {
let info = ToolInfo {
name: "test_tool".to_string(),
description: Some("A test tool".to_string()),
};
assert_eq!(info.name, "test_tool");
assert_eq!(info.description, Some("A test tool".to_string()));
}
#[test]
fn invalid_header_error_does_not_expose_value() {
let secret = "secret-token\ninvalid";
let config = McpClientConfig::new("http://localhost").with_header("Authorization", secret);
let error = build_http_client(&config).expect_err("invalid header must fail");
assert!(error.to_string().contains("Authorization"));
assert!(!error.to_string().contains("secret-token"));
}
#[test]
fn test_capped_exponential_backoff() {
let policy = CappedExponentialBackoff::new(
None, Duration::from_secs(1), Duration::from_secs(60), );
assert_eq!(policy.retry(0), Some(Duration::from_secs(1)));
assert_eq!(policy.retry(1), Some(Duration::from_secs(2)));
assert_eq!(policy.retry(2), Some(Duration::from_secs(4)));
assert_eq!(policy.retry(6), Some(Duration::from_secs(60)));
assert_eq!(policy.retry(9), Some(Duration::from_secs(60)));
}
#[test]
fn test_capped_exponential_backoff_with_max_times() {
let policy =
CappedExponentialBackoff::new(Some(3), Duration::from_secs(1), Duration::from_secs(60));
assert_eq!(policy.retry(0), Some(Duration::from_secs(1)));
assert_eq!(policy.retry(1), Some(Duration::from_secs(2)));
assert_eq!(policy.retry(2), Some(Duration::from_secs(4)));
assert_eq!(policy.retry(3), None);
}
#[test]
fn test_capped_exponential_backoff_never_overflows() {
let policy = CappedExponentialBackoff::default();
assert_eq!(policy.retry(31), Some(Duration::from_secs(60)));
assert_eq!(policy.retry(32), Some(Duration::from_secs(60)));
assert_eq!(policy.retry(usize::MAX), Some(Duration::from_secs(60)));
}
}