1use 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#[derive(Debug)]
18pub enum McpTransport {
19 Stdio(Child),
21 #[allow(dead_code)]
23 Http {
24 url: String,
26 client: reqwest::Client,
28 },
29}
30
31#[derive(Debug)]
33pub struct McpClient {
34 transport: McpTransport,
35 next_id: u64,
37}
38
39impl McpClient {
40 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 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 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 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 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(ref mut 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#[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#[allow(dead_code)]
174impl McpClient {
175 #[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 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#[derive(Debug, Clone, serde::Deserialize)]
230#[allow(non_snake_case)]
231pub struct McpTool {
232 pub name: String,
234 #[serde(default)]
236 pub description: String,
237 #[serde(default)]
239 pub inputSchema: Value,
240}
241
242#[derive(Debug, Clone, serde::Deserialize)]
244#[allow(non_snake_case)]
245pub struct McpToolResult {
246 #[serde(default)]
248 pub content: Vec<McpContent>,
249 #[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#[derive(Debug, Clone, serde::Deserialize)]
276#[serde(tag = "type")]
277#[allow(non_snake_case)]
278pub enum McpContent {
279 #[serde(rename = "text")]
281 Text {
282 text: String,
284 },
285 #[serde(rename = "resource")]
287 Resource {
288 resource: McpResource,
290 },
291 #[serde(rename = "image")]
293 Image {
294 data: String,
296 mimeType: String,
298 },
299}
300
301#[derive(Debug, Clone, serde::Deserialize)]
303#[allow(non_snake_case)]
304pub struct McpResource {
305 pub uri: String,
307 #[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}