Skip to main content

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}