use std::io::BufRead;
use std::io::Write;
use std::process::Child;
use std::process::Command;
use std::process::Stdio;
use std::time::Duration;
use anyhow::Context;
use serde_json::Value;
#[derive(Debug)]
pub enum McpTransport {
Stdio(Child),
#[allow(dead_code)]
Http {
url: String,
client: reqwest::Client,
},
}
#[derive(Debug)]
pub struct McpClient {
transport: McpTransport,
next_id: u64,
}
impl McpClient {
pub fn connect_stdio(command: &str, args: &[String]) -> anyhow::Result<Self> {
let child = Command::new(command)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.context("MCP: failed to spawn server process")?;
let mut client = Self {
transport: McpTransport::Stdio(child),
next_id: 1,
};
let init = serde_json::json!({
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "llm-browser-testkit",
"version": "0.1.2"
}
},
"id": 0
});
let _response = client.send_request(&init)?;
let initialized = serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/initialized"
});
client.send_request(&initialized)?;
Ok(client)
}
pub fn list_tools(&mut self) -> anyhow::Result<Vec<McpTool>> {
let req = serde_json::json!({
"jsonrpc": "2.0",
"method": "tools/list",
"id": 0
});
let resp = self.send_request(&req)?;
let tools: Vec<McpTool> = serde_json::from_value(resp["result"]["tools"].clone())
.context("MCP: failed to parse tools list")?;
Ok(tools)
}
pub fn call_tool(&mut self, tool_name: &str, args: &Value) -> anyhow::Result<McpToolResult> {
let params = if args.is_null() {
serde_json::json!({ "name": tool_name })
} else {
serde_json::json!({
"name": tool_name,
"arguments": args
})
};
let req = serde_json::json!({
"jsonrpc": "2.0",
"method": "tools/call",
"params": params,
"id": 0
});
let resp = self.send_request(&req)?;
let result: McpToolResult = serde_json::from_value(resp["result"].clone())
.context("MCP: failed to parse tool result")?;
Ok(result)
}
fn send_request(&mut self, request: &Value) -> anyhow::Result<Value> {
let id = self.next_id;
self.next_id += 1;
match &mut self.transport {
McpTransport::Stdio(child) => send_request_stdio(id, child, request),
McpTransport::Http { .. } => {
anyhow::bail!("MCP HTTP transport not yet implemented")
}
}
}
}
#[allow(clippy::significant_drop_tightening)]
fn send_request_stdio(_id: u64, child: &mut Child, request: &Value) -> anyhow::Result<Value> {
let mut request_str = serde_json::to_string(request)?;
request_str.push('\n');
let stdin = child.stdin.as_mut().context("MCP: stdin not available")?;
stdin
.write_all(request_str.as_bytes())
.context("MCP: write to stdin failed")?;
stdin.flush().context("MCP: flush stdin failed")?;
let stdout = child.stdout.as_mut().context("MCP: stdout not available")?;
let mut reader = std::io::BufReader::new(stdout);
let mut line = String::new();
reader
.read_line(&mut line)
.context("MCP: read from stdout failed")?;
let resp: Value = serde_json::from_str(&line).context("MCP: failed to parse JSON response")?;
if let Some(error) = resp["error"]["message"].as_str() {
anyhow::bail!("MCP error: {error}");
}
Ok(resp)
}
#[allow(dead_code)]
impl McpClient {
#[allow(dead_code)]
pub async fn connect_http(url: &str, timeout: Duration) -> anyhow::Result<Self> {
let client = reqwest::Client::builder()
.timeout(timeout)
.build()
.context("build reqwest client")?;
let mcp = Self {
transport: McpTransport::Http {
url: url.trim_end_matches('/').to_owned(),
client: client.clone(),
},
next_id: 1,
};
let init = serde_json::json!({
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "llm-browser-testkit",
"version": "0.1.2"
}
},
"id": 0
});
let resp = client
.post(url)
.header("Content-Type", "application/json")
.json(&init)
.send()
.await
.context("MCP HTTP: initialize failed")?;
let json: Value = resp.json().await.context("MCP HTTP: parse response")?;
if let Some(error) = json["error"]["message"].as_str() {
anyhow::bail!("MCP error: {error}");
}
Ok(mcp)
}
}
#[derive(Debug, Clone, serde::Deserialize)]
#[allow(non_snake_case)]
pub struct McpTool {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub inputSchema: Value,
}
#[derive(Debug, Clone, serde::Deserialize)]
#[allow(non_snake_case)]
pub struct McpToolResult {
#[serde(default)]
pub content: Vec<McpContent>,
#[serde(default)]
pub isError: bool,
}
impl std::fmt::Display for McpToolResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (i, c) in self.content.iter().enumerate() {
if i > 0 {
writeln!(f)?;
}
match c {
McpContent::Text { text } => write!(f, "{text}")?,
McpContent::Resource { resource } => {
write!(f, "[resource: {}]", resource.uri)?;
}
McpContent::Image { data, mimeType } => {
write!(f, "[image: {mimeType}, {} bytes]", data.len())?;
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, serde::Deserialize)]
#[serde(tag = "type")]
#[allow(non_snake_case)]
pub enum McpContent {
#[serde(rename = "text")]
Text {
text: String,
},
#[serde(rename = "resource")]
Resource {
resource: McpResource,
},
#[serde(rename = "image")]
Image {
data: String,
mimeType: String,
},
}
#[derive(Debug, Clone, serde::Deserialize)]
#[allow(non_snake_case)]
pub struct McpResource {
pub uri: String,
#[serde(default)]
pub mimeType: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mcp_tool_result_display_text() {
let result = McpToolResult {
content: vec![McpContent::Text {
text: "hello world".into(),
}],
isError: false,
};
assert_eq!(result.to_string(), "hello world");
}
#[test]
fn test_mcp_tool_result_display_multiple() {
let result = McpToolResult {
content: vec![
McpContent::Text {
text: "line1".into(),
},
McpContent::Text {
text: "line2".into(),
},
],
isError: false,
};
assert_eq!(result.to_string(), "line1\nline2");
}
#[test]
fn test_mcp_tool_result_display_resource() {
let result = McpToolResult {
content: vec![McpContent::Resource {
resource: McpResource {
uri: "file:///test".into(),
mimeType: "text/plain".into(),
},
}],
isError: false,
};
assert_eq!(result.to_string(), "[resource: file:///test]");
}
#[test]
fn test_mcp_tool_result_display_image() {
let result = McpToolResult {
content: vec![McpContent::Image {
data: "base64data".into(),
mimeType: "image/png".into(),
}],
isError: false,
};
assert_eq!(result.to_string(), "[image: image/png, 10 bytes]");
}
#[test]
fn test_mcp_tool_deserialize() {
let json = serde_json::json!({
"name": "query",
"description": "Run a SQL query",
"inputSchema": {"type": "object"}
});
let tool: McpTool = serde_json::from_value(json).unwrap();
assert_eq!(tool.name, "query");
assert_eq!(tool.description, "Run a SQL query");
}
}