use std::path::Path;
use serde_json::{json, Value};
use super::{McpPromptArg, McpServer, McpTransport, PromptMessage};
pub(super) const PROTOCOL_VERSION: &str = "2025-11-25";
pub(crate) trait McpConnection: Send + Sync {
fn request(&self, method: &str, params: Value) -> Result<Value, String>;
fn notify(&self, method: &str, params: Value) -> Result<(), String>;
}
pub(crate) struct McpToolDef {
pub name: String,
pub description: String,
pub input_schema: Value,
}
pub(crate) struct McpResource {
pub uri: String,
pub name: String,
pub description: String,
}
pub(crate) struct McpPromptDef {
pub name: String,
pub description: String,
pub arguments: Vec<McpPromptArg>,
}
pub(crate) struct McpClient {
conn: Box<dyn McpConnection>,
}
impl McpClient {
pub(crate) fn connect(server: &McpServer, cwd: &Path) -> Result<(McpClient, Vec<McpToolDef>), String> {
let conn: Box<dyn McpConnection> = match &server.transport {
McpTransport::Stdio { command, args, env } => {
Box::new(super::stdio::StdioConnection::spawn(&server.name, command, args, env, cwd)?)
}
McpTransport::Http { url, headers } => Box::new(super::http::HttpConnection::new(&server.name, url, headers)),
};
Self::handshake(conn)
}
fn handshake(conn: Box<dyn McpConnection>) -> Result<(McpClient, Vec<McpToolDef>), String> {
let client = McpClient { conn };
client.initialize()?;
let tools = client.list_tools()?;
Ok((client, tools))
}
fn initialize(&self) -> Result<(), String> {
let params = json!({
"protocolVersion": PROTOCOL_VERSION,
"capabilities": {},
"clientInfo": { "name": "openai-compatible", "version": env!("CARGO_PKG_VERSION") }
});
self.conn.request("initialize", params)?;
self.conn.notify("notifications/initialized", json!({}))
}
fn list_tools(&self) -> Result<Vec<McpToolDef>, String> {
let mut out = Vec::new();
let mut cursor: Option<String> = None;
loop {
let params = cursor.as_ref().map_or_else(|| json!({}), |c| json!({ "cursor": c }));
let result = self.conn.request("tools/list", params)?;
if let Some(arr) = result.get("tools").and_then(Value::as_array) {
for t in arr {
let Some(name) = t.get("name").and_then(Value::as_str) else { continue };
out.push(McpToolDef {
name: name.to_owned(),
description: t.get("description").and_then(Value::as_str).unwrap_or_default().to_owned(),
input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| json!({ "type": "object" })),
});
}
}
match result.get("nextCursor").and_then(Value::as_str) {
Some(c) => cursor = Some(c.to_owned()),
None => return Ok(out),
}
}
}
pub(crate) fn call(&self, name: &str, arguments: &Value) -> Result<String, String> {
let result = self.conn.request("tools/call", json!({ "name": name, "arguments": arguments }))?;
let text = match result.get("content").and_then(Value::as_array) {
Some(blocks) => flatten_content(blocks),
None => String::new(),
};
if result.get("isError").and_then(Value::as_bool).unwrap_or(false) {
return Err(if text.is_empty() { "the tool reported an error".to_owned() } else { text });
}
Ok(text)
}
pub(crate) fn list_resources(&self) -> Vec<McpResource> {
let Ok(result) = self.conn.request("resources/list", json!({})) else {
return Vec::new();
};
result
.get("resources")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|r| {
let uri = r.get("uri").and_then(Value::as_str)?;
Some(McpResource {
uri: uri.to_owned(),
name: r.get("name").and_then(Value::as_str).unwrap_or(uri).to_owned(),
description: r.get("description").and_then(Value::as_str).unwrap_or_default().to_owned(),
})
})
.collect()
})
.unwrap_or_default()
}
pub(crate) fn read_resource(&self, uri: &str) -> Result<String, String> {
let result = self.conn.request("resources/read", json!({ "uri": uri }))?;
Ok(match result.get("contents").and_then(Value::as_array) {
Some(items) => flatten_resource_contents(items),
None => String::new(),
})
}
pub(crate) fn list_prompts(&self) -> Vec<McpPromptDef> {
let Ok(result) = self.conn.request("prompts/list", json!({})) else {
return Vec::new();
};
result
.get("prompts")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(parse_prompt_def).collect())
.unwrap_or_default()
}
pub(crate) fn get_prompt(&self, name: &str, arguments: &[(String, String)]) -> Result<Vec<PromptMessage>, String> {
let args: serde_json::Map<String, Value> =
arguments.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect();
let result = self.conn.request("prompts/get", json!({ "name": name, "arguments": args }))?;
Ok(result
.get("messages")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|m| {
let role = m.get("role").and_then(Value::as_str)?;
Some(PromptMessage { role: role.to_owned(), content: prompt_content_text(m.get("content")) })
})
.collect()
})
.unwrap_or_default())
}
}
pub(super) fn flatten_content(blocks: &[Value]) -> String {
blocks
.iter()
.filter_map(|b| match b.get("type").and_then(Value::as_str) {
Some("text") => b.get("text").and_then(Value::as_str).map(str::to_owned),
Some(other) => Some(format!("[{other} content omitted]")),
None => None,
})
.collect::<Vec<_>>()
.join("\n")
}
fn flatten_resource_contents(items: &[Value]) -> String {
items
.iter()
.filter_map(|c| {
c.get("text")
.and_then(Value::as_str)
.map(str::to_owned)
.or_else(|| c.get("blob").map(|_| "[binary resource omitted]".to_owned()))
})
.collect::<Vec<_>>()
.join("\n")
}
fn parse_prompt_def(p: &Value) -> Option<McpPromptDef> {
let name = p.get("name").and_then(Value::as_str)?;
let arguments = p
.get("arguments")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|arg| {
let n = arg.get("name").and_then(Value::as_str)?;
Some(McpPromptArg {
name: n.to_owned(),
description: arg.get("description").and_then(Value::as_str).unwrap_or_default().to_owned(),
required: arg.get("required").and_then(Value::as_bool).unwrap_or(false),
})
})
.collect()
})
.unwrap_or_default();
Some(McpPromptDef {
name: name.to_owned(),
description: p.get("description").and_then(Value::as_str).unwrap_or_default().to_owned(),
arguments,
})
}
fn prompt_content_text(content: Option<&Value>) -> String {
match content {
Some(Value::Array(arr)) => flatten_content(arr),
Some(Value::Object(_)) => {
content.and_then(|c| c.get("text")).and_then(Value::as_str).unwrap_or_default().to_owned()
}
Some(Value::String(s)) => s.clone(),
_ => String::new(),
}
}
pub(super) fn parse_rpc_result(text: &str, id: i64) -> Result<Value, String> {
let mut messages: Vec<Value> = Vec::new();
if let Ok(v) = serde_json::from_str::<Value>(text) {
messages.push(v);
} else {
for data in text.lines().filter_map(|l| l.strip_prefix("data:").map(str::trim)) {
if let Ok(v) = serde_json::from_str::<Value>(data) {
messages.push(v);
}
}
}
let response = messages
.iter()
.find(|m| m.get("id").and_then(Value::as_i64) == Some(id))
.or_else(|| messages.iter().find(|m| m.get("result").is_some() || m.get("error").is_some()));
match response {
Some(m) if m.get("error").is_some() => Err(format!("{}", m["error"])),
Some(m) => Ok(m.get("result").cloned().unwrap_or(Value::Null)),
None => Err("no JSON-RPC response in the server reply".to_owned()),
}
}
#[cfg(test)]
#[derive(Clone, Default)]
pub(super) struct ScriptedConnection {
#[allow(clippy::type_complexity)]
replies: std::sync::Arc<
std::sync::Mutex<std::collections::HashMap<String, std::collections::VecDeque<Result<Value, String>>>>,
>,
asked: std::sync::Arc<std::sync::Mutex<Vec<(String, Value)>>>,
}
#[cfg(test)]
impl ScriptedConnection {
pub(super) fn new() -> Self {
Self::default()
}
pub(super) fn on(self, method: &str, reply: Value) -> Self {
self.queue(method, Ok(reply));
self
}
pub(super) fn failing(self, method: &str, error: &str) -> Self {
self.queue(method, Err(error.to_owned()));
self
}
fn queue(&self, method: &str, reply: Result<Value, String>) {
self.replies.lock().unwrap().entry(method.to_owned()).or_default().push_back(reply);
}
pub(super) fn asked(&self) -> Vec<(String, Value)> {
self.asked.lock().unwrap().clone()
}
pub(super) fn methods(&self) -> Vec<String> {
self.asked().into_iter().map(|(method, _)| method).collect()
}
}
#[cfg(test)]
impl McpConnection for ScriptedConnection {
fn request(&self, method: &str, params: Value) -> Result<Value, String> {
self.asked.lock().unwrap().push((method.to_owned(), params));
self.replies
.lock()
.unwrap()
.get_mut(method)
.and_then(std::collections::VecDeque::pop_front)
.unwrap_or_else(|| Err(format!("nothing scripted for {method}")))
}
fn notify(&self, method: &str, params: Value) -> Result<(), String> {
self.asked.lock().unwrap().push((method.to_owned(), params));
Ok(())
}
}
#[cfg(test)]
impl McpClient {
pub(super) fn over(conn: Box<dyn McpConnection>) -> Self {
Self { conn }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flatten_keeps_text_and_notes_other_blocks() {
let blocks = vec![
json!({ "type": "text", "text": "hello" }),
json!({ "type": "image", "data": "..." }),
json!({ "type": "text", "text": "world" }),
];
assert_eq!(flatten_content(&blocks), "hello\n[image content omitted]\nworld");
}
#[test]
fn parse_rpc_result_reads_json_sse_and_errors() {
let json = r#"{"jsonrpc":"2.0","id":2,"result":{"ok":true}}"#;
assert_eq!(parse_rpc_result(json, 2).unwrap(), json!({ "ok": true }));
let sse = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":5,\"result\":{\"v\":1}}\n\n";
assert_eq!(parse_rpc_result(sse, 5).unwrap(), json!({ "v": 1 }));
let err = r#"{"jsonrpc":"2.0","id":3,"error":{"code":-32601,"message":"nope"}}"#;
assert!(parse_rpc_result(err, 3).unwrap_err().contains("nope"));
}
fn tools_page(tools: &[&str], next: Option<&str>) -> Value {
let listed: Vec<Value> = tools
.iter()
.map(|name| json!({ "name": name, "description": format!("does {name}"), "inputSchema": { "type": "object" } }))
.collect();
match next {
Some(cursor) => json!({ "tools": listed, "nextCursor": cursor }),
None => json!({ "tools": listed }),
}
}
#[test]
fn the_handshake_announces_itself_before_asking_what_is_offered() {
let conn = ScriptedConnection::new()
.on("initialize", json!({ "protocolVersion": PROTOCOL_VERSION }))
.on("tools/list", tools_page(&["search"], None));
let recorder = conn.clone();
let (_client, tools) = McpClient::handshake(Box::new(conn)).expect("handshake");
assert_eq!(
recorder.methods(),
["initialize", "notifications/initialized", "tools/list"],
"in that order"
);
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "search");
}
#[test]
fn a_server_that_refuses_the_handshake_is_not_reported_as_having_no_tools() {
let conn = ScriptedConnection::new().failing("initialize", "protocol version not supported");
let err = McpClient::handshake(Box::new(conn)).map(|_| ()).expect_err("must fail");
assert!(err.contains("protocol version"), "got {err}");
}
#[test]
fn a_paginated_tool_list_is_followed_to_the_end() {
let conn = ScriptedConnection::new()
.on("initialize", json!({}))
.on("tools/list", tools_page(&["one", "two"], Some("page2")))
.on("tools/list", tools_page(&["three"], None));
let recorder = conn.clone();
let (_client, tools) = McpClient::handshake(Box::new(conn)).expect("handshake");
let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, ["one", "two", "three"], "every page is collected");
let cursors: Vec<Value> = recorder
.asked()
.into_iter()
.filter(|(method, _)| method == "tools/list")
.map(|(_, params)| params)
.collect();
assert_eq!(cursors[0], json!({}), "the first page asks for no cursor");
assert_eq!(cursors[1], json!({ "cursor": "page2" }), "and the second sends the one it was given");
}
#[test]
fn a_tool_that_reports_its_own_failure_is_an_error_not_an_answer() {
let client = McpClient::over(Box::new(
ScriptedConnection::new()
.on("tools/call", json!({ "content": [{ "type": "text", "text": "no such file" }], "isError": true }))
.on("tools/call", json!({ "content": [{ "type": "text", "text": "it worked" }] })),
));
assert_eq!(client.call("read", &json!({})).unwrap_err(), "no such file");
assert_eq!(client.call("read", &json!({})).unwrap(), "it worked");
}
#[test]
fn optional_capabilities_are_skipped_rather_than_fatal() {
let client = McpClient::over(Box::new(
ScriptedConnection::new()
.failing("resources/list", "method not found")
.failing("prompts/list", "method not found"),
));
assert!(client.list_resources().is_empty());
assert!(client.list_prompts().is_empty());
}
#[test]
fn a_resource_without_a_name_is_listed_under_its_uri() {
let client = McpClient::over(Box::new(ScriptedConnection::new().on(
"resources/list",
json!({ "resources": [
{ "uri": "file:///a.txt", "name": "A", "description": "the first" },
{ "uri": "file:///b.txt" },
{ "name": "no uri, so not addressable" }
]}),
)));
let resources = client.list_resources();
assert_eq!(resources.len(), 2, "an entry with no uri cannot be read, so it is dropped");
assert_eq!(resources[0].name, "A");
assert_eq!(resources[1].name, "file:///b.txt", "the uri stands in for a missing name");
}
#[test]
fn connect_reports_a_spawn_failure() {
let server = McpServer::stdio("missing", "definitely-not-a-real-binary-xyz", vec![]);
match McpClient::connect(&server, Path::new(".")) {
Err(e) => assert!(e.contains("spawning"), "got: {e}"),
Ok(_) => panic!("expected a spawn failure"),
}
}
#[test]
fn connect_handshakes_lists_and_calls() {
let script = r#"
read _initialize
printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"fake","version":"0"}}}'
read _initialized
read _list
printf '%s\n' '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","description":"echoes input","inputSchema":{"type":"object"}}]}}'
read _call
printf '%s\n' '{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"pong"}],"isError":false}}'
read _reslist
printf '%s\n' '{"jsonrpc":"2.0","id":4,"result":{"resources":[{"uri":"file:///doc","name":"Doc","description":"a doc"}]}}'
read _resread
printf '%s\n' '{"jsonrpc":"2.0","id":5,"result":{"contents":[{"uri":"file:///doc","text":"doc body"}]}}'
read _promptslist
printf '%s\n' '{"jsonrpc":"2.0","id":6,"result":{"prompts":[{"name":"greet","description":"greeting","arguments":[{"name":"who","required":true}]}]}}'
read _promptsget
printf '%s\n' '{"jsonrpc":"2.0","id":7,"result":{"messages":[{"role":"user","content":{"type":"text","text":"Hello there"}}]}}'
"#;
let server = McpServer::stdio("fake", "sh", vec!["-c".to_owned(), script.to_owned()]);
let (client, tools) = McpClient::connect(&server, Path::new(".")).expect("handshake succeeds");
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "echo");
assert_eq!(tools[0].description, "echoes input");
assert_eq!(client.call("echo", &json!({ "x": 1 })).expect("call succeeds"), "pong");
let resources = client.list_resources();
assert_eq!(resources.len(), 1);
assert_eq!(resources[0].uri, "file:///doc");
assert_eq!(client.read_resource("file:///doc").expect("read succeeds"), "doc body");
let prompts = client.list_prompts();
assert_eq!(prompts.len(), 1);
assert_eq!(prompts[0].name, "greet");
assert!(prompts[0].arguments[0].required && prompts[0].arguments[0].name == "who");
let msgs = client.get_prompt("greet", &[("who".to_owned(), "world".to_owned())]).expect("get prompt");
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "user");
assert_eq!(msgs[0].content, "Hello there");
}
}