Skip to main content

atman_runtime/
mcp.rs

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    /// Send a JSON-RPC *notification* — no `id`, no response expected.
26    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)]
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, serde::Deserialize, serde::Serialize)]
73pub struct McpResource {
74    pub uri: String,
75    pub name: String,
76    #[serde(default)]
77    pub description: Option<String>,
78    #[serde(default, rename = "mimeType")]
79    pub mime_type: Option<String>,
80}
81
82#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
83pub struct McpResourceContent {
84    pub uri: String,
85    #[serde(default, rename = "mimeType")]
86    pub mime_type: Option<String>,
87    #[serde(default)]
88    pub text: Option<String>,
89    #[serde(default)]
90    pub blob: Option<String>, // base64-encoded
91}
92
93#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
94pub struct McpPrompt {
95    pub name: String,
96    #[serde(default)]
97    pub description: Option<String>,
98    #[serde(default)]
99    pub arguments: Vec<McpPromptArg>,
100}
101
102#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
103pub struct McpPromptArg {
104    pub name: String,
105    #[serde(default)]
106    pub description: Option<String>,
107    #[serde(default)]
108    pub required: bool,
109}
110
111#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
112pub struct McpPromptMessage {
113    pub role: String,
114    pub content: serde_json::Value,
115}
116
117#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
118pub struct McpPromptContent {
119    #[serde(default)]
120    pub messages: Vec<McpPromptMessage>,
121}
122
123#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
124pub struct SamplingRequest {
125    #[serde(default)]
126    pub model: Option<String>,
127    pub messages: Vec<McpPromptMessage>,
128    #[serde(default)]
129    pub max_tokens: u32,
130    #[serde(default, rename = "systemPrompt")]
131    pub system_prompt: Option<String>,
132}
133
134#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
135pub struct SamplingResponse {
136    pub role: String,
137    pub content: serde_json::Value,
138    #[serde(default)]
139    pub model: Option<String>,
140}
141
142pub type SamplingHandler = Arc<
143    dyn Fn(
144            SamplingRequest,
145        ) -> std::pin::Pin<
146            Box<dyn std::future::Future<Output = Result<SamplingResponse, RuntimeError>> + Send>,
147        > + Send
148        + Sync,
149>;
150
151pub type SharedSamplingHandler = Arc<std::sync::Mutex<Option<SamplingHandler>>>;
152
153#[derive(Debug, Clone, thiserror::Error)]
154pub enum McpError {
155    #[error("mcp io: {0}")]
156    Io(String),
157    #[error("mcp protocol: {0}")]
158    Protocol(String),
159    #[error("mcp server error {code}: {message}")]
160    ServerError { code: i64, message: String },
161    #[error("mcp timeout ({timeout_ms}ms) on {method}")]
162    Timeout { timeout_ms: u64, method: String },
163    #[error("mcp disconnected")]
164    Disconnected,
165}
166
167impl From<McpError> for RuntimeError {
168    fn from(e: McpError) -> Self {
169        RuntimeError::ToolFailed(format!("{e}"))
170    }
171}
172
173pub struct McpStdioTransport {
174    stdin: Arc<Mutex<ChildStdin>>,
175    pending: PendingCalls,
176    next_id: Arc<Mutex<u64>>,
177    #[allow(dead_code)]
178    child: Arc<Mutex<Child>>,
179    #[allow(dead_code)]
180    reader_task: Arc<Mutex<Option<JoinHandle<()>>>>,
181    timeout_ms: u64,
182    #[allow(dead_code)]
183    sampling_handler: SharedSamplingHandler,
184}
185
186impl McpTransport for McpStdioTransport {
187    fn call<'a>(
188        &'a self,
189        method: &'a str,
190        params: serde_json::Value,
191    ) -> BoxFut<'a, Result<serde_json::Value, McpError>> {
192        Box::pin(self.call_stdio(method, params))
193    }
194
195    fn notify<'a>(
196        &'a self,
197        method: &'a str,
198        params: serde_json::Value,
199    ) -> BoxFut<'a, Result<(), McpError>> {
200        Box::pin(self.notify_stdio(method, params))
201    }
202
203    fn kind(&self) -> &'static str {
204        "stdio"
205    }
206}
207
208impl McpStdioTransport {
209    pub async fn spawn(
210        cmd: &str,
211        args: &[String],
212        env: &[(String, String)],
213        timeout_ms: u64,
214        notification_tx: tokio::sync::broadcast::Sender<McpNotification>,
215        sampling_handler: SharedSamplingHandler,
216    ) -> Result<Self, McpError> {
217        let mut command = Command::new(cmd);
218        command
219            .args(args)
220            .stdin(Stdio::piped())
221            .stdout(Stdio::piped())
222            .stderr(Stdio::piped());
223        for (k, v) in env {
224            command.env(k, v);
225        }
226        let mut child = command
227            .spawn()
228            .map_err(|e| McpError::Io(format!("spawn {cmd}: {e}")))?;
229        let stdin = child
230            .stdin
231            .take()
232            .ok_or_else(|| McpError::Io("no stdin".into()))?;
233        let stdout = child
234            .stdout
235            .take()
236            .ok_or_else(|| McpError::Io("no stdout".into()))?;
237
238        let stdin_arc = Arc::new(tokio::sync::Mutex::new(stdin));
239        let stdin_for_reader = stdin_arc.clone();
240        let pending: PendingCalls = Arc::new(Mutex::new(HashMap::new()));
241        let pending_for_reader = pending.clone();
242        let notif_for_reader = notification_tx.clone();
243        let sampling_for_reader = sampling_handler.clone();
244        let reader_task = tokio::spawn(async move {
245            reader_loop(
246                stdout,
247                pending_for_reader,
248                notif_for_reader,
249                stdin_for_reader,
250                sampling_for_reader,
251            )
252            .await;
253        });
254
255        Ok(Self {
256            stdin: stdin_arc,
257            pending,
258            next_id: Arc::new(Mutex::new(1)),
259            child: Arc::new(Mutex::new(child)),
260            reader_task: Arc::new(Mutex::new(Some(reader_task))),
261            timeout_ms,
262            sampling_handler,
263        })
264    }
265
266    async fn call_stdio(
267        &self,
268        method: &str,
269        params: serde_json::Value,
270    ) -> Result<serde_json::Value, McpError> {
271        let id = {
272            let mut n = self.next_id.lock().await;
273            let v = *n;
274            *n += 1;
275            v
276        };
277        let (tx, rx) = oneshot::channel();
278        self.pending.lock().await.insert(id, tx);
279
280        let req = JsonRpcRequest {
281            jsonrpc: "2.0",
282            id,
283            method: method.into(),
284            params: Some(params),
285        };
286        let line = serde_json::to_string(&req)
287            .map_err(|e| McpError::Protocol(format!("serialize: {e}")))?;
288        {
289            let mut stdin = self.stdin.lock().await;
290            stdin
291                .write_all(line.as_bytes())
292                .await
293                .map_err(|e| McpError::Io(format!("write: {e}")))?;
294            stdin
295                .write_all(b"\n")
296                .await
297                .map_err(|e| McpError::Io(format!("write newline: {e}")))?;
298            stdin
299                .flush()
300                .await
301                .map_err(|e| McpError::Io(format!("flush: {e}")))?;
302        }
303
304        match tokio::time::timeout(Duration::from_millis(self.timeout_ms), rx).await {
305            Ok(Ok(inner)) => inner,
306            Ok(Err(_)) => {
307                self.pending.lock().await.remove(&id);
308                Err(McpError::Disconnected)
309            }
310            Err(_) => {
311                self.pending.lock().await.remove(&id);
312                Err(McpError::Timeout {
313                    timeout_ms: self.timeout_ms,
314                    method: method.into(),
315                })
316            }
317        }
318    }
319
320    async fn notify_stdio(&self, method: &str, params: serde_json::Value) -> Result<(), McpError> {
321        let req = serde_json::json!({
322            "jsonrpc": "2.0",
323            "method": method,
324            "params": params,
325        });
326        let line = serde_json::to_string(&req)
327            .map_err(|e| McpError::Protocol(format!("serialize: {e}")))?;
328        let mut stdin = self.stdin.lock().await;
329        stdin
330            .write_all(line.as_bytes())
331            .await
332            .map_err(|e| McpError::Io(format!("write: {e}")))?;
333        stdin
334            .write_all(b"\n")
335            .await
336            .map_err(|e| McpError::Io(format!("write newline: {e}")))?;
337        stdin
338            .flush()
339            .await
340            .map_err(|e| McpError::Io(format!("flush: {e}")))?;
341        Ok(())
342    }
343}
344
345async fn reader_loop(
346    stdout: ChildStdout,
347    pending: PendingCalls,
348    notification_tx: tokio::sync::broadcast::Sender<McpNotification>,
349    stdin: Arc<tokio::sync::Mutex<ChildStdin>>,
350    sampling_handler: SharedSamplingHandler,
351) {
352    let reader = BufReader::new(stdout);
353    let mut lines = reader.lines();
354    loop {
355        match lines.next_line().await {
356            Ok(Some(line)) => {
357                if line.trim().is_empty() {
358                    continue;
359                }
360                let parsed: serde_json::Value = match serde_json::from_str(&line) {
361                    Ok(v) => v,
362                    Err(_) => continue,
363                };
364
365                if let Some(method) = parsed.get("method").and_then(|v| v.as_str()) {
366                    if parsed.get("id").is_some() {
367                        if method == "sampling/createMessage" {
368                            let id = parsed.get("id").cloned();
369                            let params = parsed
370                                .get("params")
371                                .cloned()
372                                .unwrap_or(serde_json::Value::Null);
373                            let handler_arc = sampling_handler.clone();
374                            let stdin_arc = stdin.clone();
375                            tokio::spawn(async move {
376                                let handler = handler_arc.lock().unwrap().clone();
377                                let result = if let Some(h) = handler {
378                                    match serde_json::from_value::<SamplingRequest>(params.clone())
379                                    {
380                                        Ok(req) => match h(req).await {
381                                            Ok(resp) => Ok(serde_json::to_value(resp)
382                                                .unwrap_or(serde_json::Value::Null)),
383                                            Err(e) => Err(serde_json::json!({
384                                                "code": -1,
385                                                "message": e.to_string()
386                                            })),
387                                        },
388                                        Err(e) => Err(serde_json::json!({
389                                            "code": -1,
390                                            "message": format!("parse error: {e}")
391                                        })),
392                                    }
393                                } else {
394                                    Err(serde_json::json!({
395                                        "code": -1,
396                                        "message": "sampling not supported"
397                                    }))
398                                };
399                                let response = match result {
400                                    Ok(r) => serde_json::json!({
401                                        "jsonrpc": "2.0",
402                                        "id": id,
403                                        "result": r
404                                    }),
405                                    Err(e) => serde_json::json!({
406                                        "jsonrpc": "2.0",
407                                        "id": id,
408                                        "error": e
409                                    }),
410                                };
411                                let line = serde_json::to_string(&response)
412                                    .unwrap_or_else(|_| "{}".into());
413                                let mut s = stdin_arc.lock().await;
414                                let _ = s.write_all(line.as_bytes()).await;
415                                let _ = s.write_all(b"\n").await;
416                                let _ = s.flush().await;
417                            });
418                        }
419                    } else {
420                        let notif = parse_notification(method, &parsed);
421                        let _ = notification_tx.send(notif);
422                    }
423                    continue;
424                }
425
426                let Some(id) = parsed.get("id").and_then(|v| v.as_u64()) else {
427                    continue;
428                };
429                let sender = pending.lock().await.remove(&id);
430                if let Some(sender) = sender {
431                    let outcome = if let Some(err) = parsed.get("error") {
432                        Err(McpError::ServerError {
433                            code: err.get("code").and_then(|v| v.as_i64()).unwrap_or(-1),
434                            message: err
435                                .get("message")
436                                .and_then(|v| v.as_str())
437                                .unwrap_or("unknown")
438                                .to_string(),
439                        })
440                    } else {
441                        Ok(parsed
442                            .get("result")
443                            .cloned()
444                            .unwrap_or(serde_json::Value::Null))
445                    };
446                    let _ = sender.send(outcome);
447                }
448            }
449            Ok(None) => break,
450            Err(_) => break,
451        }
452    }
453    let mut pending = pending.lock().await;
454    for (_, tx) in pending.drain() {
455        let _ = tx.send(Err(McpError::Disconnected));
456    }
457}
458
459fn parse_sse_response(
460    text: &str,
461    expected_id: u64,
462    notification_tx: &tokio::sync::broadcast::Sender<McpNotification>,
463) -> Result<serde_json::Value, McpError> {
464    for line in text.lines() {
465        let line = line.trim();
466        if let Some(data) = line.strip_prefix("data:") {
467            let data = data.trim();
468            if data.is_empty() {
469                continue;
470            }
471            let parsed: serde_json::Value = match serde_json::from_str(data) {
472                Ok(v) => v,
473                Err(_) => continue,
474            };
475            if let Some(method) = parsed.get("method").and_then(|v| v.as_str()) {
476                if parsed.get("id").is_none() {
477                    let notif = parse_notification(method, &parsed);
478                    let _ = notification_tx.send(notif);
479                }
480                continue;
481            }
482            if parsed.get("id").and_then(|v| v.as_u64()) == Some(expected_id) {
483                if let Some(err) = parsed.get("error") {
484                    return Err(McpError::ServerError {
485                        code: err.get("code").and_then(|v| v.as_i64()).unwrap_or(-1),
486                        message: err
487                            .get("message")
488                            .and_then(|v| v.as_str())
489                            .unwrap_or("unknown")
490                            .to_string(),
491                    });
492                }
493                return Ok(parsed
494                    .get("result")
495                    .cloned()
496                    .unwrap_or(serde_json::Value::Null));
497            }
498        }
499    }
500    Err(McpError::Protocol(format!(
501        "SSE response missing id {expected_id}"
502    )))
503}
504
505fn parse_notification(method: &str, parsed: &serde_json::Value) -> McpNotification {
506    let params = parsed
507        .get("params")
508        .cloned()
509        .unwrap_or(serde_json::Value::Null);
510    match method {
511        "notifications/tools/list_changed" => McpNotification::ToolsListChanged,
512        "notifications/resources/list_changed" => McpNotification::ResourcesListChanged,
513        "notifications/prompts/list_changed" => McpNotification::PromptsListChanged,
514        "notifications/progress" => McpNotification::Progress {
515            progress_token: params
516                .get("progressToken")
517                .and_then(|v| v.as_str())
518                .unwrap_or("")
519                .to_string(),
520            progress: params.get("progress").and_then(|v| v.as_f64()),
521            total: params.get("total").and_then(|v| v.as_f64()),
522            message: params
523                .get("message")
524                .and_then(|v| v.as_str())
525                .map(String::from),
526        },
527        "notifications/log" => McpNotification::Log {
528            level: params
529                .get("level")
530                .and_then(|v| v.as_str())
531                .unwrap_or("info")
532                .to_string(),
533            data: params
534                .get("data")
535                .cloned()
536                .unwrap_or(serde_json::Value::Null),
537        },
538        "notifications/cancelled" => McpNotification::Cancelled {
539            request_id: params
540                .get("requestId")
541                .and_then(|v| v.as_str())
542                .unwrap_or("")
543                .to_string(),
544            reason: params
545                .get("reason")
546                .and_then(|v| v.as_str())
547                .map(String::from),
548        },
549        _ => McpNotification::Other {
550            method: method.to_string(),
551            params,
552        },
553    }
554}
555
556pub struct McpHttpTransport {
557    url: String,
558    auth_token: Option<String>,
559    client: reqwest::Client,
560    next_id: AtomicU64,
561    timeout_ms: u64,
562    retry_attempts: u32,
563    notification_tx: tokio::sync::broadcast::Sender<McpNotification>,
564}
565
566impl McpHttpTransport {
567    pub fn new(
568        url: impl Into<String>,
569        auth_token: Option<String>,
570        timeout_ms: u64,
571        notification_tx: tokio::sync::broadcast::Sender<McpNotification>,
572    ) -> Self {
573        Self {
574            url: url.into(),
575            auth_token,
576            client: reqwest::Client::new(),
577            next_id: AtomicU64::new(1),
578            timeout_ms,
579            retry_attempts: 3,
580            notification_tx,
581        }
582    }
583
584    #[doc(hidden)]
585    pub fn with_client(mut self, client: reqwest::Client) -> Self {
586        self.client = client;
587        self
588    }
589
590    async fn call_http(
591        &self,
592        method: &str,
593        params: serde_json::Value,
594    ) -> Result<serde_json::Value, McpError> {
595        let id = self.next_id.fetch_add(1, Ordering::SeqCst);
596        let body = serde_json::json!({
597            "jsonrpc": "2.0",
598            "id": id,
599            "method": method,
600            "params": params,
601        });
602        let call_timeout = Duration::from_millis(self.timeout_ms);
603        let attempt_result = tokio::time::timeout(call_timeout, async {
604            let mut delay_ms = 100u64;
605            let mut last_err: Option<McpError> = None;
606            for attempt in 0..=self.retry_attempts {
607                let mut req = self.client.post(&self.url).json(&body);
608                if let Some(t) = &self.auth_token {
609                    req = req.bearer_auth(t);
610                }
611                match req.send().await {
612                    Ok(resp) => {
613                        let status = resp.status();
614                        if status.is_success() {
615                            let content_type = resp
616                                .headers()
617                                .get("content-type")
618                                .and_then(|v| v.to_str().ok())
619                                .unwrap_or("")
620                                .to_string();
621                            let text = resp
622                                .text()
623                                .await
624                                .map_err(|e| McpError::Io(format!("mcp http body: {e}")))?;
625                            if content_type.contains("text/event-stream") {
626                                return parse_sse_response(&text, id, &self.notification_tx);
627                            }
628                            let parsed: JsonRpcResponse = serde_json::from_str(&text)
629                                .map_err(|e| McpError::Protocol(format!("mcp http parse: {e}")))?;
630                            if let Some(err) = parsed.error {
631                                return Err(McpError::ServerError {
632                                    code: err.code,
633                                    message: err.message,
634                                });
635                            }
636                            return Ok(parsed.result.unwrap_or(serde_json::Value::Null));
637                        }
638                        if status.is_server_error() {
639                            last_err =
640                                Some(McpError::Io(format!("mcp http {status}: server error")));
641                            if attempt < self.retry_attempts {
642                                tokio::time::sleep(Duration::from_millis(delay_ms)).await;
643                                delay_ms = (delay_ms * 5).min(2000);
644                                continue;
645                            }
646                        }
647                        let body_text = resp.text().await.unwrap_or_default();
648                        return Err(McpError::Io(format!("mcp http {status}: {body_text}")));
649                    }
650                    Err(e) => {
651                        last_err = Some(McpError::Io(format!("mcp http send: {e}")));
652                        if attempt < self.retry_attempts {
653                            tokio::time::sleep(Duration::from_millis(delay_ms)).await;
654                            delay_ms = (delay_ms * 5).min(2000);
655                            continue;
656                        }
657                    }
658                }
659            }
660            Err(last_err.unwrap_or(McpError::Disconnected))
661        })
662        .await;
663        match attempt_result {
664            Ok(inner) => inner,
665            Err(_) => Err(McpError::Timeout {
666                timeout_ms: self.timeout_ms,
667                method: method.into(),
668            }),
669        }
670    }
671
672    async fn notify_http(&self, method: &str, params: serde_json::Value) -> Result<(), McpError> {
673        let body = serde_json::json!({
674            "jsonrpc": "2.0",
675            "method": method,
676            "params": params,
677        });
678        let mut req = self.client.post(&self.url).json(&body);
679        if let Some(t) = &self.auth_token {
680            req = req.bearer_auth(t);
681        }
682        // Fire and forget — don't wait for response body.
683        let _ = req.send().await;
684        Ok(())
685    }
686}
687
688impl McpTransport for McpHttpTransport {
689    fn call<'a>(
690        &'a self,
691        method: &'a str,
692        params: serde_json::Value,
693    ) -> BoxFut<'a, Result<serde_json::Value, McpError>> {
694        Box::pin(self.call_http(method, params))
695    }
696
697    fn notify<'a>(
698        &'a self,
699        method: &'a str,
700        params: serde_json::Value,
701    ) -> BoxFut<'a, Result<(), McpError>> {
702        Box::pin(self.notify_http(method, params))
703    }
704
705    fn kind(&self) -> &'static str {
706        "http"
707    }
708}
709
710pub struct McpClient {
711    pub name: String,
712    transport: std::sync::Mutex<Arc<dyn McpTransport>>,
713    pub tools: Vec<McpToolSchema>,
714    reconnect: Option<ReconnectConfig>,
715    notification_tx: tokio::sync::broadcast::Sender<McpNotification>,
716    sampling_handler: SharedSamplingHandler,
717}
718
719#[derive(Debug, Clone)]
720pub enum McpNotification {
721    ToolsListChanged,
722    ResourcesListChanged,
723    PromptsListChanged,
724    Progress {
725        progress_token: String,
726        progress: Option<f64>,
727        total: Option<f64>,
728        message: Option<String>,
729    },
730    Log {
731        level: String,
732        data: serde_json::Value,
733    },
734    Cancelled {
735        request_id: String,
736        reason: Option<String>,
737    },
738    Other {
739        method: String,
740        params: serde_json::Value,
741    },
742}
743
744/// Config needed to re-create the transport on disconnect.
745enum ReconnectConfig {
746    Stdio {
747        cmd: String,
748        args: Vec<String>,
749        env: Vec<(String, String)>,
750        timeout_ms: u64,
751    },
752    Http {
753        url: String,
754        auth_token: Option<String>,
755        timeout_ms: u64,
756    },
757}
758
759impl McpClient {
760    pub async fn connect_stdio(
761        name: impl Into<String>,
762        cmd: &str,
763        args: &[String],
764        env: &[(String, String)],
765        timeout_ms: u64,
766    ) -> Result<Self, McpError> {
767        let (notification_tx, _) = tokio::sync::broadcast::channel(256);
768        let sampling_handler: SharedSamplingHandler = Arc::new(std::sync::Mutex::new(None));
769        let transport: Arc<dyn McpTransport> = Arc::new(
770            McpStdioTransport::spawn(
771                cmd,
772                args,
773                env,
774                timeout_ms,
775                notification_tx.clone(),
776                sampling_handler.clone(),
777            )
778            .await?,
779        );
780        let name = name.into();
781        let mut client = Self::finish_connect(name.clone(), transport).await?;
782        client.notification_tx = notification_tx;
783        client.sampling_handler = sampling_handler;
784        client.reconnect = Some(ReconnectConfig::Stdio {
785            cmd: cmd.to_string(),
786            args: args.to_vec(),
787            env: env.to_vec(),
788            timeout_ms,
789        });
790        Ok(client)
791    }
792
793    pub async fn connect_http(
794        name: impl Into<String>,
795        url: impl Into<String>,
796        auth_token: Option<String>,
797        timeout_ms: u64,
798    ) -> Result<Self, McpError> {
799        let (notification_tx, _) = tokio::sync::broadcast::channel(256);
800        let url_str: String = url.into();
801        let transport: Arc<dyn McpTransport> = Arc::new(McpHttpTransport::new(
802            url_str.clone(),
803            auth_token.clone(),
804            timeout_ms,
805            notification_tx.clone(),
806        ));
807        let name = name.into();
808        let mut client = Self::finish_connect(name.clone(), transport).await?;
809        client.notification_tx = notification_tx;
810        client.reconnect = Some(ReconnectConfig::Http {
811            url: url_str,
812            auth_token,
813            timeout_ms,
814        });
815        Ok(client)
816    }
817
818    pub async fn connect_with_transport(
819        name: impl Into<String>,
820        transport: Arc<dyn McpTransport>,
821    ) -> Result<Self, McpError> {
822        Self::finish_connect(name.into(), transport).await
823    }
824
825    async fn finish_connect(
826        name: String,
827        transport: Arc<dyn McpTransport>,
828    ) -> Result<Self, McpError> {
829        let init_params = serde_json::json!({
830            "protocolVersion": "2024-11-05",
831            "capabilities": {},
832            "clientInfo": {"name": "atman", "version": env!("CARGO_PKG_VERSION")}
833        });
834        transport.call("initialize", init_params).await?;
835        transport
836            .notify("notifications/initialized", serde_json::Value::Null)
837            .await?;
838        let list = transport.call("tools/list", serde_json::json!({})).await?;
839        let tools = parse_tools_list(&list)?;
840        let (notification_tx, _) = tokio::sync::broadcast::channel(256);
841        Ok(Self {
842            name,
843            transport: std::sync::Mutex::new(transport),
844            tools,
845            reconnect: None,
846            notification_tx,
847            sampling_handler: Arc::new(std::sync::Mutex::new(None)),
848        })
849    }
850
851    pub fn set_sampling_handler(&self, handler: SamplingHandler) {
852        *self.sampling_handler.lock().unwrap() = Some(handler);
853    }
854
855    pub fn transport_kind(&self) -> &'static str {
856        let t = self.transport.lock().unwrap();
857        t.kind()
858    }
859
860    pub fn subscribe_notifications(&self) -> tokio::sync::broadcast::Receiver<McpNotification> {
861        self.notification_tx.subscribe()
862    }
863
864    async fn reconnect(&self) -> Result<(), McpError> {
865        let cfg = self.reconnect.as_ref().ok_or(McpError::Disconnected)?;
866        let new_transport: Arc<dyn McpTransport> = match cfg {
867            ReconnectConfig::Stdio {
868                cmd,
869                args,
870                env,
871                timeout_ms,
872            } => Arc::new(
873                McpStdioTransport::spawn(
874                    cmd,
875                    args,
876                    env,
877                    *timeout_ms,
878                    self.notification_tx.clone(),
879                    self.sampling_handler.clone(),
880                )
881                .await?,
882            ),
883            ReconnectConfig::Http {
884                url,
885                auth_token,
886                timeout_ms,
887            } => Arc::new(McpHttpTransport::new(
888                url,
889                auth_token.clone(),
890                *timeout_ms,
891                self.notification_tx.clone(),
892            )),
893        };
894        // Re-initialize the new transport.
895        let init_params = serde_json::json!({
896            "protocolVersion": "2024-11-05",
897            "capabilities": {},
898            "clientInfo": {"name": "atman", "version": env!("CARGO_PKG_VERSION")}
899        });
900        new_transport.call("initialize", init_params).await?;
901        new_transport
902            .notify("notifications/initialized", serde_json::Value::Null)
903            .await?;
904        *self.transport.lock().unwrap() = new_transport;
905        Ok(())
906    }
907
908    pub async fn call_tool(
909        &self,
910        tool_name: &str,
911        arguments: serde_json::Value,
912    ) -> Result<crate::value::Value, McpError> {
913        let params = serde_json::json!({
914            "name": tool_name,
915            "arguments": arguments,
916        });
917        let transport = { self.transport.lock().unwrap().clone() };
918        let result = transport.call("tools/call", params.clone()).await;
919        match result {
920            Err(McpError::Disconnected) => {
921                self.reconnect().await?;
922                let transport = { self.transport.lock().unwrap().clone() };
923                let retry = transport.call("tools/call", params).await?;
924                Ok(mcp_result_to_value(retry))
925            }
926            other => Ok(mcp_result_to_value(other?)),
927        }
928    }
929
930    pub async fn list_resources(&self) -> Result<Vec<McpResource>, McpError> {
931        let transport = { self.transport.lock().unwrap().clone() };
932        let v = transport
933            .call("resources/list", serde_json::json!({}))
934            .await?;
935        v.get("resources")
936            .and_then(|r| r.as_array())
937            .map(|arr| {
938                arr.iter()
939                    .filter_map(|r| serde_json::from_value(r.clone()).ok())
940                    .collect()
941            })
942            .ok_or_else(|| {
943                McpError::Protocol(format!("resources/list missing `resources` array: {v}"))
944            })
945    }
946
947    pub async fn read_resource(&self, uri: &str) -> Result<Vec<McpResourceContent>, McpError> {
948        let params = serde_json::json!({ "uri": uri });
949        let transport = { self.transport.lock().unwrap().clone() };
950        let v = transport.call("resources/read", params).await?;
951        v.get("contents")
952            .and_then(|c| c.as_array())
953            .map(|arr| {
954                arr.iter()
955                    .filter_map(|c| serde_json::from_value(c.clone()).ok())
956                    .collect()
957            })
958            .ok_or_else(|| {
959                McpError::Protocol(format!("resources/read missing `contents` array: {v}"))
960            })
961    }
962
963    pub async fn list_prompts(&self) -> Result<Vec<McpPrompt>, McpError> {
964        let transport = { self.transport.lock().unwrap().clone() };
965        let v = transport
966            .call("prompts/list", serde_json::json!({}))
967            .await?;
968        v.get("prompts")
969            .and_then(|p| p.as_array())
970            .map(|arr| {
971                arr.iter()
972                    .filter_map(|p| serde_json::from_value(p.clone()).ok())
973                    .collect()
974            })
975            .ok_or_else(|| McpError::Protocol(format!("prompts/list missing `prompts` array: {v}")))
976    }
977
978    pub async fn get_prompt(
979        &self,
980        name: &str,
981        arguments: serde_json::Value,
982    ) -> Result<McpPromptContent, McpError> {
983        let params = serde_json::json!({ "name": name, "arguments": arguments });
984        let transport = { self.transport.lock().unwrap().clone() };
985        let v = transport.call("prompts/get", params).await?;
986        serde_json::from_value(v)
987            .map_err(|e| McpError::Protocol(format!("prompts/get parse error: {e}")))
988    }
989}
990
991fn parse_tools_list(v: &serde_json::Value) -> Result<Vec<McpToolSchema>, McpError> {
992    let arr = v.get("tools").and_then(|t| t.as_array()).ok_or_else(|| {
993        McpError::Protocol(format!("tools/list response missing `tools` array: {v}"))
994    })?;
995    let mut out = Vec::with_capacity(arr.len());
996    for item in arr {
997        let schema: McpToolSchema = serde_json::from_value(item.clone())
998            .map_err(|e| McpError::Protocol(format!("tool schema: {e}")))?;
999        out.push(schema);
1000    }
1001    Ok(out)
1002}
1003
1004pub fn mcp_result_to_value(result: serde_json::Value) -> crate::value::Value {
1005    if let Some(content) = result.get("content").and_then(|c| c.as_array()) {
1006        let text_parts: Vec<String> = content
1007            .iter()
1008            .filter_map(|item| {
1009                if item.get("type").and_then(|t| t.as_str()) == Some("text") {
1010                    item.get("text").and_then(|t| t.as_str()).map(String::from)
1011                } else {
1012                    None
1013                }
1014            })
1015            .collect();
1016        let is_error = result
1017            .get("isError")
1018            .and_then(|b| b.as_bool())
1019            .unwrap_or(false);
1020        return crate::value::Value::Struct(vec![
1021            ("text".into(), crate::value::Value::Str(text_parts.join(""))),
1022            ("is_error".into(), crate::value::Value::Bool(is_error)),
1023            ("raw".into(), crate::value::Value::from_json(result)),
1024        ]);
1025    }
1026    crate::value::Value::from_json(result)
1027}
1028
1029pub fn value_to_mcp_args(args: &crate::tool::ToolArgs) -> serde_json::Value {
1030    let mut map = serde_json::Map::new();
1031    for (name, value) in &args.named {
1032        map.insert(name.clone(), value.to_json());
1033    }
1034    serde_json::Value::Object(map)
1035}
1036
1037// Some MCP servers use a schema like {action: enum, params: {type: "object"}}
1038// where params is a catch-all for every argument not named at the top level.
1039// atman sends flat arguments and Zod drops everything except action+params,
1040// so we detect the pattern and repack unmatched keys into the container.
1041
1042#[derive(Debug, Clone)]
1043struct ReconcilePlan {
1044    explicit_keys: HashSet<String>,
1045    container_key: String,
1046    container_required: bool,
1047}
1048
1049fn build_reconcile_plan(schema: &serde_json::Value) -> Option<ReconcilePlan> {
1050    let props = schema.get("properties")?.as_object()?;
1051    let required: HashSet<&str> = schema
1052        .get("required")
1053        .and_then(|r| r.as_array())
1054        .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
1055        .unwrap_or_default();
1056
1057    let mut explicit_keys = HashSet::new();
1058    let mut catch_all: Option<(&str, bool)> = None;
1059
1060    for (name, prop) in props {
1061        let is_obj = prop.get("type").and_then(|t| t.as_str()) == Some("object");
1062        let has_sub_props = prop.get("properties").is_some_and(|p| p.is_object());
1063        let add_props_false =
1064            prop.get("additionalProperties").and_then(|a| a.as_bool()) == Some(false);
1065
1066        if is_obj && !has_sub_props && !add_props_false {
1067            if catch_all.is_some() {
1068                return None;
1069            }
1070            catch_all = Some((name.as_str(), required.contains(name.as_str())));
1071        } else {
1072            explicit_keys.insert(name.clone());
1073        }
1074    }
1075
1076    let (container_key, container_required) = catch_all?;
1077    Some(ReconcilePlan {
1078        explicit_keys,
1079        container_key: container_key.to_owned(),
1080        container_required,
1081    })
1082}
1083
1084fn reconcile(
1085    plan: &ReconcilePlan,
1086    flat: serde_json::Map<String, serde_json::Value>,
1087) -> serde_json::Value {
1088    let mut container = serde_json::Map::new();
1089    let mut out = serde_json::Map::new();
1090
1091    for (k, v) in flat {
1092        if plan.explicit_keys.contains(&k) {
1093            out.insert(k, v);
1094        } else if k == plan.container_key {
1095            if let serde_json::Value::Object(inner) = v {
1096                for (ik, iv) in inner {
1097                    container.insert(ik, iv);
1098                }
1099            } else {
1100                container.insert(k, v);
1101            }
1102        } else {
1103            container.insert(k, v);
1104        }
1105    }
1106
1107    if !container.is_empty() || plan.container_required {
1108        out.insert(
1109            plan.container_key.clone(),
1110            serde_json::Value::Object(container),
1111        );
1112    }
1113
1114    serde_json::Value::Object(out)
1115}
1116
1117pub struct McpToolAdapter {
1118    qualified_name: String,
1119    tool_name: String,
1120    tier: crate::tool::Tier,
1121    client: Arc<McpClient>,
1122    reconcile: Option<ReconcilePlan>,
1123    schema: serde_json::Value,
1124    description: Option<String>,
1125}
1126
1127impl McpToolAdapter {
1128    pub fn new(
1129        client: Arc<McpClient>,
1130        tool_name: impl Into<String>,
1131        tier: crate::tool::Tier,
1132        schema: Option<&serde_json::Value>,
1133        description: Option<&str>,
1134    ) -> Self {
1135        let tool_name = tool_name.into();
1136        let qualified_name = format!("mcp.{}.{}", client.name, tool_name);
1137        let reconcile = schema.and_then(build_reconcile_plan);
1138        let schema = schema
1139            .cloned()
1140            .unwrap_or_else(|| serde_json::json!({"type": "object"}));
1141        Self {
1142            qualified_name,
1143            tool_name,
1144            tier,
1145            client,
1146            reconcile,
1147            schema,
1148            description: description.map(str::to_string),
1149        }
1150    }
1151}
1152
1153impl crate::tool::Tool for McpToolAdapter {
1154    fn name(&self) -> &str {
1155        &self.qualified_name
1156    }
1157
1158    fn tier(&self) -> crate::tool::Tier {
1159        self.tier
1160    }
1161
1162    fn input_schema(&self) -> serde_json::Value {
1163        self.schema.clone()
1164    }
1165
1166    fn description(&self) -> Option<&str> {
1167        self.description.as_deref()
1168    }
1169
1170    fn call<'a>(
1171        &'a self,
1172        args: crate::tool::ToolArgs,
1173        _ctx: &'a crate::tool::ToolCtx,
1174    ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1175        Box::pin(async move {
1176            let params = value_to_mcp_args(&args);
1177            let params = if let Some(plan) = &self.reconcile {
1178                let map = match params {
1179                    serde_json::Value::Object(m) => m,
1180                    _ => return Err(RuntimeError::ToolFailed("mcp: expected object args".into())),
1181                };
1182                reconcile(plan, map)
1183            } else {
1184                params
1185            };
1186            let v = self.client.call_tool(&self.tool_name, params).await?;
1187            Ok(v)
1188        })
1189    }
1190}
1191
1192/// Name and description of a single tool exposed by an MCP server.
1193#[derive(Debug, Clone, PartialEq, Eq, documented::Documented, documented::DocumentedFields)]
1194pub struct McpToolInfo {
1195    /// The tool name as reported by the MCP server.
1196    pub name: String,
1197    /// Human-readable description of what the tool does, if provided.
1198    pub description: Option<String>,
1199}
1200
1201#[derive(Debug, Clone, PartialEq, Eq)]
1202pub enum McpServerState {
1203    Disabled,
1204    Pending,
1205    Connecting,
1206    Connected {
1207        tool_count: usize,
1208        tools: Vec<McpToolInfo>,
1209    },
1210    Error {
1211        message: String,
1212    },
1213    Disconnected {
1214        message: String,
1215    },
1216    Timeout {
1217        message: String,
1218    },
1219}
1220
1221#[derive(Debug, Clone, PartialEq, Eq)]
1222pub struct McpServerStatus {
1223    pub name: String,
1224    pub transport: TransportKind,
1225    pub state: McpServerState,
1226}
1227
1228impl McpServerStatus {
1229    pub fn is_ok(&self) -> bool {
1230        matches!(self.state, McpServerState::Connected { .. })
1231    }
1232}
1233
1234/// Derive ok/total counts from a list of server statuses.
1235pub fn mcp_counts(servers: &[McpServerStatus]) -> (u16, u16) {
1236    let total = servers.len() as u16;
1237    let ok = servers.iter().filter(|s| s.is_ok()).count() as u16;
1238    (ok, total)
1239}
1240
1241/// Transport protocol used to communicate with an MCP server.
1242#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, documented::DocumentedVariants)]
1243pub enum TransportKind {
1244    #[default]
1245    /// Spawn a subprocess and communicate via stdin/stdout (JSON-RPC over stdio).
1246    Stdio,
1247    /// Send JSON-RPC requests over HTTP.
1248    Http,
1249    /// Receive server-pushed messages via Server-Sent Events.
1250    Sse,
1251}
1252
1253/// Configuration for a single MCP server connection.
1254#[derive(Debug, Clone, documented::Documented, documented::DocumentedFields)]
1255pub struct McpServerConfig {
1256    /// Unique name for this server, used in tool names as `mcp.{name}.{tool}`.
1257    pub name: String,
1258    /// Transport protocol: stdio, http, or sse.
1259    #[allow(clippy::field_reassign_with_default)]
1260    pub transport: TransportKind,
1261    /// stdio: the executable command to spawn.
1262    pub command: String,
1263    /// stdio: command-line arguments passed to the executable.
1264    pub args: Vec<String>,
1265    /// stdio: environment variables for the subprocess.
1266    pub env: Vec<(String, String)>,
1267    /// http/sse: the server URL to connect to.
1268    pub url: Option<String>,
1269    /// http/sse: bearer token for authentication.
1270    pub auth_token: Option<String>,
1271    /// http/sse: custom HTTP headers.
1272    pub headers: Vec<(String, String)>,
1273    /// Approval tier applied to all tools from this server.
1274    pub tier: crate::tool::Tier,
1275    /// Connection initialization timeout in milliseconds.
1276    pub timeout_ms: u64,
1277    /// If true, skip this server at startup.
1278    pub disabled: bool,
1279}
1280
1281impl McpServerConfig {
1282    pub fn stdio(
1283        name: impl Into<String>,
1284        command: impl Into<String>,
1285        args: Vec<String>,
1286        tier: crate::tool::Tier,
1287        timeout_ms: u64,
1288    ) -> Self {
1289        Self {
1290            name: name.into(),
1291            transport: TransportKind::Stdio,
1292            command: command.into(),
1293            args,
1294            env: Vec::new(),
1295            url: None,
1296            auth_token: None,
1297            headers: Vec::new(),
1298            tier,
1299            timeout_ms,
1300            disabled: false,
1301        }
1302    }
1303
1304    pub fn http(
1305        name: impl Into<String>,
1306        url: impl Into<String>,
1307        auth_token: Option<String>,
1308        tier: crate::tool::Tier,
1309        timeout_ms: u64,
1310    ) -> Self {
1311        Self {
1312            name: name.into(),
1313            transport: TransportKind::Http,
1314            command: String::new(),
1315            args: Vec::new(),
1316            env: Vec::new(),
1317            url: Some(url.into()),
1318            auth_token,
1319            headers: Vec::new(),
1320            tier,
1321            timeout_ms,
1322            disabled: false,
1323        }
1324    }
1325
1326    pub fn sse(
1327        name: impl Into<String>,
1328        url: impl Into<String>,
1329        auth_token: Option<String>,
1330        tier: crate::tool::Tier,
1331        timeout_ms: u64,
1332    ) -> Self {
1333        Self {
1334            name: name.into(),
1335            transport: TransportKind::Sse,
1336            command: String::new(),
1337            args: Vec::new(),
1338            env: Vec::new(),
1339            url: Some(url.into()),
1340            auth_token,
1341            headers: Vec::new(),
1342            tier,
1343            timeout_ms,
1344            disabled: false,
1345        }
1346    }
1347}
1348
1349pub async fn register_from_configs(
1350    reg: &crate::tool::ToolRegistry,
1351    configs: &[McpServerConfig],
1352) -> Vec<Result<McpClientStatus, McpBootError>> {
1353    let mut out = Vec::with_capacity(configs.len());
1354    for cfg in configs {
1355        let outcome = match cfg.transport {
1356            TransportKind::Stdio => {
1357                McpClient::connect_stdio(
1358                    &cfg.name,
1359                    &cfg.command,
1360                    &cfg.args,
1361                    &cfg.env,
1362                    cfg.timeout_ms,
1363                )
1364                .await
1365            }
1366            TransportKind::Http => match cfg.url.as_deref() {
1367                Some(url) => {
1368                    McpClient::connect_http(&cfg.name, url, cfg.auth_token.clone(), cfg.timeout_ms)
1369                        .await
1370                }
1371                None => Err(McpError::Protocol("http transport requires `url`".into())),
1372            },
1373            TransportKind::Sse => match cfg.url.as_deref() {
1374                Some(url) => {
1375                    McpClient::connect_http(&cfg.name, url, cfg.auth_token.clone(), cfg.timeout_ms)
1376                        .await
1377                }
1378                None => Err(McpError::Protocol("sse transport requires `url`".into())),
1379            },
1380        };
1381        match outcome {
1382            Ok(client) => {
1383                let tool_count = client.tools.len();
1384                let transport_kind = client.transport_kind();
1385                let arc_client = Arc::new(client);
1386                for tool in &arc_client.tools {
1387                    let adapter = McpToolAdapter::new(
1388                        arc_client.clone(),
1389                        &tool.name,
1390                        cfg.tier,
1391                        tool.input_schema.as_ref(),
1392                        tool.description.as_deref(),
1393                    );
1394                    reg.register(Arc::new(adapter));
1395                }
1396                out.push(Ok(McpClientStatus {
1397                    name: cfg.name.clone(),
1398                    tool_count,
1399                    transport: transport_kind,
1400                    tools: arc_client
1401                        .tools
1402                        .iter()
1403                        .map(|t| McpToolInfo {
1404                            name: t.name.clone(),
1405                            description: t.description.clone(),
1406                        })
1407                        .collect(),
1408                }));
1409            }
1410            Err(e) => out.push(Err(McpBootError {
1411                name: cfg.name.clone(),
1412                error: e,
1413            })),
1414        }
1415    }
1416    out
1417}
1418
1419#[derive(Debug)]
1420pub struct McpClientStatus {
1421    pub name: String,
1422    pub tool_count: usize,
1423    pub transport: &'static str,
1424    pub tools: Vec<McpToolInfo>,
1425}
1426
1427#[derive(Debug, thiserror::Error)]
1428#[error("mcp `{name}` failed: {error}")]
1429pub struct McpBootError {
1430    pub name: String,
1431    pub error: McpError,
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436    use super::*;
1437
1438    #[tokio::test]
1439    async fn stdio_transport_call_returns_result() {
1440        let script = r#"
1441import sys, json
1442for line in sys.stdin:
1443    req = json.loads(line)
1444    resp = {"jsonrpc": "2.0", "id": req["id"], "result": {"echo": req.get("params")}}
1445    print(json.dumps(resp), flush=True)
1446"#;
1447        let dir = tempfile::tempdir().unwrap();
1448        let script_path = dir.path().join("mcp_echo.py");
1449        std::fs::write(&script_path, script).unwrap();
1450
1451        let transport = McpStdioTransport::spawn(
1452            "python3",
1453            &[script_path.display().to_string()],
1454            &[],
1455            5000,
1456            tokio::sync::broadcast::channel(256).0,
1457            Arc::new(std::sync::Mutex::new(None)),
1458        )
1459        .await
1460        .unwrap();
1461
1462        let result = transport
1463            .call("hello", serde_json::json!({"x": 1}))
1464            .await
1465            .unwrap();
1466        assert_eq!(result, serde_json::json!({"echo": {"x": 1}}));
1467    }
1468
1469    #[tokio::test]
1470    async fn stdio_transport_propagates_server_error() {
1471        let script = r#"
1472import sys, json
1473for line in sys.stdin:
1474    req = json.loads(line)
1475    resp = {"jsonrpc": "2.0", "id": req["id"], "error": {"code": -32601, "message": "not found"}}
1476    print(json.dumps(resp), flush=True)
1477"#;
1478        let dir = tempfile::tempdir().unwrap();
1479        let script_path = dir.path().join("mcp_err.py");
1480        std::fs::write(&script_path, script).unwrap();
1481
1482        let transport = McpStdioTransport::spawn(
1483            "python3",
1484            &[script_path.display().to_string()],
1485            &[],
1486            5000,
1487            tokio::sync::broadcast::channel(256).0,
1488            Arc::new(std::sync::Mutex::new(None)),
1489        )
1490        .await
1491        .unwrap();
1492        let err = transport
1493            .call("boom", serde_json::json!({}))
1494            .await
1495            .unwrap_err();
1496        assert!(matches!(err, McpError::ServerError { code: -32601, .. }));
1497    }
1498
1499    #[tokio::test]
1500    async fn stdio_transport_call_times_out() {
1501        let script = r#"
1502import sys
1503for line in sys.stdin:
1504    pass
1505"#;
1506        let dir = tempfile::tempdir().unwrap();
1507        let script_path = dir.path().join("mcp_silent.py");
1508        std::fs::write(&script_path, script).unwrap();
1509
1510        let transport = McpStdioTransport::spawn(
1511            "python3",
1512            &[script_path.display().to_string()],
1513            &[],
1514            200,
1515            tokio::sync::broadcast::channel(256).0,
1516            Arc::new(std::sync::Mutex::new(None)),
1517        )
1518        .await
1519        .unwrap();
1520        let err = transport
1521            .call("hangs", serde_json::json!({}))
1522            .await
1523            .unwrap_err();
1524        assert!(matches!(err, McpError::Timeout { .. }));
1525    }
1526
1527    #[test]
1528    fn tool_schema_deserialize_from_mcp_list_tools_response() {
1529        let json = serde_json::json!({
1530            "name": "read_file",
1531            "description": "reads a file",
1532            "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}}
1533        });
1534        let s: McpToolSchema = serde_json::from_value(json).unwrap();
1535        assert_eq!(s.name, "read_file");
1536        assert!(s.description.as_deref().unwrap().contains("reads"));
1537    }
1538
1539    #[test]
1540    fn no_catch_all_returns_none() {
1541        let schema = serde_json::json!({
1542            "type": "object",
1543            "properties": {
1544                "document_id": {"type": "string"}
1545            },
1546            "required": ["document_id"]
1547        });
1548        assert!(build_reconcile_plan(&schema).is_none());
1549    }
1550
1551    #[test]
1552    fn single_catch_all_builds_plan() {
1553        let schema = serde_json::json!({
1554            "type": "object",
1555            "properties": {
1556                "action": {"type": "string", "enum": ["get", "list", "update"]},
1557                "params": {"type": "object"}
1558            },
1559            "required": ["action"]
1560        });
1561        let plan = build_reconcile_plan(&schema).expect("should build a plan");
1562        assert!(plan.explicit_keys.contains("action"));
1563        assert!(!plan.explicit_keys.contains("params"));
1564        assert_eq!(plan.container_key, "params");
1565        assert!(!plan.container_required);
1566    }
1567
1568    #[test]
1569    fn container_required_is_detected() {
1570        let schema = serde_json::json!({
1571            "type": "object",
1572            "properties": {
1573                "action": {"type": "string"},
1574                "params": {"type": "object"}
1575            },
1576            "required": ["action", "params"]
1577        });
1578        let plan = build_reconcile_plan(&schema).expect("should build");
1579        assert!(plan.container_required);
1580    }
1581
1582    #[test]
1583    fn reconcile_moves_unmatched_into_container() {
1584        let plan = ReconcilePlan {
1585            explicit_keys: ["action".into()].into(),
1586            container_key: "params".into(),
1587            container_required: false,
1588        };
1589        let flat: serde_json::Map<_, _> = serde_json::json!({
1590            "action": "update",
1591            "guid": "abc",
1592            "due": "2026-07-17"
1593        })
1594        .as_object()
1595        .unwrap()
1596        .clone();
1597
1598        let out = reconcile(&plan, flat);
1599        assert_eq!(out["action"], "update");
1600        assert_eq!(out["params"]["guid"], "abc");
1601        assert_eq!(out["params"]["due"], "2026-07-17");
1602    }
1603
1604    #[test]
1605    fn reconcile_merges_container_key_instead_of_nesting() {
1606        let plan = ReconcilePlan {
1607            explicit_keys: ["action".into()].into(),
1608            container_key: "params".into(),
1609            container_required: false,
1610        };
1611        let flat: serde_json::Map<_, _> = serde_json::json!({
1612            "action": "list_events",
1613            "params": {
1614                "calendar_id": "cal_123",
1615                "start_time": "2026-07-31T00:00:00+08:00"
1616            }
1617        })
1618        .as_object()
1619        .unwrap()
1620        .clone();
1621
1622        let out = reconcile(&plan, flat);
1623        assert_eq!(out["action"], "list_events");
1624        assert_eq!(out["params"]["calendar_id"], "cal_123");
1625        assert_eq!(out["params"]["start_time"], "2026-07-31T00:00:00+08:00");
1626        assert!(
1627            out["params"].get("params").is_none(),
1628            "params should not be nested inside params"
1629        );
1630    }
1631
1632    #[test]
1633    fn reconcile_leaves_standard_schema_untouched() {
1634        let schema = serde_json::json!({
1635            "type": "object",
1636            "properties": {
1637                "action": {"type": "string"},
1638                "guid": {"type": "string"}
1639            },
1640            "required": ["action", "guid"]
1641        });
1642        assert!(build_reconcile_plan(&schema).is_none());
1643    }
1644
1645    #[test]
1646    fn two_catch_alls_bails() {
1647        let schema = serde_json::json!({
1648            "type": "object",
1649            "properties": {
1650                "action": {"type": "string"},
1651                "params": {"type": "object"},
1652                "extras": {"type": "object"}
1653            }
1654        });
1655        assert!(build_reconcile_plan(&schema).is_none());
1656    }
1657
1658    #[test]
1659    fn sealed_object_not_catch_all() {
1660        let schema = serde_json::json!({
1661            "type": "object",
1662            "properties": {
1663                "action": {"type": "string"},
1664                "lock": {"type": "object", "additionalProperties": false}
1665            }
1666        });
1667        assert!(build_reconcile_plan(&schema).is_none());
1668    }
1669
1670    #[test]
1671    fn nested_object_with_properties_not_catch_all() {
1672        let schema = serde_json::json!({
1673            "type": "object",
1674            "properties": {
1675                "action": {"type": "string"},
1676                "address": {
1677                    "type": "object",
1678                    "properties": {
1679                        "street": {"type": "string"},
1680                        "city": {"type": "string"}
1681                    }
1682                }
1683            }
1684        });
1685        assert!(build_reconcile_plan(&schema).is_none());
1686    }
1687
1688    #[test]
1689    fn required_empty_container_still_emitted() {
1690        let plan = ReconcilePlan {
1691            explicit_keys: ["action".into()].into(),
1692            container_key: "body".into(),
1693            container_required: true,
1694        };
1695        let flat: serde_json::Map<_, _> = serde_json::json!({"action": "ping"})
1696            .as_object()
1697            .unwrap()
1698            .clone();
1699
1700        let out = reconcile(&plan, flat);
1701        assert_eq!(out["action"], "ping");
1702        assert!(out.get("body").and_then(|v| v.as_object()).is_some());
1703    }
1704
1705    #[test]
1706    fn empty_non_required_container_omitted() {
1707        let plan = ReconcilePlan {
1708            explicit_keys: ["action".into()].into(),
1709            container_key: "params".into(),
1710            container_required: false,
1711        };
1712        let flat: serde_json::Map<_, _> = serde_json::json!({"action": "list"})
1713            .as_object()
1714            .unwrap()
1715            .clone();
1716
1717        let out = reconcile(&plan, flat);
1718        assert_eq!(out["action"], "list");
1719        assert!(out.get("params").is_none());
1720    }
1721}