Skip to main content

llm_browser_testkit/
mcp_client.rs

1//! MCP (Model Context Protocol) client.
2//!
3//! Connects to MCP servers via stdio (subprocess) or HTTP transport,
4//! lists tools, and calls them.
5
6use std::io::BufRead;
7use std::io::Write;
8use std::process::Child;
9use std::process::Command;
10use std::process::Stdio;
11use std::time::Duration;
12
13use anyhow::Context;
14use serde_json::Value;
15
16/// Transport mode for an MCP connection.
17#[derive(Debug)]
18pub enum McpTransport {
19    /// Spawn a subprocess and communicate via stdio (stdin/stdout).
20    Stdio(Child),
21    /// HTTP-based transport (streamable HTTP or SSE).
22    #[allow(dead_code)]
23    Http {
24        /// Server base URL for HTTP requests.
25        url: String,
26        /// HTTP client instance.
27        client: reqwest::Client,
28    },
29}
30
31/// MCP client for a connected server.
32#[derive(Debug)]
33pub struct McpClient {
34    transport: McpTransport,
35    /// Cached request ID counter.
36    next_id: u64,
37}
38
39impl McpClient {
40    /// Connects to an MCP server via stdio, spawning the given command.
41    ///
42    /// Sends the `initialize` request and waits for the response.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error if the subprocess cannot be spawned, the initialize
47    /// handshake fails, or the server is unreachable.
48    pub fn connect_stdio(command: &str, args: &[String]) -> anyhow::Result<Self> {
49        let child = Command::new(command)
50            .args(args)
51            .stdin(Stdio::piped())
52            .stdout(Stdio::piped())
53            .stderr(Stdio::inherit())
54            .spawn()
55            .context("MCP: failed to spawn server process")?;
56
57        let mut client = Self {
58            transport: McpTransport::Stdio(child),
59            next_id: 1,
60        };
61
62        // MCP initialize handshake
63        let init = serde_json::json!({
64            "jsonrpc": "2.0",
65            "method": "initialize",
66            "params": {
67                "protocolVersion": "2024-11-05",
68                "capabilities": {},
69                "clientInfo": {
70                    "name": "llm-browser-testkit",
71                    "version": "0.1.2"
72                }
73            },
74            "id": 0
75        });
76        let _response = client.send_request(&init)?;
77
78        // Send initialized notification
79        let initialized = serde_json::json!({
80            "jsonrpc": "2.0",
81            "method": "notifications/initialized"
82        });
83        client.send_request(&initialized)?;
84
85        Ok(client)
86    }
87
88    /// Lists available tools on the MCP server.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if the request fails or the server returns an error.
93    pub fn list_tools(&mut self) -> anyhow::Result<Vec<McpTool>> {
94        let req = serde_json::json!({
95            "jsonrpc": "2.0",
96            "method": "tools/list",
97            "id": 0
98        });
99        let resp = self.send_request(&req)?;
100        let tools: Vec<McpTool> = serde_json::from_value(resp["result"]["tools"].clone())
101            .context("MCP: failed to parse tools list")?;
102        Ok(tools)
103    }
104
105    /// Calls a tool on the MCP server with the given arguments.
106    ///
107    /// # Errors
108    ///
109    /// Returns an error if the request fails or the server returns an error.
110    pub fn call_tool(&mut self, tool_name: &str, args: &Value) -> anyhow::Result<McpToolResult> {
111        let params = if args.is_null() {
112            serde_json::json!({ "name": tool_name })
113        } else {
114            serde_json::json!({
115                "name": tool_name,
116                "arguments": args
117            })
118        };
119
120        let req = serde_json::json!({
121            "jsonrpc": "2.0",
122            "method": "tools/call",
123            "params": params,
124            "id": 0
125        });
126        let resp = self.send_request(&req)?;
127        let result: McpToolResult = serde_json::from_value(resp["result"].clone())
128            .context("MCP: failed to parse tool result")?;
129        Ok(result)
130    }
131
132    fn send_request(&mut self, request: &Value) -> anyhow::Result<Value> {
133        let id = self.next_id;
134        self.next_id += 1;
135        match &mut self.transport {
136            McpTransport::Stdio(child) => send_request_stdio(id, child, request),
137            McpTransport::Http { .. } => {
138                anyhow::bail!("MCP HTTP transport not yet implemented")
139            }
140        }
141    }
142}
143
144/// Sends a JSON-RPC request over stdio to an MCP server child process.
145#[allow(clippy::significant_drop_tightening)]
146fn send_request_stdio(_id: u64, child: &mut Child, request: &Value) -> anyhow::Result<Value> {
147    let mut request_str = serde_json::to_string(request)?;
148    request_str.push('\n');
149
150    let stdin = child.stdin.as_mut().context("MCP: stdin not available")?;
151    stdin
152        .write_all(request_str.as_bytes())
153        .context("MCP: write to stdin failed")?;
154    stdin.flush().context("MCP: flush stdin failed")?;
155
156    let stdout = child.stdout.as_mut().context("MCP: stdout not available")?;
157    let mut reader = std::io::BufReader::new(stdout);
158    let mut line = String::new();
159    reader
160        .read_line(&mut line)
161        .context("MCP: read from stdout failed")?;
162
163    let resp: Value = serde_json::from_str(&line).context("MCP: failed to parse JSON response")?;
164
165    if let Some(error) = resp["error"]["message"].as_str() {
166        anyhow::bail!("MCP error: {error}");
167    }
168
169    Ok(resp)
170}
171
172// Suppress dead_code on connect_http for now
173#[allow(dead_code)]
174impl McpClient {
175    /// Connects to an MCP server via HTTP transport.
176    ///
177    /// # Errors
178    ///
179    /// Returns an error if the server is unreachable or the initialize
180    /// handshake fails.
181    #[allow(dead_code)]
182    pub async fn connect_http(url: &str, timeout: Duration) -> anyhow::Result<Self> {
183        let client = reqwest::Client::builder()
184            .timeout(timeout)
185            .build()
186            .context("build reqwest client")?;
187
188        let mcp = Self {
189            transport: McpTransport::Http {
190                url: url.trim_end_matches('/').to_owned(),
191                client: client.clone(),
192            },
193            next_id: 1,
194        };
195
196        // Initialize handshake over HTTP
197        let init = serde_json::json!({
198            "jsonrpc": "2.0",
199            "method": "initialize",
200            "params": {
201                "protocolVersion": "2024-11-05",
202                "capabilities": {},
203                "clientInfo": {
204                    "name": "llm-browser-testkit",
205                    "version": "0.1.2"
206                }
207            },
208            "id": 0
209        });
210
211        let resp = client
212            .post(url)
213            .header("Content-Type", "application/json")
214            .json(&init)
215            .send()
216            .await
217            .context("MCP HTTP: initialize failed")?;
218
219        let json: Value = resp.json().await.context("MCP HTTP: parse response")?;
220        if let Some(error) = json["error"]["message"].as_str() {
221            anyhow::bail!("MCP error: {error}");
222        }
223
224        Ok(mcp)
225    }
226}
227
228/// A tool exposed by an MCP server.
229#[derive(Debug, Clone, serde::Deserialize)]
230#[allow(non_snake_case)]
231pub struct McpTool {
232    /// Tool name.
233    pub name: String,
234    /// Human-readable description.
235    #[serde(default)]
236    pub description: String,
237    /// JSON Schema for tool arguments.
238    #[serde(default)]
239    pub inputSchema: Value,
240}
241
242/// Result from calling an MCP tool.
243#[derive(Debug, Clone, serde::Deserialize)]
244#[allow(non_snake_case)]
245pub struct McpToolResult {
246    /// Content blocks returned by the tool.
247    #[serde(default)]
248    pub content: Vec<McpContent>,
249    /// Whether the result is an error.
250    #[serde(default)]
251    pub isError: bool,
252}
253
254impl std::fmt::Display for McpToolResult {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        for (i, c) in self.content.iter().enumerate() {
257            if i > 0 {
258                writeln!(f)?;
259            }
260            match c {
261                McpContent::Text { text } => write!(f, "{text}")?,
262                McpContent::Resource { resource } => {
263                    write!(f, "[resource: {}]", resource.uri)?;
264                }
265                McpContent::Image { data, mimeType } => {
266                    write!(f, "[image: {mimeType}, {} bytes]", data.len())?;
267                }
268            }
269        }
270        Ok(())
271    }
272}
273
274/// Content block within an MCP tool result.
275#[derive(Debug, Clone, serde::Deserialize)]
276#[serde(tag = "type")]
277#[allow(non_snake_case)]
278pub enum McpContent {
279    /// Plain text content.
280    #[serde(rename = "text")]
281    Text {
282        /// The text content.
283        text: String,
284    },
285    /// A resource reference.
286    #[serde(rename = "resource")]
287    Resource {
288        /// The resource descriptor.
289        resource: McpResource,
290    },
291    /// Base64-encoded image.
292    #[serde(rename = "image")]
293    Image {
294        /// Base64-encoded image data.
295        data: String,
296        /// MIME type (e.g. image/png).
297        mimeType: String,
298    },
299}
300
301/// Resource descriptor from an MCP response.
302#[derive(Debug, Clone, serde::Deserialize)]
303#[allow(non_snake_case)]
304pub struct McpResource {
305    /// Resource URI.
306    pub uri: String,
307    /// MIME type.
308    #[serde(default)]
309    pub mimeType: String,
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn test_mcp_tool_result_display_text() {
318        let result = McpToolResult {
319            content: vec![McpContent::Text {
320                text: "hello world".into(),
321            }],
322            isError: false,
323        };
324        assert_eq!(result.to_string(), "hello world");
325    }
326
327    #[test]
328    fn test_mcp_tool_result_display_multiple() {
329        let result = McpToolResult {
330            content: vec![
331                McpContent::Text {
332                    text: "line1".into(),
333                },
334                McpContent::Text {
335                    text: "line2".into(),
336                },
337            ],
338            isError: false,
339        };
340        assert_eq!(result.to_string(), "line1\nline2");
341    }
342
343    #[test]
344    fn test_mcp_tool_result_display_resource() {
345        let result = McpToolResult {
346            content: vec![McpContent::Resource {
347                resource: McpResource {
348                    uri: "file:///test".into(),
349                    mimeType: "text/plain".into(),
350                },
351            }],
352            isError: false,
353        };
354        assert_eq!(result.to_string(), "[resource: file:///test]");
355    }
356
357    #[test]
358    fn test_mcp_tool_result_display_image() {
359        let result = McpToolResult {
360            content: vec![McpContent::Image {
361                data: "base64data".into(),
362                mimeType: "image/png".into(),
363            }],
364            isError: false,
365        };
366        assert_eq!(result.to_string(), "[image: image/png, 10 bytes]");
367    }
368
369    #[test]
370    fn test_mcp_tool_deserialize() {
371        let json = serde_json::json!({
372            "name": "query",
373            "description": "Run a SQL query",
374            "inputSchema": {"type": "object"}
375        });
376        let tool: McpTool = serde_json::from_value(json).unwrap();
377        assert_eq!(tool.name, "query");
378        assert_eq!(tool.description, "Run a SQL query");
379    }
380}