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    /// The server's `annotations.readOnlyHint` — an UNTRUSTED self-declaration
40    /// that the tool has no side effects. Absent ⇒ false, i.e. write-shaped
41    /// (fail closed). Feeds the external-writes policy floor.
42    pub read_only_hint: bool,
43}
44
45/// Result of calling an MCP tool
46#[derive(Debug, Clone)]
47pub struct McpToolResult {
48    pub content: Vec<ContentBlock>,
49    pub is_error: bool,
50}
51
52/// A content block in an MCP tool result. Per the 2025-11-25 spec,
53/// servers may return text, image, audio, resource_link (URI reference),
54/// or embedded resource content. Older servers only emit text/image.
55#[derive(Debug, Clone)]
56pub enum ContentBlock {
57    Text(String),
58    Image {
59        data: String,
60        mime_type: String,
61    },
62    /// Audio content — base64-encoded data + mime type (e.g., `audio/wav`).
63    /// Routed to the model's image attachment channel for now; adapters
64    /// that don't support audio will silently drop the bytes but keep
65    /// the text hint from the tool output.
66    Audio {
67        data: String,
68        mime_type: String,
69    },
70    /// URI reference to an external resource. Rendered as text for the
71    /// model so it can follow up with another tool call if needed.
72    ResourceLink {
73        uri: String,
74        name: Option<String>,
75        description: Option<String>,
76        mime_type: Option<String>,
77    },
78    /// Embedded resource — same shape as a read_resource response.
79    /// Either `text` or `blob` (base64) is present depending on the
80    /// resource's kind. Rendered as text for the model.
81    Resource {
82        uri: String,
83        mime_type: Option<String>,
84        text: Option<String>,
85        blob: Option<String>,
86    },
87}
88
89/// Parse one `tools/list` entry. `None` for nameless entries (skipped, as
90/// before). Annotations are optional per the MCP spec; a missing or
91/// non-boolean `readOnlyHint` is treated as false — write-shaped, fail
92/// closed.
93fn tool_def_from_json(tool: &Value) -> Option<McpToolDef> {
94    let name = tool.get("name").and_then(|v| v.as_str())?;
95    if name.is_empty() {
96        return None;
97    }
98    Some(McpToolDef {
99        name: name.to_string(),
100        description: tool
101            .get("description")
102            .and_then(|v| v.as_str())
103            .unwrap_or("")
104            .to_string(),
105        input_schema: tool
106            .get("inputSchema")
107            .cloned()
108            .unwrap_or_else(|| json!({"type": "object", "properties": {}})),
109        read_only_hint: tool
110            .pointer("/annotations/readOnlyHint")
111            .and_then(|v| v.as_bool())
112            .unwrap_or(false),
113    })
114}
115
116impl McpClient {
117    /// Create a new MCP client wrapping a transport.
118    pub(super) fn new(transport: Transport) -> Self {
119        Self {
120            transport,
121            server_info: None,
122            shutdown: std::sync::atomic::AtomicBool::new(false),
123        }
124    }
125
126    /// Perform the MCP initialization handshake.
127    ///
128    /// Sends `initialize` request with our client info and protocol version,
129    /// then sends `notifications/initialized` to signal readiness.
130    pub async fn initialize(&mut self) -> Result<ServerInfo> {
131        let result = self
132            .transport
133            .send_request(
134                "initialize",
135                json!({
136                    // MCP spec version as of 2026-04. Servers negotiate
137                    // down to older versions if they don't support this;
138                    // spec requires them to respond with their latest
139                    // supported version, which we currently accept
140                    // silently. Bump when MCP ships a newer revision
141                    // with features we depend on.
142                    "protocolVersion": "2025-11-25",
143                    "capabilities": {},
144                    "clientInfo": {
145                        "name": "mermaid",
146                        "version": env!("CARGO_PKG_VERSION"),
147                    }
148                }),
149            )
150            .await?;
151
152        // Parse server info
153        let server_info = ServerInfo {
154            name: result
155                .pointer("/serverInfo/name")
156                .and_then(|v| v.as_str())
157                .unwrap_or("unknown")
158                .to_string(),
159            version: result
160                .pointer("/serverInfo/version")
161                .and_then(|v| v.as_str())
162                .map(|s| s.to_string()),
163        };
164
165        // Record the negotiated protocol version BEFORE the initialized
166        // notification: over HTTP every request after initialize — including
167        // that notification — must carry the MCP-Protocol-Version header.
168        if let Some(version) = result.get("protocolVersion").and_then(|v| v.as_str()) {
169            self.transport.set_protocol_version(version);
170        }
171
172        // Send initialized notification
173        self.transport
174            .send_notification("notifications/initialized", json!({}))
175            .await?;
176
177        self.server_info = Some(server_info.clone());
178        Ok(server_info)
179    }
180
181    /// Discover all tools available from this server, following `nextCursor`
182    /// pagination so a server that pages its tool list isn't silently truncated
183    /// to page one. Bounded by a page cap so a server that echoes a stuck cursor
184    /// can't loop forever.
185    pub async fn list_tools(&self) -> Result<Vec<McpToolDef>> {
186        const MAX_PAGES: usize = 100;
187        let mut tools = Vec::new();
188        let mut cursor: Option<String> = None;
189
190        for _ in 0..MAX_PAGES {
191            let params = match &cursor {
192                Some(c) => json!({ "cursor": c }),
193                None => json!({}),
194            };
195            let result = self.transport.send_request("tools/list", params).await?;
196
197            let tools_array = result
198                .get("tools")
199                .and_then(|v| v.as_array())
200                .ok_or_else(|| anyhow!("MCP tools/list response missing 'tools' array"))?;
201
202            for tool in tools_array {
203                if let Some(def) = tool_def_from_json(tool) {
204                    tools.push(def);
205                }
206            }
207
208            match result.get("nextCursor").and_then(|v| v.as_str()) {
209                Some(next) if !next.is_empty() => cursor = Some(next.to_string()),
210                _ => break,
211            }
212        }
213
214        Ok(tools)
215    }
216
217    /// Call a tool on this server and return the result.
218    pub async fn call_tool(&self, name: &str, arguments: &Value) -> Result<McpToolResult> {
219        let params = json!({
220            "name": name,
221            "arguments": arguments,
222        });
223
224        let result = self
225            .transport
226            .send_request_with_timeout("tools/call", params, Transport::tool_call_timeout_secs())
227            .await?;
228
229        let is_error = result
230            .get("isError")
231            .and_then(|v| v.as_bool())
232            .unwrap_or(false);
233
234        let content_array = result
235            .get("content")
236            .and_then(|v| v.as_array())
237            .cloned()
238            .unwrap_or_default();
239
240        let mut content = Vec::new();
241        for block in content_array {
242            let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
243            match block_type {
244                "text" => {
245                    if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
246                        content.push(ContentBlock::Text(text.to_string()));
247                    }
248                },
249                "image" => {
250                    let data = block
251                        .get("data")
252                        .and_then(|v| v.as_str())
253                        .unwrap_or("")
254                        .to_string();
255                    let mime_type = block
256                        .get("mimeType")
257                        .and_then(|v| v.as_str())
258                        .unwrap_or("image/png")
259                        .to_string();
260                    content.push(ContentBlock::Image { data, mime_type });
261                },
262                "audio" => {
263                    let data = block
264                        .get("data")
265                        .and_then(|v| v.as_str())
266                        .unwrap_or("")
267                        .to_string();
268                    let mime_type = block
269                        .get("mimeType")
270                        .and_then(|v| v.as_str())
271                        .unwrap_or("audio/wav")
272                        .to_string();
273                    content.push(ContentBlock::Audio { data, mime_type });
274                },
275                "resource_link" => {
276                    let uri = block
277                        .get("uri")
278                        .and_then(|v| v.as_str())
279                        .unwrap_or("")
280                        .to_string();
281                    if uri.is_empty() {
282                        continue;
283                    }
284                    content.push(ContentBlock::ResourceLink {
285                        uri,
286                        name: block.get("name").and_then(|v| v.as_str()).map(String::from),
287                        description: block
288                            .get("description")
289                            .and_then(|v| v.as_str())
290                            .map(String::from),
291                        mime_type: block
292                            .get("mimeType")
293                            .and_then(|v| v.as_str())
294                            .map(String::from),
295                    });
296                },
297                "resource" => {
298                    // Embedded resource — nested under `resource`.
299                    let res = match block.get("resource") {
300                        Some(r) => r,
301                        None => continue,
302                    };
303                    let uri = res
304                        .get("uri")
305                        .and_then(|v| v.as_str())
306                        .unwrap_or("")
307                        .to_string();
308                    if uri.is_empty() {
309                        continue;
310                    }
311                    content.push(ContentBlock::Resource {
312                        uri,
313                        mime_type: res
314                            .get("mimeType")
315                            .and_then(|v| v.as_str())
316                            .map(String::from),
317                        text: res.get("text").and_then(|v| v.as_str()).map(String::from),
318                        blob: res.get("blob").and_then(|v| v.as_str()).map(String::from),
319                    });
320                },
321                _ => {
322                    // Unknown content type — treat as text if it has a text field
323                    if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
324                        content.push(ContentBlock::Text(text.to_string()));
325                    }
326                },
327            }
328        }
329
330        Ok(McpToolResult { content, is_error })
331    }
332
333    /// Shut down the transport (kills the server process).
334    pub async fn shutdown(&self) {
335        self.shutdown
336            .store(true, std::sync::atomic::Ordering::Release);
337        self.transport.shutdown().await;
338    }
339
340    /// `true` once [`Self::shutdown`] has run. The manager checks this so a
341    /// `call_tool` to a stopped-but-still-registered server returns a clean
342    /// error rather than a broken-pipe transport failure.
343    pub fn is_shutdown(&self) -> bool {
344        self.shutdown.load(std::sync::atomic::Ordering::Acquire)
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn tool_def_parses_read_only_hint() {
354        // Annotated read-only tool carries the hint through.
355        let def = tool_def_from_json(&json!({
356            "name": "get_thing",
357            "description": "d",
358            "inputSchema": {"type": "object"},
359            "annotations": {"readOnlyHint": true}
360        }))
361        .unwrap();
362        assert!(def.read_only_hint);
363
364        // Absent annotations (the common case) ⇒ write-shaped, fail closed.
365        let def = tool_def_from_json(&json!({"name": "send_thing"})).unwrap();
366        assert!(!def.read_only_hint);
367        assert_eq!(
368            def.input_schema,
369            json!({"type": "object", "properties": {}})
370        );
371
372        // Non-boolean hints and nameless entries are rejected safely.
373        let def = tool_def_from_json(&json!({
374            "name": "odd",
375            "annotations": {"readOnlyHint": "yes"}
376        }))
377        .unwrap();
378        assert!(!def.read_only_hint);
379        assert!(tool_def_from_json(&json!({"description": "nameless"})).is_none());
380    }
381}