pub mod bridge;
pub mod transport;
pub mod types;
use std::sync::atomic::{AtomicU32, Ordering};
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use tokio::io::{AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout};
use tracing::{debug, info};
use transport::{encode_message, read_message};
use types::{
McpResource, McpResourceContent, McpResourceReadResult, McpResourcesListResult,
McpToolCallResult, McpToolDefinition, McpToolsListResult,
};
pub struct McpClient {
#[allow(dead_code)]
process: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
next_id: AtomicU32,
}
impl McpClient {
pub async fn start(server_cmd: &str, args: &[&str]) -> Result<Self> {
info!("Starting MCP server: {} {:?}", server_cmd, args);
let mut child = tokio::process::Command::new(server_cmd)
.args(args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit())
.spawn()
.with_context(|| {
format!(
"Failed to spawn MCP server '{}'. Is it installed?",
server_cmd
)
})?;
let stdin = child
.stdin
.take()
.context("MCP server stdin was not piped")?;
let stdout_raw = child
.stdout
.take()
.context("MCP server stdout was not piped")?;
let stdout = BufReader::new(stdout_raw);
Ok(McpClient {
process: child,
stdin,
stdout,
next_id: AtomicU32::new(1),
})
}
pub async fn initialize(&mut self) -> Result<()> {
info!("MCP: initializing");
let result = self
.send_request(
"initialize",
json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "xcodeai",
"version": env!("CARGO_PKG_VERSION"),
}
}),
)
.await?;
debug!("MCP: server capabilities: {}", result);
self.send_notification("notifications/initialized", json!({}))
.await?;
info!("MCP: initialized successfully");
Ok(())
}
#[allow(dead_code)]
pub async fn shutdown(&mut self) -> Result<()> {
info!("MCP: shutting down");
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), self.process.wait()).await;
let _ = self.process.kill().await;
info!("MCP: shutdown complete");
Ok(())
}
pub async fn list_tools(&mut self) -> Result<Vec<McpToolDefinition>> {
debug!("MCP: listing tools");
let result = self.send_request("tools/list", json!({})).await?;
let list: McpToolsListResult =
serde_json::from_value(result).context("Failed to parse tools/list response")?;
info!("MCP: server has {} tools", list.tools.len());
Ok(list.tools)
}
pub async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<McpToolCallResult> {
debug!("MCP: calling tool '{}'", name);
let result = self
.send_request(
"tools/call",
json!({
"name": name,
"arguments": arguments,
}),
)
.await?;
let call_result: McpToolCallResult = serde_json::from_value(result)
.with_context(|| format!("Failed to parse tools/call response for '{}'", name))?;
if call_result.is_error {
debug!("MCP: tool '{}' returned an error result", name);
} else {
debug!("MCP: tool '{}' succeeded", name);
}
Ok(call_result)
}
#[allow(dead_code)]
pub async fn list_resources(&mut self) -> Result<Vec<McpResource>> {
debug!("MCP: listing resources");
let result = self.send_request("resources/list", json!({})).await?;
let list: McpResourcesListResult =
serde_json::from_value(result).context("Failed to parse resources/list response")?;
info!("MCP: server has {} resources", list.resources.len());
Ok(list.resources)
}
#[allow(dead_code)]
pub async fn read_resource(&mut self, uri: &str) -> Result<Vec<McpResourceContent>> {
debug!("MCP: reading resource '{}'", uri);
let result = self
.send_request(
"resources/read",
json!({
"uri": uri,
}),
)
.await?;
let read_result: McpResourceReadResult = serde_json::from_value(result)
.with_context(|| format!("Failed to parse resources/read response for '{}'", uri))?;
Ok(read_result.contents)
}
pub async fn send_request(&mut self, method: &str, params: Value) -> Result<Value> {
let id = self.next_id.fetch_add(1, Ordering::SeqCst);
let message = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
});
debug!("MCP → {} (id={})", method, id);
self.write_message(&message).await?;
loop {
let response = read_message(&mut self.stdout)
.await
.with_context(|| format!("Failed to read MCP response for '{}'", method))?;
match response.get("id") {
Some(resp_id) if *resp_id == json!(id) => {
debug!("MCP ← {} (id={})", method, id);
if let Some(err) = response.get("error") {
bail!("MCP server returned error for '{}': {}", method, err);
}
return Ok(response.get("result").cloned().unwrap_or(Value::Null));
}
_ => {
debug!("MCP: discarding unmatched message: {}", response);
}
}
}
}
pub async fn send_notification(&mut self, method: &str, params: Value) -> Result<()> {
let message = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
});
debug!("MCP → {} (notification)", method);
self.write_message(&message).await
}
async fn write_message(&mut self, value: &Value) -> Result<()> {
let bytes = encode_message(value);
self.stdin
.write_all(&bytes)
.await
.context("Failed to write to MCP server stdin")?;
self.stdin
.flush()
.await
.context("Failed to flush MCP server stdin")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::transport::encode_message;
use serde_json::{json, Value};
#[test]
fn test_initialize_request_structure() {
let params = json!({
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "xcodeai",
"version": "1.1.0"
}
});
let id: u32 = 1;
let message = json!({
"jsonrpc": "2.0",
"id": id,
"method": "initialize",
"params": params,
});
assert_eq!(message["jsonrpc"], "2.0");
assert_eq!(message["method"], "initialize");
assert_eq!(message["params"]["protocolVersion"], "2024-11-05");
assert_eq!(message["params"]["clientInfo"]["name"], "xcodeai");
}
#[test]
fn test_initialized_notification_no_id() {
let notification = json!({
"jsonrpc": "2.0",
"method": "notifications/initialized",
"params": {}
});
assert!(
notification.get("id").is_none(),
"Notification must not have id"
);
assert_eq!(notification["method"], "notifications/initialized");
}
#[test]
fn test_tools_call_request_structure() {
let id: u32 = 3;
let name = "read_file";
let arguments = json!({ "path": "/tmp/notes.txt" });
let message = json!({
"jsonrpc": "2.0",
"id": id,
"method": "tools/call",
"params": {
"name": name,
"arguments": arguments,
}
});
assert_eq!(message["method"], "tools/call");
assert_eq!(message["params"]["name"], "read_file");
assert_eq!(message["params"]["arguments"]["path"], "/tmp/notes.txt");
}
#[test]
fn test_resources_read_request_structure() {
let id: u32 = 4;
let uri = "file:///home/user/notes.txt";
let message = json!({
"jsonrpc": "2.0",
"id": id,
"method": "resources/read",
"params": { "uri": uri }
});
assert_eq!(message["method"], "resources/read");
assert_eq!(message["params"]["uri"], uri);
}
#[test]
fn test_response_id_matching() {
let our_id: u32 = 42;
let matching_response = json!({
"jsonrpc": "2.0",
"id": our_id,
"result": { "tools": [] }
});
let other_response = json!({
"jsonrpc": "2.0",
"id": 99,
"result": {}
});
let notification = json!({
"jsonrpc": "2.0",
"method": "notifications/progress",
"params": { "progress": 50 }
});
assert_eq!(
matching_response.get("id"),
Some(&json!(our_id)),
"Matching response should have our id"
);
assert_ne!(
other_response.get("id"),
Some(&json!(our_id)),
"Other response has different id"
);
assert!(notification.get("id").is_none(), "Notification has no id");
}
#[test]
fn test_error_response_detection() {
let error_response = json!({
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32601,
"message": "Method not found"
}
});
assert!(
error_response.get("error").is_some(),
"Error response should have 'error' field"
);
assert!(
error_response.get("result").is_none(),
"Error response should not have 'result' field"
);
}
#[test]
fn test_message_framing_for_mcp() {
let message = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
});
let encoded = encode_message(&message);
let text = String::from_utf8(encoded).unwrap();
let cl_line = text.lines().next().unwrap();
let cl_val: usize = cl_line
.strip_prefix("Content-Length: ")
.unwrap()
.parse()
.unwrap();
let body_start = text.find("\r\n\r\n").unwrap() + 4;
let body = &text[body_start..];
assert_eq!(body.len(), cl_val, "Content-Length must match body length");
let _: Value = serde_json::from_str(body).expect("Body must be valid JSON");
}
}