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.peer_info()
220 }
221
222 pub fn into_handler(self, mcp_id: String, tool_filter: ToolFilter) -> ProxyHandler {
231 ProxyHandler::with_tool_filter(self.inner, mcp_id, tool_filter)
232 }
233
234 pub fn into_running_service(self) -> BackendRunningService {
238 self.inner
239 }
240}
241
242#[derive(Clone, Debug)]
244pub struct ToolInfo {
245 pub name: String,
247 pub description: Option<String>,
249}
250
251fn build_http_client(config: &McpClientConfig) -> Result<reqwest::Client> {
253 let mut headers = reqwest::header::HeaderMap::new();
254 for (key, value) in &config.headers {
255 let header_name = key
256 .parse::<reqwest::header::HeaderName>()
257 .with_context(|| format!("Invalid header name: {}", key))?;
258 let header_value = value
259 .parse()
260 .with_context(|| format!("Invalid header value for {key}"))?;
261 headers.insert(header_name, header_value);
262 }
263
264 let mut builder = reqwest::Client::builder().default_headers(headers);
265
266 if let Some(timeout) = config.connect_timeout {
267 builder = builder.connect_timeout(timeout);
268 }
269
270 if let Some(timeout) = config.read_timeout {
271 builder = builder.timeout(timeout);
272 }
273
274 builder.build().context("Failed to build HTTP client")
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 #[test]
282 fn test_tool_info() {
283 let info = ToolInfo {
284 name: "test_tool".to_string(),
285 description: Some("A test tool".to_string()),
286 };
287 assert_eq!(info.name, "test_tool");
288 assert_eq!(info.description, Some("A test tool".to_string()));
289 }
290
291 #[test]
292 fn invalid_header_error_does_not_expose_value() {
293 let secret = "secret-token\ninvalid";
294 let config = McpClientConfig::new("http://localhost").with_header("Authorization", secret);
295 let error = build_http_client(&config).expect_err("invalid header must fail");
296
297 assert!(error.to_string().contains("Authorization"));
298 assert!(!error.to_string().contains("secret-token"));
299 }
300
301 #[test]
302 fn test_capped_exponential_backoff() {
303 let policy = CappedExponentialBackoff::new(
305 None, Duration::from_secs(1), Duration::from_secs(60), );
309
310 assert_eq!(policy.retry(0), Some(Duration::from_secs(1)));
312 assert_eq!(policy.retry(1), Some(Duration::from_secs(2)));
314 assert_eq!(policy.retry(2), Some(Duration::from_secs(4)));
316 assert_eq!(policy.retry(6), Some(Duration::from_secs(60)));
318 assert_eq!(policy.retry(9), Some(Duration::from_secs(60)));
320 }
321
322 #[test]
323 fn test_capped_exponential_backoff_with_max_times() {
324 let policy =
325 CappedExponentialBackoff::new(Some(3), Duration::from_secs(1), Duration::from_secs(60));
326
327 assert_eq!(policy.retry(0), Some(Duration::from_secs(1)));
328 assert_eq!(policy.retry(1), Some(Duration::from_secs(2)));
329 assert_eq!(policy.retry(2), Some(Duration::from_secs(4)));
330 assert_eq!(policy.retry(3), None);
332 }
333
334 #[test]
335 fn test_capped_exponential_backoff_never_overflows() {
336 let policy = CappedExponentialBackoff::default();
337
338 assert_eq!(policy.retry(31), Some(Duration::from_secs(60)));
339 assert_eq!(policy.retry(32), Some(Duration::from_secs(60)));
340 assert_eq!(policy.retry(usize::MAX), Some(Duration::from_secs(60)));
341 }
342}