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