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