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 0.12 transport details and exposes a simple interface.
5
6use anyhow::{Context, Result};
7use mcp_common::McpClientConfig;
8use rmcp::{
9    RoleClient, ServiceExt,
10    model::{ClientCapabilities, ClientInfo, Implementation},
11    service::RunningService,
12    transport::streamable_http_client::{StreamableHttpClientTransport, StreamableHttpClientTransportConfig},
13};
14
15use crate::proxy_handler::ProxyHandler;
16use mcp_common::ToolFilter;
17
18/// Opaque wrapper for Streamable HTTP client connection
19///
20/// This type encapsulates an active connection to an MCP server via Streamable HTTP protocol.
21/// It hides the internal `RunningService` type and provides only the methods
22/// needed by consuming code.
23///
24/// Note: This type is not Clone because the underlying RunningService
25/// is designed for single-owner use. Use `into_handler()` or `into_running_service()`
26/// to consume the connection.
27///
28/// # Example
29///
30/// ```rust,ignore
31/// use mcp_streamable_proxy::{StreamClientConnection, McpClientConfig};
32///
33/// let config = McpClientConfig::new("http://localhost:8080/mcp")
34///     .with_header("Authorization", "Bearer token");
35///
36/// let conn = StreamClientConnection::connect(config).await?;
37/// let tools = conn.list_tools().await?;
38/// println!("Available tools: {:?}", tools);
39/// ```
40pub struct StreamClientConnection {
41    inner: RunningService<RoleClient, ClientInfo>,
42}
43
44impl StreamClientConnection {
45    /// Connect to a Streamable HTTP MCP server
46    ///
47    /// # Arguments
48    /// * `config` - Client configuration including URL and headers
49    ///
50    /// # Returns
51    /// * `Ok(StreamClientConnection)` - Successfully connected client
52    /// * `Err` - Connection failed
53    pub async fn connect(config: McpClientConfig) -> Result<Self> {
54        let http_client = build_http_client(&config)?;
55
56        let transport_config = StreamableHttpClientTransportConfig {
57            uri: config.url.clone().into(),
58            ..Default::default()
59        };
60
61        let transport = StreamableHttpClientTransport::with_client(http_client, transport_config);
62
63        let client_info = create_default_client_info();
64        let running = client_info
65            .serve(transport)
66            .await
67            .context("Failed to initialize MCP client")?;
68
69        Ok(Self { inner: running })
70    }
71
72    /// List available tools from the MCP server
73    pub async fn list_tools(&self) -> Result<Vec<ToolInfo>> {
74        let result = self.inner.list_tools(None).await?;
75        Ok(result
76            .tools
77            .into_iter()
78            .map(|t| ToolInfo {
79                name: t.name.to_string(),
80                description: t.description.map(|d| d.to_string()),
81            })
82            .collect())
83    }
84
85    /// Check if the connection is closed
86    pub fn is_closed(&self) -> bool {
87        use std::ops::Deref;
88        self.inner.deref().is_transport_closed()
89    }
90
91    /// Get the peer info from the server
92    pub fn peer_info(&self) -> Option<&rmcp::model::ServerInfo> {
93        self.inner.peer_info()
94    }
95
96    /// Convert this connection into a ProxyHandler for serving
97    ///
98    /// This consumes the connection and creates a ProxyHandler that can
99    /// proxy requests to the backend MCP server.
100    ///
101    /// # Arguments
102    /// * `mcp_id` - Identifier for logging purposes
103    /// * `tool_filter` - Tool filtering configuration
104    pub fn into_handler(self, mcp_id: String, tool_filter: ToolFilter) -> ProxyHandler {
105        ProxyHandler::with_tool_filter(self.inner, mcp_id, tool_filter)
106    }
107
108    /// Extract the internal RunningService for use with swap_backend
109    ///
110    /// This is used internally to support backend hot-swapping.
111    pub fn into_running_service(self) -> RunningService<RoleClient, ClientInfo> {
112        self.inner
113    }
114}
115
116/// Simplified tool information
117#[derive(Clone, Debug)]
118pub struct ToolInfo {
119    /// Tool name
120    pub name: String,
121    /// Tool description (optional)
122    pub description: Option<String>,
123}
124
125/// Build an HTTP client with the given configuration
126fn build_http_client(config: &McpClientConfig) -> Result<reqwest::Client> {
127    let mut headers = reqwest::header::HeaderMap::new();
128    for (key, value) in &config.headers {
129        let header_name = key
130            .parse::<reqwest::header::HeaderName>()
131            .with_context(|| format!("Invalid header name: {}", key))?;
132        let header_value = value
133            .parse()
134            .with_context(|| format!("Invalid header value for {}: {}", key, value))?;
135        headers.insert(header_name, header_value);
136    }
137
138    let mut builder = reqwest::Client::builder().default_headers(headers);
139
140    if let Some(timeout) = config.connect_timeout {
141        builder = builder.connect_timeout(timeout);
142    }
143
144    if let Some(timeout) = config.read_timeout {
145        builder = builder.timeout(timeout);
146    }
147
148    builder.build().context("Failed to build HTTP client")
149}
150
151/// Create default client info for MCP handshake
152fn create_default_client_info() -> ClientInfo {
153    ClientInfo {
154        protocol_version: Default::default(),
155        capabilities: ClientCapabilities::builder()
156            .enable_experimental()
157            .enable_roots()
158            .enable_roots_list_changed()
159            .enable_sampling()
160            .build(),
161        client_info: Implementation {
162            name: "mcp-streamable-proxy-client".to_string(),
163            version: env!("CARGO_PKG_VERSION").to_string(),
164            title: None,
165            website_url: None,
166            icons: None,
167        },
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_tool_info() {
177        let info = ToolInfo {
178            name: "test_tool".to_string(),
179            description: Some("A test tool".to_string()),
180        };
181        assert_eq!(info.name, "test_tool");
182        assert_eq!(info.description, Some("A test tool".to_string()));
183    }
184}