use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::transport::Transport;
pub struct McpClient {
transport: Transport,
pub server_info: Option<ServerInfo>,
shutdown: std::sync::atomic::AtomicBool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerInfo {
pub name: String,
pub version: Option<String>,
}
#[derive(Debug, Clone)]
pub struct McpToolDef {
pub name: String,
pub description: String,
pub input_schema: Value,
pub read_only_hint: bool,
}
#[derive(Debug, Clone)]
pub struct McpToolResult {
pub content: Vec<ContentBlock>,
pub is_error: bool,
}
#[derive(Debug, Clone)]
pub enum ContentBlock {
Text(String),
Image {
data: String,
mime_type: String,
},
Audio {
data: String,
mime_type: String,
},
ResourceLink {
uri: String,
name: Option<String>,
description: Option<String>,
mime_type: Option<String>,
},
Resource {
uri: String,
mime_type: Option<String>,
text: Option<String>,
blob: Option<String>,
},
}
fn tool_def_from_json(tool: &Value) -> Option<McpToolDef> {
let name = tool.get("name").and_then(|v| v.as_str())?;
if name.is_empty() {
return None;
}
Some(McpToolDef {
name: name.to_string(),
description: tool
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
input_schema: tool
.get("inputSchema")
.cloned()
.unwrap_or_else(|| json!({"type": "object", "properties": {}})),
read_only_hint: tool
.pointer("/annotations/readOnlyHint")
.and_then(|v| v.as_bool())
.unwrap_or(false),
})
}
impl McpClient {
pub(super) fn new(transport: Transport) -> Self {
Self {
transport,
server_info: None,
shutdown: std::sync::atomic::AtomicBool::new(false),
}
}
pub async fn initialize(&mut self) -> Result<ServerInfo> {
let result = self
.transport
.send_request(
"initialize",
json!({
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {
"name": "mermaid",
"version": env!("CARGO_PKG_VERSION"),
}
}),
)
.await?;
let server_info = ServerInfo {
name: result
.pointer("/serverInfo/name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
version: result
.pointer("/serverInfo/version")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
};
if let Some(version) = result.get("protocolVersion").and_then(|v| v.as_str()) {
self.transport.set_protocol_version(version);
}
self.transport
.send_notification("notifications/initialized", json!({}))
.await?;
self.server_info = Some(server_info.clone());
Ok(server_info)
}
pub async fn list_tools(&self) -> Result<Vec<McpToolDef>> {
const MAX_PAGES: usize = 100;
let mut tools = Vec::new();
let mut cursor: Option<String> = None;
for _ in 0..MAX_PAGES {
let params = match &cursor {
Some(c) => json!({ "cursor": c }),
None => json!({}),
};
let result = self.transport.send_request("tools/list", params).await?;
let tools_array = result
.get("tools")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow!("MCP tools/list response missing 'tools' array"))?;
for tool in tools_array {
if let Some(def) = tool_def_from_json(tool) {
tools.push(def);
}
}
match result.get("nextCursor").and_then(|v| v.as_str()) {
Some(next) if !next.is_empty() => cursor = Some(next.to_string()),
_ => break,
}
}
Ok(tools)
}
pub async fn call_tool(&self, name: &str, arguments: &Value) -> Result<McpToolResult> {
let params = json!({
"name": name,
"arguments": arguments,
});
let result = self
.transport
.send_request_with_timeout("tools/call", params, Transport::tool_call_timeout_secs())
.await?;
let is_error = result
.get("isError")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let content_array = result
.get("content")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut content = Vec::new();
for block in content_array {
let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
match block_type {
"text" => {
if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
content.push(ContentBlock::Text(text.to_string()));
}
},
"image" => {
let data = block
.get("data")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let mime_type = block
.get("mimeType")
.and_then(|v| v.as_str())
.unwrap_or("image/png")
.to_string();
content.push(ContentBlock::Image { data, mime_type });
},
"audio" => {
let data = block
.get("data")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let mime_type = block
.get("mimeType")
.and_then(|v| v.as_str())
.unwrap_or("audio/wav")
.to_string();
content.push(ContentBlock::Audio { data, mime_type });
},
"resource_link" => {
let uri = block
.get("uri")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if uri.is_empty() {
continue;
}
content.push(ContentBlock::ResourceLink {
uri,
name: block.get("name").and_then(|v| v.as_str()).map(String::from),
description: block
.get("description")
.and_then(|v| v.as_str())
.map(String::from),
mime_type: block
.get("mimeType")
.and_then(|v| v.as_str())
.map(String::from),
});
},
"resource" => {
let res = match block.get("resource") {
Some(r) => r,
None => continue,
};
let uri = res
.get("uri")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if uri.is_empty() {
continue;
}
content.push(ContentBlock::Resource {
uri,
mime_type: res
.get("mimeType")
.and_then(|v| v.as_str())
.map(String::from),
text: res.get("text").and_then(|v| v.as_str()).map(String::from),
blob: res.get("blob").and_then(|v| v.as_str()).map(String::from),
});
},
_ => {
if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
content.push(ContentBlock::Text(text.to_string()));
}
},
}
}
Ok(McpToolResult { content, is_error })
}
pub async fn shutdown(&self) {
self.shutdown
.store(true, std::sync::atomic::Ordering::Release);
self.transport.shutdown().await;
}
pub fn is_shutdown(&self) -> bool {
self.shutdown.load(std::sync::atomic::Ordering::Acquire)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_def_parses_read_only_hint() {
let def = tool_def_from_json(&json!({
"name": "get_thing",
"description": "d",
"inputSchema": {"type": "object"},
"annotations": {"readOnlyHint": true}
}))
.unwrap();
assert!(def.read_only_hint);
let def = tool_def_from_json(&json!({"name": "send_thing"})).unwrap();
assert!(!def.read_only_hint);
assert_eq!(
def.input_schema,
json!({"type": "object", "properties": {}})
);
let def = tool_def_from_json(&json!({
"name": "odd",
"annotations": {"readOnlyHint": "yes"}
}))
.unwrap();
assert!(!def.read_only_hint);
assert!(tool_def_from_json(&json!({"description": "nameless"})).is_none());
}
}