1use anyhow::{Result, anyhow};
10use serde::{Deserialize, Serialize};
11use serde_json::{Value, json};
12
13use super::transport::Transport;
14
15pub struct McpClient {
17 transport: Transport,
18 pub server_info: Option<ServerInfo>,
20 shutdown: std::sync::atomic::AtomicBool,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ServerInfo {
29 pub name: String,
30 pub version: Option<String>,
31}
32
33#[derive(Debug, Clone)]
35pub struct McpToolDef {
36 pub name: String,
37 pub description: String,
38 pub input_schema: Value,
39 pub read_only_hint: bool,
43}
44
45#[derive(Debug, Clone)]
47pub struct McpToolResult {
48 pub content: Vec<ContentBlock>,
49 pub is_error: bool,
50}
51
52#[derive(Debug, Clone)]
56pub enum ContentBlock {
57 Text(String),
58 Image {
59 data: String,
60 mime_type: String,
61 },
62 Audio {
67 data: String,
68 mime_type: String,
69 },
70 ResourceLink {
73 uri: String,
74 name: Option<String>,
75 description: Option<String>,
76 mime_type: Option<String>,
77 },
78 Resource {
82 uri: String,
83 mime_type: Option<String>,
84 text: Option<String>,
85 blob: Option<String>,
86 },
87}
88
89fn 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 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 pub async fn initialize(&mut self) -> Result<ServerInfo> {
131 let result = self
132 .transport
133 .send_request(
134 "initialize",
135 json!({
136 "protocolVersion": "2025-11-25",
143 "capabilities": {},
144 "clientInfo": {
145 "name": "mermaid",
146 "version": env!("CARGO_PKG_VERSION"),
147 }
148 }),
149 )
150 .await?;
151
152 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 if let Some(version) = result.get("protocolVersion").and_then(|v| v.as_str()) {
169 self.transport.set_protocol_version(version);
170 }
171
172 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 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 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 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 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 pub async fn shutdown(&self) {
335 self.shutdown
336 .store(true, std::sync::atomic::Ordering::Release);
337 self.transport.shutdown().await;
338 }
339
340 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 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 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 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}