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