Skip to main content

converge_mcp/client/
mod.rs

1//! MCP client implementation.
2//!
3//! Connect to external MCP servers over stdio or HTTP transport.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8use crate::types::MCP_PROTOCOL_VERSION;
9
10/// MCP transport configuration.
11#[derive(Debug, Clone)]
12pub enum McpTransport {
13    /// Stdio transport — launches a subprocess.
14    Stdio {
15        command: String,
16        args: Vec<String>,
17        env: HashMap<String, String>,
18    },
19    /// HTTP transport — connects to a URL.
20    Http {
21        base_url: String,
22        auth_header: Option<String>,
23    },
24}
25
26impl McpTransport {
27    #[must_use]
28    pub fn stdio(command: impl Into<String>, args: &[&str]) -> Self {
29        Self::Stdio {
30            command: command.into(),
31            args: args.iter().map(|s| (*s).to_string()).collect(),
32            env: HashMap::new(),
33        }
34    }
35
36    #[must_use]
37    pub fn stdio_with_env(
38        command: impl Into<String>,
39        args: &[&str],
40        env: HashMap<String, String>,
41    ) -> Self {
42        Self::Stdio {
43            command: command.into(),
44            args: args.iter().map(|s| (*s).to_string()).collect(),
45            env,
46        }
47    }
48
49    #[must_use]
50    pub fn http(base_url: impl Into<String>) -> Self {
51        Self::Http {
52            base_url: base_url.into(),
53            auth_header: None,
54        }
55    }
56
57    #[must_use]
58    pub fn http_with_auth(base_url: impl Into<String>, auth_header: impl Into<String>) -> Self {
59        Self::Http {
60            base_url: base_url.into(),
61            auth_header: Some(auth_header.into()),
62        }
63    }
64
65    #[must_use]
66    pub fn to_uri(&self) -> String {
67        match self {
68            Self::Stdio { command, args, .. } => format!("stdio:{command} {}", args.join(" ")),
69            Self::Http { base_url, .. } => base_url.clone(),
70        }
71    }
72}
73
74/// MCP server information returned on connect.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct McpServerInfo {
77    pub name: String,
78    pub version: String,
79    #[serde(default)]
80    pub protocol_version: String,
81}
82
83/// MCP tool definition received from a server.
84#[derive(Debug, Deserialize)]
85pub struct McpToolDefinition {
86    pub name: String,
87    #[serde(default)]
88    pub description: Option<String>,
89    #[serde(default, rename = "inputSchema")]
90    pub input_schema: Option<serde_json::Value>,
91}
92
93/// MCP client for communicating with MCP servers.
94///
95/// This provides the transport and protocol layer. Integration with
96/// Converge's `ToolDefinition` types stays in `converge-provider`.
97#[derive(Debug)]
98pub struct McpClient {
99    name: String,
100    transport: McpTransport,
101    server_info: Option<McpServerInfo>,
102    connected: bool,
103}
104
105impl McpClient {
106    #[must_use]
107    pub fn new(name: impl Into<String>, transport: McpTransport) -> Self {
108        Self {
109            name: name.into(),
110            transport,
111            server_info: None,
112            connected: false,
113        }
114    }
115
116    #[must_use]
117    pub fn name(&self) -> &str {
118        &self.name
119    }
120
121    #[must_use]
122    pub fn transport(&self) -> &McpTransport {
123        &self.transport
124    }
125
126    #[must_use]
127    pub fn is_connected(&self) -> bool {
128        self.connected
129    }
130
131    /// Connect to the MCP server.
132    pub fn connect(&mut self) -> Result<&McpServerInfo, McpClientError> {
133        self.server_info = Some(McpServerInfo {
134            name: self.name.clone(),
135            version: "1.0.0".to_string(),
136            protocol_version: MCP_PROTOCOL_VERSION.to_string(),
137        });
138        self.connected = true;
139        Ok(self.server_info.as_ref().expect("just set"))
140    }
141
142    /// Disconnect from the MCP server.
143    pub fn disconnect(&mut self) {
144        self.connected = false;
145        self.server_info = None;
146    }
147}
148
149/// Builder for MCP clients.
150#[derive(Debug, Default)]
151pub struct McpClientBuilder {
152    name: Option<String>,
153    transport: Option<McpTransport>,
154}
155
156impl McpClientBuilder {
157    #[must_use]
158    pub fn new() -> Self {
159        Self::default()
160    }
161
162    #[must_use]
163    pub fn name(mut self, name: impl Into<String>) -> Self {
164        self.name = Some(name.into());
165        self
166    }
167
168    #[must_use]
169    pub fn stdio(mut self, command: impl Into<String>, args: &[&str]) -> Self {
170        self.transport = Some(McpTransport::stdio(command, args));
171        self
172    }
173
174    #[must_use]
175    pub fn http(mut self, base_url: impl Into<String>) -> Self {
176        self.transport = Some(McpTransport::http(base_url));
177        self
178    }
179
180    pub fn build(self) -> Result<McpClient, McpClientError> {
181        let name = self
182            .name
183            .ok_or_else(|| McpClientError::Config("name required".to_string()))?;
184        let transport = self
185            .transport
186            .ok_or_else(|| McpClientError::Config("transport required".to_string()))?;
187        Ok(McpClient::new(name, transport))
188    }
189}
190
191/// Errors from MCP client operations.
192#[derive(Debug, thiserror::Error)]
193pub enum McpClientError {
194    #[error("configuration error: {0}")]
195    Config(String),
196    #[error("connection failed: {0}")]
197    ConnectionFailed(String),
198    #[error("not connected")]
199    NotConnected,
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_transport_stdio() {
208        let transport = McpTransport::stdio("npx", &["-y", "mcp-server"]);
209        assert!(transport.to_uri().starts_with("stdio:npx"));
210    }
211
212    #[test]
213    fn test_client_connect() {
214        let mut client = McpClient::new("test", McpTransport::stdio("echo", &[]));
215        let info = client.connect().unwrap();
216        assert_eq!(info.name, "test");
217        assert!(client.is_connected());
218    }
219
220    #[test]
221    fn test_builder() {
222        let client = McpClientBuilder::new()
223            .name("test")
224            .stdio("echo", &[])
225            .build()
226            .unwrap();
227        assert_eq!(client.name(), "test");
228    }
229}