mcp_streamable_proxy/
client.rs1use anyhow::{Context, Result};
7use mcp_common::McpClientConfig;
8use rmcp::{
9 ServiceExt,
10 transport::{
11 common::client_side_sse::SseRetryPolicy,
12 streamable_http_client::{
13 StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
14 },
15 },
16};
17use std::sync::Arc;
18use std::time::Duration;
19
20use crate::backend_client::{BackendNotificationBridge, UpstreamPeerRegistry};
21use crate::fallback::DiscoverySnapshot;
22use crate::proxy_handler::{BackendRunningService, ProxyHandler};
23use mcp_common::ToolFilter;
24
25#[derive(Debug, Clone)]
33pub struct CappedExponentialBackoff {
34 pub max_times: Option<usize>,
36 pub base_duration: Duration,
38 pub max_interval: Duration,
40}
41
42impl CappedExponentialBackoff {
43 pub fn new(max_times: Option<usize>, base_duration: Duration, max_interval: Duration) -> Self {
50 Self {
51 max_times,
52 base_duration,
53 max_interval,
54 }
55 }
56}
57
58impl Default for CappedExponentialBackoff {
59 fn default() -> Self {
60 Self {
61 max_times: None,
62 base_duration: Duration::from_secs(1),
63 max_interval: Duration::from_secs(60),
64 }
65 }
66}
67
68impl SseRetryPolicy for CappedExponentialBackoff {
69 fn retry(&self, current_times: usize) -> Option<Duration> {
70 if let Some(max_times) = self.max_times
72 && current_times >= max_times
73 {
74 return None;
75 }
76
77 Some(mcp_common::capped_exponential_delay(
78 self.base_duration,
79 self.max_interval,
80 current_times,
81 ))
82 }
83}
84
85pub struct StreamClientConnection {
108 inner: BackendRunningService,
109}
110
111impl StreamClientConnection {
112 pub async fn connect(config: McpClientConfig) -> Result<Self> {
114 Self::connect_with_peers(config, Arc::new(UpstreamPeerRegistry::new())).await
115 }
116
117 pub async fn connect_with_peers(
119 config: McpClientConfig,
120 upstream_peers: Arc<UpstreamPeerRegistry>,
121 ) -> Result<Self> {
122 let http_client = build_http_client(&config)?;
123
124 let retry_policy = CappedExponentialBackoff::new(
126 None, Duration::from_secs(1), Duration::from_secs(60), );
130
131 let mut transport_config =
132 StreamableHttpClientTransportConfig::with_uri(config.url.clone());
133 transport_config.retry_config = Arc::new(retry_policy);
134
135 let transport = StreamableHttpClientTransport::with_client(http_client, transport_config);
136
137 let bridge = BackendNotificationBridge::with_default_info(upstream_peers);
138 let running = bridge
139 .serve(transport)
140 .await
141 .context("Failed to initialize MCP client")?;
142
143 Ok(Self { inner: running })
144 }
145
146 pub async fn list_tools(&self) -> Result<Vec<ToolInfo>> {
148 let result = self.inner.list_tools(None).await?;
149 Ok(result
150 .tools
151 .into_iter()
152 .map(|t| ToolInfo {
153 name: t.name.to_string(),
154 description: t.description.map(|d| d.to_string()),
155 })
156 .collect())
157 }
158
159 pub async fn fetch_discovery_snapshot(&self) -> Result<DiscoverySnapshot> {
161 use std::collections::HashSet;
162
163 const MAX_PAGES: usize = 10_000;
164 let server_info = self
165 .peer_info()
166 .map(|info| (*info).clone())
167 .context("Streamable HTTP upstream did not return initialize server info")?;
168 let mut tools = Vec::new();
169 let mut cursor: Option<String> = None;
170 let mut seen = HashSet::new();
171 let mut response_envelope = None;
172
173 for _ in 0..MAX_PAGES {
174 let request = cursor.clone().map(|cursor| {
175 rmcp::model::PaginatedRequestParams::default().with_cursor(Some(cursor))
176 });
177 let mut result = self
178 .inner
179 .list_tools(request)
180 .await
181 .context("failed to list Streamable HTTP upstream tools")?;
182 tools.append(&mut result.tools);
183 let next_cursor = result.next_cursor.take();
184 if response_envelope.is_none() {
185 response_envelope = Some(result);
186 }
187 match next_cursor {
188 Some(next) => {
189 if !seen.insert(next.clone()) {
190 anyhow::bail!(
191 "Streamable HTTP tools pagination returned a repeated cursor"
192 );
193 }
194 cursor = Some(next);
195 }
196 None => {
197 let mut complete = response_envelope
198 .context("Streamable HTTP tools pagination returned no response")?;
199 complete.tools = tools;
200 complete.next_cursor = None;
201 return Ok(DiscoverySnapshot {
202 server_info,
203 tools: complete,
204 });
205 }
206 }
207 }
208 anyhow::bail!("Streamable HTTP tools pagination exceeded {MAX_PAGES} pages")
209 }
210
211 pub fn is_closed(&self) -> bool {
213 use std::ops::Deref;
214 self.inner.deref().is_transport_closed()
215 }
216
217 pub fn peer_info(&self) -> Option<Arc<rmcp::model::ServerInfo>> {
219 self.inner
222 .peer_info()
223 .map(|p| Arc::new(crate::proxy_handler::peer_info_to_server_info((*p).clone())))
224 }
225
226 pub fn into_handler(self, mcp_id: String, tool_filter: ToolFilter) -> ProxyHandler {
235 ProxyHandler::with_tool_filter(self.inner, mcp_id, tool_filter)
236 }
237
238 pub fn into_running_service(self) -> BackendRunningService {
242 self.inner
243 }
244}
245
246#[derive(Clone, Debug)]
248pub struct ToolInfo {
249 pub name: String,
251 pub description: Option<String>,
253}
254
255fn build_http_client(config: &McpClientConfig) -> Result<reqwest::Client> {
257 let mut headers = reqwest::header::HeaderMap::new();
258 for (key, value) in &config.headers {
259 let header_name = key
260 .parse::<reqwest::header::HeaderName>()
261 .with_context(|| format!("Invalid header name: {}", key))?;
262 let header_value = value
263 .parse()
264 .with_context(|| format!("Invalid header value for {key}"))?;
265 headers.insert(header_name, header_value);
266 }
267
268 let mut builder = reqwest::Client::builder().default_headers(headers);
269
270 if let Some(timeout) = config.connect_timeout {
271 builder = builder.connect_timeout(timeout);
272 }
273
274 if let Some(timeout) = config.read_timeout {
275 builder = builder.timeout(timeout);
276 }
277
278 builder.build().context("Failed to build HTTP client")
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn test_tool_info() {
287 let info = ToolInfo {
288 name: "test_tool".to_string(),
289 description: Some("A test tool".to_string()),
290 };
291 assert_eq!(info.name, "test_tool");
292 assert_eq!(info.description, Some("A test tool".to_string()));
293 }
294
295 #[test]
296 fn invalid_header_error_does_not_expose_value() {
297 let secret = "secret-token\ninvalid";
298 let config = McpClientConfig::new("http://localhost").with_header("Authorization", secret);
299 let error = build_http_client(&config).expect_err("invalid header must fail");
300
301 assert!(error.to_string().contains("Authorization"));
302 assert!(!error.to_string().contains("secret-token"));
303 }
304
305 #[test]
306 fn test_capped_exponential_backoff() {
307 let policy = CappedExponentialBackoff::new(
309 None, Duration::from_secs(1), Duration::from_secs(60), );
313
314 assert_eq!(policy.retry(0), Some(Duration::from_secs(1)));
316 assert_eq!(policy.retry(1), Some(Duration::from_secs(2)));
318 assert_eq!(policy.retry(2), Some(Duration::from_secs(4)));
320 assert_eq!(policy.retry(6), Some(Duration::from_secs(60)));
322 assert_eq!(policy.retry(9), Some(Duration::from_secs(60)));
324 }
325
326 #[test]
327 fn test_capped_exponential_backoff_with_max_times() {
328 let policy =
329 CappedExponentialBackoff::new(Some(3), Duration::from_secs(1), Duration::from_secs(60));
330
331 assert_eq!(policy.retry(0), Some(Duration::from_secs(1)));
332 assert_eq!(policy.retry(1), Some(Duration::from_secs(2)));
333 assert_eq!(policy.retry(2), Some(Duration::from_secs(4)));
334 assert_eq!(policy.retry(3), None);
336 }
337
338 #[test]
339 fn test_capped_exponential_backoff_never_overflows() {
340 let policy = CappedExponentialBackoff::default();
341
342 assert_eq!(policy.retry(31), Some(Duration::from_secs(60)));
343 assert_eq!(policy.retry(32), Some(Duration::from_secs(60)));
344 assert_eq!(policy.retry(usize::MAX), Some(Duration::from_secs(60)));
345 }
346}