mermaid_cli/mcp/client.rs
1//! MCP protocol client — higher-level API over a [`Transport`] (stdio child
2//! process or Streamable HTTP endpoint).
3//!
4//! Implements the three protocol methods we need:
5//! - `initialize` — handshake and capability negotiation
6//! - `tools/list` — discover available tools
7//! - `tools/call` — invoke a tool and get results
8
9use anyhow::{Result, anyhow};
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12
13use super::transport::Transport;
14
15/// MCP protocol client for a single server connection.
16pub struct McpClient {
17 transport: Transport,
18 /// Server info from initialization
19 pub server_info: Option<ServerInfo>,
20 /// Set once [`Self::shutdown`] runs, so a later `call_tool` returns a clean
21 /// "stopped" error instead of a broken-pipe transport error — the manager
22 /// keeps the entry in its frozen map, so the client outlives its process.
23 shutdown: std::sync::atomic::AtomicBool,
24}
25
26/// Info returned by the server during initialization
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ServerInfo {
29 pub name: String,
30 pub version: Option<String>,
31}
32
33/// A tool definition discovered from an MCP server
34#[derive(Debug, Clone)]
35pub struct McpToolDef {
36 pub name: String,
37 pub description: String,
38 pub input_schema: Value,
39}
40
41/// Result of calling an MCP tool
42#[derive(Debug, Clone)]
43pub struct McpToolResult {
44 pub content: Vec<ContentBlock>,
45 pub is_error: bool,
46}
47
48/// A content block in an MCP tool result. Per the 2025-11-25 spec,
49/// servers may return text, image, audio, resource_link (URI reference),
50/// or embedded resource content. Older servers only emit text/image.
51#[derive(Debug, Clone)]
52pub enum ContentBlock {
53 Text(String),
54 Image {
55 data: String,
56 mime_type: String,
57 },
58 /// Audio content — base64-encoded data + mime type (e.g., `audio/wav`).
59 /// Routed to the model's image attachment channel for now; adapters
60 /// that don't support audio will silently drop the bytes but keep
61 /// the text hint from the tool output.
62 Audio {
63 data: String,
64 mime_type: String,
65 },
66 /// URI reference to an external resource. Rendered as text for the
67 /// model so it can follow up with another tool call if needed.
68 ResourceLink {
69 uri: String,
70 name: Option<String>,
71 description: Option<String>,
72 mime_type: Option<String>,
73 },
74 /// Embedded resource — same shape as a read_resource response.
75 /// Either `text` or `blob` (base64) is present depending on the
76 /// resource's kind. Rendered as text for the model.
77 Resource {
78 uri: String,
79 mime_type: Option<String>,
80 text: Option<String>,
81 blob: Option<String>,
82 },
83}
84
85impl McpClient {
86 /// Create a new MCP client wrapping a transport.
87 pub(super) fn new(transport: Transport) -> Self {
88 Self {
89 transport,
90 server_info: None,
91 shutdown: std::sync::atomic::AtomicBool::new(false),
92 }
93 }
94
95 /// Perform the MCP initialization handshake.
96 ///
97 /// Sends `initialize` request with our client info and protocol version,
98 /// then sends `notifications/initialized` to signal readiness.
99 pub async fn initialize(&mut self) -> Result<ServerInfo> {
100 let result = self
101 .transport
102 .send_request(
103 "initialize",
104 json!({
105 // MCP spec version as of 2026-04. Servers negotiate
106 // down to older versions if they don't support this;
107 // spec requires them to respond with their latest
108 // supported version, which we currently accept
109 // silently. Bump when MCP ships a newer revision
110 // with features we depend on.
111 "protocolVersion": "2025-11-25",
112 "capabilities": {},
113 "clientInfo": {
114 "name": "mermaid",
115 "version": env!("CARGO_PKG_VERSION"),
116 }
117 }),
118 )
119 .await?;
120
121 // Parse server info
122 let server_info = ServerInfo {
123 name: result
124 .pointer("/serverInfo/name")
125 .and_then(|v| v.as_str())
126 .unwrap_or("unknown")
127 .to_string(),
128 version: result
129 .pointer("/serverInfo/version")
130 .and_then(|v| v.as_str())
131 .map(|s| s.to_string()),
132 };
133
134 // Record the negotiated protocol version BEFORE the initialized
135 // notification: over HTTP every request after initialize — including
136 // that notification — must carry the MCP-Protocol-Version header.
137 if let Some(version) = result.get("protocolVersion").and_then(|v| v.as_str()) {
138 self.transport.set_protocol_version(version);
139 }
140
141 // Send initialized notification
142 self.transport
143 .send_notification("notifications/initialized", json!({}))
144 .await?;
145
146 self.server_info = Some(server_info.clone());
147 Ok(server_info)
148 }
149
150 /// Discover all tools available from this server, following `nextCursor`
151 /// pagination so a server that pages its tool list isn't silently truncated
152 /// to page one. Bounded by a page cap so a server that echoes a stuck cursor
153 /// can't loop forever.
154 pub async fn list_tools(&self) -> Result<Vec<McpToolDef>> {
155 const MAX_PAGES: usize = 100;
156 let mut tools = Vec::new();
157 let mut cursor: Option<String> = None;
158
159 for _ in 0..MAX_PAGES {
160 let params = match &cursor {
161 Some(c) => json!({ "cursor": c }),
162 None => json!({}),
163 };
164 let result = self.transport.send_request("tools/list", params).await?;
165
166 let tools_array = result
167 .get("tools")
168 .and_then(|v| v.as_array())
169 .ok_or_else(|| anyhow!("MCP tools/list response missing 'tools' array"))?;
170
171 for tool in tools_array {
172 let name = tool
173 .get("name")
174 .and_then(|v| v.as_str())
175 .unwrap_or("")
176 .to_string();
177 let description = tool
178 .get("description")
179 .and_then(|v| v.as_str())
180 .unwrap_or("")
181 .to_string();
182 let input_schema = tool
183 .get("inputSchema")
184 .cloned()
185 .unwrap_or_else(|| json!({"type": "object", "properties": {}}));
186
187 if !name.is_empty() {
188 tools.push(McpToolDef {
189 name,
190 description,
191 input_schema,
192 });
193 }
194 }
195
196 match result.get("nextCursor").and_then(|v| v.as_str()) {
197 Some(next) if !next.is_empty() => cursor = Some(next.to_string()),
198 _ => break,
199 }
200 }
201
202 Ok(tools)
203 }
204
205 /// Call a tool on this server and return the result.
206 pub async fn call_tool(&self, name: &str, arguments: &Value) -> Result<McpToolResult> {
207 let params = json!({
208 "name": name,
209 "arguments": arguments,
210 });
211
212 let result = self
213 .transport
214 .send_request_with_timeout("tools/call", params, Transport::tool_call_timeout_secs())
215 .await?;
216
217 let is_error = result
218 .get("isError")
219 .and_then(|v| v.as_bool())
220 .unwrap_or(false);
221
222 let content_array = result
223 .get("content")
224 .and_then(|v| v.as_array())
225 .cloned()
226 .unwrap_or_default();
227
228 let mut content = Vec::new();
229 for block in content_array {
230 let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
231 match block_type {
232 "text" => {
233 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
234 content.push(ContentBlock::Text(text.to_string()));
235 }
236 },
237 "image" => {
238 let data = block
239 .get("data")
240 .and_then(|v| v.as_str())
241 .unwrap_or("")
242 .to_string();
243 let mime_type = block
244 .get("mimeType")
245 .and_then(|v| v.as_str())
246 .unwrap_or("image/png")
247 .to_string();
248 content.push(ContentBlock::Image { data, mime_type });
249 },
250 "audio" => {
251 let data = block
252 .get("data")
253 .and_then(|v| v.as_str())
254 .unwrap_or("")
255 .to_string();
256 let mime_type = block
257 .get("mimeType")
258 .and_then(|v| v.as_str())
259 .unwrap_or("audio/wav")
260 .to_string();
261 content.push(ContentBlock::Audio { data, mime_type });
262 },
263 "resource_link" => {
264 let uri = block
265 .get("uri")
266 .and_then(|v| v.as_str())
267 .unwrap_or("")
268 .to_string();
269 if uri.is_empty() {
270 continue;
271 }
272 content.push(ContentBlock::ResourceLink {
273 uri,
274 name: block.get("name").and_then(|v| v.as_str()).map(String::from),
275 description: block
276 .get("description")
277 .and_then(|v| v.as_str())
278 .map(String::from),
279 mime_type: block
280 .get("mimeType")
281 .and_then(|v| v.as_str())
282 .map(String::from),
283 });
284 },
285 "resource" => {
286 // Embedded resource — nested under `resource`.
287 let res = match block.get("resource") {
288 Some(r) => r,
289 None => continue,
290 };
291 let uri = res
292 .get("uri")
293 .and_then(|v| v.as_str())
294 .unwrap_or("")
295 .to_string();
296 if uri.is_empty() {
297 continue;
298 }
299 content.push(ContentBlock::Resource {
300 uri,
301 mime_type: res
302 .get("mimeType")
303 .and_then(|v| v.as_str())
304 .map(String::from),
305 text: res.get("text").and_then(|v| v.as_str()).map(String::from),
306 blob: res.get("blob").and_then(|v| v.as_str()).map(String::from),
307 });
308 },
309 _ => {
310 // Unknown content type — treat as text if it has a text field
311 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
312 content.push(ContentBlock::Text(text.to_string()));
313 }
314 },
315 }
316 }
317
318 Ok(McpToolResult { content, is_error })
319 }
320
321 /// Shut down the transport (kills the server process).
322 pub async fn shutdown(&self) {
323 self.shutdown
324 .store(true, std::sync::atomic::Ordering::Release);
325 self.transport.shutdown().await;
326 }
327
328 /// `true` once [`Self::shutdown`] has run. The manager checks this so a
329 /// `call_tool` to a stopped-but-still-registered server returns a clean
330 /// error rather than a broken-pipe transport failure.
331 pub fn is_shutdown(&self) -> bool {
332 self.shutdown.load(std::sync::atomic::Ordering::Acquire)
333 }
334}