1use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::{Arc, Mutex as StdMutex};
12use std::time::Duration;
13use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader};
14use tokio::process::{Child, ChildStderr};
15use tokio::sync::{oneshot, Mutex};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct McpServerConfig {
20 pub name: String,
22 pub command: String,
24 #[serde(default)]
26 pub args: Vec<String>,
27 #[serde(default)]
29 pub env: HashMap<String, String>,
30 pub cwd: Option<String>,
32}
33
34type Pending = Arc<StdMutex<HashMap<u64, oneshot::Sender<McpResponse>>>>;
36
37pub struct McpServer {
47 config: McpServerConfig,
48 child: Child,
49 stdin: tokio::io::BufWriter<tokio::process::ChildStdin>,
50 next_id: u64,
51 pending: Pending,
52 reader: tokio::task::JoinHandle<()>,
54 stderr_reader: tokio::task::JoinHandle<()>,
58 alive: Arc<AtomicBool>,
61 request_timeout: Duration,
65}
66
67impl Drop for McpServer {
68 fn drop(&mut self) {
69 self.reader.abort();
73 self.stderr_reader.abort();
74 let _ = self.child.start_kill();
75 }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct McpToolInfo {
81 pub name: String,
82 pub description: Option<String>,
83 #[serde(rename = "inputSchema")]
84 pub input_schema: Option<Value>,
85}
86
87#[async_trait::async_trait]
97pub trait McpSession: Send {
98 async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String>;
100 async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, String>;
102 async fn call_tool_with_timeout(
107 &mut self,
108 name: &str,
109 arguments: Value,
110 _timeout: Option<Duration>,
111 ) -> Result<Value, String> {
112 self.call_tool(name, arguments).await
113 }
114 fn name(&self) -> &str;
116}
117
118#[async_trait::async_trait]
119impl McpSession for McpServer {
120 async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
121 McpServer::list_tools(self).await
122 }
123 async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, String> {
124 McpServer::call_tool(self, name, arguments).await
125 }
126 async fn call_tool_with_timeout(
127 &mut self,
128 name: &str,
129 arguments: Value,
130 timeout: Option<Duration>,
131 ) -> Result<Value, String> {
132 McpServer::call_tool_with_timeout(self, name, arguments, timeout).await
133 }
134 fn name(&self) -> &str {
135 McpServer::name(self)
136 }
137}
138
139#[derive(Debug, Serialize)]
141struct McpRequest {
142 jsonrpc: &'static str,
143 method: String,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 params: Option<Value>,
146 id: u64,
147}
148
149#[derive(Debug, Deserialize)]
151struct McpResponse {
152 result: Option<Value>,
153 error: Option<McpError>,
154 id: Option<u64>,
156}
157
158#[derive(Debug, Deserialize)]
159struct McpError {
160 #[allow(dead_code)]
161 code: Option<i64>,
162 message: String,
163}
164
165fn route_line(line: &str, pending: &StdMutex<HashMap<u64, oneshot::Sender<McpResponse>>>) {
171 let resp: McpResponse = match serde_json::from_str(line) {
172 Ok(r) => r,
173 Err(_) => return, };
175 if let Some(id) = resp.id {
176 if let Some(tx) = pending.lock().unwrap().remove(&id) {
177 let _ = tx.send(resp);
179 }
180 }
182}
183
184async fn reader_loop<R: AsyncBufRead + Unpin>(
188 mut stdout: R,
189 pending: Pending,
190 alive: Arc<AtomicBool>,
191 server_name: String,
192) {
193 let mut line = String::new();
194 loop {
195 line.clear();
196 match stdout.read_line(&mut line).await {
197 Ok(0) | Err(_) => break, Ok(_) => route_line(&line, &pending),
199 }
200 }
201 alive.store(false, Ordering::SeqCst);
202 pending.lock().unwrap().clear(); tracing::debug!(server = %server_name, "MCP reader exited; connection closed");
204}
205
206async fn stderr_drain_loop(stderr: ChildStderr, server_name: String) {
209 let mut lines = BufReader::new(stderr).lines();
210 while let Ok(Some(line)) = lines.next_line().await {
211 tracing::debug!(server = %server_name, "mcp stderr: {line}");
212 }
213}
214
215impl McpServer {
216 pub async fn start(config: McpServerConfig) -> Result<Self, String> {
218 let mut cmd = crate::spawn::program_command(&config.command);
222 cmd.args(&config.args)
223 .stdin(std::process::Stdio::piped())
224 .stdout(std::process::Stdio::piped())
225 .stderr(std::process::Stdio::piped());
226
227 if let Some(ref cwd) = config.cwd {
228 cmd.current_dir(cwd);
229 }
230 for (k, v) in &config.env {
231 cmd.env(k, v);
232 }
233
234 let mut child = cmd
235 .spawn()
236 .map_err(|e| format!("failed to start MCP server '{}': {}", config.name, e))?;
237
238 let stdin = child
239 .stdin
240 .take()
241 .ok_or_else(|| "MCP server has no stdin".to_string())?;
242 let stdout = child
243 .stdout
244 .take()
245 .ok_or_else(|| "MCP server has no stdout".to_string())?;
246 let stderr = child
247 .stderr
248 .take()
249 .ok_or_else(|| "MCP server has no stderr".to_string())?;
250
251 let pending: Pending = Arc::new(StdMutex::new(HashMap::new()));
252 let alive = Arc::new(AtomicBool::new(true));
253 let reader = tokio::spawn(reader_loop(
254 BufReader::new(stdout),
255 Arc::clone(&pending),
256 Arc::clone(&alive),
257 config.name.clone(),
258 ));
259 let stderr_reader = tokio::spawn(stderr_drain_loop(stderr, config.name.clone()));
260
261 let mut server = Self {
262 config,
263 child,
264 stdin: tokio::io::BufWriter::new(stdin),
265 next_id: 1,
266 pending,
267 reader,
268 stderr_reader,
269 alive,
270 request_timeout: Duration::from_secs(600),
276 };
277
278 server
280 .send_request(
281 "initialize",
282 Some(serde_json::json!({
283 "protocolVersion": "2024-11-05",
284 "capabilities": {},
285 "clientInfo": {
286 "name": "car-runtime",
287 "version": env!("CARGO_PKG_VERSION")
288 }
289 })),
290 )
291 .await?;
292
293 let notification = serde_json::json!({
295 "jsonrpc": "2.0",
296 "method": "notifications/initialized"
297 });
298 let msg =
299 serde_json::to_string(¬ification).map_err(|e| format!("serialize error: {e}"))?;
300 server.write_message(&msg).await?;
301
302 Ok(server)
303 }
304
305 async fn reconnect(&mut self) -> Result<(), String> {
307 tracing::warn!(server = %self.config.name, "MCP connection closed; reconnecting (server-side state is lost)");
308 self.reader.abort(); self.stderr_reader.abort();
310 let _ = self.child.kill().await;
311 let fresh = Box::pin(Self::start(self.config.clone())).await?;
316 *self = fresh;
317 Ok(())
318 }
319
320 async fn write_message(&mut self, msg: &str) -> Result<(), String> {
322 self.stdin
323 .write_all(msg.as_bytes())
324 .await
325 .map_err(|e| format!("write to MCP server: {e}"))?;
326 self.stdin
327 .write_all(b"\n")
328 .await
329 .map_err(|e| format!("write newline: {e}"))?;
330 self.stdin
331 .flush()
332 .await
333 .map_err(|e| format!("flush: {e}"))?;
334 Ok(())
335 }
336
337 async fn send_request(&mut self, method: &str, params: Option<Value>) -> Result<Value, String> {
338 self.send_request_with_timeout(method, params, None).await
339 }
340
341 async fn send_request_with_timeout(
346 &mut self,
347 method: &str,
348 params: Option<Value>,
349 timeout: Option<Duration>,
350 ) -> Result<Value, String> {
351 let await_timeout = timeout.unwrap_or(self.request_timeout);
352 if !self.alive.load(Ordering::SeqCst) {
354 self.reconnect().await.map_err(|e| {
355 format!(
356 "MCP session '{}' is dead and reconnect failed: {e}",
357 self.config.name
358 )
359 })?;
360 }
361
362 let id = self.next_id;
363 self.next_id += 1;
364
365 let (tx, rx) = oneshot::channel();
368 self.pending.lock().unwrap().insert(id, tx);
369
370 let req = McpRequest {
371 jsonrpc: "2.0",
372 method: method.to_string(),
373 params,
374 id,
375 };
376 let msg = serde_json::to_string(&req).map_err(|e| format!("serialize error: {e}"))?;
377
378 if let Err(e) = self.write_message(&msg).await {
379 self.pending.lock().unwrap().remove(&id);
380 self.alive.store(false, Ordering::SeqCst); return Err(e);
382 }
383
384 let resp = match tokio::time::timeout(await_timeout, rx).await {
389 Ok(Ok(resp)) => resp,
390 Ok(Err(_)) => {
391 return Err(format!(
392 "MCP server '{}' closed the connection",
393 self.config.name
394 ))
395 }
396 Err(_) => {
397 self.pending.lock().unwrap().remove(&id);
398 return Err(format!("MCP request '{method}' timed out"));
399 }
400 };
401
402 if let Some(err) = resp.error {
403 return Err(format!("MCP error: {}", err.message));
404 }
405 resp.result
406 .ok_or_else(|| "MCP server returned no result".to_string())
407 }
408
409 pub async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
411 let result = self.send_request("tools/list", None).await?;
412 let tools = result
413 .get("tools")
414 .and_then(|t| t.as_array())
415 .cloned()
416 .unwrap_or_default();
417
418 tools
419 .into_iter()
420 .map(|t| serde_json::from_value(t).map_err(|e| format!("invalid tool definition: {e}")))
421 .collect()
422 }
423
424 pub async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, String> {
426 self.call_tool_with_timeout(name, arguments, None).await
427 }
428
429 pub async fn call_tool_with_timeout(
434 &mut self,
435 name: &str,
436 arguments: Value,
437 timeout: Option<Duration>,
438 ) -> Result<Value, String> {
439 let result = self
440 .send_request_with_timeout(
441 "tools/call",
442 Some(serde_json::json!({
443 "name": name,
444 "arguments": arguments,
445 })),
446 timeout,
447 )
448 .await?;
449
450 parse_tool_result(result)
451 }
452
453 pub async fn shutdown(mut self) {
455 let _ = self.stdin.shutdown().await;
456 let _ = self.child.kill().await;
457 let _ = self.child.wait().await;
458 }
459
460 pub fn name(&self) -> &str {
462 &self.config.name
463 }
464}
465
466pub struct McpToolExecutor {
472 servers: Arc<Mutex<HashMap<String, Arc<Mutex<dyn McpSession>>>>>,
473 tool_routes: Arc<Mutex<HashMap<String, String>>>,
475 fallback: Option<Arc<dyn super::ToolExecutor>>,
477}
478
479impl McpToolExecutor {
480 pub fn new() -> Self {
481 Self {
482 servers: Arc::new(Mutex::new(HashMap::new())),
483 tool_routes: Arc::new(Mutex::new(HashMap::new())),
484 fallback: None,
485 }
486 }
487
488 pub fn with_fallback(mut self, fallback: Arc<dyn super::ToolExecutor>) -> Self {
489 self.fallback = Some(fallback);
490 self
491 }
492
493 pub async fn add_server(&self, mut server: McpServer) -> Result<Vec<String>, String> {
496 let server_name = server.config.name.clone();
497 let tools = server.list_tools().await?;
498
499 let tool_names: Vec<String> = tools
500 .iter()
501 .map(|t| format!("mcp_{}_{}", server_name, t.name))
502 .collect();
503
504 {
506 let mut routes = self.tool_routes.lock().await;
507 for (info, canonical_name) in tools.iter().zip(tool_names.iter()) {
508 routes.insert(canonical_name.clone(), server_name.clone());
509 routes.insert(info.name.clone(), server_name.clone());
511 }
512 }
513
514 let session: Arc<Mutex<dyn McpSession>> = Arc::new(Mutex::new(server));
516 self.servers.lock().await.insert(server_name, session);
517
518 Ok(tool_names)
519 }
520
521 pub async fn add_session(&self, name: impl Into<String>, session: Arc<Mutex<dyn McpSession>>) {
528 self.servers.lock().await.insert(name.into(), session);
529 }
530
531 pub async fn set_route(&self, tool: impl Into<String>, server: impl Into<String>) {
536 self.tool_routes
537 .lock()
538 .await
539 .insert(tool.into(), server.into());
540 }
541
542 pub async fn remove_server(&self, name: &str) {
544 self.servers.lock().await.remove(name);
545 self.tool_routes.lock().await.retain(|_, v| v != name);
546 }
547
548 pub async fn clear_routes_for_server(&self, name: &str) {
551 self.tool_routes.lock().await.retain(|_, v| v != name);
552 }
553
554 pub async fn remove_route(&self, tool: &str) {
558 self.tool_routes.lock().await.remove(tool);
559 }
560
561 pub async fn handles(&self, tool: &str) -> bool {
563 self.tool_routes.lock().await.contains_key(tool)
564 }
565
566 pub async fn session(&self, name: &str) -> Option<Arc<Mutex<dyn McpSession>>> {
574 self.servers.lock().await.get(name).cloned()
575 }
576
577 pub fn share_with_fallback(&self, fallback: Arc<dyn super::ToolExecutor>) -> Self {
582 Self {
583 servers: Arc::clone(&self.servers),
584 tool_routes: Arc::clone(&self.tool_routes),
585 fallback: Some(fallback),
586 }
587 }
588
589 pub async fn tool_schemas(&self) -> Vec<(String, car_ir::ToolSchema)> {
591 let mut schemas = Vec::new();
592 let servers = self.servers.lock().await;
593 for (server_name, server) in servers.iter() {
594 let mut srv = server.lock().await;
595 if let Ok(tools) = srv.list_tools().await {
596 for tool in tools {
597 let canonical_name = format!("mcp_{}_{}", server_name, tool.name);
598 schemas.push((
599 server_name.clone(),
600 car_ir::ToolSchema {
601 name: canonical_name,
602 source: car_ir::ToolSourceKind::Mcp,
603 description: tool.description.unwrap_or_default(),
604 parameters: tool
605 .input_schema
606 .unwrap_or(serde_json::json!({"type": "object"})),
607 returns: None,
608 idempotent: false,
609 cache_ttl_secs: None,
610 rate_limit: None,
611 },
612 ));
613 }
614 }
615 }
616 schemas
617 }
618
619 pub async fn shutdown_all(&self) {
621 let mut servers = self.servers.lock().await;
622 servers.drain();
624 }
625}
626
627impl Default for McpToolExecutor {
628 fn default() -> Self {
629 Self::new()
630 }
631}
632
633#[async_trait::async_trait]
634impl super::ToolExecutor for McpToolExecutor {
635 async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
636 self.execute_with_action(tool, params, "", None).await
637 }
638
639 async fn execute_with_action(
640 &self,
641 tool: &str,
642 params: &Value,
643 action_id: &str,
644 timeout_ms: Option<u64>,
645 ) -> Result<Value, String> {
646 self.execute_with_action_in_session(tool, params, action_id, timeout_ms, None, 1)
647 .await
648 }
649
650 async fn execute_with_action_in_session(
651 &self,
652 tool: &str,
653 params: &Value,
654 action_id: &str,
655 timeout_ms: Option<u64>,
656 session_id: Option<&str>,
657 attempt: u32,
658 ) -> Result<Value, String> {
659 let server_name = {
661 let routes = self.tool_routes.lock().await;
662 routes.get(tool).cloned()
663 };
664
665 if let Some(server_name) = server_name {
666 let servers = self.servers.lock().await;
667 if let Some(server) = servers.get(&server_name) {
668 let mut srv = server.lock().await;
669 let bare_name = tool
671 .strip_prefix(&format!("mcp_{}_", server_name))
672 .unwrap_or(tool);
673 return srv
682 .call_tool_with_timeout(
683 bare_name,
684 params.clone(),
685 timeout_ms.map(Duration::from_millis),
686 )
687 .await;
688 }
689 }
690
691 if let Some(ref fallback) = self.fallback {
693 return fallback
694 .execute_with_action_in_session(
695 tool, params, action_id, timeout_ms, session_id, attempt,
696 )
697 .await;
698 }
699
700 Err(format!("unknown MCP tool: '{}'", tool))
701 }
702
703 async fn execute_with_action_state_in_session(
704 &self,
705 tool: &str,
706 params: &Value,
707 action_id: &str,
708 timeout_ms: Option<u64>,
709 session_id: Option<&str>,
710 attempt: u32,
711 expected_effects: &std::collections::HashMap<String, Value>,
712 return_schema: Option<&Value>,
713 ) -> Result<super::ToolExecution, String> {
714 let server_name = {
715 let routes = self.tool_routes.lock().await;
716 routes.get(tool).cloned()
717 };
718 let routed_to_live_mcp = if let Some(server_name) = server_name.as_deref() {
719 self.servers.lock().await.contains_key(server_name)
720 } else {
721 false
722 };
723 if routed_to_live_mcp {
724 return self
725 .execute_with_action_in_session(
726 tool, params, action_id, timeout_ms, session_id, attempt,
727 )
728 .await
729 .map(super::ToolExecution::output_only);
730 }
731 if let Some(ref fallback) = self.fallback {
732 return fallback
733 .execute_with_action_state_in_session(
734 tool,
735 params,
736 action_id,
737 timeout_ms,
738 session_id,
739 attempt,
740 expected_effects,
741 return_schema,
742 )
743 .await;
744 }
745 Err(format!("unknown MCP tool: '{}'", tool))
746 }
747
748 async fn execute_classified(
749 &self,
750 tool: &str,
751 params: &Value,
752 action_id: &str,
753 timeout_ms: Option<u64>,
754 session_id: Option<&str>,
755 attempt: u32,
756 expected_effects: &std::collections::HashMap<String, Value>,
757 return_schema: Option<&Value>,
758 ) -> Result<super::ToolExecution, car_ir::ToolFailure> {
759 let server_name = {
760 let routes = self.tool_routes.lock().await;
761 routes.get(tool).cloned()
762 };
763 if let Some(server_name) = server_name {
767 let server = self.servers.lock().await.get(&server_name).cloned();
768 if let Some(server) = server {
769 let bare_name = tool
770 .strip_prefix(&format!("mcp_{server_name}_"))
771 .unwrap_or(tool);
772 return server
773 .lock()
774 .await
775 .call_tool_with_timeout(
776 bare_name,
777 params.clone(),
778 timeout_ms.map(Duration::from_millis),
779 )
780 .await
781 .map(super::ToolExecution::output_only)
782 .map_err(car_ir::ToolFailure::ordinary);
783 }
784 }
785 if let Some(ref fallback) = self.fallback {
786 return fallback
787 .execute_classified(
788 tool,
789 params,
790 action_id,
791 timeout_ms,
792 session_id,
793 attempt,
794 expected_effects,
795 return_schema,
796 )
797 .await;
798 }
799 Err(car_ir::ToolFailure::ordinary(format!(
800 "unknown MCP tool: '{tool}'"
801 )))
802 }
803}
804
805fn parse_tool_result(result: Value) -> Result<Value, String> {
818 let text = result
819 .get("content")
820 .and_then(|c| c.as_array())
821 .and_then(|content| {
822 let texts: Vec<&str> = content
823 .iter()
824 .filter_map(|block| {
825 if block.get("type").and_then(|t| t.as_str()) == Some("text") {
826 block.get("text").and_then(|t| t.as_str())
827 } else {
828 None
829 }
830 })
831 .collect();
832 if texts.is_empty() {
833 None
834 } else {
835 Some(texts.join("\n"))
836 }
837 });
838
839 if result.get("isError").and_then(|v| v.as_bool()) == Some(true) {
840 return Err(
841 text.unwrap_or_else(|| "tool returned isError with no text content".to_string())
842 );
843 }
844
845 if let Some(text) = text {
846 return Ok(Value::String(text));
847 }
848
849 Ok(result)
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855 use serde_json::json;
856
857 #[test]
858 fn tool_result_iserror_true_propagates_as_err() {
859 let r = json!({
861 "content": [{"type": "text", "text": "fetch failed"}],
862 "isError": true
863 });
864 assert_eq!(parse_tool_result(r), Err("fetch failed".to_string()));
865 }
866
867 #[test]
868 fn tool_result_iserror_true_without_text_still_errs() {
869 let r = json!({ "content": [], "isError": true });
870 assert!(parse_tool_result(r).is_err());
871 }
872
873 #[test]
874 fn tool_result_success_returns_joined_text() {
875 let r = json!({
876 "content": [{"type": "text", "text": "line1"}, {"type": "text", "text": "line2"}],
877 "isError": false
878 });
879 assert_eq!(
880 parse_tool_result(r),
881 Ok(Value::String("line1\nline2".to_string()))
882 );
883 }
884
885 #[test]
886 fn tool_result_no_iserror_field_is_success() {
887 let r = json!({ "content": [{"type": "text", "text": "ok"}] });
889 assert_eq!(parse_tool_result(r), Ok(Value::String("ok".to_string())));
890 }
891
892 #[test]
893 fn tool_result_structured_no_text_passes_through() {
894 let r = json!({ "structuredContent": {"x": 1} });
895 assert_eq!(parse_tool_result(r.clone()), Ok(r));
896 }
897
898 fn pending() -> StdMutex<HashMap<u64, oneshot::Sender<McpResponse>>> {
899 StdMutex::new(HashMap::new())
900 }
901
902 #[tokio::test]
903 async fn routes_response_to_matching_waiter() {
904 let p = pending();
905 let (tx, rx) = oneshot::channel();
906 p.lock().unwrap().insert(7, tx);
907 route_line(r#"{"jsonrpc":"2.0","id":7,"result":{"value":42}}"#, &p);
908 let resp = rx.await.expect("waiter delivered");
909 assert!(resp.result.is_some());
910 assert!(p.lock().unwrap().is_empty());
912 }
913
914 #[tokio::test]
915 async fn unknown_id_is_discarded_without_disturbing_other_waiters() {
916 let p = pending();
917 let (tx, _rx) = oneshot::channel();
918 p.lock().unwrap().insert(1, tx);
919 route_line(r#"{"jsonrpc":"2.0","id":999,"result":{}}"#, &p);
921 assert!(p.lock().unwrap().contains_key(&1));
923 }
924
925 #[test]
926 fn notifications_and_garbage_are_ignored() {
927 let p = pending();
928 route_line(
930 r#"{"jsonrpc":"2.0","method":"notifications/progress","params":{}}"#,
931 &p,
932 );
933 route_line("not json at all", &p);
934 assert!(p.lock().unwrap().is_empty());
935 }
936
937 #[tokio::test]
938 async fn error_response_is_routed_for_send_request_to_surface() {
939 let p = pending();
940 let (tx, rx) = oneshot::channel();
941 p.lock().unwrap().insert(3, tx);
942 route_line(
943 r#"{"jsonrpc":"2.0","id":3,"error":{"code":-1,"message":"tool failed"}}"#,
944 &p,
945 );
946 let resp = rx.await.unwrap();
947 assert!(resp.error.is_some());
948 assert_eq!(resp.error.unwrap().message, "tool failed");
949 }
950
951 #[tokio::test]
952 async fn reader_loop_routes_then_marks_dead_and_clears_on_eof() {
953 let pending: Pending = Arc::new(StdMutex::new(HashMap::new()));
954 let alive = Arc::new(AtomicBool::new(true));
955 let (tx, rx) = oneshot::channel();
956 let (tx2, rx2) = oneshot::channel();
958 pending.lock().unwrap().insert(1, tx);
959 pending.lock().unwrap().insert(2, tx2);
960
961 let input = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\n";
962 reader_loop(
963 BufReader::new(&input[..]),
964 Arc::clone(&pending),
965 Arc::clone(&alive),
966 "t".into(),
967 )
968 .await;
969
970 assert!(rx.await.unwrap().result.is_some(), "id 1 routed");
971 assert!(!alive.load(Ordering::SeqCst), "EOF marks the session dead");
972 assert!(pending.lock().unwrap().is_empty(), "waiters swept on EOF");
973 assert!(rx2.await.is_err());
975 }
976
977 #[tokio::test]
978 async fn reader_loop_skips_noise_without_desync() {
979 let pending: Pending = Arc::new(StdMutex::new(HashMap::new()));
983 let alive = Arc::new(AtomicBool::new(true));
984 let (tx, rx) = oneshot::channel();
985 pending.lock().unwrap().insert(5, tx);
986
987 let input = b"garbage not json\n\
988 {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\",\"params\":{}}\n\
989 {\"jsonrpc\":\"2.0\",\"id\":5,\"result\":{\"done\":true}}\n";
990 reader_loop(
991 BufReader::new(&input[..]),
992 Arc::clone(&pending),
993 alive,
994 "t".into(),
995 )
996 .await;
997
998 assert!(
999 rx.await.unwrap().result.is_some(),
1000 "id 5 delivered past noise"
1001 );
1002 }
1003
1004 struct RecordingSession {
1007 name: String,
1008 seen: Arc<StdMutex<Vec<Option<Duration>>>>,
1009 }
1010
1011 #[async_trait::async_trait]
1012 impl McpSession for RecordingSession {
1013 async fn list_tools(&mut self) -> Result<Vec<McpToolInfo>, String> {
1014 Ok(vec![])
1015 }
1016 async fn call_tool(&mut self, _name: &str, _arguments: Value) -> Result<Value, String> {
1017 self.seen.lock().unwrap().push(None);
1021 Ok(json!({ "ok": true }))
1022 }
1023 async fn call_tool_with_timeout(
1024 &mut self,
1025 _name: &str,
1026 _arguments: Value,
1027 timeout: Option<Duration>,
1028 ) -> Result<Value, String> {
1029 self.seen.lock().unwrap().push(timeout);
1030 Ok(json!({ "ok": true }))
1031 }
1032 fn name(&self) -> &str {
1033 &self.name
1034 }
1035 }
1036
1037 struct TerminalFallback;
1038
1039 #[async_trait::async_trait]
1040 impl crate::ToolExecutor for TerminalFallback {
1041 async fn execute(&self, _tool: &str, _params: &Value) -> Result<Value, String> {
1042 Err("terminal callback".into())
1043 }
1044
1045 async fn execute_classified(
1046 &self,
1047 _tool: &str,
1048 _params: &Value,
1049 _action_id: &str,
1050 _timeout_ms: Option<u64>,
1051 _session_id: Option<&str>,
1052 _attempt: u32,
1053 _expected_effects: &HashMap<String, Value>,
1054 _return_schema: Option<&Value>,
1055 ) -> Result<crate::ToolExecution, car_ir::ToolFailure> {
1056 Err(car_ir::ToolFailure::terminal("terminal callback"))
1057 }
1058 }
1059
1060 #[tokio::test]
1061 async fn classified_dispatch_survives_route_removal_without_erasing_terminal_failure() {
1062 use crate::ToolExecutor;
1063 use std::task::Poll;
1064
1065 let seen = Arc::new(StdMutex::new(Vec::new()));
1066 let exec = McpToolExecutor::new().with_fallback(Arc::new(TerminalFallback));
1067 exec.add_session(
1068 "srv",
1069 Arc::new(Mutex::new(RecordingSession {
1070 name: "srv".into(),
1071 seen: Arc::clone(&seen),
1072 })),
1073 )
1074 .await;
1075 exec.set_route("mcp_srv_run", "srv").await;
1076 let params = json!({});
1077 let effects = HashMap::new();
1078 let servers = exec.servers.lock().await;
1079 let mut call = Box::pin(exec.execute_classified(
1080 "mcp_srv_run",
1081 ¶ms,
1082 "a0",
1083 Some(321),
1084 None,
1085 0,
1086 &effects,
1087 None,
1088 ));
1089 assert!(futures::poll!(call.as_mut()).is_pending());
1091 let mut routes = exec.tool_routes.lock().await;
1092 drop(servers);
1093 let outcome = futures::poll!(call.as_mut());
1095 routes.remove("mcp_srv_run");
1096 drop(routes);
1097 let result = match outcome {
1098 Poll::Ready(result) => result,
1099 Poll::Pending => call.await,
1100 };
1101 match result {
1102 Ok(_) => assert_eq!(
1103 *seen.lock().unwrap(),
1104 vec![Some(Duration::from_millis(321))]
1105 ),
1106 Err(failure) => assert_eq!(failure, car_ir::ToolFailure::terminal("terminal callback")),
1107 }
1108 let failure = exec
1110 .execute_classified("mcp_srv_run", ¶ms, "a1", None, None, 0, &effects, None)
1111 .await
1112 .unwrap_err();
1113 assert_eq!(failure, car_ir::ToolFailure::terminal("terminal callback"));
1114 }
1115
1116 #[tokio::test]
1123 async fn action_budget_reaches_the_mcp_session() {
1124 use crate::ToolExecutor;
1125
1126 let seen = Arc::new(StdMutex::new(Vec::new()));
1127 let exec = McpToolExecutor::new();
1128 exec.add_session(
1129 "srv",
1130 Arc::new(Mutex::new(RecordingSession {
1131 name: "srv".to_string(),
1132 seen: Arc::clone(&seen),
1133 })),
1134 )
1135 .await;
1136 exec.set_route("mcp_srv_run", "srv").await;
1137
1138 exec.execute_with_action("mcp_srv_run", &json!({}), "a0", Some(90_000))
1140 .await
1141 .expect("call succeeds");
1142 exec.execute("mcp_srv_run", &json!({}))
1144 .await
1145 .expect("call succeeds");
1146
1147 assert_eq!(
1148 *seen.lock().unwrap(),
1149 vec![Some(Duration::from_millis(90_000)), None],
1150 "the action budget must reach the session, not be discarded"
1151 );
1152 }
1153}