Skip to main content

mcp_streamable_proxy/
client.rs

1//! Streamable HTTP Client Connection Module
2//!
3//! Provides a high-level API for connecting to MCP servers via Streamable HTTP protocol.
4//! This module encapsulates the rmcp transport details and exposes a simple interface.
5
6use 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/// 自定义的指数退避重试策略,支持最大间隔限制
26///
27/// 重试间隔按照指数增长,但不会超过 max_interval
28/// - 第 1 次重试:base_duration × 2^0
29/// - 第 2 次重试:base_duration × 2^1
30/// - ...
31/// - 第 n 次重试:min(base_duration × 2^(n-1), max_interval)
32#[derive(Debug, Clone)]
33pub struct CappedExponentialBackoff {
34    /// 最大重试次数,None 表示无限制
35    pub max_times: Option<usize>,
36    /// 基础延迟时间(第一次重试前的等待时间)
37    pub base_duration: Duration,
38    /// 最大延迟间隔(重试间隔不会超过这个值)
39    pub max_interval: Duration,
40}
41
42impl CappedExponentialBackoff {
43    /// 创建一个新的带上限的指数退避策略
44    ///
45    /// # Arguments
46    /// * `max_times` - 最大重试次数,None 表示无限制
47    /// * `base_duration` - 基础延迟时间
48    /// * `max_interval` - 最大延迟间隔
49    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        // 检查是否超过最大重试次数
71        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
85/// Opaque wrapper for Streamable HTTP client connection
86///
87/// This type encapsulates an active connection to an MCP server via Streamable HTTP protocol.
88/// It hides the internal `RunningService` type and provides only the methods
89/// needed by consuming code.
90///
91/// Note: This type is not Clone because the underlying RunningService
92/// is designed for single-owner use. Use `into_handler()` or `into_running_service()`
93/// to consume the connection.
94///
95/// # Example
96///
97/// ```rust,ignore
98/// use mcp_streamable_proxy::{StreamClientConnection, McpClientConfig};
99///
100/// let config = McpClientConfig::new("http://localhost:8080/mcp")
101///     .with_header("Authorization", "Bearer token");
102///
103/// let conn = StreamClientConnection::connect(config).await?;
104/// let tools = conn.list_tools().await?;
105/// println!("Available tools: {:?}", tools);
106/// ```
107pub struct StreamClientConnection {
108    inner: BackendRunningService,
109}
110
111impl StreamClientConnection {
112    /// Connect to a Streamable HTTP MCP server (new upstream peer registry).
113    pub async fn connect(config: McpClientConfig) -> Result<Self> {
114        Self::connect_with_peers(config, Arc::new(UpstreamPeerRegistry::new())).await
115    }
116
117    /// Connect using a shared [`UpstreamPeerRegistry`] (for proxy reconnect / hot-swap).
118    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        // 配置指数退避重试策略,最大间隔 1 分钟,不限制重试次数
125        let retry_policy = CappedExponentialBackoff::new(
126            None,                    // 不限制重试次数
127            Duration::from_secs(1),  // 基础延迟 1 秒
128            Duration::from_secs(60), // 最大间隔 60 秒
129        );
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    /// List available tools from the MCP server
147    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    /// Fetch an unfiltered, complete discovery snapshot from the real upstream.
160    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    /// Check if the connection is closed
212    pub fn is_closed(&self) -> bool {
213        use std::ops::Deref;
214        self.inner.deref().is_transport_closed()
215    }
216
217    /// Get the peer info from the server
218    pub fn peer_info(&self) -> Option<Arc<rmcp::model::ServerInfo>> {
219        self.inner.peer_info()
220    }
221
222    /// Convert this connection into a ProxyHandler for serving
223    ///
224    /// This consumes the connection and creates a ProxyHandler that can
225    /// proxy requests to the backend MCP server.
226    ///
227    /// # Arguments
228    /// * `mcp_id` - Identifier for logging purposes
229    /// * `tool_filter` - Tool filtering configuration
230    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    /// Extract the internal RunningService for use with swap_backend
235    ///
236    /// This is used internally to support backend hot-swapping.
237    pub fn into_running_service(self) -> BackendRunningService {
238        self.inner
239    }
240}
241
242/// Simplified tool information
243#[derive(Clone, Debug)]
244pub struct ToolInfo {
245    /// Tool name
246    pub name: String,
247    /// Tool description (optional)
248    pub description: Option<String>,
249}
250
251/// Build an HTTP client with the given configuration
252fn 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        // 测试带上限的指数退避策略
304        let policy = CappedExponentialBackoff::new(
305            None,                    // 不限制重试次数
306            Duration::from_secs(1),  // 基础延迟 1 秒
307            Duration::from_secs(60), // 最大间隔 60 秒
308        );
309
310        // 验证第 1 次重试:1 秒
311        assert_eq!(policy.retry(0), Some(Duration::from_secs(1)));
312        // 验证第 2 次重试:2 秒
313        assert_eq!(policy.retry(1), Some(Duration::from_secs(2)));
314        // 验证第 3 次重试:4 秒
315        assert_eq!(policy.retry(2), Some(Duration::from_secs(4)));
316        // 验证第 7 次重试:64 秒,但被限制为 60 秒
317        assert_eq!(policy.retry(6), Some(Duration::from_secs(60)));
318        // 验证第 10 次重试:仍然是 60 秒
319        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        // 超过最大次数
331        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}