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