Skip to main content

mermaid_cli/mcp/
client.rs

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