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