Skip to main content

harness/
mcp.rs

1//! MCP (Model Context Protocol) client + tool runtime.
2//!
3//! Speaks MCP 2024-11-05 in two transports:
4//!
5//! - **HTTP POST** (`bootstrap.mcp_servers[].type = "url"`): every request
6//!   is a self-contained `POST <url>` with a JSON-RPC 2.0 body. We do
7//!   NOT do the SSE streaming variant of the streamable HTTP transport.
8//! - **stdio** (`bootstrap.mcp_servers[].type = "stdio"`): spawn the
9//!   configured `command` as a child process, exchange newline-delimited
10//!   JSON-RPC 2.0 messages over its stdin/stdout. Stderr is forwarded to
11//!   the RD tracing log under `target = "harness::mcp::stdio"`. The
12//!   process lives for the McpClient's lifetime (one per MCP server);
13//!   Drop kills it (best-effort SIGKILL via `tokio::process::Child::kill`).
14//!
15//! All configuration (command, args, env vars to forward, working dir,
16//! HTTP url, timeout) is sourced from bootstrap.yaml — the harness
17//! crate never reads `std::env::var` directly.
18//!
19//! Lifecycle on a session boot:
20//!   1. `McpToolRuntime::discover(servers)`
21//!      ├─ for each server: `McpClient::new` → `initialize` →
22//!      │  `tools/list` → cache the spec list with `{server}__` prefix
23//!      └─ unreachable servers log + skip (don't fail the session)
24//!   2. AgentLoopHarness sees the MCP tools through the composite
25//!      `ToolRuntime` (native + MCP merged via `CompositeToolRuntime`)
26//!   3. On invocation: route by tool-name prefix to the right
27//!      `McpClient.tools_call`, strip prefix before sending to server
28//!
29//! Session-id handling: some MCP server implementations issue an
30//! `Mcp-Session-Id` header on `initialize` and require it on subsequent
31//! requests. `McpClient` records the first non-empty value it sees and
32//! replays it; servers that don't issue one stay stateless and that's
33//! fine too.
34
35use 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
50/// MCP protocol version we negotiate with the server. The wire format
51/// is stable across patches; bump this only when the spec maintainers
52/// publish a version that changes the methods we use (`initialize` /
53/// `tools/list` / `tools/call`).
54pub const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
55
56/// Default per-request timeout. MCP tools can be slow (calling other
57/// LLMs / external APIs) but 30 s catches the common deadlocks. Same
58/// budget governs HTTP requests and stdio request-response round-trips.
59pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
60
61/// Transport-agnostic config for a single MCP server. The variant
62/// determines whether we POST to a URL or spawn a child process.
63#[derive(Debug, Clone)]
64pub struct McpServerConfig {
65    pub name: String,
66    /// Per-call tool invocation timeout (maps to `tool_timeout_sec`).
67    /// Default: 30 s for backwards-compat; bootstrap sets it to
68    /// the MCP-spec default of 60 s via `with_timeout`.
69    pub timeout: Duration,
70    /// Time allowed for `initialize` + `tools/list` handshake.
71    /// Default: 10 s (OpenAI Codex default).
72    pub startup_timeout: Duration,
73    /// If `true`, a failure to initialise aborts session boot.
74    /// Default: `false` (unreachable servers are silently skipped).
75    pub required: bool,
76    /// Tool allowlist (short names, no `{server}__` prefix).
77    /// Empty = all tools exposed.
78    pub enabled_tools: Vec<String>,
79    pub transport: McpTransport,
80}
81
82/// Transport mechanism for an MCP server.
83#[derive(Debug, Clone)]
84pub enum McpTransport {
85    /// MCP over HTTP POST / SSE. Sessions are tracked via the
86    /// `Mcp-Session-Id` header per spec.
87    Http {
88        url: String,
89        /// Static headers sent on every request (e.g. `Authorization`).
90        headers: HashMap<String, String>,
91    },
92    /// MCP over stdio: spawn `command` (with `args`, `env`, `working_dir`),
93    /// exchange newline-delimited JSON-RPC 2.0 messages over its
94    /// stdin/stdout. The process is owned by the `McpClient`; Drop
95    /// kills it.
96    ///
97    /// `env` is the **full** env passed to the child — anything not in
98    /// this map is NOT inherited (we deliberately don't read
99    /// `std::env::vars()` so all per-session secrets stay in
100    /// bootstrap.yaml). The child still receives a baseline `PATH`
101    /// derived from `command` lookup, but no other host env leaks in.
102    Stdio {
103        command: String,
104        args: Vec<String>,
105        env: HashMap<String, String>,
106        working_dir: Option<String>,
107    },
108}
109
110impl McpServerConfig {
111    /// HTTP-transport convenience constructor. Same name as the v1
112    /// `new()` for source compat.
113    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    /// stdio-transport convenience constructor. `env` / `working_dir`
132    /// default to empty; use the struct literal form or chain setters
133    /// if you need them.
134    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    /// Per-call tool invocation timeout.
151    pub fn with_timeout(mut self, timeout: Duration) -> Self {
152        self.timeout = timeout;
153        self
154    }
155
156    /// Time allowed for `initialize` + `tools/list` handshake.
157    pub fn with_startup_timeout(mut self, timeout: Duration) -> Self {
158        self.startup_timeout = timeout;
159        self
160    }
161
162    /// If `true`, init failure aborts session boot.
163    pub fn with_required(mut self, required: bool) -> Self {
164        self.required = required;
165        self
166    }
167
168    /// Tool allowlist (short names without `{server}__` prefix).
169    pub fn with_enabled_tools(mut self, tools: Vec<String>) -> Self {
170        self.enabled_tools = tools;
171        self
172    }
173
174    /// Add a static HTTP request header (HTTP transport only).
175    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// ── JSON-RPC 2.0 wire types ─────────────────────────────────────────
214
215#[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    /// `None` for server-initiated notifications (stdio transport). HTTP
237    /// transport always carries the response id, but we accept both
238    /// shapes from the same parse routine.
239    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// ── MCP-specific payload types (under JSON-RPC `result`) ────────────
254
255#[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/// MCP content block on a `tools/call` response.
282///
283/// Text variants flow into `ToolOutcome.output`'s `content` string;
284/// Image variants are surfaced as `UserAttachment::Image` on
285/// `ToolOutcome.attachments` so that providers which support vision
286/// in the tool-result slot (Anthropic) can present them to the model.
287/// Providers that don't (OpenAI) degrade them to a text placeholder
288/// in `chat_message_to_wire`.
289///
290/// `resource` / other variants we don't yet model are collapsed into
291/// bracketed placeholder text — they're rare in practice and the
292/// shape varies enough to warrant a dedicated pass when we add them.
293#[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
309// ── McpClient ───────────────────────────────────────────────────────
310
311/// One client per MCP server. Stateless on the wire except for the
312/// optional `Mcp-Session-Id` header (HTTP transport only) — see
313/// module docstring.
314pub 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    /// Static headers sent on every request (e.g. `Authorization`).
330    headers: HashMap<String, String>,
331    session_id: Arc<RwLock<Option<String>>>,
332}
333
334/// Stdio inner — owns the child process via the writer/reader task
335/// pair. `request_tx` is the only way to talk to the child; closing
336/// it tears down the pair on Drop.
337struct 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    /// JSON-RPC request that expects a response. Reply lands on `reply`
344    /// via the reader task's id-routing map.
345    Call {
346        id: u64,
347        body: String,
348        reply: tokio::sync::oneshot::Sender<Result<Value, McpError>>,
349    },
350    /// JSON-RPC notification — write + forget.
351    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    /// Run the spec-required initialization handshake. Captures the
394    /// session id (if any) and notifies the server that we're ready.
395    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        // notifications/initialized has no `id` and expects no response —
406        // we fire-and-forget. Errors get logged but don't fail boot.
407        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    /// Pull the server's tool advertisement. Each `ToolSpec` returned
419    /// has the **unprefixed** name; `McpToolRuntime::discover` prefixes
420    /// with `{server_name}__` to avoid collisions.
421    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    /// Invoke an MCP tool. `name` is the **unprefixed** MCP-side tool
437    /// name (caller has already stripped the `{server}__` prefix).
438    /// Returns a `ToolOutcome` shaped the same way `SandboxToolRuntime`
439    /// returns its outcomes — the harness layer doesn't care which
440    /// runtime served the call.
441    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        // Split MCP content blocks into a text channel (goes into
451        // the tool_result content string) and an image channel
452        // (goes into `attachments`, preserved for the next
453        // user-turn projection on vision-capable providers).
454        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                        // Server sent an image block but no payload —
462                        // surface a placeholder so the model still
463                        // knows something visual was returned.
464                        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            // Wrap text content in a JSON object so it slots into the
494            // same `tool_result.content` shape native tools produce.
495            Ok(json!({"content": content_str}))
496        };
497        Ok(ToolOutcome {
498            output,
499            attachments,
500        })
501    }
502
503    /// Internal: send a request, parse the JSON-RPC envelope, return
504    /// the `result` field (or the `error` mapped to `McpError::Server`).
505    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    /// Fire-and-forget notification (JSON-RPC has no `id`, no response).
520    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        // Advertise both response shapes so servers can pick. MCP SDK's
539        // default streamable-HTTP transport will reply with
540        // `text/event-stream` if it sees this header — we MUST then
541        // parse SSE rather than `resp.text()` (which would block until
542        // the stream is fully drained but, more importantly, the bytes
543        // we get back are SSE framing, not JSON).
544        let mut req = http
545            .http
546            .post(&http.url)
547            .header("Accept", "application/json, text/event-stream")
548            .json(body);
549        // Inject static headers (e.g. Authorization) configured in bootstrap.
550        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        // Server may issue a session id on initialize; capture the first
565        // non-empty one we see. Subsequent calls replay it.
566        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        // Branch on Content-Type. SSE responses can carry multiple
590        // `message` events (server-side notifications + our response);
591        // we drain them all and pick the JSON-RPC envelope whose `id`
592        // matches what we sent.
593        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        // Notifications still advertise SSE; some servers reply with
622        // `202 Accepted` + an SSE stream that carries no envelope (it
623        // was just an ACK). We send + drop the response body.
624        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
691/// Drain a `text/event-stream` response from an MCP server and return
692/// the JSON-RPC envelope whose `id` matches `expected_id`. Server-side
693/// notifications (no `id`) and unrelated responses (other `id`s) are
694/// logged at debug + discarded — the streamable-HTTP transport may
695/// interleave them with our response.
696///
697/// Fails if the stream ends without an id-matching envelope, or with an
698/// `error` field on the matched envelope.
699async 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        // MCP streamable-HTTP uses default event name (`message`) for
711        // JSON-RPC envelopes. We accept either an empty event name or
712        // explicit `message`; anything else (ping, retry, custom) is
713        // ignored.
714        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        // Server-initiated notification (no id): we don't implement
740        // sampling / elicitation yet, so drop and keep draining.
741        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            // Stale / unrelated reply (shouldn't really happen on a
751            // request-scoped POST stream, but defend anyway).
752            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
774/// Drop wires teardown for stdio inners: signal the worker to stop +
775/// best-effort kill on the child if it's still alive.
776impl Drop for McpClient {
777    fn drop(&mut self) {
778        if let McpClientInner::Stdio(stdio) = &mut self.inner {
779            // Closing the sender lets the writer task observe EOF and
780            // exit, which closes the child's stdin; most well-behaved
781            // MCP servers exit on EOF. As a safety net, kill the child
782            // explicitly so a misbehaved server can't leak processes.
783            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
794// ── stdio transport ─────────────────────────────────────────────────
795
796/// Spawn the configured MCP server as a child process, wire up a
797/// writer task (drains an mpsc of outbound requests/notifications to
798/// the child's stdin) and a reader task (parses newline-delimited
799/// JSON-RPC messages from the child's stdout, routes responses to
800/// their `oneshot` waiters by `id`). Stderr is drained into RD
801/// tracing under `target = "harness::mcp::stdio"`.
802///
803/// Returns an `McpClientInner::Stdio` ready for `call`/`notify`.
804fn 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 FIRST so nothing host-side leaks in. The MCP server
821        // sees exactly what bootstrap.yaml said and nothing more.
822        .env_clear();
823    for (k, v) in &env {
824        cmd.env(k, v);
825    }
826    // Keep the child env sealed, but provide PATH by default so common
827    // stdio launchers like `npx` can resolve their own subprocesses.
828    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    // Shared map from outbound request id → oneshot reply slot. The
853    // writer task installs entries; the reader task pops them.
854    let pending: PendingReplies = Arc::new(std::sync::Mutex::new(HashMap::new()));
855
856    // Writer task: drain outbound mpsc → child stdin.
857    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                        // Register pending FIRST so a quick reply can't
867                        // race the writer.
868                        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                            // Pop the waiter we just registered + return
873                            // the error so the caller doesn't hang.
874                            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            // Channel closed → drop stdin so child sees EOF and exits.
904            // (Explicit drop for documentation; happens implicitly too.)
905            drop(stdin);
906        });
907    }
908
909    // Reader task: parse newline-delimited JSON-RPC → route by id.
910    {
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                        // EOF — server closed stdout. Fail all pending.
921                        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                        // We only care about responses (have `id`).
949                        // Server-initiated notifications without `id`
950                        // are ignored — we don't implement sampling /
951                        // elicitation yet.
952                        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    // Stderr drain — best-effort tracing. Don't wait on this task; if
999    // the child's stderr is huge we still let the reader/writer
1000    // dominate scheduling.
1001    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    // Park the child so Drop on McpClient can kill it. We DON'T
1026    // .await it — exit code is best-effort observed via the reader's
1027    // EOF detection.
1028    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// ── McpToolRuntime ──────────────────────────────────────────────────
1047
1048/// ToolRuntime that fronts one or more MCP servers. Built once at
1049/// session boot via `discover()`. Cheap to clone (Arc internally).
1050#[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    /// Tool-name prefix separator between the server name and the
1063    /// upstream tool name. Chosen as `__` because it's invalid in most
1064    /// MCP server names already and unlikely to collide. The agent loop
1065    /// receives the prefixed name; `tools_call` strips it before
1066    /// forwarding to the server.
1067    pub const NAME_SEPARATOR: &'static str = "__";
1068
1069    /// Connect to every configured server, handshake, list tools.
1070    /// Unreachable / misbehaving servers log a warning and are skipped —
1071    /// a single down dependency shouldn't fail the whole session.
1072    /// Returns the runtime even if 0 servers came up (specs() will just
1073    /// be empty); caller decides whether that's acceptable.
1074    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            // Extract enabled_tools before config is consumed by McpClient::new.
1083            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                    // `discover` returns a partial runtime; caller checks
1106                    // server_count() and aborts if required server is absent.
1107                    // Flag the absence via a sentinel rather than panicking.
1108                } 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                // Apply enabled_tools allowlist: skip tools not in the list
1134                // (empty set = all tools allowed).
1135                if !enabled_tools.is_empty() && !enabled_tools.contains(&original) {
1136                    continue;
1137                }
1138                // Prefix server name onto the tool to avoid collisions
1139                // when multiple MCP servers offer same-named tools.
1140                spec.name = format!("{server_name}{}{original}", Self::NAME_SEPARATOR);
1141                if tool_to_client.contains_key(&spec.name) {
1142                    // Two servers with the same name → operator misconfigured.
1143                    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    /// Number of MCP servers that successfully came up (initialize + tools/list ok).
1165    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        // Strip the `{server}__` prefix before sending to the MCP server.
1182        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    // MCP errors are mostly transport-level / protocol-level — bucket
1200    // them all as `Runtime` (the catch-all). Caller (agent_loop) will
1201    // surface this as `NativeHarnessError::ToolRuntime` → eventually
1202    // `sandbox_failed_error` on the wire, which conveys "MCP side
1203    // broke" close enough. We don't add Timeout etc explicitly because
1204    // reqwest's timeout already surfaces as Transport.
1205    ToolRuntimeError::Runtime(format!("MCP: {err}"))
1206}
1207
1208// ── CompositeToolRuntime ────────────────────────────────────────────
1209
1210/// Layer two ToolRuntimes into one. Used to combine native tools
1211/// (`SandboxToolRuntime`) and MCP tools (`McpToolRuntime`) into a
1212/// single thing the agent loop's generic `R: ToolRuntime` can consume.
1213///
1214/// Dispatch policy: try `primary` first; if it reports `UnknownTool`,
1215/// fall back to `secondary`. Native tools live in primary by convention
1216/// (cheaper to call, no network hop).
1217#[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// Blanket impl so `Arc<dyn ToolRuntime>` itself satisfies ToolRuntime —
1263// lets the agent loop's `R: ToolRuntime + Clone` accept an Arc of a
1264// trait object directly. Forwards to the inner concrete impl.
1265#[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    /// Spin up a one-shot mock HTTP server that responds to MCP requests
1291    /// with scripted JSON bodies. Returns the URL the client should
1292    /// POST to. Server task ends after `expected_requests` requests.
1293    /// Cheaper than wiremock; covers our minimal needs.
1294    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                // Drain request: read until \r\n\r\n then Content-Length
1306                // bytes. Hand-rolled because we don't need full HTTP parsing.
1307                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                // Reply with the scripted JSON body.
1342                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    /// SSE variant: for each request, the mock replies with a
1360    /// `text/event-stream` response whose body is each scripted entry
1361    /// (a list of SSE events) concatenated. Each "entry" is itself a
1362    /// `Vec<String>` of SSE event blocks (each ending with `\n\n`) so
1363    /// a single response can carry multiple events — exercising the
1364    /// "discard server-initiated notifications between expected
1365    /// envelopes" code path. Connection close terminates the stream.
1366    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                // We send the response WITHOUT Content-Length and with
1412                // Connection: close — closing the TCP stream signals
1413                // end-of-stream to the client's SSE parser. Each event
1414                // is already terminated by its own `\n\n`.
1415                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    /// Build one SSE `message`-event block from a JSON-RPC body.
1430    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        // Scripted responses for: initialize, notifications/initialized
1441        // (notification gets no response but the test server still
1442        // returns OK), tools/list, tools/call.
1443        let (url, _server) = spawn_mock_mcp_server(vec![
1444            jsonrpc_result(1, json!({"protocolVersion": "2024-11-05", "capabilities": {}})),
1445            // Notification — server returns empty 200 with empty body
1446            // is technically OK; we send a benign empty JSON-RPC anyway
1447            // so the mock parses cleanly.
1448            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        // MCP servers that return an `image` content block (e.g. a
1484        // screenshot tool) should surface the bytes as
1485        // ToolOutcome.attachments — NOT degrade them to placeholder
1486        // text. Anthropic projection then puts the image into the
1487        // tool_result block array; OpenAI projection appends a
1488        // placeholder note in the tool-role string.
1489        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        // Text channel: only the actual text block — image isn't
1515        // smuggled in as a stringified payload.
1516        assert_eq!(v["content"], "captured");
1517        // Attachment channel: image bytes preserved verbatim.
1518        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        // Tool name carries the `fs__` prefix.
1611        assert_eq!(specs.len(), 1);
1612        assert_eq!(specs[0].name, "fs__echo");
1613
1614        // invoke with prefixed name — runtime strips prefix on the wire.
1615        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        // No server configured — runtime is empty. invoke on any tool
1630        // surfaces UnknownTool, which is the contract used by
1631        // CompositeToolRuntime's fallback logic.
1632        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        // specs union (in primary-then-secondary order)
1714        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        // primary serves "bash"
1718        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        // primary returns UnknownTool → fall back to secondary
1730        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        // Neither knows → final UnknownTool
1742        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    /// Build a tiny stdio MCP server in pure shell: stays in a loop
1755    /// reading newline-delimited JSON-RPC from stdin and writing
1756    /// pre-canned responses based on the request's `method`. Used to
1757    /// drive the stdio transport without a real Python/Node MCP
1758    /// implementation on the test host.
1759    ///
1760    /// The script handles:
1761    /// - `initialize` → returns `{protocolVersion, capabilities:{}}`
1762    /// - `tools/list` → returns one fake tool `echo`
1763    /// - `tools/call` → returns text content `"stdio-routed"`
1764    /// - `notifications/initialized` (no id) → consumed silently
1765    ///
1766    /// Returns the absolute path to the temp script file.
1767    fn write_mock_stdio_server(dir: &std::path::Path) -> std::path::PathBuf {
1768        let path = dir.join("mock-mcp-stdio.sh");
1769        // The id needs to be parsed back from the inbound request so we
1770        // echo it in the response — use sed to extract.
1771        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        // chmod +x via std::fs metadata.
1799        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        // End-to-end stdio path: spawn a shell-based MCP server, run
1809        // initialize → tools/list → tools/call, assert routing works
1810        // without ever touching HTTP. This is the cheapest way to prove
1811        // the writer/reader/id-routing wiring is correct.
1812        // Use the OS temp dir directly — pulls no new dev-dep, fine for
1813        // a sandboxed CI worker. We pick a per-test filename so parallel
1814        // test runs don't clobber each other.
1815        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        // Spawning a nonexistent command must surface a Transport
1846        // error eagerly from `McpClient::new`, not hang later on
1847        // initialize. This is the failure mode operators hit when a
1848        // bootstrap.yaml references an MCP server whose CLI isn't
1849        // installed on the RD host.
1850        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        // `sleep` never reads stdin and never writes stdout, so the
1870        // reader task can't route anything → call must hit the
1871        // per-request timeout cleanly (not hang).
1872        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        // Streamable-HTTP transport: server replies with
1887        // `text/event-stream`. The client must detect Content-Type and
1888        // drain the SSE stream looking for the id-matching envelope.
1889        // initialize → notifications/initialized → tools/list →
1890        // tools/call. Each request gets one SSE message in response
1891        // (notifications get an empty stream the server will just
1892        // close after).
1893        // id sequence: only `call()` calls increment `next_id`;
1894        // `notify()` (notifications/initialized) does not. So:
1895        //   initialize → 1, tools/list → 2, tools/call → 3.
1896        let (url, _server) = spawn_mock_mcp_sse_server(vec![
1897            // initialize
1898            vec![sse_event(&jsonrpc_result(
1899                1,
1900                json!({"protocolVersion": "2024-11-05", "capabilities": {}}),
1901            ))],
1902            // notifications/initialized — no envelope, just close
1903            vec![],
1904            // tools/list
1905            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            // tools/call
1916            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        // SSE stream may interleave server-initiated notifications
1939        // (no `id`) before the actual response. The parser must drop
1940        // them and keep draining until it finds the id match.
1941        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            // initialize — single notification + unrelated id + real
1946            // response. Ordering matters: parser must skip the first
1947            // two and consume the third.
1948            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            // notifications/initialized
1957            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        // An SSE envelope with `error` field maps to McpError::Server,
1970        // not Transport — same as the JSON path.
1971        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}