1use std::collections::{HashMap, HashSet};
2use std::process::Stdio;
3use std::sync::Arc;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::Duration;
6
7use serde::{Deserialize, Serialize};
8use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
9use tokio::process::{Child, ChildStdin, ChildStdout, Command};
10use tokio::sync::{Mutex, oneshot};
11use tokio::task::JoinHandle;
12
13use crate::error::RuntimeError;
14use crate::tool::BoxFut;
15
16type PendingCalls = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<serde_json::Value, McpError>>>>>;
17
18pub trait McpTransport: Send + Sync {
19 fn call<'a>(
20 &'a self,
21 method: &'a str,
22 params: serde_json::Value,
23 ) -> BoxFut<'a, Result<serde_json::Value, McpError>>;
24
25 fn notify<'a>(
27 &'a self,
28 method: &'a str,
29 params: serde_json::Value,
30 ) -> BoxFut<'a, Result<(), McpError>>;
31
32 fn kind(&self) -> &'static str;
33}
34
35#[derive(Debug, Serialize, Deserialize, Clone)]
36pub struct JsonRpcRequest {
37 pub jsonrpc: &'static str,
38 pub id: u64,
39 pub method: String,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub params: Option<serde_json::Value>,
42}
43
44#[derive(Debug, Serialize, Deserialize, Clone)]
45pub struct JsonRpcResponse {
46 #[allow(dead_code)]
47 pub jsonrpc: String,
48 pub id: Option<u64>,
49 #[serde(default)]
50 pub result: Option<serde_json::Value>,
51 #[serde(default)]
52 pub error: Option<JsonRpcError>,
53}
54
55#[derive(Debug, Serialize, Deserialize, Clone)]
56pub struct JsonRpcError {
57 pub code: i64,
58 pub message: String,
59 #[serde(default)]
60 pub data: Option<serde_json::Value>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
64pub struct McpToolSchema {
65 pub name: String,
66 #[serde(default)]
67 pub description: Option<String>,
68 #[serde(default, rename = "inputSchema")]
69 pub input_schema: Option<serde_json::Value>,
70}
71
72#[derive(Debug, Clone)]
73pub struct McpToolSnapshot {
74 pub fingerprint: String,
75 pub tools: Arc<[McpToolSchema]>,
76}
77
78impl McpToolSnapshot {
79 fn build(mut tools: Vec<McpToolSchema>) -> Result<Self, McpError> {
80 for tool in &mut tools {
81 if let Some(schema) = &mut tool.input_schema {
82 canonicalize_json(schema);
83 }
84 }
85 tools.sort_by(|left, right| left.name.cmp(&right.name));
86 if let Some(duplicate) = tools
87 .windows(2)
88 .find(|pair| pair[0].name == pair[1].name)
89 .map(|pair| pair[0].name.clone())
90 {
91 return Err(McpError::Protocol(format!(
92 "tools/list returned duplicate tool `{duplicate}`"
93 )));
94 }
95 let bytes = serde_json::to_vec(&tools)
96 .map_err(|error| McpError::Protocol(format!("serialize tool snapshot: {error}")))?;
97 Ok(Self {
98 fingerprint: format!("blake3:{}", blake3::hash(&bytes).to_hex()),
99 tools: tools.into(),
100 })
101 }
102}
103
104fn canonicalize_json(value: &mut serde_json::Value) {
105 match value {
106 serde_json::Value::Object(object) => {
107 let mut entries: Vec<_> = std::mem::take(object).into_iter().collect();
108 for (_, value) in &mut entries {
109 canonicalize_json(value);
110 }
111 entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
112 object.extend(entries);
113 }
114 serde_json::Value::Array(items) => {
115 for item in items {
116 canonicalize_json(item);
117 }
118 }
119 _ => {}
120 }
121}
122
123#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
124pub struct McpResource {
125 pub uri: String,
126 pub name: String,
127 #[serde(default)]
128 pub description: Option<String>,
129 #[serde(default, rename = "mimeType")]
130 pub mime_type: Option<String>,
131}
132
133#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
134pub struct McpResourceContent {
135 pub uri: String,
136 #[serde(default, rename = "mimeType")]
137 pub mime_type: Option<String>,
138 #[serde(default)]
139 pub text: Option<String>,
140 #[serde(default)]
141 pub blob: Option<String>, }
143
144#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
145pub struct McpPrompt {
146 pub name: String,
147 #[serde(default)]
148 pub description: Option<String>,
149 #[serde(default)]
150 pub arguments: Vec<McpPromptArg>,
151}
152
153#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
154pub struct McpPromptArg {
155 pub name: String,
156 #[serde(default)]
157 pub description: Option<String>,
158 #[serde(default)]
159 pub required: bool,
160}
161
162#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
163pub struct McpPromptMessage {
164 pub role: String,
165 pub content: serde_json::Value,
166}
167
168#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
169pub struct McpPromptContent {
170 #[serde(default)]
171 pub messages: Vec<McpPromptMessage>,
172}
173
174#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
175pub struct SamplingRequest {
176 #[serde(default)]
177 pub model: Option<String>,
178 pub messages: Vec<McpPromptMessage>,
179 #[serde(default)]
180 pub max_tokens: u32,
181 #[serde(default, rename = "systemPrompt")]
182 pub system_prompt: Option<String>,
183}
184
185#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
186pub struct SamplingResponse {
187 pub role: String,
188 pub content: serde_json::Value,
189 #[serde(default)]
190 pub model: Option<String>,
191}
192
193pub type SamplingHandler = Arc<
194 dyn Fn(
195 SamplingRequest,
196 ) -> std::pin::Pin<
197 Box<dyn std::future::Future<Output = Result<SamplingResponse, RuntimeError>> + Send>,
198 > + Send
199 + Sync,
200>;
201
202pub type SharedSamplingHandler = Arc<std::sync::Mutex<Option<SamplingHandler>>>;
203
204#[derive(Debug, Clone, thiserror::Error)]
205pub enum McpError {
206 #[error("mcp io: {0}")]
207 Io(String),
208 #[error("mcp protocol: {0}")]
209 Protocol(String),
210 #[error("mcp server error {code}: {message}")]
211 ServerError { code: i64, message: String },
212 #[error("mcp timeout ({timeout_ms}ms) on {method}")]
213 Timeout { timeout_ms: u64, method: String },
214 #[error("mcp disconnected")]
215 Disconnected,
216}
217
218impl From<McpError> for RuntimeError {
219 fn from(e: McpError) -> Self {
220 RuntimeError::ToolFailed(format!("{e}"))
221 }
222}
223
224pub struct McpStdioTransport {
225 stdin: Arc<Mutex<ChildStdin>>,
226 pending: PendingCalls,
227 next_id: Arc<Mutex<u64>>,
228 #[allow(dead_code)]
229 child: Arc<Mutex<Child>>,
230 #[allow(dead_code)]
231 reader_task: Arc<Mutex<Option<JoinHandle<()>>>>,
232 timeout_ms: u64,
233 #[allow(dead_code)]
234 sampling_handler: SharedSamplingHandler,
235}
236
237impl McpTransport for McpStdioTransport {
238 fn call<'a>(
239 &'a self,
240 method: &'a str,
241 params: serde_json::Value,
242 ) -> BoxFut<'a, Result<serde_json::Value, McpError>> {
243 Box::pin(self.call_stdio(method, params))
244 }
245
246 fn notify<'a>(
247 &'a self,
248 method: &'a str,
249 params: serde_json::Value,
250 ) -> BoxFut<'a, Result<(), McpError>> {
251 Box::pin(self.notify_stdio(method, params))
252 }
253
254 fn kind(&self) -> &'static str {
255 "stdio"
256 }
257}
258
259impl McpStdioTransport {
260 pub async fn spawn(
261 cmd: &str,
262 args: &[String],
263 env: &[(String, String)],
264 timeout_ms: u64,
265 notification_tx: tokio::sync::broadcast::Sender<McpNotification>,
266 sampling_handler: SharedSamplingHandler,
267 ) -> Result<Self, McpError> {
268 let mut command = Command::new(cmd);
269 command
270 .args(args)
271 .stdin(Stdio::piped())
272 .stdout(Stdio::piped())
273 .stderr(Stdio::piped());
274 for (k, v) in env {
275 command.env(k, v);
276 }
277 let mut child = command
278 .spawn()
279 .map_err(|e| McpError::Io(format!("spawn {cmd}: {e}")))?;
280 let stdin = child
281 .stdin
282 .take()
283 .ok_or_else(|| McpError::Io("no stdin".into()))?;
284 let stdout = child
285 .stdout
286 .take()
287 .ok_or_else(|| McpError::Io("no stdout".into()))?;
288
289 let stdin_arc = Arc::new(tokio::sync::Mutex::new(stdin));
290 let stdin_for_reader = stdin_arc.clone();
291 let pending: PendingCalls = Arc::new(Mutex::new(HashMap::new()));
292 let pending_for_reader = pending.clone();
293 let notif_for_reader = notification_tx.clone();
294 let sampling_for_reader = sampling_handler.clone();
295 let reader_task = tokio::spawn(async move {
296 reader_loop(
297 stdout,
298 pending_for_reader,
299 notif_for_reader,
300 stdin_for_reader,
301 sampling_for_reader,
302 )
303 .await;
304 });
305
306 Ok(Self {
307 stdin: stdin_arc,
308 pending,
309 next_id: Arc::new(Mutex::new(1)),
310 child: Arc::new(Mutex::new(child)),
311 reader_task: Arc::new(Mutex::new(Some(reader_task))),
312 timeout_ms,
313 sampling_handler,
314 })
315 }
316
317 async fn call_stdio(
318 &self,
319 method: &str,
320 params: serde_json::Value,
321 ) -> Result<serde_json::Value, McpError> {
322 let id = {
323 let mut n = self.next_id.lock().await;
324 let v = *n;
325 *n += 1;
326 v
327 };
328 let (tx, rx) = oneshot::channel();
329 self.pending.lock().await.insert(id, tx);
330
331 let req = JsonRpcRequest {
332 jsonrpc: "2.0",
333 id,
334 method: method.into(),
335 params: Some(params),
336 };
337 let line = serde_json::to_string(&req)
338 .map_err(|e| McpError::Protocol(format!("serialize: {e}")))?;
339 {
340 let mut stdin = self.stdin.lock().await;
341 stdin
342 .write_all(line.as_bytes())
343 .await
344 .map_err(|e| McpError::Io(format!("write: {e}")))?;
345 stdin
346 .write_all(b"\n")
347 .await
348 .map_err(|e| McpError::Io(format!("write newline: {e}")))?;
349 stdin
350 .flush()
351 .await
352 .map_err(|e| McpError::Io(format!("flush: {e}")))?;
353 }
354
355 match tokio::time::timeout(Duration::from_millis(self.timeout_ms), rx).await {
356 Ok(Ok(inner)) => inner,
357 Ok(Err(_)) => {
358 self.pending.lock().await.remove(&id);
359 Err(McpError::Disconnected)
360 }
361 Err(_) => {
362 self.pending.lock().await.remove(&id);
363 Err(McpError::Timeout {
364 timeout_ms: self.timeout_ms,
365 method: method.into(),
366 })
367 }
368 }
369 }
370
371 async fn notify_stdio(&self, method: &str, params: serde_json::Value) -> Result<(), McpError> {
372 let req = serde_json::json!({
373 "jsonrpc": "2.0",
374 "method": method,
375 "params": params,
376 });
377 let line = serde_json::to_string(&req)
378 .map_err(|e| McpError::Protocol(format!("serialize: {e}")))?;
379 let mut stdin = self.stdin.lock().await;
380 stdin
381 .write_all(line.as_bytes())
382 .await
383 .map_err(|e| McpError::Io(format!("write: {e}")))?;
384 stdin
385 .write_all(b"\n")
386 .await
387 .map_err(|e| McpError::Io(format!("write newline: {e}")))?;
388 stdin
389 .flush()
390 .await
391 .map_err(|e| McpError::Io(format!("flush: {e}")))?;
392 Ok(())
393 }
394}
395
396async fn reader_loop(
397 stdout: ChildStdout,
398 pending: PendingCalls,
399 notification_tx: tokio::sync::broadcast::Sender<McpNotification>,
400 stdin: Arc<tokio::sync::Mutex<ChildStdin>>,
401 sampling_handler: SharedSamplingHandler,
402) {
403 let reader = BufReader::new(stdout);
404 let mut lines = reader.lines();
405 loop {
406 match lines.next_line().await {
407 Ok(Some(line)) => {
408 if line.trim().is_empty() {
409 continue;
410 }
411 let parsed: serde_json::Value = match serde_json::from_str(&line) {
412 Ok(v) => v,
413 Err(_) => continue,
414 };
415
416 if let Some(method) = parsed.get("method").and_then(|v| v.as_str()) {
417 if parsed.get("id").is_some() {
418 if method == "sampling/createMessage" {
419 let id = parsed.get("id").cloned();
420 let params = parsed
421 .get("params")
422 .cloned()
423 .unwrap_or(serde_json::Value::Null);
424 let handler_arc = sampling_handler.clone();
425 let stdin_arc = stdin.clone();
426 tokio::spawn(async move {
427 let handler = handler_arc.lock().unwrap().clone();
428 let result = if let Some(h) = handler {
429 match serde_json::from_value::<SamplingRequest>(params.clone())
430 {
431 Ok(req) => match h(req).await {
432 Ok(resp) => Ok(serde_json::to_value(resp)
433 .unwrap_or(serde_json::Value::Null)),
434 Err(e) => Err(serde_json::json!({
435 "code": -1,
436 "message": e.to_string()
437 })),
438 },
439 Err(e) => Err(serde_json::json!({
440 "code": -1,
441 "message": format!("parse error: {e}")
442 })),
443 }
444 } else {
445 Err(serde_json::json!({
446 "code": -1,
447 "message": "sampling not supported"
448 }))
449 };
450 let response = match result {
451 Ok(r) => serde_json::json!({
452 "jsonrpc": "2.0",
453 "id": id,
454 "result": r
455 }),
456 Err(e) => serde_json::json!({
457 "jsonrpc": "2.0",
458 "id": id,
459 "error": e
460 }),
461 };
462 let line = serde_json::to_string(&response)
463 .unwrap_or_else(|_| "{}".into());
464 let mut s = stdin_arc.lock().await;
465 let _ = s.write_all(line.as_bytes()).await;
466 let _ = s.write_all(b"\n").await;
467 let _ = s.flush().await;
468 });
469 }
470 } else {
471 let notif = parse_notification(method, &parsed);
472 let _ = notification_tx.send(notif);
473 }
474 continue;
475 }
476
477 let Some(id) = parsed.get("id").and_then(|v| v.as_u64()) else {
478 continue;
479 };
480 let sender = pending.lock().await.remove(&id);
481 if let Some(sender) = sender {
482 let outcome = if let Some(err) = parsed.get("error") {
483 Err(McpError::ServerError {
484 code: err.get("code").and_then(|v| v.as_i64()).unwrap_or(-1),
485 message: err
486 .get("message")
487 .and_then(|v| v.as_str())
488 .unwrap_or("unknown")
489 .to_string(),
490 })
491 } else {
492 Ok(parsed
493 .get("result")
494 .cloned()
495 .unwrap_or(serde_json::Value::Null))
496 };
497 let _ = sender.send(outcome);
498 }
499 }
500 Ok(None) => break,
501 Err(_) => break,
502 }
503 }
504 let mut pending = pending.lock().await;
505 for (_, tx) in pending.drain() {
506 let _ = tx.send(Err(McpError::Disconnected));
507 }
508}
509
510fn parse_sse_response(
511 text: &str,
512 expected_id: u64,
513 notification_tx: &tokio::sync::broadcast::Sender<McpNotification>,
514) -> Result<serde_json::Value, McpError> {
515 for line in text.lines() {
516 let line = line.trim();
517 if let Some(data) = line.strip_prefix("data:") {
518 let data = data.trim();
519 if data.is_empty() {
520 continue;
521 }
522 let parsed: serde_json::Value = match serde_json::from_str(data) {
523 Ok(v) => v,
524 Err(_) => continue,
525 };
526 if let Some(method) = parsed.get("method").and_then(|v| v.as_str()) {
527 if parsed.get("id").is_none() {
528 let notif = parse_notification(method, &parsed);
529 let _ = notification_tx.send(notif);
530 }
531 continue;
532 }
533 if parsed.get("id").and_then(|v| v.as_u64()) == Some(expected_id) {
534 if let Some(err) = parsed.get("error") {
535 return Err(McpError::ServerError {
536 code: err.get("code").and_then(|v| v.as_i64()).unwrap_or(-1),
537 message: err
538 .get("message")
539 .and_then(|v| v.as_str())
540 .unwrap_or("unknown")
541 .to_string(),
542 });
543 }
544 return Ok(parsed
545 .get("result")
546 .cloned()
547 .unwrap_or(serde_json::Value::Null));
548 }
549 }
550 }
551 Err(McpError::Protocol(format!(
552 "SSE response missing id {expected_id}"
553 )))
554}
555
556fn parse_notification(method: &str, parsed: &serde_json::Value) -> McpNotification {
557 let params = parsed
558 .get("params")
559 .cloned()
560 .unwrap_or(serde_json::Value::Null);
561 match method {
562 "notifications/tools/list_changed" => McpNotification::ToolsListChanged,
563 "notifications/resources/list_changed" => McpNotification::ResourcesListChanged,
564 "notifications/prompts/list_changed" => McpNotification::PromptsListChanged,
565 "notifications/progress" => McpNotification::Progress {
566 progress_token: params
567 .get("progressToken")
568 .and_then(|v| v.as_str())
569 .unwrap_or("")
570 .to_string(),
571 progress: params.get("progress").and_then(|v| v.as_f64()),
572 total: params.get("total").and_then(|v| v.as_f64()),
573 message: params
574 .get("message")
575 .and_then(|v| v.as_str())
576 .map(String::from),
577 },
578 "notifications/log" => McpNotification::Log {
579 level: params
580 .get("level")
581 .and_then(|v| v.as_str())
582 .unwrap_or("info")
583 .to_string(),
584 data: params
585 .get("data")
586 .cloned()
587 .unwrap_or(serde_json::Value::Null),
588 },
589 "notifications/cancelled" => McpNotification::Cancelled {
590 request_id: params
591 .get("requestId")
592 .and_then(|v| v.as_str())
593 .unwrap_or("")
594 .to_string(),
595 reason: params
596 .get("reason")
597 .and_then(|v| v.as_str())
598 .map(String::from),
599 },
600 _ => McpNotification::Other {
601 method: method.to_string(),
602 params,
603 },
604 }
605}
606
607pub struct McpHttpTransport {
608 url: String,
609 auth_token: Option<String>,
610 client: reqwest::Client,
611 next_id: AtomicU64,
612 timeout_ms: u64,
613 retry_attempts: u32,
614 notification_tx: tokio::sync::broadcast::Sender<McpNotification>,
615}
616
617impl McpHttpTransport {
618 pub fn new(
619 url: impl Into<String>,
620 auth_token: Option<String>,
621 timeout_ms: u64,
622 notification_tx: tokio::sync::broadcast::Sender<McpNotification>,
623 ) -> Self {
624 Self {
625 url: url.into(),
626 auth_token,
627 client: reqwest::Client::new(),
628 next_id: AtomicU64::new(1),
629 timeout_ms,
630 retry_attempts: 3,
631 notification_tx,
632 }
633 }
634
635 #[doc(hidden)]
636 pub fn with_client(mut self, client: reqwest::Client) -> Self {
637 self.client = client;
638 self
639 }
640
641 async fn call_http(
642 &self,
643 method: &str,
644 params: serde_json::Value,
645 ) -> Result<serde_json::Value, McpError> {
646 let id = self.next_id.fetch_add(1, Ordering::SeqCst);
647 let body = serde_json::json!({
648 "jsonrpc": "2.0",
649 "id": id,
650 "method": method,
651 "params": params,
652 });
653 let call_timeout = Duration::from_millis(self.timeout_ms);
654 let attempt_result = tokio::time::timeout(call_timeout, async {
655 let mut delay_ms = 100u64;
656 let mut last_err: Option<McpError> = None;
657 for attempt in 0..=self.retry_attempts {
658 let mut req = self.client.post(&self.url).json(&body);
659 if let Some(t) = &self.auth_token {
660 req = req.bearer_auth(t);
661 }
662 match req.send().await {
663 Ok(resp) => {
664 let status = resp.status();
665 if status.is_success() {
666 let content_type = resp
667 .headers()
668 .get("content-type")
669 .and_then(|v| v.to_str().ok())
670 .unwrap_or("")
671 .to_string();
672 let text = resp
673 .text()
674 .await
675 .map_err(|e| McpError::Io(format!("mcp http body: {e}")))?;
676 if content_type.contains("text/event-stream") {
677 return parse_sse_response(&text, id, &self.notification_tx);
678 }
679 let parsed: JsonRpcResponse = serde_json::from_str(&text)
680 .map_err(|e| McpError::Protocol(format!("mcp http parse: {e}")))?;
681 if let Some(err) = parsed.error {
682 return Err(McpError::ServerError {
683 code: err.code,
684 message: err.message,
685 });
686 }
687 return Ok(parsed.result.unwrap_or(serde_json::Value::Null));
688 }
689 if status.is_server_error() {
690 last_err =
691 Some(McpError::Io(format!("mcp http {status}: server error")));
692 if attempt < self.retry_attempts {
693 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
694 delay_ms = (delay_ms * 5).min(2000);
695 continue;
696 }
697 }
698 let body_text = resp.text().await.unwrap_or_default();
699 return Err(McpError::Io(format!("mcp http {status}: {body_text}")));
700 }
701 Err(e) => {
702 last_err = Some(McpError::Io(format!("mcp http send: {e}")));
703 if attempt < self.retry_attempts {
704 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
705 delay_ms = (delay_ms * 5).min(2000);
706 continue;
707 }
708 }
709 }
710 }
711 Err(last_err.unwrap_or(McpError::Disconnected))
712 })
713 .await;
714 match attempt_result {
715 Ok(inner) => inner,
716 Err(_) => Err(McpError::Timeout {
717 timeout_ms: self.timeout_ms,
718 method: method.into(),
719 }),
720 }
721 }
722
723 async fn notify_http(&self, method: &str, params: serde_json::Value) -> Result<(), McpError> {
724 let body = serde_json::json!({
725 "jsonrpc": "2.0",
726 "method": method,
727 "params": params,
728 });
729 let mut req = self.client.post(&self.url).json(&body);
730 if let Some(t) = &self.auth_token {
731 req = req.bearer_auth(t);
732 }
733 let _ = req.send().await;
735 Ok(())
736 }
737}
738
739impl McpTransport for McpHttpTransport {
740 fn call<'a>(
741 &'a self,
742 method: &'a str,
743 params: serde_json::Value,
744 ) -> BoxFut<'a, Result<serde_json::Value, McpError>> {
745 Box::pin(self.call_http(method, params))
746 }
747
748 fn notify<'a>(
749 &'a self,
750 method: &'a str,
751 params: serde_json::Value,
752 ) -> BoxFut<'a, Result<(), McpError>> {
753 Box::pin(self.notify_http(method, params))
754 }
755
756 fn kind(&self) -> &'static str {
757 "http"
758 }
759}
760
761pub struct McpClient {
762 pub name: String,
763 transport: std::sync::Mutex<Arc<dyn McpTransport>>,
764 tool_snapshot: std::sync::RwLock<Arc<McpToolSnapshot>>,
765 tool_refresh: tokio::sync::Mutex<()>,
766 reconnect: Option<ReconnectConfig>,
767 notification_tx: tokio::sync::broadcast::Sender<McpNotification>,
768 retained_notification_rx:
769 std::sync::Mutex<Option<tokio::sync::broadcast::Receiver<McpNotification>>>,
770 sampling_handler: SharedSamplingHandler,
771}
772
773#[derive(Debug, Clone)]
774pub enum McpNotification {
775 ToolsListChanged,
776 ResourcesListChanged,
777 PromptsListChanged,
778 Progress {
779 progress_token: String,
780 progress: Option<f64>,
781 total: Option<f64>,
782 message: Option<String>,
783 },
784 Log {
785 level: String,
786 data: serde_json::Value,
787 },
788 Cancelled {
789 request_id: String,
790 reason: Option<String>,
791 },
792 Other {
793 method: String,
794 params: serde_json::Value,
795 },
796}
797
798enum ReconnectConfig {
800 Stdio {
801 cmd: String,
802 args: Vec<String>,
803 env: Vec<(String, String)>,
804 timeout_ms: u64,
805 },
806 Http {
807 url: String,
808 auth_token: Option<String>,
809 timeout_ms: u64,
810 },
811}
812
813impl McpClient {
814 pub async fn connect_stdio(
815 name: impl Into<String>,
816 cmd: &str,
817 args: &[String],
818 env: &[(String, String)],
819 timeout_ms: u64,
820 ) -> Result<Self, McpError> {
821 let (notification_tx, notification_rx) = tokio::sync::broadcast::channel(256);
822 let sampling_handler: SharedSamplingHandler = Arc::new(std::sync::Mutex::new(None));
823 let transport: Arc<dyn McpTransport> = Arc::new(
824 McpStdioTransport::spawn(
825 cmd,
826 args,
827 env,
828 timeout_ms,
829 notification_tx.clone(),
830 sampling_handler.clone(),
831 )
832 .await?,
833 );
834 let name = name.into();
835 let mut client = Self::finish_connect(
836 name.clone(),
837 transport,
838 notification_tx,
839 notification_rx,
840 sampling_handler,
841 )
842 .await?;
843 client.reconnect = Some(ReconnectConfig::Stdio {
844 cmd: cmd.to_string(),
845 args: args.to_vec(),
846 env: env.to_vec(),
847 timeout_ms,
848 });
849 Ok(client)
850 }
851
852 pub async fn connect_http(
853 name: impl Into<String>,
854 url: impl Into<String>,
855 auth_token: Option<String>,
856 timeout_ms: u64,
857 ) -> Result<Self, McpError> {
858 let (notification_tx, notification_rx) = tokio::sync::broadcast::channel(256);
859 let url_str: String = url.into();
860 let sampling_handler: SharedSamplingHandler = Arc::new(std::sync::Mutex::new(None));
861 let transport: Arc<dyn McpTransport> = Arc::new(McpHttpTransport::new(
862 url_str.clone(),
863 auth_token.clone(),
864 timeout_ms,
865 notification_tx.clone(),
866 ));
867 let name = name.into();
868 let mut client = Self::finish_connect(
869 name.clone(),
870 transport,
871 notification_tx,
872 notification_rx,
873 sampling_handler,
874 )
875 .await?;
876 client.reconnect = Some(ReconnectConfig::Http {
877 url: url_str,
878 auth_token,
879 timeout_ms,
880 });
881 Ok(client)
882 }
883
884 pub async fn connect_with_transport(
885 name: impl Into<String>,
886 transport: Arc<dyn McpTransport>,
887 ) -> Result<Self, McpError> {
888 let (notification_tx, notification_rx) = tokio::sync::broadcast::channel(256);
889 Self::finish_connect(
890 name.into(),
891 transport,
892 notification_tx,
893 notification_rx,
894 Arc::new(std::sync::Mutex::new(None)),
895 )
896 .await
897 }
898
899 async fn finish_connect(
900 name: String,
901 transport: Arc<dyn McpTransport>,
902 notification_tx: tokio::sync::broadcast::Sender<McpNotification>,
903 notification_rx: tokio::sync::broadcast::Receiver<McpNotification>,
904 sampling_handler: SharedSamplingHandler,
905 ) -> Result<Self, McpError> {
906 let init_params = serde_json::json!({
907 "protocolVersion": "2024-11-05",
908 "capabilities": {},
909 "clientInfo": {"name": "atman", "version": env!("CARGO_PKG_VERSION")}
910 });
911 transport.call("initialize", init_params).await?;
912 transport
913 .notify("notifications/initialized", serde_json::Value::Null)
914 .await?;
915 let tool_snapshot = fetch_tool_snapshot(transport.as_ref()).await?;
916 Ok(Self {
917 name,
918 transport: std::sync::Mutex::new(transport),
919 tool_snapshot: std::sync::RwLock::new(Arc::new(tool_snapshot)),
920 tool_refresh: tokio::sync::Mutex::new(()),
921 reconnect: None,
922 notification_tx,
923 retained_notification_rx: std::sync::Mutex::new(Some(notification_rx)),
924 sampling_handler,
925 })
926 }
927
928 pub fn set_sampling_handler(&self, handler: SamplingHandler) {
929 *self.sampling_handler.lock().unwrap() = Some(handler);
930 }
931
932 pub fn transport_kind(&self) -> &'static str {
933 let t = self.transport.lock().unwrap();
934 t.kind()
935 }
936
937 pub fn subscribe_notifications(&self) -> tokio::sync::broadcast::Receiver<McpNotification> {
938 self.retained_notification_rx
939 .lock()
940 .unwrap()
941 .take()
942 .unwrap_or_else(|| self.notification_tx.subscribe())
943 }
944
945 pub fn tool_snapshot(&self) -> Arc<McpToolSnapshot> {
946 self.tool_snapshot.read().unwrap().clone()
947 }
948
949 pub async fn refresh_tool_snapshot(&self) -> Result<Option<Arc<McpToolSnapshot>>, McpError> {
950 let _refresh = self.tool_refresh.lock().await;
951 let transport = { self.transport.lock().unwrap().clone() };
952 let candidate = Arc::new(fetch_tool_snapshot(transport.as_ref()).await?);
953 let mut current = self.tool_snapshot.write().unwrap();
954 if current.fingerprint == candidate.fingerprint {
955 return Ok(None);
956 }
957 *current = candidate.clone();
958 Ok(Some(candidate))
959 }
960
961 async fn reconnect(&self) -> Result<(), McpError> {
962 let cfg = self.reconnect.as_ref().ok_or(McpError::Disconnected)?;
963 let new_transport: Arc<dyn McpTransport> = match cfg {
964 ReconnectConfig::Stdio {
965 cmd,
966 args,
967 env,
968 timeout_ms,
969 } => Arc::new(
970 McpStdioTransport::spawn(
971 cmd,
972 args,
973 env,
974 *timeout_ms,
975 self.notification_tx.clone(),
976 self.sampling_handler.clone(),
977 )
978 .await?,
979 ),
980 ReconnectConfig::Http {
981 url,
982 auth_token,
983 timeout_ms,
984 } => Arc::new(McpHttpTransport::new(
985 url,
986 auth_token.clone(),
987 *timeout_ms,
988 self.notification_tx.clone(),
989 )),
990 };
991 let init_params = serde_json::json!({
993 "protocolVersion": "2024-11-05",
994 "capabilities": {},
995 "clientInfo": {"name": "atman", "version": env!("CARGO_PKG_VERSION")}
996 });
997 new_transport.call("initialize", init_params).await?;
998 new_transport
999 .notify("notifications/initialized", serde_json::Value::Null)
1000 .await?;
1001 *self.transport.lock().unwrap() = new_transport;
1002 Ok(())
1003 }
1004
1005 pub async fn call_tool(
1006 &self,
1007 tool_name: &str,
1008 arguments: serde_json::Value,
1009 ) -> Result<crate::value::Value, McpError> {
1010 let params = serde_json::json!({
1011 "name": tool_name,
1012 "arguments": arguments,
1013 });
1014 let transport = { self.transport.lock().unwrap().clone() };
1015 let result = transport.call("tools/call", params.clone()).await;
1016 match result {
1017 Err(McpError::Disconnected) => {
1018 self.reconnect().await?;
1019 let transport = { self.transport.lock().unwrap().clone() };
1020 let retry = transport.call("tools/call", params).await?;
1021 Ok(mcp_result_to_value(retry))
1022 }
1023 other => Ok(mcp_result_to_value(other?)),
1024 }
1025 }
1026
1027 pub async fn list_resources(&self) -> Result<Vec<McpResource>, McpError> {
1028 let transport = { self.transport.lock().unwrap().clone() };
1029 let v = transport
1030 .call("resources/list", serde_json::json!({}))
1031 .await?;
1032 v.get("resources")
1033 .and_then(|r| r.as_array())
1034 .map(|arr| {
1035 arr.iter()
1036 .filter_map(|r| serde_json::from_value(r.clone()).ok())
1037 .collect()
1038 })
1039 .ok_or_else(|| {
1040 McpError::Protocol(format!("resources/list missing `resources` array: {v}"))
1041 })
1042 }
1043
1044 pub async fn read_resource(&self, uri: &str) -> Result<Vec<McpResourceContent>, McpError> {
1045 let params = serde_json::json!({ "uri": uri });
1046 let transport = { self.transport.lock().unwrap().clone() };
1047 let v = transport.call("resources/read", params).await?;
1048 v.get("contents")
1049 .and_then(|c| c.as_array())
1050 .map(|arr| {
1051 arr.iter()
1052 .filter_map(|c| serde_json::from_value(c.clone()).ok())
1053 .collect()
1054 })
1055 .ok_or_else(|| {
1056 McpError::Protocol(format!("resources/read missing `contents` array: {v}"))
1057 })
1058 }
1059
1060 pub async fn list_prompts(&self) -> Result<Vec<McpPrompt>, McpError> {
1061 let transport = { self.transport.lock().unwrap().clone() };
1062 let v = transport
1063 .call("prompts/list", serde_json::json!({}))
1064 .await?;
1065 v.get("prompts")
1066 .and_then(|p| p.as_array())
1067 .map(|arr| {
1068 arr.iter()
1069 .filter_map(|p| serde_json::from_value(p.clone()).ok())
1070 .collect()
1071 })
1072 .ok_or_else(|| McpError::Protocol(format!("prompts/list missing `prompts` array: {v}")))
1073 }
1074
1075 pub async fn get_prompt(
1076 &self,
1077 name: &str,
1078 arguments: serde_json::Value,
1079 ) -> Result<McpPromptContent, McpError> {
1080 let params = serde_json::json!({ "name": name, "arguments": arguments });
1081 let transport = { self.transport.lock().unwrap().clone() };
1082 let v = transport.call("prompts/get", params).await?;
1083 serde_json::from_value(v)
1084 .map_err(|e| McpError::Protocol(format!("prompts/get parse error: {e}")))
1085 }
1086}
1087
1088const MAX_TOOLS_LIST_PAGES: usize = 1024;
1089
1090async fn fetch_tool_snapshot(transport: &dyn McpTransport) -> Result<McpToolSnapshot, McpError> {
1091 let mut tools = Vec::new();
1092 let mut cursor: Option<String> = None;
1093 let mut seen_cursors = HashSet::new();
1094 for _ in 0..MAX_TOOLS_LIST_PAGES {
1095 let params = cursor.as_ref().map_or_else(
1096 || serde_json::json!({}),
1097 |cursor| serde_json::json!({"cursor": cursor}),
1098 );
1099 let page = transport.call("tools/list", params).await?;
1100 let (mut page_tools, next_cursor) = parse_tools_list_page(&page)?;
1101 tools.append(&mut page_tools);
1102 let Some(next_cursor) = next_cursor else {
1103 return McpToolSnapshot::build(tools);
1104 };
1105 if !seen_cursors.insert(next_cursor.clone()) {
1106 return Err(McpError::Protocol(format!(
1107 "tools/list repeated cursor `{next_cursor}`"
1108 )));
1109 }
1110 cursor = Some(next_cursor);
1111 }
1112 Err(McpError::Protocol(format!(
1113 "tools/list exceeded {MAX_TOOLS_LIST_PAGES} pages"
1114 )))
1115}
1116
1117fn parse_tools_list_page(
1118 v: &serde_json::Value,
1119) -> Result<(Vec<McpToolSchema>, Option<String>), McpError> {
1120 let arr = v.get("tools").and_then(|t| t.as_array()).ok_or_else(|| {
1121 McpError::Protocol(format!("tools/list response missing `tools` array: {v}"))
1122 })?;
1123 let mut out = Vec::with_capacity(arr.len());
1124 for item in arr {
1125 let schema: McpToolSchema = serde_json::from_value(item.clone())
1126 .map_err(|e| McpError::Protocol(format!("tool schema: {e}")))?;
1127 out.push(schema);
1128 }
1129 let next_cursor = match v.get("nextCursor") {
1130 None | Some(serde_json::Value::Null) => None,
1131 Some(serde_json::Value::String(cursor)) => Some(cursor.clone()),
1132 Some(other) => {
1133 return Err(McpError::Protocol(format!(
1134 "tools/list `nextCursor` must be a string: {other}"
1135 )));
1136 }
1137 };
1138 Ok((out, next_cursor))
1139}
1140
1141pub fn mcp_result_to_value(result: serde_json::Value) -> crate::value::Value {
1142 if let Some(content) = result.get("content").and_then(|c| c.as_array()) {
1143 let text_parts: Vec<String> = content
1144 .iter()
1145 .filter_map(|item| {
1146 if item.get("type").and_then(|t| t.as_str()) == Some("text") {
1147 item.get("text").and_then(|t| t.as_str()).map(String::from)
1148 } else {
1149 None
1150 }
1151 })
1152 .collect();
1153 let is_error = result
1154 .get("isError")
1155 .and_then(|b| b.as_bool())
1156 .unwrap_or(false);
1157 return crate::value::Value::Struct(vec![
1158 ("text".into(), crate::value::Value::Str(text_parts.join(""))),
1159 ("is_error".into(), crate::value::Value::Bool(is_error)),
1160 ("raw".into(), crate::value::Value::from_json(result)),
1161 ]);
1162 }
1163 crate::value::Value::from_json(result)
1164}
1165
1166pub fn value_to_mcp_args(args: &crate::tool::ToolArgs) -> serde_json::Value {
1167 let mut map = serde_json::Map::new();
1168 for (name, value) in &args.named {
1169 map.insert(name.clone(), value.to_json());
1170 }
1171 serde_json::Value::Object(map)
1172}
1173
1174#[derive(Debug, Clone)]
1180struct ReconcilePlan {
1181 explicit_keys: HashSet<String>,
1182 container_key: String,
1183 container_required: bool,
1184}
1185
1186fn build_reconcile_plan(schema: &serde_json::Value) -> Option<ReconcilePlan> {
1187 let props = schema.get("properties")?.as_object()?;
1188 let required: HashSet<&str> = schema
1189 .get("required")
1190 .and_then(|r| r.as_array())
1191 .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
1192 .unwrap_or_default();
1193
1194 let mut explicit_keys = HashSet::new();
1195 let mut catch_all: Option<(&str, bool)> = None;
1196
1197 for (name, prop) in props {
1198 let is_obj = prop.get("type").and_then(|t| t.as_str()) == Some("object");
1199 let has_sub_props = prop.get("properties").is_some_and(|p| p.is_object());
1200 let add_props_false =
1201 prop.get("additionalProperties").and_then(|a| a.as_bool()) == Some(false);
1202
1203 if is_obj && !has_sub_props && !add_props_false {
1204 if catch_all.is_some() {
1205 return None;
1206 }
1207 catch_all = Some((name.as_str(), required.contains(name.as_str())));
1208 } else {
1209 explicit_keys.insert(name.clone());
1210 }
1211 }
1212
1213 let (container_key, container_required) = catch_all?;
1214 Some(ReconcilePlan {
1215 explicit_keys,
1216 container_key: container_key.to_owned(),
1217 container_required,
1218 })
1219}
1220
1221fn reconcile(
1222 plan: &ReconcilePlan,
1223 flat: serde_json::Map<String, serde_json::Value>,
1224) -> serde_json::Value {
1225 let mut container = serde_json::Map::new();
1226 let mut out = serde_json::Map::new();
1227
1228 for (k, v) in flat {
1229 if plan.explicit_keys.contains(&k) {
1230 out.insert(k, v);
1231 } else if k == plan.container_key {
1232 if let serde_json::Value::Object(inner) = v {
1233 for (ik, iv) in inner {
1234 container.insert(ik, iv);
1235 }
1236 } else {
1237 container.insert(k, v);
1238 }
1239 } else {
1240 container.insert(k, v);
1241 }
1242 }
1243
1244 if !container.is_empty() || plan.container_required {
1245 out.insert(
1246 plan.container_key.clone(),
1247 serde_json::Value::Object(container),
1248 );
1249 }
1250
1251 serde_json::Value::Object(out)
1252}
1253
1254pub struct McpToolAdapter {
1255 qualified_name: String,
1256 tool_name: String,
1257 tier: crate::tool::Tier,
1258 client: Arc<McpClient>,
1259 reconcile: Option<ReconcilePlan>,
1260 schema: serde_json::Value,
1261 description: Option<String>,
1262}
1263
1264impl McpToolAdapter {
1265 pub fn new(
1266 client: Arc<McpClient>,
1267 tool_name: impl Into<String>,
1268 tier: crate::tool::Tier,
1269 schema: Option<&serde_json::Value>,
1270 description: Option<&str>,
1271 ) -> Self {
1272 let tool_name = tool_name.into();
1273 let qualified_name = format!("mcp.{}.{}", client.name, tool_name);
1274 let reconcile = schema.and_then(build_reconcile_plan);
1275 let schema = schema
1276 .cloned()
1277 .unwrap_or_else(|| serde_json::json!({"type": "object"}));
1278 Self {
1279 qualified_name,
1280 tool_name,
1281 tier,
1282 client,
1283 reconcile,
1284 schema,
1285 description: description.map(str::to_string),
1286 }
1287 }
1288}
1289
1290impl crate::tool::Tool for McpToolAdapter {
1291 fn name(&self) -> &str {
1292 &self.qualified_name
1293 }
1294
1295 fn tier(&self) -> crate::tool::Tier {
1296 self.tier
1297 }
1298
1299 fn input_schema(&self) -> serde_json::Value {
1300 self.schema.clone()
1301 }
1302
1303 fn description(&self) -> Option<&str> {
1304 self.description.as_deref()
1305 }
1306
1307 fn call<'a>(
1308 &'a self,
1309 args: crate::tool::ToolArgs,
1310 _ctx: &'a crate::tool::ToolCtx,
1311 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1312 Box::pin(async move {
1313 let params = value_to_mcp_args(&args);
1314 let params = if let Some(plan) = &self.reconcile {
1315 let map = match params {
1316 serde_json::Value::Object(m) => m,
1317 _ => return Err(RuntimeError::ToolFailed("mcp: expected object args".into())),
1318 };
1319 reconcile(plan, map)
1320 } else {
1321 params
1322 };
1323 let v = self.client.call_tool(&self.tool_name, params).await?;
1324 Ok(v)
1325 })
1326 }
1327}
1328
1329#[derive(Debug, Clone, PartialEq, Eq, documented::Documented, documented::DocumentedFields)]
1331pub struct McpToolInfo {
1332 pub name: String,
1334 pub description: Option<String>,
1336}
1337
1338#[derive(Debug, Clone, PartialEq, Eq)]
1339pub enum McpServerState {
1340 Disabled,
1341 Pending,
1342 Connecting,
1343 Connected {
1344 tool_count: usize,
1345 tools: Vec<McpToolInfo>,
1346 },
1347 Error {
1348 message: String,
1349 },
1350 Disconnected {
1351 message: String,
1352 },
1353 Timeout {
1354 message: String,
1355 },
1356}
1357
1358#[derive(Debug, Clone, PartialEq, Eq)]
1359pub struct McpServerStatus {
1360 pub name: String,
1361 pub transport: TransportKind,
1362 pub state: McpServerState,
1363}
1364
1365impl McpServerStatus {
1366 pub fn is_ok(&self) -> bool {
1367 matches!(self.state, McpServerState::Connected { .. })
1368 }
1369}
1370
1371pub fn mcp_counts(servers: &[McpServerStatus]) -> (u16, u16) {
1373 let total = servers.len() as u16;
1374 let ok = servers.iter().filter(|s| s.is_ok()).count() as u16;
1375 (ok, total)
1376}
1377
1378#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, documented::DocumentedVariants)]
1380pub enum TransportKind {
1381 #[default]
1382 Stdio,
1384 Http,
1386 Sse,
1388}
1389
1390#[derive(Debug, Clone, documented::Documented, documented::DocumentedFields)]
1392pub struct McpServerConfig {
1393 pub name: String,
1395 #[allow(clippy::field_reassign_with_default)]
1397 pub transport: TransportKind,
1398 pub command: String,
1400 pub args: Vec<String>,
1402 pub env: Vec<(String, String)>,
1404 pub url: Option<String>,
1406 pub auth_token: Option<String>,
1408 pub headers: Vec<(String, String)>,
1410 pub tier: crate::tool::Tier,
1412 pub timeout_ms: u64,
1414 pub disabled: bool,
1416}
1417
1418impl McpServerConfig {
1419 pub fn stdio(
1420 name: impl Into<String>,
1421 command: impl Into<String>,
1422 args: Vec<String>,
1423 tier: crate::tool::Tier,
1424 timeout_ms: u64,
1425 ) -> Self {
1426 Self {
1427 name: name.into(),
1428 transport: TransportKind::Stdio,
1429 command: command.into(),
1430 args,
1431 env: Vec::new(),
1432 url: None,
1433 auth_token: None,
1434 headers: Vec::new(),
1435 tier,
1436 timeout_ms,
1437 disabled: false,
1438 }
1439 }
1440
1441 pub fn http(
1442 name: impl Into<String>,
1443 url: impl Into<String>,
1444 auth_token: Option<String>,
1445 tier: crate::tool::Tier,
1446 timeout_ms: u64,
1447 ) -> Self {
1448 Self {
1449 name: name.into(),
1450 transport: TransportKind::Http,
1451 command: String::new(),
1452 args: Vec::new(),
1453 env: Vec::new(),
1454 url: Some(url.into()),
1455 auth_token,
1456 headers: Vec::new(),
1457 tier,
1458 timeout_ms,
1459 disabled: false,
1460 }
1461 }
1462
1463 pub fn sse(
1464 name: impl Into<String>,
1465 url: impl Into<String>,
1466 auth_token: Option<String>,
1467 tier: crate::tool::Tier,
1468 timeout_ms: u64,
1469 ) -> Self {
1470 Self {
1471 name: name.into(),
1472 transport: TransportKind::Sse,
1473 command: String::new(),
1474 args: Vec::new(),
1475 env: Vec::new(),
1476 url: Some(url.into()),
1477 auth_token,
1478 headers: Vec::new(),
1479 tier,
1480 timeout_ms,
1481 disabled: false,
1482 }
1483 }
1484}
1485
1486pub async fn register_from_configs(
1487 reg: &crate::tool::ToolRegistry,
1488 configs: &[McpServerConfig],
1489) -> Vec<Result<McpClientStatus, McpBootError>> {
1490 let mut out = Vec::with_capacity(configs.len());
1491 for cfg in configs {
1492 let outcome = match cfg.transport {
1493 TransportKind::Stdio => {
1494 McpClient::connect_stdio(
1495 &cfg.name,
1496 &cfg.command,
1497 &cfg.args,
1498 &cfg.env,
1499 cfg.timeout_ms,
1500 )
1501 .await
1502 }
1503 TransportKind::Http => match cfg.url.as_deref() {
1504 Some(url) => {
1505 McpClient::connect_http(&cfg.name, url, cfg.auth_token.clone(), cfg.timeout_ms)
1506 .await
1507 }
1508 None => Err(McpError::Protocol("http transport requires `url`".into())),
1509 },
1510 TransportKind::Sse => match cfg.url.as_deref() {
1511 Some(url) => {
1512 McpClient::connect_http(&cfg.name, url, cfg.auth_token.clone(), cfg.timeout_ms)
1513 .await
1514 }
1515 None => Err(McpError::Protocol("sse transport requires `url`".into())),
1516 },
1517 };
1518 match outcome {
1519 Ok(client) => {
1520 let transport_kind = client.transport_kind();
1521 let arc_client = Arc::new(client);
1522 let snapshot = arc_client.tool_snapshot();
1523 publish_tool_snapshot(reg, arc_client.clone(), cfg.tier, &snapshot);
1524 out.push(Ok(McpClientStatus {
1525 name: cfg.name.clone(),
1526 tool_count: snapshot.tools.len(),
1527 transport: transport_kind,
1528 tools: snapshot
1529 .tools
1530 .iter()
1531 .map(|t| McpToolInfo {
1532 name: t.name.clone(),
1533 description: t.description.clone(),
1534 })
1535 .collect(),
1536 }));
1537 }
1538 Err(e) => out.push(Err(McpBootError {
1539 name: cfg.name.clone(),
1540 error: e,
1541 })),
1542 }
1543 }
1544 out
1545}
1546
1547pub fn mcp_tool_namespace(server_name: &str) -> String {
1548 format!("mcp.{server_name}.")
1549}
1550
1551pub fn publish_tool_snapshot(
1552 registry: &crate::tool::ToolRegistry,
1553 client: Arc<McpClient>,
1554 tier: crate::tool::Tier,
1555 snapshot: &McpToolSnapshot,
1556) {
1557 let tools = snapshot
1558 .tools
1559 .iter()
1560 .map(|tool| {
1561 Arc::new(McpToolAdapter::new(
1562 client.clone(),
1563 &tool.name,
1564 tier,
1565 tool.input_schema.as_ref(),
1566 tool.description.as_deref(),
1567 )) as Arc<dyn crate::tool::Tool>
1568 })
1569 .collect();
1570 registry.replace_namespace(&mcp_tool_namespace(&client.name), tools);
1571}
1572
1573#[derive(Debug)]
1574pub struct McpClientStatus {
1575 pub name: String,
1576 pub tool_count: usize,
1577 pub transport: &'static str,
1578 pub tools: Vec<McpToolInfo>,
1579}
1580
1581#[derive(Debug, thiserror::Error)]
1582#[error("mcp `{name}` failed: {error}")]
1583pub struct McpBootError {
1584 pub name: String,
1585 pub error: McpError,
1586}
1587
1588#[cfg(test)]
1589mod tests {
1590 use super::*;
1591
1592 struct PagedToolTransport {
1593 repeated_cursor: bool,
1594 calls: std::sync::Mutex<Vec<(String, serde_json::Value)>>,
1595 }
1596
1597 impl PagedToolTransport {
1598 fn new(repeated_cursor: bool) -> Self {
1599 Self {
1600 repeated_cursor,
1601 calls: std::sync::Mutex::new(Vec::new()),
1602 }
1603 }
1604 }
1605
1606 impl McpTransport for PagedToolTransport {
1607 fn call<'a>(
1608 &'a self,
1609 method: &'a str,
1610 params: serde_json::Value,
1611 ) -> BoxFut<'a, Result<serde_json::Value, McpError>> {
1612 self.calls
1613 .lock()
1614 .unwrap()
1615 .push((method.to_string(), params.clone()));
1616 let result = match method {
1617 "initialize" => Ok(serde_json::json!({})),
1618 "tools/list" if params.get("cursor").is_none() => Ok(serde_json::json!({
1619 "tools": [{
1620 "name": "zeta",
1621 "inputSchema": {
1622 "type": "object",
1623 "properties": {"z": {"type": "string"}, "a": {"type": "string"}}
1624 }
1625 }],
1626 "nextCursor": "page-2"
1627 })),
1628 "tools/list" if self.repeated_cursor => Ok(serde_json::json!({
1629 "tools": [],
1630 "nextCursor": "page-2"
1631 })),
1632 "tools/list" => Ok(serde_json::json!({
1633 "tools": [{"name": "alpha", "inputSchema": {"type": "object"}}]
1634 })),
1635 other => Err(McpError::Protocol(format!("unexpected call `{other}`"))),
1636 };
1637 Box::pin(async move { result })
1638 }
1639
1640 fn notify<'a>(
1641 &'a self,
1642 _method: &'a str,
1643 _params: serde_json::Value,
1644 ) -> BoxFut<'a, Result<(), McpError>> {
1645 Box::pin(async { Ok(()) })
1646 }
1647
1648 fn kind(&self) -> &'static str {
1649 "test"
1650 }
1651 }
1652
1653 struct MutableToolTransport {
1654 response: std::sync::Mutex<Result<serde_json::Value, String>>,
1655 }
1656
1657 impl MutableToolTransport {
1658 fn new(tool_name: &str) -> Self {
1659 Self {
1660 response: std::sync::Mutex::new(Ok(Self::tool_page(tool_name))),
1661 }
1662 }
1663
1664 fn set_tool(&self, tool_name: &str) {
1665 *self.response.lock().unwrap() = Ok(Self::tool_page(tool_name));
1666 }
1667
1668 fn fail(&self, message: &str) {
1669 *self.response.lock().unwrap() = Err(message.to_string());
1670 }
1671
1672 fn tool_page(tool_name: &str) -> serde_json::Value {
1673 serde_json::json!({
1674 "tools": [{"name": tool_name, "inputSchema": {"type": "object"}}]
1675 })
1676 }
1677 }
1678
1679 impl McpTransport for MutableToolTransport {
1680 fn call<'a>(
1681 &'a self,
1682 method: &'a str,
1683 _params: serde_json::Value,
1684 ) -> BoxFut<'a, Result<serde_json::Value, McpError>> {
1685 let result = match method {
1686 "initialize" => Ok(serde_json::json!({})),
1687 "tools/list" => self
1688 .response
1689 .lock()
1690 .unwrap()
1691 .clone()
1692 .map_err(McpError::Protocol),
1693 other => Err(McpError::Protocol(format!("unexpected call `{other}`"))),
1694 };
1695 Box::pin(async move { result })
1696 }
1697
1698 fn notify<'a>(
1699 &'a self,
1700 _method: &'a str,
1701 _params: serde_json::Value,
1702 ) -> BoxFut<'a, Result<(), McpError>> {
1703 Box::pin(async { Ok(()) })
1704 }
1705
1706 fn kind(&self) -> &'static str {
1707 "test"
1708 }
1709 }
1710
1711 #[tokio::test]
1712 async fn connect_reads_every_tools_page_into_one_canonical_snapshot() {
1713 let transport = Arc::new(PagedToolTransport::new(false));
1714 let client = McpClient::connect_with_transport("paged", transport.clone())
1715 .await
1716 .unwrap();
1717
1718 let snapshot = client.tool_snapshot();
1719 assert_eq!(
1720 snapshot
1721 .tools
1722 .iter()
1723 .map(|tool| tool.name.as_str())
1724 .collect::<Vec<_>>(),
1725 vec!["alpha", "zeta"]
1726 );
1727 let list_params = transport
1728 .calls
1729 .lock()
1730 .unwrap()
1731 .iter()
1732 .filter(|(method, _)| method == "tools/list")
1733 .map(|(_, params)| params.clone())
1734 .collect::<Vec<_>>();
1735 assert_eq!(
1736 list_params,
1737 vec![
1738 serde_json::json!({}),
1739 serde_json::json!({"cursor": "page-2"})
1740 ]
1741 );
1742 }
1743
1744 #[tokio::test]
1745 async fn connect_rejects_repeated_tools_page_cursor() {
1746 let transport = Arc::new(PagedToolTransport::new(true));
1747 let error = match McpClient::connect_with_transport("paged", transport).await {
1748 Ok(_) => panic!("repeated cursor should fail"),
1749 Err(error) => error,
1750 };
1751
1752 assert!(error.to_string().contains("repeated cursor `page-2`"));
1753 }
1754
1755 #[tokio::test]
1756 async fn notification_receiver_retains_events_until_runtime_subscribes() {
1757 let transport = Arc::new(MutableToolTransport::new("alpha"));
1758 let client = McpClient::connect_with_transport("dynamic", transport)
1759 .await
1760 .unwrap();
1761
1762 client
1763 .notification_tx
1764 .send(McpNotification::ToolsListChanged)
1765 .unwrap();
1766 let mut notifications = client.subscribe_notifications();
1767
1768 assert!(matches!(
1769 notifications.try_recv(),
1770 Ok(McpNotification::ToolsListChanged)
1771 ));
1772 }
1773
1774 #[tokio::test]
1775 async fn refresh_replaces_changed_namespace_and_preserves_last_good_on_failure() {
1776 let transport = Arc::new(MutableToolTransport::new("alpha"));
1777 let client = Arc::new(
1778 McpClient::connect_with_transport("dynamic", transport.clone())
1779 .await
1780 .unwrap(),
1781 );
1782 let registry = crate::tool::ToolRegistry::new();
1783 let initial = client.tool_snapshot();
1784 publish_tool_snapshot(®istry, client.clone(), crate::tool::Tier::Zero, &initial);
1785
1786 assert!(client.refresh_tool_snapshot().await.unwrap().is_none());
1787 transport.set_tool("beta");
1788 let changed = client
1789 .refresh_tool_snapshot()
1790 .await
1791 .unwrap()
1792 .expect("changed snapshot");
1793 publish_tool_snapshot(®istry, client.clone(), crate::tool::Tier::Zero, &changed);
1794 assert!(!registry.has("mcp.dynamic.alpha"));
1795 assert!(registry.has("mcp.dynamic.beta"));
1796
1797 let last_good = client.tool_snapshot().fingerprint.clone();
1798 transport.fail("refresh unavailable");
1799 assert!(client.refresh_tool_snapshot().await.is_err());
1800 assert_eq!(client.tool_snapshot().fingerprint, last_good);
1801 assert!(registry.has("mcp.dynamic.beta"));
1802 }
1803
1804 #[tokio::test]
1805 async fn stdio_transport_call_returns_result() {
1806 let script = r#"
1807import sys, json
1808for line in sys.stdin:
1809 req = json.loads(line)
1810 resp = {"jsonrpc": "2.0", "id": req["id"], "result": {"echo": req.get("params")}}
1811 print(json.dumps(resp), flush=True)
1812"#;
1813 let dir = tempfile::tempdir().unwrap();
1814 let script_path = dir.path().join("mcp_echo.py");
1815 std::fs::write(&script_path, script).unwrap();
1816
1817 let transport = McpStdioTransport::spawn(
1818 "python3",
1819 &[script_path.display().to_string()],
1820 &[],
1821 5000,
1822 tokio::sync::broadcast::channel(256).0,
1823 Arc::new(std::sync::Mutex::new(None)),
1824 )
1825 .await
1826 .unwrap();
1827
1828 let result = transport
1829 .call("hello", serde_json::json!({"x": 1}))
1830 .await
1831 .unwrap();
1832 assert_eq!(result, serde_json::json!({"echo": {"x": 1}}));
1833 }
1834
1835 #[tokio::test]
1836 async fn stdio_transport_propagates_server_error() {
1837 let script = r#"
1838import sys, json
1839for line in sys.stdin:
1840 req = json.loads(line)
1841 resp = {"jsonrpc": "2.0", "id": req["id"], "error": {"code": -32601, "message": "not found"}}
1842 print(json.dumps(resp), flush=True)
1843"#;
1844 let dir = tempfile::tempdir().unwrap();
1845 let script_path = dir.path().join("mcp_err.py");
1846 std::fs::write(&script_path, script).unwrap();
1847
1848 let transport = McpStdioTransport::spawn(
1849 "python3",
1850 &[script_path.display().to_string()],
1851 &[],
1852 5000,
1853 tokio::sync::broadcast::channel(256).0,
1854 Arc::new(std::sync::Mutex::new(None)),
1855 )
1856 .await
1857 .unwrap();
1858 let err = transport
1859 .call("boom", serde_json::json!({}))
1860 .await
1861 .unwrap_err();
1862 assert!(matches!(err, McpError::ServerError { code: -32601, .. }));
1863 }
1864
1865 #[tokio::test]
1866 async fn stdio_transport_call_times_out() {
1867 let script = r#"
1868import sys
1869for line in sys.stdin:
1870 pass
1871"#;
1872 let dir = tempfile::tempdir().unwrap();
1873 let script_path = dir.path().join("mcp_silent.py");
1874 std::fs::write(&script_path, script).unwrap();
1875
1876 let transport = McpStdioTransport::spawn(
1877 "python3",
1878 &[script_path.display().to_string()],
1879 &[],
1880 200,
1881 tokio::sync::broadcast::channel(256).0,
1882 Arc::new(std::sync::Mutex::new(None)),
1883 )
1884 .await
1885 .unwrap();
1886 let err = transport
1887 .call("hangs", serde_json::json!({}))
1888 .await
1889 .unwrap_err();
1890 assert!(matches!(err, McpError::Timeout { .. }));
1891 }
1892
1893 #[test]
1894 fn tool_schema_deserialize_from_mcp_list_tools_response() {
1895 let json = serde_json::json!({
1896 "name": "read_file",
1897 "description": "reads a file",
1898 "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}}
1899 });
1900 let s: McpToolSchema = serde_json::from_value(json).unwrap();
1901 assert_eq!(s.name, "read_file");
1902 assert!(s.description.as_deref().unwrap().contains("reads"));
1903 }
1904
1905 #[test]
1906 fn no_catch_all_returns_none() {
1907 let schema = serde_json::json!({
1908 "type": "object",
1909 "properties": {
1910 "document_id": {"type": "string"}
1911 },
1912 "required": ["document_id"]
1913 });
1914 assert!(build_reconcile_plan(&schema).is_none());
1915 }
1916
1917 #[test]
1918 fn single_catch_all_builds_plan() {
1919 let schema = serde_json::json!({
1920 "type": "object",
1921 "properties": {
1922 "action": {"type": "string", "enum": ["get", "list", "update"]},
1923 "params": {"type": "object"}
1924 },
1925 "required": ["action"]
1926 });
1927 let plan = build_reconcile_plan(&schema).expect("should build a plan");
1928 assert!(plan.explicit_keys.contains("action"));
1929 assert!(!plan.explicit_keys.contains("params"));
1930 assert_eq!(plan.container_key, "params");
1931 assert!(!plan.container_required);
1932 }
1933
1934 #[test]
1935 fn container_required_is_detected() {
1936 let schema = serde_json::json!({
1937 "type": "object",
1938 "properties": {
1939 "action": {"type": "string"},
1940 "params": {"type": "object"}
1941 },
1942 "required": ["action", "params"]
1943 });
1944 let plan = build_reconcile_plan(&schema).expect("should build");
1945 assert!(plan.container_required);
1946 }
1947
1948 #[test]
1949 fn reconcile_moves_unmatched_into_container() {
1950 let plan = ReconcilePlan {
1951 explicit_keys: ["action".into()].into(),
1952 container_key: "params".into(),
1953 container_required: false,
1954 };
1955 let flat: serde_json::Map<_, _> = serde_json::json!({
1956 "action": "update",
1957 "guid": "abc",
1958 "due": "2026-07-17"
1959 })
1960 .as_object()
1961 .unwrap()
1962 .clone();
1963
1964 let out = reconcile(&plan, flat);
1965 assert_eq!(out["action"], "update");
1966 assert_eq!(out["params"]["guid"], "abc");
1967 assert_eq!(out["params"]["due"], "2026-07-17");
1968 }
1969
1970 #[test]
1971 fn reconcile_merges_container_key_instead_of_nesting() {
1972 let plan = ReconcilePlan {
1973 explicit_keys: ["action".into()].into(),
1974 container_key: "params".into(),
1975 container_required: false,
1976 };
1977 let flat: serde_json::Map<_, _> = serde_json::json!({
1978 "action": "list_events",
1979 "params": {
1980 "calendar_id": "cal_123",
1981 "start_time": "2026-07-31T00:00:00+08:00"
1982 }
1983 })
1984 .as_object()
1985 .unwrap()
1986 .clone();
1987
1988 let out = reconcile(&plan, flat);
1989 assert_eq!(out["action"], "list_events");
1990 assert_eq!(out["params"]["calendar_id"], "cal_123");
1991 assert_eq!(out["params"]["start_time"], "2026-07-31T00:00:00+08:00");
1992 assert!(
1993 out["params"].get("params").is_none(),
1994 "params should not be nested inside params"
1995 );
1996 }
1997
1998 #[test]
1999 fn reconcile_leaves_standard_schema_untouched() {
2000 let schema = serde_json::json!({
2001 "type": "object",
2002 "properties": {
2003 "action": {"type": "string"},
2004 "guid": {"type": "string"}
2005 },
2006 "required": ["action", "guid"]
2007 });
2008 assert!(build_reconcile_plan(&schema).is_none());
2009 }
2010
2011 #[test]
2012 fn two_catch_alls_bails() {
2013 let schema = serde_json::json!({
2014 "type": "object",
2015 "properties": {
2016 "action": {"type": "string"},
2017 "params": {"type": "object"},
2018 "extras": {"type": "object"}
2019 }
2020 });
2021 assert!(build_reconcile_plan(&schema).is_none());
2022 }
2023
2024 #[test]
2025 fn sealed_object_not_catch_all() {
2026 let schema = serde_json::json!({
2027 "type": "object",
2028 "properties": {
2029 "action": {"type": "string"},
2030 "lock": {"type": "object", "additionalProperties": false}
2031 }
2032 });
2033 assert!(build_reconcile_plan(&schema).is_none());
2034 }
2035
2036 #[test]
2037 fn nested_object_with_properties_not_catch_all() {
2038 let schema = serde_json::json!({
2039 "type": "object",
2040 "properties": {
2041 "action": {"type": "string"},
2042 "address": {
2043 "type": "object",
2044 "properties": {
2045 "street": {"type": "string"},
2046 "city": {"type": "string"}
2047 }
2048 }
2049 }
2050 });
2051 assert!(build_reconcile_plan(&schema).is_none());
2052 }
2053
2054 #[test]
2055 fn required_empty_container_still_emitted() {
2056 let plan = ReconcilePlan {
2057 explicit_keys: ["action".into()].into(),
2058 container_key: "body".into(),
2059 container_required: true,
2060 };
2061 let flat: serde_json::Map<_, _> = serde_json::json!({"action": "ping"})
2062 .as_object()
2063 .unwrap()
2064 .clone();
2065
2066 let out = reconcile(&plan, flat);
2067 assert_eq!(out["action"], "ping");
2068 assert!(out.get("body").and_then(|v| v.as_object()).is_some());
2069 }
2070
2071 #[test]
2072 fn empty_non_required_container_omitted() {
2073 let plan = ReconcilePlan {
2074 explicit_keys: ["action".into()].into(),
2075 container_key: "params".into(),
2076 container_required: false,
2077 };
2078 let flat: serde_json::Map<_, _> = serde_json::json!({"action": "list"})
2079 .as_object()
2080 .unwrap()
2081 .clone();
2082
2083 let out = reconcile(&plan, flat);
2084 assert_eq!(out["action"], "list");
2085 assert!(out.get("params").is_none());
2086 }
2087}