1use std::collections::HashMap;
36use std::sync::atomic::{AtomicU64, Ordering};
37use std::sync::{Arc, RwLock};
38use std::time::Duration;
39
40use async_trait::async_trait;
41use serde::{Deserialize, Serialize};
42use serde_json::{json, Value};
43
44use crate::model::{ImageData, ImageSource, UserAttachment};
45use crate::tools::{
46 ToolFailure, ToolFailureKind, ToolInvocation, ToolOutcome, ToolRuntime, ToolRuntimeError,
47 ToolSpec,
48};
49
50pub const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
55
56pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
60
61#[derive(Debug, Clone)]
64pub struct McpServerConfig {
65 pub name: String,
66 pub timeout: Duration,
70 pub startup_timeout: Duration,
73 pub required: bool,
76 pub enabled_tools: Vec<String>,
79 pub transport: McpTransport,
80}
81
82#[derive(Debug, Clone)]
84pub enum McpTransport {
85 Http {
88 url: String,
89 headers: HashMap<String, String>,
91 },
92 Stdio {
103 command: String,
104 args: Vec<String>,
105 env: HashMap<String, String>,
106 working_dir: Option<String>,
107 },
108}
109
110impl McpServerConfig {
111 pub fn new(name: impl Into<String>, url: impl Into<String>) -> Self {
114 Self::http(name, url)
115 }
116
117 pub fn http(name: impl Into<String>, url: impl Into<String>) -> Self {
118 Self {
119 name: name.into(),
120 timeout: DEFAULT_TIMEOUT,
121 startup_timeout: Duration::from_secs(10),
122 required: false,
123 enabled_tools: Vec::new(),
124 transport: McpTransport::Http {
125 url: url.into(),
126 headers: HashMap::new(),
127 },
128 }
129 }
130
131 pub fn stdio(name: impl Into<String>, command: impl Into<String>, args: Vec<String>) -> Self {
135 Self {
136 name: name.into(),
137 timeout: DEFAULT_TIMEOUT,
138 startup_timeout: Duration::from_secs(10),
139 required: false,
140 enabled_tools: Vec::new(),
141 transport: McpTransport::Stdio {
142 command: command.into(),
143 args,
144 env: HashMap::new(),
145 working_dir: None,
146 },
147 }
148 }
149
150 pub fn with_timeout(mut self, timeout: Duration) -> Self {
152 self.timeout = timeout;
153 self
154 }
155
156 pub fn with_startup_timeout(mut self, timeout: Duration) -> Self {
158 self.startup_timeout = timeout;
159 self
160 }
161
162 pub fn with_required(mut self, required: bool) -> Self {
164 self.required = required;
165 self
166 }
167
168 pub fn with_enabled_tools(mut self, tools: Vec<String>) -> Self {
170 self.enabled_tools = tools;
171 self
172 }
173
174 pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
176 if let McpTransport::Http { headers, .. } = &mut self.transport {
177 headers.insert(key.into(), value.into());
178 }
179 self
180 }
181
182 pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
183 if let McpTransport::Stdio { env, .. } = &mut self.transport {
184 env.insert(key.into(), value.into());
185 }
186 self
187 }
188
189 pub fn with_working_dir(mut self, dir: impl Into<String>) -> Self {
190 if let McpTransport::Stdio { working_dir, .. } = &mut self.transport {
191 *working_dir = Some(dir.into());
192 }
193 self
194 }
195}
196
197#[derive(Debug, thiserror::Error)]
198pub enum McpError {
199 #[error("timeout: {0}")]
200 Timeout(String),
201 #[error("transport: {0}")]
202 Transport(String),
203 #[error("HTTP {status}: {body}")]
204 Http { status: u16, body: String },
205 #[error("decode: {0}")]
206 Decode(String),
207 #[error("server error code={code} message={message}")]
208 Server { code: i64, message: String },
209 #[error("missing field {0}")]
210 MissingField(&'static str),
211}
212
213#[derive(Debug, Serialize)]
216struct McpRequest<'a> {
217 jsonrpc: &'static str,
218 id: u64,
219 method: &'a str,
220 #[serde(skip_serializing_if = "Option::is_none")]
221 params: Option<Value>,
222}
223
224#[derive(Debug, Serialize)]
225struct McpNotification<'a> {
226 jsonrpc: &'static str,
227 method: &'a str,
228 #[serde(skip_serializing_if = "Option::is_none")]
229 params: Option<Value>,
230}
231
232#[derive(Debug, Deserialize)]
233struct McpResponse {
234 #[allow(dead_code)]
235 jsonrpc: String,
236 id: Option<u64>,
240 result: Option<Value>,
241 error: Option<McpResponseError>,
242}
243
244#[derive(Debug, Deserialize)]
245struct McpResponseError {
246 code: i64,
247 message: String,
248 #[serde(default)]
249 #[allow(dead_code)]
250 data: Option<Value>,
251}
252
253#[derive(Debug, Deserialize)]
256struct McpToolDef {
257 name: String,
258 #[serde(default)]
259 description: String,
260 #[serde(rename = "inputSchema", default = "default_input_schema")]
261 input_schema: Value,
262}
263
264fn default_input_schema() -> Value {
265 json!({"type": "object", "properties": {}})
266}
267
268#[derive(Debug, Deserialize)]
269struct McpToolsListResult {
270 tools: Vec<McpToolDef>,
271}
272
273#[derive(Debug, Deserialize)]
274struct McpToolsCallResult {
275 #[serde(default)]
276 content: Vec<McpContent>,
277 #[serde(default, rename = "isError")]
278 is_error: bool,
279}
280
281#[derive(Debug, Deserialize)]
294#[serde(tag = "type")]
295enum McpContent {
296 #[serde(rename = "text")]
297 Text { text: String },
298 #[serde(rename = "image")]
299 Image {
300 #[serde(default, rename = "mimeType")]
301 mime_type: String,
302 #[serde(default)]
303 data: String,
304 },
305 #[serde(other)]
306 Other,
307}
308
309pub struct McpClient {
315 name: String,
316 timeout: Duration,
317 next_id: AtomicU64,
318 inner: McpClientInner,
319}
320
321enum McpClientInner {
322 Http(HttpInner),
323 Stdio(StdioInner),
324}
325
326struct HttpInner {
327 http: reqwest::Client,
328 url: String,
329 headers: HashMap<String, String>,
331 session_id: Arc<RwLock<Option<String>>>,
332}
333
334struct StdioInner {
338 request_tx: tokio::sync::mpsc::Sender<StdioRequest>,
339 pending_kill: Option<Arc<std::sync::Mutex<Option<tokio::process::Child>>>>,
340}
341
342enum StdioRequest {
343 Call {
346 id: u64,
347 body: String,
348 reply: tokio::sync::oneshot::Sender<Result<Value, McpError>>,
349 },
350 Notify { body: String },
352}
353
354type PendingReplies =
355 Arc<std::sync::Mutex<HashMap<u64, tokio::sync::oneshot::Sender<Result<Value, McpError>>>>>;
356
357impl McpClient {
358 pub fn new(config: McpServerConfig) -> Result<Self, McpError> {
359 let name = config.name.clone();
360 let timeout = config.timeout;
361 let inner = match config.transport {
362 McpTransport::Http { url, headers } => {
363 let http = reqwest::Client::builder()
364 .timeout(timeout)
365 .build()
366 .map_err(|e| McpError::Transport(e.to_string()))?;
367 McpClientInner::Http(HttpInner {
368 http,
369 url,
370 headers,
371 session_id: Arc::new(RwLock::new(None)),
372 })
373 }
374 McpTransport::Stdio {
375 command,
376 args,
377 env,
378 working_dir,
379 } => spawn_stdio(&name, command, args, env, working_dir)?,
380 };
381 Ok(Self {
382 name,
383 timeout,
384 next_id: AtomicU64::new(1),
385 inner,
386 })
387 }
388
389 pub fn name(&self) -> &str {
390 &self.name
391 }
392
393 pub async fn initialize(&self) -> Result<(), McpError> {
396 let params = json!({
397 "protocolVersion": MCP_PROTOCOL_VERSION,
398 "capabilities": {},
399 "clientInfo": {
400 "name": "agentmatrix-runtime-driver",
401 "version": env!("CARGO_PKG_VERSION"),
402 }
403 });
404 let _ = self.call("initialize", Some(params)).await?;
405 if let Err(e) = self.notify("notifications/initialized", None).await {
408 tracing::warn!(
409 target: "harness::mcp",
410 server = %self.name,
411 error = %e,
412 "notifications/initialized fire-and-forget failed; continuing"
413 );
414 }
415 Ok(())
416 }
417
418 pub async fn tools_list(&self) -> Result<Vec<ToolSpec>, McpError> {
422 let value = self.call("tools/list", None).await?;
423 let result: McpToolsListResult = serde_json::from_value(value)
424 .map_err(|e| McpError::Decode(format!("tools/list result: {e}")))?;
425 Ok(result
426 .tools
427 .into_iter()
428 .map(|t| ToolSpec {
429 name: t.name,
430 description: t.description,
431 input_schema: t.input_schema,
432 })
433 .collect())
434 }
435
436 pub async fn tools_call(&self, name: &str, arguments: Value) -> Result<ToolOutcome, McpError> {
442 let params = json!({
443 "name": name,
444 "arguments": arguments,
445 });
446 let value = self.call("tools/call", Some(params)).await?;
447 let result: McpToolsCallResult = serde_json::from_value(value)
448 .map_err(|e| McpError::Decode(format!("tools/call result: {e}")))?;
449
450 let mut text_parts: Vec<String> = Vec::new();
455 let mut attachments: Vec<UserAttachment> = Vec::new();
456 for c in result.content {
457 match c {
458 McpContent::Text { text } => text_parts.push(text),
459 McpContent::Image { mime_type, data } => {
460 if data.is_empty() {
461 text_parts.push(format!("[image {mime_type} returned with empty data]"));
465 } else {
466 attachments.push(UserAttachment::Image(ImageSource {
467 media_type: if mime_type.is_empty() {
468 "image/png".to_string()
469 } else {
470 mime_type
471 },
472 data: ImageData::Base64(data),
473 }));
474 }
475 }
476 McpContent::Other => {
477 text_parts.push("[non-text MCP content elided]".into());
478 }
479 }
480 }
481 let content_str = text_parts.join("\n");
482
483 let output = if result.is_error {
484 Err(ToolFailure::new(
485 ToolFailureKind::Runtime,
486 if content_str.is_empty() {
487 format!("MCP tool {name} reported error")
488 } else {
489 format!("MCP tool {name} error: {content_str}")
490 },
491 ))
492 } else {
493 Ok(json!({"content": content_str}))
496 };
497 Ok(ToolOutcome {
498 output,
499 attachments,
500 })
501 }
502
503 async fn call(&self, method: &str, params: Option<Value>) -> Result<Value, McpError> {
506 let id = self.next_id.fetch_add(1, Ordering::SeqCst);
507 let body = McpRequest {
508 jsonrpc: "2.0",
509 id,
510 method,
511 params,
512 };
513 match &self.inner {
514 McpClientInner::Http(http) => self.call_http(http, id, &body).await,
515 McpClientInner::Stdio(stdio) => self.call_stdio(stdio, id, &body).await,
516 }
517 }
518
519 async fn notify(&self, method: &str, params: Option<Value>) -> Result<(), McpError> {
521 let body = McpNotification {
522 jsonrpc: "2.0",
523 method,
524 params,
525 };
526 match &self.inner {
527 McpClientInner::Http(http) => self.notify_http(http, &body).await,
528 McpClientInner::Stdio(stdio) => self.notify_stdio(stdio, &body).await,
529 }
530 }
531
532 async fn call_http(
533 &self,
534 http: &HttpInner,
535 id: u64,
536 body: &McpRequest<'_>,
537 ) -> Result<Value, McpError> {
538 let mut req = http
545 .http
546 .post(&http.url)
547 .header("Accept", "application/json, text/event-stream")
548 .json(body);
549 for (k, v) in &http.headers {
551 req = req.header(k.as_str(), v.as_str());
552 }
553 if let Some(sid) = cached_session_id(&http.session_id) {
554 req = req.header("Mcp-Session-Id", sid);
555 }
556 let resp = req.send().await.map_err(|e| {
557 if e.is_timeout() {
558 McpError::Timeout(e.to_string())
559 } else {
560 McpError::Transport(e.to_string())
561 }
562 })?;
563
564 if let Some(sid) = resp
567 .headers()
568 .get("mcp-session-id")
569 .and_then(|v| v.to_str().ok())
570 {
571 if !sid.is_empty() {
572 if let Ok(mut guard) = http.session_id.write() {
573 if guard.is_none() {
574 *guard = Some(sid.to_string());
575 }
576 }
577 }
578 }
579
580 let status = resp.status();
581 if !status.is_success() {
582 let body_text = resp.text().await.unwrap_or_default();
583 return Err(McpError::Http {
584 status: status.as_u16(),
585 body: body_text.chars().take(512).collect(),
586 });
587 }
588
589 let content_type = resp
594 .headers()
595 .get(reqwest::header::CONTENT_TYPE)
596 .and_then(|v| v.to_str().ok())
597 .map(|s| s.to_ascii_lowercase())
598 .unwrap_or_default();
599
600 if content_type.starts_with("text/event-stream") {
601 parse_mcp_sse_response(resp, id, &self.name).await
602 } else {
603 let body_text = resp.text().await.unwrap_or_default();
604 let parsed: McpResponse = serde_json::from_str(&body_text)
605 .map_err(|e| McpError::Decode(format!("response body: {e}; raw={body_text}")))?;
606 if let Some(err) = parsed.error {
607 return Err(McpError::Server {
608 code: err.code,
609 message: err.message,
610 });
611 }
612 parsed.result.ok_or(McpError::MissingField("result"))
613 }
614 }
615
616 async fn notify_http(
617 &self,
618 http: &HttpInner,
619 body: &McpNotification<'_>,
620 ) -> Result<(), McpError> {
621 let mut req = http
625 .http
626 .post(&http.url)
627 .header("Accept", "application/json, text/event-stream")
628 .json(body);
629 for (k, v) in &http.headers {
630 req = req.header(k.as_str(), v.as_str());
631 }
632 if let Some(sid) = cached_session_id(&http.session_id) {
633 req = req.header("Mcp-Session-Id", sid);
634 }
635 req.send()
636 .await
637 .map_err(|e| McpError::Transport(e.to_string()))?;
638 Ok(())
639 }
640
641 async fn call_stdio(
642 &self,
643 stdio: &StdioInner,
644 id: u64,
645 body: &McpRequest<'_>,
646 ) -> Result<Value, McpError> {
647 let line = serde_json::to_string(body)
648 .map_err(|e| McpError::Decode(format!("encode request: {e}")))?;
649 let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
650 stdio
651 .request_tx
652 .send(StdioRequest::Call {
653 id,
654 body: line,
655 reply: reply_tx,
656 })
657 .await
658 .map_err(|_| McpError::Transport("stdio worker gone".into()))?;
659 match tokio::time::timeout(self.timeout, reply_rx).await {
660 Ok(Ok(result)) => result,
661 Ok(Err(_)) => Err(McpError::Transport(
662 "stdio reply channel closed before response".into(),
663 )),
664 Err(_) => Err(McpError::Timeout(format!(
665 "stdio request timed out after {:?}",
666 self.timeout
667 ))),
668 }
669 }
670
671 async fn notify_stdio(
672 &self,
673 stdio: &StdioInner,
674 body: &McpNotification<'_>,
675 ) -> Result<(), McpError> {
676 let line = serde_json::to_string(body)
677 .map_err(|e| McpError::Decode(format!("encode notification: {e}")))?;
678 stdio
679 .request_tx
680 .send(StdioRequest::Notify { body: line })
681 .await
682 .map_err(|_| McpError::Transport("stdio worker gone".into()))?;
683 Ok(())
684 }
685}
686
687fn cached_session_id(slot: &Arc<RwLock<Option<String>>>) -> Option<String> {
688 slot.read().ok().and_then(|g| g.clone())
689}
690
691async fn parse_mcp_sse_response(
700 resp: reqwest::Response,
701 expected_id: u64,
702 server_name: &str,
703) -> Result<Value, McpError> {
704 use eventsource_stream::Eventsource;
705 use futures::StreamExt;
706
707 let mut events = resp.bytes_stream().eventsource();
708 while let Some(ev) = events.next().await {
709 let ev = ev.map_err(|e| McpError::Transport(format!("SSE transport error: {e}")))?;
710 if !ev.event.is_empty() && ev.event != "message" {
715 tracing::debug!(
716 target: "harness::mcp",
717 server = %server_name,
718 event = %ev.event,
719 "ignoring non-message SSE event"
720 );
721 continue;
722 }
723 let trimmed = ev.data.trim();
724 if trimmed.is_empty() {
725 continue;
726 }
727 let parsed: McpResponse = match serde_json::from_str(trimmed) {
728 Ok(v) => v,
729 Err(e) => {
730 tracing::warn!(
731 target: "harness::mcp",
732 server = %server_name,
733 error = %e,
734 "SSE event body is not a JSON-RPC envelope; skipping"
735 );
736 continue;
737 }
738 };
739 let Some(rid) = parsed.id else {
742 tracing::debug!(
743 target: "harness::mcp",
744 server = %server_name,
745 "ignoring server-initiated notification mid-SSE stream"
746 );
747 continue;
748 };
749 if rid != expected_id {
750 tracing::debug!(
753 target: "harness::mcp",
754 server = %server_name,
755 rid,
756 expected_id,
757 "ignoring SSE response with mismatched id"
758 );
759 continue;
760 }
761 if let Some(err) = parsed.error {
762 return Err(McpError::Server {
763 code: err.code,
764 message: err.message,
765 });
766 }
767 return parsed.result.ok_or(McpError::MissingField("result"));
768 }
769 Err(McpError::Transport(format!(
770 "SSE stream closed without a JSON-RPC response matching id={expected_id}"
771 )))
772}
773
774impl Drop for McpClient {
777 fn drop(&mut self) {
778 if let McpClientInner::Stdio(stdio) = &mut self.inner {
779 if let Some(child_slot) = stdio.pending_kill.take() {
784 if let Ok(mut guard) = child_slot.lock() {
785 if let Some(mut child) = guard.take() {
786 let _ = child.start_kill();
787 }
788 }
789 }
790 }
791 }
792}
793
794fn spawn_stdio(
805 name: &str,
806 command: String,
807 args: Vec<String>,
808 env: HashMap<String, String>,
809 working_dir: Option<String>,
810) -> Result<McpClientInner, McpError> {
811 use std::process::Stdio;
812 use tokio::io::{AsyncBufReadExt, BufReader};
813 use tokio::process::Command;
814
815 let mut cmd = Command::new(&command);
816 cmd.args(&args)
817 .stdin(Stdio::piped())
818 .stdout(Stdio::piped())
819 .stderr(Stdio::piped())
820 .env_clear();
823 for (k, v) in &env {
824 cmd.env(k, v);
825 }
826 if !env.contains_key("PATH") {
829 if let Some(path) = std::env::var_os("PATH") {
830 cmd.env("PATH", path);
831 }
832 }
833 if let Some(dir) = working_dir.as_deref() {
834 cmd.current_dir(dir);
835 }
836 cmd.kill_on_drop(true);
837
838 let mut child = cmd
839 .spawn()
840 .map_err(|e| McpError::Transport(format!("stdio spawn {command:?}: {e}")))?;
841
842 let stdin = child
843 .stdin
844 .take()
845 .ok_or_else(|| McpError::Transport("stdio child has no stdin".into()))?;
846 let stdout = child
847 .stdout
848 .take()
849 .ok_or_else(|| McpError::Transport("stdio child has no stdout".into()))?;
850 let stderr = child.stderr.take();
851
852 let pending: PendingReplies = Arc::new(std::sync::Mutex::new(HashMap::new()));
855
856 let (request_tx, mut request_rx) = tokio::sync::mpsc::channel::<StdioRequest>(32);
858 {
859 let pending = pending.clone();
860 let server_name = name.to_string();
861 tokio::spawn(async move {
862 let mut stdin = stdin;
863 while let Some(req) = request_rx.recv().await {
864 match req {
865 StdioRequest::Call { id, body, reply } => {
866 if let Ok(mut guard) = pending.lock() {
869 guard.insert(id, reply);
870 }
871 if let Err(e) = write_stdio_line(&mut stdin, &body).await {
872 if let Ok(mut guard) = pending.lock() {
875 if let Some(slot) = guard.remove(&id) {
876 let _ = slot.send(Err(McpError::Transport(format!(
877 "stdio write failed: {e}"
878 ))));
879 }
880 }
881 tracing::warn!(
882 target: "harness::mcp::stdio",
883 server = %server_name,
884 error = %e,
885 "stdio writer terminated"
886 );
887 break;
888 }
889 }
890 StdioRequest::Notify { body } => {
891 if let Err(e) = write_stdio_line(&mut stdin, &body).await {
892 tracing::warn!(
893 target: "harness::mcp::stdio",
894 server = %server_name,
895 error = %e,
896 "stdio writer terminated during notify"
897 );
898 break;
899 }
900 }
901 }
902 }
903 drop(stdin);
906 });
907 }
908
909 {
911 let pending = pending.clone();
912 let server_name = name.to_string();
913 tokio::spawn(async move {
914 let mut reader = BufReader::new(stdout);
915 let mut line = String::new();
916 loop {
917 line.clear();
918 match reader.read_line(&mut line).await {
919 Ok(0) => {
920 if let Ok(mut guard) = pending.lock() {
922 for (_, slot) in guard.drain() {
923 let _ = slot.send(Err(McpError::Transport(
924 "stdio server closed stdout".into(),
925 )));
926 }
927 }
928 break;
929 }
930 Ok(_) => {
931 let trimmed = line.trim();
932 if trimmed.is_empty() {
933 continue;
934 }
935 let parsed: McpResponse = match serde_json::from_str(trimmed) {
936 Ok(v) => v,
937 Err(e) => {
938 tracing::warn!(
939 target: "harness::mcp::stdio",
940 server = %server_name,
941 error = %e,
942 line = %trimmed.chars().take(256).collect::<String>(),
943 "stdio reader could not parse JSON-RPC envelope"
944 );
945 continue;
946 }
947 };
948 let Some(id) = parsed.id else {
953 tracing::debug!(
954 target: "harness::mcp::stdio",
955 server = %server_name,
956 "ignoring server-initiated notification"
957 );
958 continue;
959 };
960 let slot = if let Ok(mut guard) = pending.lock() {
961 guard.remove(&id)
962 } else {
963 None
964 };
965 if let Some(slot) = slot {
966 let result = if let Some(err) = parsed.error {
967 Err(McpError::Server {
968 code: err.code,
969 message: err.message,
970 })
971 } else {
972 parsed.result.ok_or(McpError::MissingField("result"))
973 };
974 let _ = slot.send(result);
975 } else {
976 tracing::debug!(
977 target: "harness::mcp::stdio",
978 server = %server_name,
979 id,
980 "stdio reply for unknown id (timeout already fired?)"
981 );
982 }
983 }
984 Err(e) => {
985 tracing::warn!(
986 target: "harness::mcp::stdio",
987 server = %server_name,
988 error = %e,
989 "stdio reader I/O error"
990 );
991 break;
992 }
993 }
994 }
995 });
996 }
997
998 if let Some(stderr) = stderr {
1002 let server_name = name.to_string();
1003 tokio::spawn(async move {
1004 let mut reader = BufReader::new(stderr);
1005 let mut line = String::new();
1006 loop {
1007 line.clear();
1008 match reader.read_line(&mut line).await {
1009 Ok(0) | Err(_) => break,
1010 Ok(_) => {
1011 let trimmed = line.trim_end();
1012 if !trimmed.is_empty() {
1013 tracing::debug!(
1014 target: "harness::mcp::stdio",
1015 server = %server_name,
1016 stderr = %trimmed,
1017 );
1018 }
1019 }
1020 }
1021 }
1022 });
1023 }
1024
1025 let child_slot = Arc::new(std::sync::Mutex::new(Some(child)));
1029
1030 Ok(McpClientInner::Stdio(StdioInner {
1031 request_tx,
1032 pending_kill: Some(child_slot),
1033 }))
1034}
1035
1036async fn write_stdio_line<W: tokio::io::AsyncWrite + Unpin>(
1037 stdin: &mut W,
1038 body: &str,
1039) -> std::io::Result<()> {
1040 use tokio::io::AsyncWriteExt;
1041 stdin.write_all(body.as_bytes()).await?;
1042 stdin.write_all(b"\n").await?;
1043 stdin.flush().await
1044}
1045
1046#[derive(Clone)]
1051pub struct McpToolRuntime {
1052 inner: Arc<McpToolRuntimeInner>,
1053}
1054
1055struct McpToolRuntimeInner {
1056 clients: Vec<McpClient>,
1057 specs: Vec<ToolSpec>,
1058 tool_to_client: HashMap<String, usize>,
1059}
1060
1061impl McpToolRuntime {
1062 pub const NAME_SEPARATOR: &'static str = "__";
1068
1069 pub async fn discover(servers: Vec<McpServerConfig>) -> Self {
1075 let mut clients: Vec<McpClient> = Vec::with_capacity(servers.len());
1076 let mut specs: Vec<ToolSpec> = Vec::new();
1077 let mut tool_to_client: HashMap<String, usize> = HashMap::new();
1078
1079 for config in servers {
1080 let server_name = config.name.clone();
1081 let required = config.required;
1082 let enabled_tools: std::collections::HashSet<String> =
1084 config.enabled_tools.iter().cloned().collect();
1085 let client = match McpClient::new(config) {
1086 Ok(c) => c,
1087 Err(e) => {
1088 tracing::warn!(
1089 target: "harness::mcp",
1090 server = %server_name,
1091 error = %e,
1092 "McpClient::new failed; skipping server"
1093 );
1094 continue;
1095 }
1096 };
1097 if let Err(e) = client.initialize().await {
1098 if required {
1099 tracing::error!(
1100 target: "harness::mcp",
1101 server = %server_name,
1102 error = %e,
1103 "required MCP server failed to initialize; session boot will fail"
1104 );
1105 } else {
1109 tracing::warn!(
1110 target: "harness::mcp",
1111 server = %server_name,
1112 error = %e,
1113 "MCP initialize failed; skipping server"
1114 );
1115 }
1116 continue;
1117 }
1118 let server_specs = match client.tools_list().await {
1119 Ok(s) => s,
1120 Err(e) => {
1121 tracing::warn!(
1122 target: "harness::mcp",
1123 server = %server_name,
1124 error = %e,
1125 "MCP tools/list failed; skipping server"
1126 );
1127 continue;
1128 }
1129 };
1130 let client_idx = clients.len();
1131 for mut spec in server_specs {
1132 let original = spec.name.clone();
1133 if !enabled_tools.is_empty() && !enabled_tools.contains(&original) {
1136 continue;
1137 }
1138 spec.name = format!("{server_name}{}{original}", Self::NAME_SEPARATOR);
1141 if tool_to_client.contains_key(&spec.name) {
1142 tracing::warn!(
1144 target: "harness::mcp",
1145 tool = %spec.name,
1146 "duplicate MCP tool name after prefixing; later registration wins"
1147 );
1148 }
1149 tool_to_client.insert(spec.name.clone(), client_idx);
1150 specs.push(spec);
1151 }
1152 clients.push(client);
1153 }
1154
1155 Self {
1156 inner: Arc::new(McpToolRuntimeInner {
1157 clients,
1158 specs,
1159 tool_to_client,
1160 }),
1161 }
1162 }
1163
1164 pub fn server_count(&self) -> usize {
1166 self.inner.clients.len()
1167 }
1168}
1169
1170#[async_trait]
1171impl ToolRuntime for McpToolRuntime {
1172 fn specs(&self) -> Vec<ToolSpec> {
1173 self.inner.specs.clone()
1174 }
1175
1176 async fn invoke(&self, invocation: ToolInvocation) -> Result<ToolOutcome, ToolRuntimeError> {
1177 let Some(&idx) = self.inner.tool_to_client.get(&invocation.name) else {
1178 return Err(ToolRuntimeError::UnknownTool(invocation.name));
1179 };
1180 let client = &self.inner.clients[idx];
1181 let original_name = invocation
1183 .name
1184 .split_once(Self::NAME_SEPARATOR)
1185 .map(|(_, name)| name)
1186 .unwrap_or(invocation.name.as_str())
1187 .to_string();
1188 client
1189 .tools_call(&original_name, invocation.input)
1190 .await
1191 .map_err(mcp_error_to_tool_runtime_error)
1192 }
1193}
1194
1195fn mcp_error_to_tool_runtime_error(err: McpError) -> ToolRuntimeError {
1196 if matches!(err, McpError::Timeout(_)) {
1197 return ToolRuntimeError::Timeout(format!("MCP: {err}"));
1198 }
1199 ToolRuntimeError::Runtime(format!("MCP: {err}"))
1206}
1207
1208#[derive(Clone)]
1218pub struct CompositeToolRuntime {
1219 primary: Arc<dyn ToolRuntime>,
1220 secondary: Arc<dyn ToolRuntime>,
1221}
1222
1223impl CompositeToolRuntime {
1224 pub fn new(primary: Arc<dyn ToolRuntime>, secondary: Arc<dyn ToolRuntime>) -> Self {
1225 Self { primary, secondary }
1226 }
1227}
1228
1229#[async_trait]
1230impl ToolRuntime for CompositeToolRuntime {
1231 fn specs(&self) -> Vec<ToolSpec> {
1232 let mut combined = self.primary.specs();
1233 combined.extend(self.secondary.specs());
1234 combined
1235 }
1236
1237 async fn invoke(&self, invocation: ToolInvocation) -> Result<ToolOutcome, ToolRuntimeError> {
1238 match self.primary.invoke(invocation.clone()).await {
1239 Err(ToolRuntimeError::UnknownTool(_)) => self.secondary.invoke(invocation).await,
1240 other => other,
1241 }
1242 }
1243
1244 async fn invoke_cancellable(
1245 &self,
1246 invocation: ToolInvocation,
1247 cancel: Option<&tokio_util::sync::CancellationToken>,
1248 ) -> Result<ToolOutcome, ToolRuntimeError> {
1249 match self
1250 .primary
1251 .invoke_cancellable(invocation.clone(), cancel)
1252 .await
1253 {
1254 Err(ToolRuntimeError::UnknownTool(_)) => {
1255 self.secondary.invoke_cancellable(invocation, cancel).await
1256 }
1257 other => other,
1258 }
1259 }
1260}
1261
1262#[async_trait]
1266impl ToolRuntime for Arc<dyn ToolRuntime> {
1267 fn specs(&self) -> Vec<ToolSpec> {
1268 (**self).specs()
1269 }
1270
1271 async fn invoke(&self, invocation: ToolInvocation) -> Result<ToolOutcome, ToolRuntimeError> {
1272 (**self).invoke(invocation).await
1273 }
1274
1275 async fn invoke_cancellable(
1276 &self,
1277 invocation: ToolInvocation,
1278 cancel: Option<&tokio_util::sync::CancellationToken>,
1279 ) -> Result<ToolOutcome, ToolRuntimeError> {
1280 (**self).invoke_cancellable(invocation, cancel).await
1281 }
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286 use super::*;
1287 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1288 use tokio::net::TcpListener;
1289
1290 async fn spawn_mock_mcp_server(
1295 scripted_responses: Vec<String>,
1296 ) -> (String, tokio::task::JoinHandle<()>) {
1297 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1298 let addr = listener.local_addr().unwrap();
1299 let url = format!("http://{addr}/mcp");
1300
1301 let handle = tokio::spawn(async move {
1302 let mut remaining = scripted_responses.into_iter();
1303 while let Some(response_body) = remaining.next() {
1304 let (mut stream, _) = listener.accept().await.unwrap();
1305 let mut buf = Vec::with_capacity(2048);
1308 let mut header_end = 0;
1309 loop {
1310 let mut tmp = [0u8; 1024];
1311 let n = stream.read(&mut tmp).await.unwrap();
1312 if n == 0 {
1313 break;
1314 }
1315 buf.extend_from_slice(&tmp[..n]);
1316 if let Some(pos) = find_header_end(&buf) {
1317 header_end = pos + 4;
1318 break;
1319 }
1320 }
1321 let headers = std::str::from_utf8(&buf[..header_end.saturating_sub(4)])
1322 .unwrap()
1323 .to_lowercase();
1324 let mut content_length = 0usize;
1325 for line in headers.lines() {
1326 if let Some(v) = line.strip_prefix("content-length:") {
1327 content_length = v.trim().parse().unwrap_or(0);
1328 }
1329 }
1330 let mut already_read = buf.len() - header_end;
1331 while already_read < content_length {
1332 let mut tmp = [0u8; 1024];
1333 let n = stream.read(&mut tmp).await.unwrap();
1334 if n == 0 {
1335 break;
1336 }
1337 buf.extend_from_slice(&tmp[..n]);
1338 already_read += n;
1339 }
1340
1341 let response = format!(
1343 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1344 response_body.len(),
1345 response_body
1346 );
1347 stream.write_all(response.as_bytes()).await.unwrap();
1348 stream.flush().await.unwrap();
1349 let _ = stream.shutdown().await;
1350 }
1351 });
1352 (url, handle)
1353 }
1354
1355 fn find_header_end(buf: &[u8]) -> Option<usize> {
1356 buf.windows(4).position(|w| w == b"\r\n\r\n")
1357 }
1358
1359 async fn spawn_mock_mcp_sse_server(
1367 scripted_responses: Vec<Vec<String>>,
1368 ) -> (String, tokio::task::JoinHandle<()>) {
1369 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1370 let addr = listener.local_addr().unwrap();
1371 let url = format!("http://{addr}/mcp");
1372
1373 let handle = tokio::spawn(async move {
1374 let mut remaining = scripted_responses.into_iter();
1375 while let Some(events) = remaining.next() {
1376 let (mut stream, _) = listener.accept().await.unwrap();
1377 let mut buf = Vec::with_capacity(2048);
1378 let mut header_end = 0;
1379 loop {
1380 let mut tmp = [0u8; 1024];
1381 let n = stream.read(&mut tmp).await.unwrap();
1382 if n == 0 {
1383 break;
1384 }
1385 buf.extend_from_slice(&tmp[..n]);
1386 if let Some(pos) = find_header_end(&buf) {
1387 header_end = pos + 4;
1388 break;
1389 }
1390 }
1391 let headers = std::str::from_utf8(&buf[..header_end.saturating_sub(4)])
1392 .unwrap()
1393 .to_lowercase();
1394 let mut content_length = 0usize;
1395 for line in headers.lines() {
1396 if let Some(v) = line.strip_prefix("content-length:") {
1397 content_length = v.trim().parse().unwrap_or(0);
1398 }
1399 }
1400 let mut already_read = buf.len() - header_end;
1401 while already_read < content_length {
1402 let mut tmp = [0u8; 1024];
1403 let n = stream.read(&mut tmp).await.unwrap();
1404 if n == 0 {
1405 break;
1406 }
1407 buf.extend_from_slice(&tmp[..n]);
1408 already_read += n;
1409 }
1410
1411 let body: String = events.concat();
1416 let header_block = "HTTP/1.1 200 OK\r\n\
1417 Content-Type: text/event-stream\r\n\
1418 Cache-Control: no-cache\r\n\
1419 Connection: close\r\n\r\n";
1420 stream.write_all(header_block.as_bytes()).await.unwrap();
1421 stream.write_all(body.as_bytes()).await.unwrap();
1422 stream.flush().await.unwrap();
1423 let _ = stream.shutdown().await;
1424 }
1425 });
1426 (url, handle)
1427 }
1428
1429 fn sse_event(body: &str) -> String {
1431 format!("event: message\ndata: {body}\n\n")
1432 }
1433
1434 fn jsonrpc_result(id: u64, result: Value) -> String {
1435 json!({"jsonrpc": "2.0", "id": id, "result": result}).to_string()
1436 }
1437
1438 #[tokio::test]
1439 async fn mcp_client_initializes_lists_and_calls_a_tool() {
1440 let (url, _server) = spawn_mock_mcp_server(vec![
1444 jsonrpc_result(1, json!({"protocolVersion": "2024-11-05", "capabilities": {}})),
1445 json!({}).to_string(),
1449 jsonrpc_result(
1450 3,
1451 json!({
1452 "tools": [{
1453 "name": "echo",
1454 "description": "echo back input",
1455 "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}}
1456 }]
1457 }),
1458 ),
1459 jsonrpc_result(
1460 4,
1461 json!({
1462 "content": [{"type": "text", "text": "hello back"}],
1463 "isError": false
1464 }),
1465 ),
1466 ])
1467 .await;
1468 let client = McpClient::new(McpServerConfig::new("fs", url)).unwrap();
1469 client.initialize().await.expect("init");
1470 let specs = client.tools_list().await.expect("list");
1471 assert_eq!(specs.len(), 1);
1472 assert_eq!(specs[0].name, "echo");
1473 let outcome = client
1474 .tools_call("echo", json!({"text": "hi"}))
1475 .await
1476 .expect("call");
1477 let v = outcome.output.expect("ok output");
1478 assert_eq!(v["content"], "hello back");
1479 }
1480
1481 #[tokio::test]
1482 async fn mcp_client_extracts_image_content_into_attachments() {
1483 let (url, _server) = spawn_mock_mcp_server(vec![
1490 jsonrpc_result(
1491 1,
1492 json!({"protocolVersion": "2024-11-05", "capabilities": {}}),
1493 ),
1494 json!({}).to_string(),
1495 jsonrpc_result(
1496 3,
1497 json!({
1498 "content": [
1499 {"type": "text", "text": "captured"},
1500 {"type": "image", "mimeType": "image/png", "data": "PNGBYTES"}
1501 ],
1502 "isError": false
1503 }),
1504 ),
1505 ])
1506 .await;
1507 let client = McpClient::new(McpServerConfig::new("screen", url)).unwrap();
1508 client.initialize().await.unwrap();
1509 let outcome = client
1510 .tools_call("screenshot", json!({}))
1511 .await
1512 .expect("call");
1513 let v = outcome.output.expect("ok output");
1514 assert_eq!(v["content"], "captured");
1517 assert_eq!(outcome.attachments.len(), 1);
1519 let UserAttachment::Image(src) = &outcome.attachments[0];
1520 assert_eq!(src.media_type, "image/png");
1521 match &src.data {
1522 ImageData::Base64(b) => assert_eq!(b, "PNGBYTES"),
1523 ImageData::Url(_) => panic!("expected base64, got url"),
1524 }
1525 }
1526
1527 #[tokio::test]
1528 async fn mcp_client_surfaces_tool_error_as_tool_failure() {
1529 let (url, _server) = spawn_mock_mcp_server(vec![
1530 jsonrpc_result(
1531 1,
1532 json!({"protocolVersion": "2024-11-05", "capabilities": {}}),
1533 ),
1534 json!({}).to_string(),
1535 jsonrpc_result(
1536 3,
1537 json!({
1538 "content": [{"type": "text", "text": "file not found"}],
1539 "isError": true
1540 }),
1541 ),
1542 ])
1543 .await;
1544 let client = McpClient::new(McpServerConfig::new("fs", url)).unwrap();
1545 client.initialize().await.unwrap();
1546 let outcome = client
1547 .tools_call("read", json!({"path": "/none"}))
1548 .await
1549 .unwrap();
1550 let failure = outcome.output.expect_err("expected ToolFailure");
1551 assert_eq!(failure.kind, ToolFailureKind::Runtime);
1552 assert!(failure.message.contains("file not found"));
1553 }
1554
1555 #[tokio::test]
1556 async fn mcp_client_surfaces_jsonrpc_error_as_mcp_server_error() {
1557 let (url, _server) = spawn_mock_mcp_server(vec![
1558 jsonrpc_result(
1559 1,
1560 json!({"protocolVersion": "2024-11-05", "capabilities": {}}),
1561 ),
1562 json!({}).to_string(),
1563 json!({
1564 "jsonrpc": "2.0",
1565 "id": 3,
1566 "error": {"code": -32601, "message": "method not found"}
1567 })
1568 .to_string(),
1569 ])
1570 .await;
1571 let client = McpClient::new(McpServerConfig::new("fs", url)).unwrap();
1572 client.initialize().await.unwrap();
1573 let err = client.tools_list().await.unwrap_err();
1574 match err {
1575 McpError::Server { code, message } => {
1576 assert_eq!(code, -32601);
1577 assert!(message.contains("method not found"));
1578 }
1579 other => panic!("expected Server error, got {other:?}"),
1580 }
1581 }
1582
1583 #[tokio::test]
1584 async fn mcp_tool_runtime_prefixes_tool_names_and_routes_calls() {
1585 let (url, _server) = spawn_mock_mcp_server(vec![
1586 jsonrpc_result(
1587 1,
1588 json!({"protocolVersion": "2024-11-05", "capabilities": {}}),
1589 ),
1590 json!({}).to_string(),
1591 jsonrpc_result(
1592 3,
1593 json!({
1594 "tools": [{
1595 "name": "echo",
1596 "description": "echo",
1597 "inputSchema": {"type": "object"}
1598 }]
1599 }),
1600 ),
1601 jsonrpc_result(
1602 4,
1603 json!({"content": [{"type": "text", "text": "routed"}], "isError": false}),
1604 ),
1605 ])
1606 .await;
1607 let rt = McpToolRuntime::discover(vec![McpServerConfig::new("fs", url)]).await;
1608 assert_eq!(rt.server_count(), 1);
1609 let specs = rt.specs();
1610 assert_eq!(specs.len(), 1);
1612 assert_eq!(specs[0].name, "fs__echo");
1613
1614 let outcome = rt
1616 .invoke(ToolInvocation {
1617 id: "tc1".into(),
1618 name: "fs__echo".into(),
1619 input: json!({"text": "x"}),
1620 raw_emitted_args: None,
1621 })
1622 .await
1623 .unwrap();
1624 assert_eq!(outcome.output.unwrap()["content"], "routed");
1625 }
1626
1627 #[tokio::test]
1628 async fn mcp_tool_runtime_unknown_tool_returns_runtime_error() {
1629 let rt = McpToolRuntime::discover(vec![]).await;
1633 let err = rt
1634 .invoke(ToolInvocation {
1635 id: "tc".into(),
1636 name: "nope__whatever".into(),
1637 input: json!({}),
1638 raw_emitted_args: None,
1639 })
1640 .await
1641 .unwrap_err();
1642 assert!(matches!(err, ToolRuntimeError::UnknownTool(ref s) if s == "nope__whatever"));
1643 }
1644
1645 #[derive(Clone, Default)]
1646 struct FakeNativeRuntime {
1647 names: Vec<&'static str>,
1648 }
1649
1650 #[async_trait]
1651 impl ToolRuntime for FakeNativeRuntime {
1652 fn specs(&self) -> Vec<ToolSpec> {
1653 self.names
1654 .iter()
1655 .map(|n| ToolSpec {
1656 name: n.to_string(),
1657 description: "fake".into(),
1658 input_schema: json!({"type": "object"}),
1659 })
1660 .collect()
1661 }
1662 async fn invoke(&self, inv: ToolInvocation) -> Result<ToolOutcome, ToolRuntimeError> {
1663 if self.names.contains(&inv.name.as_str()) {
1664 Ok(ToolOutcome {
1665 output: Ok(json!({"served_by": "native", "name": inv.name})),
1666 attachments: vec![],
1667 })
1668 } else {
1669 Err(ToolRuntimeError::UnknownTool(inv.name))
1670 }
1671 }
1672 }
1673
1674 #[derive(Clone, Default)]
1675 struct FakeMcpRuntime {
1676 names: Vec<&'static str>,
1677 }
1678
1679 #[async_trait]
1680 impl ToolRuntime for FakeMcpRuntime {
1681 fn specs(&self) -> Vec<ToolSpec> {
1682 self.names
1683 .iter()
1684 .map(|n| ToolSpec {
1685 name: n.to_string(),
1686 description: "mcp".into(),
1687 input_schema: json!({"type": "object"}),
1688 })
1689 .collect()
1690 }
1691 async fn invoke(&self, inv: ToolInvocation) -> Result<ToolOutcome, ToolRuntimeError> {
1692 if self.names.contains(&inv.name.as_str()) {
1693 Ok(ToolOutcome {
1694 output: Ok(json!({"served_by": "mcp", "name": inv.name})),
1695 attachments: vec![],
1696 })
1697 } else {
1698 Err(ToolRuntimeError::UnknownTool(inv.name))
1699 }
1700 }
1701 }
1702
1703 #[tokio::test]
1704 async fn composite_runtime_merges_specs_and_falls_back_to_secondary() {
1705 let native = Arc::new(FakeNativeRuntime {
1706 names: vec!["bash", "read"],
1707 }) as Arc<dyn ToolRuntime>;
1708 let mcp = Arc::new(FakeMcpRuntime {
1709 names: vec!["fs__list", "git__diff"],
1710 }) as Arc<dyn ToolRuntime>;
1711 let composite = CompositeToolRuntime::new(native, mcp);
1712
1713 let names: Vec<String> = composite.specs().into_iter().map(|s| s.name).collect();
1715 assert_eq!(names, vec!["bash", "read", "fs__list", "git__diff"]);
1716
1717 let outcome = composite
1719 .invoke(ToolInvocation {
1720 id: "tc".into(),
1721 name: "bash".into(),
1722 input: json!({}),
1723 raw_emitted_args: None,
1724 })
1725 .await
1726 .unwrap();
1727 assert_eq!(outcome.output.unwrap()["served_by"], "native");
1728
1729 let outcome = composite
1731 .invoke(ToolInvocation {
1732 id: "tc".into(),
1733 name: "fs__list".into(),
1734 input: json!({}),
1735 raw_emitted_args: None,
1736 })
1737 .await
1738 .unwrap();
1739 assert_eq!(outcome.output.unwrap()["served_by"], "mcp");
1740
1741 let err = composite
1743 .invoke(ToolInvocation {
1744 id: "tc".into(),
1745 name: "ghost".into(),
1746 input: json!({}),
1747 raw_emitted_args: None,
1748 })
1749 .await
1750 .unwrap_err();
1751 assert!(matches!(err, ToolRuntimeError::UnknownTool(_)));
1752 }
1753
1754 fn write_mock_stdio_server(dir: &std::path::Path) -> std::path::PathBuf {
1768 let path = dir.join("mock-mcp-stdio.sh");
1769 let body = r#"#!/usr/bin/env bash
1772set -u
1773while IFS= read -r line; do
1774 # Pull out method + id (best-effort sed; the test driver only sends
1775 # well-formed JSON so we don't need a real parser).
1776 method=$(echo "$line" | sed -n 's/.*"method"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
1777 id=$(echo "$line" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9]*\).*/\1/p')
1778 case "$method" in
1779 initialize)
1780 printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2024-11-05","capabilities":{}}}\n' "$id"
1781 ;;
1782 tools/list)
1783 printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"echo","description":"d","inputSchema":{"type":"object"}}]}}\n' "$id"
1784 ;;
1785 tools/call)
1786 printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"stdio-routed"}],"isError":false}}\n' "$id"
1787 ;;
1788 notifications/initialized)
1789 # No response for notifications.
1790 ;;
1791 *)
1792 printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"method not found"}}\n' "$id"
1793 ;;
1794 esac
1795done
1796"#;
1797 std::fs::write(&path, body).unwrap();
1798 use std::os::unix::fs::PermissionsExt;
1800 let mut perms = std::fs::metadata(&path).unwrap().permissions();
1801 perms.set_mode(0o755);
1802 std::fs::set_permissions(&path, perms).unwrap();
1803 path
1804 }
1805
1806 #[tokio::test]
1807 async fn mcp_stdio_initialize_lists_and_calls_a_tool() {
1808 let tmp = std::env::temp_dir().join(format!(
1816 "rd-mock-mcp-stdio-{}-{}",
1817 std::process::id(),
1818 std::time::SystemTime::now()
1819 .duration_since(std::time::UNIX_EPOCH)
1820 .unwrap()
1821 .as_nanos()
1822 ));
1823 std::fs::create_dir_all(&tmp).unwrap();
1824 let script = write_mock_stdio_server(&tmp);
1825
1826 let config =
1827 McpServerConfig::stdio("local-fs", script.to_string_lossy().into_owned(), vec![])
1828 .with_timeout(Duration::from_secs(5));
1829 let client = McpClient::new(config).expect("spawn");
1830 client.initialize().await.expect("init over stdio");
1831 let specs = client.tools_list().await.expect("list over stdio");
1832 assert_eq!(specs.len(), 1);
1833 assert_eq!(specs[0].name, "echo");
1834
1835 let outcome = client
1836 .tools_call("echo", json!({"text": "hi"}))
1837 .await
1838 .expect("call over stdio");
1839 let v = outcome.output.expect("ok output");
1840 assert_eq!(v["content"], "stdio-routed");
1841 }
1842
1843 #[tokio::test]
1844 async fn mcp_stdio_returns_transport_error_when_command_missing() {
1845 let config = McpServerConfig::stdio(
1851 "nope",
1852 "/definitely/not/a/real/binary-xyz".to_string(),
1853 vec![],
1854 );
1855 let err = match McpClient::new(config) {
1856 Ok(_) => panic!("must fail to spawn"),
1857 Err(e) => e,
1858 };
1859 match err {
1860 McpError::Transport(msg) => {
1861 assert!(msg.contains("stdio spawn"), "got: {msg}");
1862 }
1863 other => panic!("expected Transport, got {other:?}"),
1864 }
1865 }
1866
1867 #[tokio::test]
1868 async fn mcp_stdio_call_times_out_when_server_doesnt_reply() {
1869 let config = McpServerConfig::stdio("silent", "sleep".to_string(), vec!["30".to_string()])
1873 .with_timeout(Duration::from_millis(250));
1874 let client = McpClient::new(config).expect("spawn cat");
1875 let err = client.initialize().await.expect_err("must time out");
1876 match err {
1877 McpError::Timeout(msg) => {
1878 assert!(msg.contains("timed out"), "got: {msg}");
1879 }
1880 other => panic!("expected Timeout, got {other:?}"),
1881 }
1882 }
1883
1884 #[tokio::test]
1885 async fn mcp_http_sse_response_yields_jsonrpc_result() {
1886 let (url, _server) = spawn_mock_mcp_sse_server(vec![
1897 vec![sse_event(&jsonrpc_result(
1899 1,
1900 json!({"protocolVersion": "2024-11-05", "capabilities": {}}),
1901 ))],
1902 vec![],
1904 vec![sse_event(&jsonrpc_result(
1906 2,
1907 json!({
1908 "tools": [{
1909 "name": "echo",
1910 "description": "d",
1911 "inputSchema": {"type": "object"}
1912 }]
1913 }),
1914 ))],
1915 vec![sse_event(&jsonrpc_result(
1917 3,
1918 json!({
1919 "content": [{"type": "text", "text": "sse-routed"}],
1920 "isError": false
1921 }),
1922 ))],
1923 ])
1924 .await;
1925 let client = McpClient::new(McpServerConfig::http("sse-fs", url)).expect("build client");
1926 client.initialize().await.expect("init over sse");
1927 let specs = client.tools_list().await.expect("list over sse");
1928 assert_eq!(specs.len(), 1);
1929 let outcome = client
1930 .tools_call("echo", json!({}))
1931 .await
1932 .expect("call over sse");
1933 assert_eq!(outcome.output.unwrap()["content"], "sse-routed");
1934 }
1935
1936 #[tokio::test]
1937 async fn mcp_http_sse_response_skips_server_notifications() {
1938 let server_notification =
1942 r#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"percent":42}}"#;
1943 let unrelated = r#"{"jsonrpc":"2.0","id":999,"result":{"unrelated":true}}"#;
1944 let (url, _server) = spawn_mock_mcp_sse_server(vec![
1945 vec![
1949 sse_event(server_notification),
1950 sse_event(unrelated),
1951 sse_event(&jsonrpc_result(
1952 1,
1953 json!({"protocolVersion": "2024-11-05", "capabilities": {}}),
1954 )),
1955 ],
1956 vec![],
1958 ])
1959 .await;
1960 let client = McpClient::new(McpServerConfig::http("sse-noisy", url)).expect("build client");
1961 client
1962 .initialize()
1963 .await
1964 .expect("must skip notifications and find id match");
1965 }
1966
1967 #[tokio::test]
1968 async fn mcp_http_sse_response_propagates_jsonrpc_error() {
1969 let (url, _server) = spawn_mock_mcp_sse_server(vec![vec![sse_event(
1972 &json!({
1973 "jsonrpc": "2.0",
1974 "id": 1,
1975 "error": {"code": -32601, "message": "method not found"}
1976 })
1977 .to_string(),
1978 )]])
1979 .await;
1980 let client = McpClient::new(McpServerConfig::http("sse-err", url)).expect("build");
1981 let err = client.initialize().await.unwrap_err();
1982 match err {
1983 McpError::Server { code, message } => {
1984 assert_eq!(code, -32601);
1985 assert!(message.contains("method not found"));
1986 }
1987 other => panic!("expected Server, got {other:?}"),
1988 }
1989 }
1990}