Skip to main content

recursive/
mcp.rs

1//! MCP (Model Context Protocol) client — stdio and HTTP+SSE transport, JSON-RPC 2.0.
2//!
3//! Supports the bounded subset needed for tool proxy:
4//! - `initialize` / `initialized` handshake
5//! - `tools/list` to discover tools
6//! - `tools/call` to invoke them
7//!
8//! Also supports:
9//! - `resources/list` and `resources/read`
10//! - `prompts/list` and `prompts/get`
11
12use async_trait::async_trait;
13use futures_util::StreamExt;
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use std::collections::HashMap;
17use std::fmt;
18use std::path::Path;
19use std::sync::Arc;
20use std::time::Duration;
21use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
22use tokio::process::{Child, ChildStdin, ChildStdout, Command};
23use tokio::sync::Mutex;
24use tokio::time::timeout;
25
26use crate::error::{Error, Result};
27use crate::llm::ToolSpec;
28use crate::tools::Tool;
29
30// ---------------------------------------------------------------------------
31// Public types
32// ---------------------------------------------------------------------------
33
34/// Configuration for a single MCP server in the Claude Code `.mcp.json` format.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct McpServerConfig {
37    /// Command for stdio transport (mutually exclusive with `url`).
38    #[serde(default)]
39    pub command: String,
40    #[serde(default)]
41    pub args: Vec<String>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub env: Option<HashMap<String, String>>,
44    /// URL for HTTP+SSE transport (mutually exclusive with `command`).
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub url: Option<String>,
47}
48
49/// Configuration for a single MCP server.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct McpServer {
52    pub name: String,
53    /// Command for stdio transport, or empty if using HTTP+SSE.
54    #[serde(default)]
55    pub command: String,
56    #[serde(default)]
57    pub args: Vec<String>,
58    /// URL for HTTP+SSE transport, or None if using stdio.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub url: Option<String>,
61    /// Extra environment variables to set when spawning a stdio subprocess.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub env: Option<HashMap<String, String>>,
64}
65
66impl From<(String, McpServerConfig)> for McpServer {
67    fn from((name, config): (String, McpServerConfig)) -> Self {
68        Self {
69            name,
70            command: config.command,
71            args: config.args,
72            url: config.url,
73            env: config.env,
74        }
75    }
76}
77
78/// Optional 2025-03-26 MCP tool annotations that carry hints about
79/// the tool's side-effects. All fields default to `false` / `None`.
80#[derive(Debug, Clone, Default, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct McpToolAnnotations {
83    /// Hints the tool only reads data without side-effects.
84    #[serde(default)]
85    pub read_only_hint: bool,
86    /// Hints the tool may produce destructive side-effects.
87    #[serde(default)]
88    pub destructive_hint: bool,
89    /// Hints the tool is idempotent.
90    #[serde(default)]
91    pub idempotent_hint: bool,
92    /// Hints the tool interacts with the open world (network, I/O).
93    #[serde(default)]
94    pub open_world_hint: bool,
95}
96
97/// Per-MCP-server trust level. Controls whether annotation hints from
98/// a server are believed when classifying side-effects.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum McpServerTrust {
102    /// Annotations are trusted and used to derive [`ToolSideEffect`].
103    Trusted,
104    /// Annotations are ignored; all tools default to `External`.
105    /// This is the conservative default.
106    #[default]
107    Untrusted,
108}
109
110/// A tool spec as returned by the MCP server's `tools/list`.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct McpToolSpec {
113    pub name: String,
114    #[serde(default)]
115    pub description: String,
116    #[serde(default)]
117    pub input_schema: Value,
118    /// Optional 2025-03-26 annotations field.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub annotations: Option<McpToolAnnotations>,
121}
122
123/// A resource exposed by an MCP server.
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct McpResource {
126    pub uri: String,
127    pub name: String,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub description: Option<String>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    #[serde(rename = "mimeType")]
132    pub mime_type: Option<String>,
133}
134
135/// Content returned from reading an MCP resource.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct McpResourceContent {
138    pub uri: String,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    #[serde(rename = "mimeType")]
141    pub mime_type: Option<String>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub text: Option<String>,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub blob: Option<String>,
146}
147
148/// A prompt template exposed by an MCP server.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct McpPrompt {
151    pub name: String,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub description: Option<String>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub arguments: Option<Vec<McpPromptArgument>>,
156}
157
158/// An argument to an MCP prompt template.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct McpPromptArgument {
161    pub name: String,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub description: Option<String>,
164    #[serde(default)]
165    pub required: bool,
166}
167
168/// A message in an MCP prompt response.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct McpPromptMessage {
171    pub role: String,
172    pub content: String,
173}
174
175// ---------------------------------------------------------------------------
176// Transport abstraction
177// ---------------------------------------------------------------------------
178
179/// The underlying transport for an MCP client.
180#[allow(clippy::large_enum_variant)]
181enum McpTransport {
182    /// Stdio subprocess transport.
183    Stdio {
184        stdin: ChildStdin,
185        reader: BufReader<ChildStdout>,
186        child: Option<Child>,
187    },
188    /// HTTP+SSE transport.
189    HttpSse {
190        client: reqwest::Client,
191        /// Base SSE endpoint URL (the one that returns the event stream).
192        sse_url: String,
193        /// URL template for POST requests (from the `endpoint` event).
194        post_url: Option<String>,
195        /// Buffer for accumulating SSE data between reads.
196        buffer: String,
197    },
198}
199
200impl fmt::Debug for McpTransport {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        match self {
203            Self::Stdio { .. } => f.debug_struct("Stdio").finish(),
204            Self::HttpSse {
205                sse_url, post_url, ..
206            } => f
207                .debug_struct("HttpSse")
208                .field("sse_url", sse_url)
209                .field("post_url", post_url)
210                .finish(),
211        }
212    }
213}
214
215/// MCP elicitation request forwarded to a host (Claude `elicitation` control).
216#[derive(Debug, Clone)]
217pub struct ElicitationRequest {
218    pub mcp_server_name: String,
219    pub message: String,
220    pub mode: Option<String>,
221    pub url: Option<String>,
222    pub elicitation_id: Option<String>,
223    pub requested_schema: Option<Value>,
224    pub title: Option<String>,
225    pub display_name: Option<String>,
226    pub description: Option<String>,
227}
228
229/// Handles MCP `-32042` UrlElicitationRequired by asking the host.
230#[async_trait]
231pub trait ElicitationHandler: Send + Sync {
232    async fn elicit(&self, request: ElicitationRequest) -> Option<Value>;
233}
234
235/// Shared slot so the CLI control channel can install a handler after MCP
236/// clients are constructed.
237pub type SharedElicitationHandler = Arc<tokio::sync::RwLock<Option<Arc<dyn ElicitationHandler>>>>;
238
239/// Create an empty elicitation-handler slot.
240pub fn new_elicitation_slot() -> SharedElicitationHandler {
241    Arc::new(tokio::sync::RwLock::new(None))
242}
243
244/// An MCP client owns a transport and manages JSON-RPC communication.
245pub struct McpClient {
246    transport: McpTransport,
247    next_id: u64,
248    /// Capabilities advertised by the server during initialization.
249    capabilities: ServerCapabilities,
250    /// Name of the MCP server (for error reporting).
251    server_name: String,
252    /// How long to wait for a single line of stdio output before
253    /// declaring the server hung. Defaults to
254    /// [`DEFAULT_STDIO_READ_TIMEOUT`]; tests use a shorter value to
255    /// keep the suite fast.
256    read_timeout: Duration,
257    /// Optional host elicitation handler (Claude control channel).
258    elicitation: Option<SharedElicitationHandler>,
259}
260
261/// Default timeout for reading a single JSON-RPC line from a stdio
262/// MCP server. Generous on purpose — real servers can pause briefly
263/// during large tool runs without being broken.
264pub const DEFAULT_STDIO_READ_TIMEOUT: Duration = Duration::from_secs(10);
265
266/// Capabilities advertised by an MCP server during the initialize handshake.
267#[derive(Debug, Clone, Default)]
268pub struct ServerCapabilities {
269    pub tools: bool,
270    pub resources: bool,
271    pub prompts: bool,
272}
273
274impl fmt::Debug for McpClient {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        f.debug_struct("McpClient")
277            .field("transport", &self.transport)
278            .field("next_id", &self.next_id)
279            .field("capabilities", &self.capabilities)
280            .finish()
281    }
282}
283
284impl Drop for McpClient {
285    fn drop(&mut self) {
286        if let McpTransport::Stdio { ref mut child, .. } = &mut self.transport {
287            if let Some(mut child) = child.take() {
288                let _ = child.start_kill();
289            }
290        }
291    }
292}
293
294impl McpClient {
295    /// Spawn an MCP server (stdio or HTTP+SSE), perform the initialize
296    /// handshake, and return a ready-to-use client.
297    ///
298    /// If `server.url` is set, uses HTTP+SSE transport. Otherwise uses stdio.
299    pub async fn spawn(server: &McpServer) -> Result<Self> {
300        Self::spawn_with_timeout(server, DEFAULT_STDIO_READ_TIMEOUT).await
301    }
302
303    /// Like [`spawn`], but with a custom stdio read timeout. Tests use
304    /// a short value (e.g. 500ms) to keep the suite fast; production
305    /// callers should stick to [`spawn`].
306    pub async fn spawn_with_timeout(server: &McpServer, read_timeout: Duration) -> Result<Self> {
307        if let Some(url) = &server.url {
308            // HTTP+SSE has its own internal timeouts that are independent
309            // of stdio handshake behavior; keep its existing semantics.
310            Self::spawn_http_sse(server, url).await
311        } else {
312            Self::spawn_stdio(server, read_timeout).await
313        }
314    }
315
316    /// Spawn via stdio subprocess.
317    async fn spawn_stdio(server: &McpServer, read_timeout: Duration) -> Result<Self> {
318        let mut cmd = Command::new(&server.command);
319        cmd.args(&server.args)
320            .stdin(std::process::Stdio::piped())
321            .stdout(std::process::Stdio::piped())
322            .stderr(std::process::Stdio::null());
323        if let Some(env) = &server.env {
324            cmd.envs(env);
325        }
326        let mut child = cmd.spawn().map_err(|e| Error::Mcp {
327            server: server.name.clone(),
328            message: format!("failed to spawn: {e}"),
329        })?;
330
331        let stdin = child.stdin.take().ok_or_else(|| Error::Mcp {
332            server: server.name.clone(),
333            message: "failed to open stdin".into(),
334        })?;
335        let stdout = child.stdout.take().ok_or_else(|| Error::Mcp {
336            server: server.name.clone(),
337            message: "failed to open stdout".into(),
338        })?;
339        let reader = BufReader::new(stdout);
340
341        let mut client = Self {
342            transport: McpTransport::Stdio {
343                stdin,
344                reader,
345                child: Some(child),
346            },
347            next_id: 1,
348            capabilities: ServerCapabilities::default(),
349            server_name: server.name.clone(),
350            read_timeout,
351            elicitation: None,
352        };
353
354        client.do_initialize(&server.name).await?;
355        Ok(client)
356    }
357
358    /// Spawn via HTTP+SSE transport.
359    async fn spawn_http_sse(server: &McpServer, url: &str) -> Result<Self> {
360        let http_client = reqwest::Client::builder()
361            .timeout(Duration::from_secs(30))
362            .build()
363            .map_err(|e| Error::Mcp {
364                server: server.name.clone(),
365                message: format!("failed to build HTTP client: {e}"),
366            })?;
367
368        // Connect to the SSE endpoint and read the initial event to discover
369        // the message endpoint.
370        let response = http_client
371            .get(url)
372            .header("Accept", "text/event-stream")
373            .send()
374            .await
375            .map_err(|e| Error::Mcp {
376                server: server.name.clone(),
377                message: format!("failed to connect to SSE endpoint `{url}`: {e}"),
378            })?;
379
380        if !response.status().is_success() {
381            return Err(Error::Mcp {
382                server: server.name.clone(),
383                message: format!("SSE endpoint `{url}` returned HTTP {}", response.status()),
384            });
385        }
386
387        // Read the SSE stream to find the `endpoint` event (which tells us
388        // where to POST JSON-RPC messages). We buffer the stream for later use.
389        let mut stream = response.bytes_stream();
390        let mut sse_buffer = String::new();
391        let mut post_url: Option<String> = None;
392        let mut found_endpoint = false;
393
394        // Read up to 64KB to find the endpoint event
395        let mut total_read = 0usize;
396        let max_read = 65536;
397
398        while total_read < max_read {
399            match timeout(Duration::from_secs(10), stream.next()).await {
400                Ok(Some(Ok(chunk))) => {
401                    let chunk_str = String::from_utf8_lossy(&chunk);
402                    total_read += chunk_str.len();
403                    sse_buffer.push_str(&chunk_str);
404
405                    // Parse SSE events from the buffer
406                    if let Some(endpoint) = parse_sse_endpoint(&sse_buffer) {
407                        post_url = Some(endpoint);
408                        found_endpoint = true;
409                        break;
410                    }
411                }
412                Ok(Some(Err(e))) => {
413                    return Err(Error::Mcp {
414                        server: server.name.clone(),
415                        message: format!("error reading SSE stream from `{url}`: {e}"),
416                    });
417                }
418                Ok(None) => break, // Stream ended
419                Err(_) => {
420                    return Err(Error::Mcp {
421                        server: server.name.clone(),
422                        message: format!("timeout reading SSE stream from `{url}`"),
423                    });
424                }
425            }
426        }
427
428        if !found_endpoint {
429            return Err(Error::Mcp {
430                server: server.name.clone(),
431                message: format!(
432                    "SSE endpoint `{url}` did not send an `endpoint` event. Received data: {}",
433                    &sse_buffer[..sse_buffer.len().min(200)]
434                ),
435            });
436        }
437
438        let mut client = Self {
439            transport: McpTransport::HttpSse {
440                client: http_client,
441                sse_url: url.to_string(),
442                post_url,
443                buffer: sse_buffer,
444            },
445            next_id: 1,
446            capabilities: ServerCapabilities::default(),
447            server_name: server.name.clone(),
448            // SSE transport reads its own stream with internal timeouts;
449            // this field is unused for HTTP+SSE but must be set.
450            read_timeout: DEFAULT_STDIO_READ_TIMEOUT,
451            elicitation: None,
452        };
453
454        client.do_initialize(&server.name).await?;
455        Ok(client)
456    }
457
458    /// Perform the MCP initialize handshake (common to both transports).
459    async fn do_initialize(&mut self, server_name: &str) -> Result<()> {
460        let init_result: Value = self
461            .send_request(
462                "initialize",
463                serde_json::json!({
464                    "protocolVersion": "2024-11-05",
465                    "capabilities": {},
466                    "clientInfo": {
467                        "name": "recursive-agent",
468                        "version": "0.1.0"
469                    }
470                }),
471            )
472            .await?;
473
474        // Check protocol version in response
475        if let Some(server_proto) = init_result.get("protocolVersion").and_then(|v| v.as_str()) {
476            if server_proto != "2024-11-05" {
477                // Non-fatal: log but continue
478                tracing::warn!(
479                    target: "recursive::mcp",
480                    server = %server_name,
481                    server_protocol = %server_proto,
482                    "MCP server protocol version mismatch"
483                );
484            }
485        }
486
487        // Parse capabilities from the server response
488        if let Some(caps) = init_result.get("capabilities") {
489            self.capabilities.tools = caps.get("tools").and_then(|v| v.as_bool()).unwrap_or(false);
490            self.capabilities.resources = caps
491                .get("resources")
492                .and_then(|v| v.as_bool())
493                .unwrap_or(false);
494            self.capabilities.prompts = caps
495                .get("prompts")
496                .and_then(|v| v.as_bool())
497                .unwrap_or(false);
498        }
499
500        // Send initialized notification (no response expected)
501        self.send_notification("notifications/initialized", serde_json::json!({}))
502            .await?;
503
504        Ok(())
505    }
506
507    /// Call `tools/list` and return the discovered tool specs.
508    pub async fn list_tools(&mut self) -> Result<Vec<McpToolSpec>> {
509        let result: Value = self
510            .send_request("tools/list", serde_json::json!({}))
511            .await?;
512
513        let tools_arr = result
514            .get("tools")
515            .and_then(|v| v.as_array())
516            .ok_or_else(|| Error::Mcp {
517                server: self.server_name.clone(),
518                message: "`tools/list` response missing `tools` array".into(),
519            })?;
520
521        let mut specs = Vec::with_capacity(tools_arr.len());
522        for item in tools_arr {
523            let name = item
524                .get("name")
525                .and_then(|v| v.as_str())
526                .ok_or_else(|| Error::Mcp {
527                    server: self.server_name.clone(),
528                    message: "tool entry missing `name`".into(),
529                })?;
530            let description = item
531                .get("description")
532                .and_then(|v| v.as_str())
533                .unwrap_or("");
534            let input_schema = item
535                .get("inputSchema")
536                .cloned()
537                .unwrap_or(serde_json::json!({"type": "object"}));
538            // Try to deserialize optional annotations field.
539            let annotations = item
540                .get("annotations")
541                .and_then(|v| serde_json::from_value::<McpToolAnnotations>(v.clone()).ok());
542            specs.push(McpToolSpec {
543                name: name.to_string(),
544                description: description.to_string(),
545                input_schema,
546                annotations,
547            });
548        }
549
550        Ok(specs)
551    }
552
553    /// Attach a shared elicitation-handler slot (Claude control channel).
554    pub fn with_elicitation(mut self, slot: SharedElicitationHandler) -> Self {
555        self.elicitation = Some(slot);
556        self
557    }
558
559    /// Call a tool by name with the given arguments.
560    /// Returns the textual content of the first `content[].text` block.
561    /// Errors if `isError` is true in the response.
562    ///
563    /// When the server returns JSON-RPC `-32042` (UrlElicitationRequired) and
564    /// an [`ElicitationHandler`] is installed, the host is asked via the
565    /// Claude control channel before the error is returned to the caller.
566    pub async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<String> {
567        let result: Value = match self
568            .send_request(
569                "tools/call",
570                serde_json::json!({
571                    "name": name,
572                    "arguments": arguments,
573                }),
574            )
575            .await
576        {
577            Ok(v) => v,
578            Err(Error::Mcp { server, message }) if message.contains("code -32042") => {
579                // UrlElicitationRequired — ask the host, then surface a clear error
580                // (retry is host-driven via mcp_call / re-invoke).
581                if let Some(slot) = &self.elicitation {
582                    let handler = slot.read().await.clone();
583                    if let Some(h) = handler {
584                        let req = parse_elicitation_from_error(&server, &message);
585                        let _ = h.elicit(req).await;
586                    }
587                }
588                return Err(Error::Mcp { server, message });
589            }
590            Err(e) => return Err(e),
591        };
592
593        // Check for error
594        if result
595            .get("isError")
596            .and_then(|v| v.as_bool())
597            .unwrap_or(false)
598        {
599            let error_msg = result
600                .get("content")
601                .and_then(|v| v.as_array())
602                .and_then(|arr| arr.first())
603                .and_then(|c| c.get("text"))
604                .and_then(|v| v.as_str())
605                .unwrap_or("unknown error");
606            return Err(Error::Tool {
607                name: name.to_string(),
608                call_id: None,
609                message: error_msg.to_string(),
610            });
611        }
612
613        // Extract text content
614        let content = result
615            .get("content")
616            .and_then(|v| v.as_array())
617            .map(|arr| {
618                arr.iter()
619                    .filter_map(|c| {
620                        if c.get("type").and_then(|v| v.as_str()) == Some("text") {
621                            c.get("text").and_then(|v| v.as_str())
622                        } else {
623                            None
624                        }
625                    })
626                    .collect::<Vec<_>>()
627                    .join("\n")
628            })
629            .unwrap_or_default();
630
631        Ok(content)
632    }
633
634    /// Call `resources/list` and return the discovered resources.
635    pub async fn list_resources(&mut self) -> Result<Vec<McpResource>> {
636        if !self.capabilities.resources {
637            return Err(Error::Mcp {
638                server: self.server_name.clone(),
639                message: "server does not advertise `resources` capability".into(),
640            });
641        }
642
643        let result: Value = self
644            .send_request("resources/list", serde_json::json!({}))
645            .await?;
646
647        let resources_arr = result
648            .get("resources")
649            .and_then(|v| v.as_array())
650            .ok_or_else(|| Error::Mcp {
651                server: self.server_name.clone(),
652                message: "`resources/list` response missing `resources` array".into(),
653            })?;
654
655        let mut resources = Vec::with_capacity(resources_arr.len());
656        for item in resources_arr {
657            let resource: McpResource =
658                serde_json::from_value(item.clone()).map_err(|e| Error::Mcp {
659                    server: self.server_name.clone(),
660                    message: format!("failed to parse resource: {e}"),
661                })?;
662            resources.push(resource);
663        }
664
665        Ok(resources)
666    }
667
668    /// Call `resources/read` for a specific resource URI.
669    /// Returns the list of content items.
670    pub async fn read_resource(&mut self, uri: &str) -> Result<Vec<McpResourceContent>> {
671        if !self.capabilities.resources {
672            return Err(Error::Mcp {
673                server: self.server_name.clone(),
674                message: "server does not advertise `resources` capability".into(),
675            });
676        }
677
678        let result: Value = self
679            .send_request("resources/read", serde_json::json!({ "uri": uri }))
680            .await?;
681
682        let contents_arr = result
683            .get("contents")
684            .and_then(|v| v.as_array())
685            .ok_or_else(|| Error::Mcp {
686                server: self.server_name.clone(),
687                message: "`resources/read` response missing `contents` array".into(),
688            })?;
689
690        let mut contents = Vec::with_capacity(contents_arr.len());
691        for item in contents_arr {
692            let content: McpResourceContent =
693                serde_json::from_value(item.clone()).map_err(|e| Error::Mcp {
694                    server: self.server_name.clone(),
695                    message: format!("failed to parse resource content: {e}"),
696                })?;
697            contents.push(content);
698        }
699
700        Ok(contents)
701    }
702
703    /// Call `prompts/list` and return the discovered prompts.
704    pub async fn list_prompts(&mut self) -> Result<Vec<McpPrompt>> {
705        if !self.capabilities.prompts {
706            return Err(Error::Mcp {
707                server: self.server_name.clone(),
708                message: "server does not advertise `prompts` capability".into(),
709            });
710        }
711
712        let result: Value = self
713            .send_request("prompts/list", serde_json::json!({}))
714            .await?;
715
716        let prompts_arr = result
717            .get("prompts")
718            .and_then(|v| v.as_array())
719            .ok_or_else(|| Error::Mcp {
720                server: self.server_name.clone(),
721                message: "`prompts/list` response missing `prompts` array".into(),
722            })?;
723
724        let mut prompts = Vec::with_capacity(prompts_arr.len());
725        for item in prompts_arr {
726            let prompt: McpPrompt =
727                serde_json::from_value(item.clone()).map_err(|e| Error::Mcp {
728                    server: self.server_name.clone(),
729                    message: format!("failed to parse prompt: {e}"),
730                })?;
731            prompts.push(prompt);
732        }
733
734        Ok(prompts)
735    }
736
737    /// Call `prompts/get` for a specific prompt name with optional arguments.
738    /// Returns the list of messages.
739    pub async fn get_prompt(
740        &mut self,
741        name: &str,
742        arguments: Option<HashMap<String, String>>,
743    ) -> Result<Vec<McpPromptMessage>> {
744        if !self.capabilities.prompts {
745            return Err(Error::Mcp {
746                server: self.server_name.clone(),
747                message: "server does not advertise `prompts` capability".into(),
748            });
749        }
750
751        let mut params = serde_json::json!({ "name": name });
752        if let Some(args) = arguments {
753            params["arguments"] = serde_json::to_value(args).map_err(|e| Error::Mcp {
754                server: self.server_name.clone(),
755                message: format!("failed to serialize prompt arguments: {e}"),
756            })?;
757        }
758
759        let result: Value = self.send_request("prompts/get", params).await?;
760
761        let messages_arr = result
762            .get("messages")
763            .and_then(|v| v.as_array())
764            .ok_or_else(|| Error::Mcp {
765                server: self.server_name.clone(),
766                message: "`prompts/get` response missing `messages` array".into(),
767            })?;
768
769        let mut messages = Vec::with_capacity(messages_arr.len());
770        for item in messages_arr {
771            let role = item
772                .get("role")
773                .and_then(|v| v.as_str())
774                .ok_or_else(|| Error::Mcp {
775                    server: self.server_name.clone(),
776                    message: "prompt message missing `role`".into(),
777                })?;
778            let content = item
779                .get("content")
780                .and_then(|v| v.get("text"))
781                .and_then(|v| v.as_str())
782                .ok_or_else(|| Error::Mcp {
783                    server: self.server_name.clone(),
784                    message: "prompt message missing `content.text`".into(),
785                })?;
786            messages.push(McpPromptMessage {
787                role: role.to_string(),
788                content: content.to_string(),
789            });
790        }
791
792        Ok(messages)
793    }
794
795    // -----------------------------------------------------------------------
796    // JSON-RPC 2.0 internals
797    // -----------------------------------------------------------------------
798
799    /// Send a JSON-RPC request and await the matching response.
800    async fn send_request(&mut self, method: &str, params: Value) -> Result<Value> {
801        let id = self.next_id;
802        self.next_id += 1;
803
804        let request = serde_json::json!({
805            "jsonrpc": "2.0",
806            "id": id,
807            "method": method,
808            "params": params,
809        });
810
811        self.write_line(&request).await?;
812        self.read_response(id).await
813    }
814
815    /// Send a JSON-RPC notification (no response expected).
816    async fn send_notification(&mut self, method: &str, params: Value) -> Result<()> {
817        let notification = serde_json::json!({
818            "jsonrpc": "2.0",
819            "method": method,
820            "params": params,
821        });
822
823        self.write_line(&notification).await
824    }
825
826    /// Write a JSON-RPC message via the active transport.
827    async fn write_line(&mut self, value: &Value) -> Result<()> {
828        match &mut self.transport {
829            McpTransport::Stdio { stdin, .. } => {
830                let line = serde_json::to_string(value)?;
831                let mut full = line.into_bytes();
832                full.push(b'\n');
833                stdin.write_all(&full).await.map_err(|e| Error::Mcp {
834                    server: self.server_name.clone(),
835                    message: format!("write error: {e}"),
836                })?;
837                stdin.flush().await.map_err(|e| Error::Mcp {
838                    server: self.server_name.clone(),
839                    message: format!("flush error: {e}"),
840                })?;
841                Ok(())
842            }
843            McpTransport::HttpSse {
844                client, post_url, ..
845            } => {
846                let url = post_url.as_ref().ok_or_else(|| Error::Mcp {
847                    server: self.server_name.clone(),
848                    message: "HTTP transport: no POST endpoint available".into(),
849                })?;
850                let body = serde_json::to_string(value)?;
851                let response = client
852                    .post(url)
853                    .header("Content-Type", "application/json")
854                    .body(body)
855                    .send()
856                    .await
857                    .map_err(|e| Error::Mcp {
858                        server: self.server_name.clone(),
859                        message: format!("HTTP POST error: {e}"),
860                    })?;
861                if !response.status().is_success() {
862                    return Err(Error::Mcp {
863                        server: self.server_name.clone(),
864                        message: format!(
865                            "HTTP POST to `{url}` returned HTTP {}",
866                            response.status()
867                        ),
868                    });
869                }
870                Ok(())
871            }
872        }
873    }
874
875    /// Read a JSON-RPC response matching the given id from the active transport.
876    async fn read_response(&mut self, expected_id: u64) -> Result<Value> {
877        match &mut self.transport {
878            McpTransport::Stdio { reader, .. } => {
879                Self::read_stdio_response(reader, expected_id, &self.server_name, self.read_timeout)
880                    .await
881            }
882            McpTransport::HttpSse {
883                client,
884                sse_url,
885                post_url,
886                buffer,
887            } => {
888                Self::read_sse_response(
889                    client,
890                    sse_url,
891                    post_url,
892                    buffer,
893                    expected_id,
894                    &self.server_name,
895                )
896                .await
897            }
898        }
899    }
900
901    /// Read a JSON-RPC response from stdio.
902    async fn read_stdio_response(
903        reader: &mut BufReader<ChildStdout>,
904        expected_id: u64,
905        server_name: &str,
906        read_timeout: Duration,
907    ) -> Result<Value> {
908        let mut line_buf = String::new();
909
910        loop {
911            line_buf.clear();
912
913            let read_future = reader.read_line(&mut line_buf);
914            match timeout(read_timeout, read_future).await {
915                Ok(Ok(0)) => {
916                    return Err(Error::Mcp {
917                        server: server_name.to_string(),
918                        message: "server closed stdout unexpectedly".into(),
919                    });
920                }
921                Ok(Ok(_)) => {
922                    let trimmed = line_buf.trim();
923                    if trimmed.is_empty() {
924                        continue;
925                    }
926
927                    let parsed: Value = serde_json::from_str(trimmed).map_err(|e| Error::Mcp {
928                        server: server_name.to_string(),
929                        message: format!("server returned non-JSON line: {e}; line: {trimmed}"),
930                    })?;
931
932                    if let Some(resp_id) = parsed.get("id") {
933                        if resp_id.as_u64() == Some(expected_id) {
934                            if let Some(err) = parsed.get("error") {
935                                let msg = err
936                                    .get("message")
937                                    .and_then(|v| v.as_str())
938                                    .unwrap_or("unknown error");
939                                let code = err.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
940                                return Err(Error::Mcp {
941                                    server: server_name.to_string(),
942                                    message: format!("error (code {code}): {msg}"),
943                                });
944                            }
945                            if let Some(result) = parsed.get("result") {
946                                return Ok(result.clone());
947                            }
948                            return Err(Error::Mcp {
949                                server: server_name.to_string(),
950                                message: "response missing both `result` and `error`".into(),
951                            });
952                        }
953                    }
954                }
955                Ok(Err(e)) => {
956                    return Err(Error::Mcp {
957                        server: server_name.to_string(),
958                        message: format!("read error: {e}"),
959                    });
960                }
961                Err(_) => {
962                    return Err(Error::Mcp {
963                        server: server_name.to_string(),
964                        message: format!(
965                            "server timed out (no response within {}s)",
966                            read_timeout.as_secs()
967                        ),
968                    });
969                }
970            }
971        }
972    }
973
974    /// Read a JSON-RPC response from an SSE stream.
975    async fn read_sse_response(
976        client: &reqwest::Client,
977        sse_url: &str,
978        _post_url: &Option<String>,
979        buffer: &mut String,
980        expected_id: u64,
981        server_name: &str,
982    ) -> Result<Value> {
983        // If we have buffered data, try to parse a response from it first.
984        if !buffer.is_empty() {
985            if let Some(result) = parse_sse_response(buffer, expected_id, server_name) {
986                return result;
987            }
988        }
989
990        // Otherwise, reconnect to the SSE stream and read more events.
991        let response = client
992            .get(sse_url)
993            .header("Accept", "text/event-stream")
994            .send()
995            .await
996            .map_err(|e| Error::Mcp {
997                server: server_name.to_string(),
998                message: format!("failed to reconnect to SSE endpoint `{sse_url}`: {e}"),
999            })?;
1000
1001        if !response.status().is_success() {
1002            return Err(Error::Mcp {
1003                server: server_name.to_string(),
1004                message: format!(
1005                    "SSE endpoint `{sse_url}` returned HTTP {}",
1006                    response.status()
1007                ),
1008            });
1009        }
1010
1011        let mut stream = response.bytes_stream();
1012
1013        loop {
1014            match timeout(Duration::from_secs(10), stream.next()).await {
1015                Ok(Some(Ok(chunk))) => {
1016                    let chunk_str = String::from_utf8_lossy(&chunk);
1017                    buffer.push_str(&chunk_str);
1018
1019                    if let Some(result) = parse_sse_response(buffer, expected_id, server_name) {
1020                        return result;
1021                    }
1022                }
1023                Ok(Some(Err(e))) => {
1024                    return Err(Error::Mcp {
1025                        server: server_name.to_string(),
1026                        message: format!("error reading SSE stream from `{sse_url}`: {e}"),
1027                    });
1028                }
1029                Ok(None) => {
1030                    return Err(Error::Mcp {
1031                        server: server_name.to_string(),
1032                        message: format!("SSE stream ended without response for id {expected_id}"),
1033                    });
1034                }
1035                Err(_) => {
1036                    return Err(Error::Mcp {
1037                        server: server_name.to_string(),
1038                        message: format!("SSE timed out waiting for response for id {expected_id}"),
1039                    });
1040                }
1041            }
1042        }
1043    }
1044}
1045
1046// ---------------------------------------------------------------------------
1047// SSE parsing helpers
1048// ---------------------------------------------------------------------------
1049
1050/// Parse an SSE stream buffer looking for an `event: endpoint` followed by
1051/// `data: <url>`. Returns the URL if found.
1052fn parse_sse_endpoint(buffer: &str) -> Option<String> {
1053    let mut current_event: Option<&str> = None;
1054
1055    for line in buffer.lines() {
1056        let line = line.trim();
1057        if line.starts_with("event:") {
1058            current_event = Some(line.strip_prefix("event:").unwrap_or("").trim());
1059        } else if line.starts_with("data:") && current_event == Some("endpoint") {
1060            let data = line.strip_prefix("data:").unwrap_or("").trim();
1061            if !data.is_empty() {
1062                return Some(data.to_string());
1063            }
1064        }
1065        // Reset event if we see a blank line (end of event)
1066        if line.is_empty() {
1067            current_event = None;
1068        }
1069    }
1070
1071    None
1072}
1073
1074/// Parse an SSE stream buffer looking for a JSON-RPC response with the
1075/// given id. Returns `Some(Ok(result))` or `Some(Err(...))` if found,
1076/// or `None` if not yet available.
1077fn parse_sse_response(buffer: &str, expected_id: u64, server_name: &str) -> Option<Result<Value>> {
1078    let mut current_data = String::new();
1079
1080    for line in buffer.lines() {
1081        let trimmed = line.trim();
1082        if trimmed.starts_with("data:") {
1083            let data = trimmed.strip_prefix("data:").unwrap_or("").trim();
1084            current_data.push_str(data);
1085        } else if trimmed.is_empty() && !current_data.is_empty() {
1086            // End of an SSE event — try to parse the accumulated data as JSON
1087            if let Ok(parsed) = serde_json::from_str::<Value>(&current_data) {
1088                if let Some(resp_id) = parsed.get("id") {
1089                    if resp_id.as_u64() == Some(expected_id) {
1090                        if let Some(err) = parsed.get("error") {
1091                            let msg = err
1092                                .get("message")
1093                                .and_then(|v| v.as_str())
1094                                .unwrap_or("unknown error");
1095                            let code = err.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
1096                            return Some(Err(Error::Mcp {
1097                                server: server_name.to_string(),
1098                                message: format!("error (code {code}): {msg}"),
1099                            }));
1100                        }
1101                        if let Some(result) = parsed.get("result") {
1102                            return Some(Ok(result.clone()));
1103                        }
1104                        return Some(Err(Error::Mcp {
1105                            server: server_name.to_string(),
1106                            message: "response missing both `result` and `error`".into(),
1107                        }));
1108                    }
1109                }
1110            }
1111            current_data.clear();
1112        } else if !trimmed.starts_with("event:")
1113            && !trimmed.starts_with("data:")
1114            && !trimmed.is_empty()
1115        {
1116            // Non-data line that's not event/data — reset data accumulator
1117            // (SSE spec: unknown fields are ignored, but data is per-event)
1118            if !trimmed.starts_with(':')
1119                && !trimmed.starts_with("id:")
1120                && !trimmed.starts_with("retry:")
1121            {
1122                current_data.clear();
1123            }
1124        }
1125    }
1126
1127    // Also check if the buffer ends with a complete JSON object (no trailing newline)
1128    if !current_data.is_empty() {
1129        if let Ok(parsed) = serde_json::from_str::<Value>(&current_data) {
1130            if let Some(resp_id) = parsed.get("id") {
1131                if resp_id.as_u64() == Some(expected_id) {
1132                    if let Some(err) = parsed.get("error") {
1133                        let msg = err
1134                            .get("message")
1135                            .and_then(|v| v.as_str())
1136                            .unwrap_or("unknown error");
1137                        let code = err.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
1138                        return Some(Err(Error::Mcp {
1139                            server: server_name.to_string(),
1140                            message: format!("error (code {code}): {msg}"),
1141                        }));
1142                    }
1143                    if let Some(result) = parsed.get("result") {
1144                        return Some(Ok(result.clone()));
1145                    }
1146                }
1147            }
1148        }
1149    }
1150
1151    None
1152}
1153
1154// ---------------------------------------------------------------------------
1155// JSON-RPC 2.0 types for stdio MCP server mode
1156// ---------------------------------------------------------------------------
1157
1158/// A JSON-RPC 2.0 request.
1159#[derive(Debug, Clone, Serialize, Deserialize)]
1160pub struct JsonRpcRequest {
1161    pub jsonrpc: String,
1162    #[serde(default, skip_serializing_if = "Option::is_none")]
1163    pub id: Option<serde_json::Value>,
1164    pub method: String,
1165    #[serde(default)]
1166    pub params: serde_json::Value,
1167}
1168
1169/// A JSON-RPC 2.0 response.
1170#[derive(Debug, Clone, Serialize, Deserialize)]
1171pub struct JsonRpcResponse {
1172    pub jsonrpc: String,
1173    #[serde(default, skip_serializing_if = "Option::is_none")]
1174    pub id: Option<serde_json::Value>,
1175    #[serde(default, skip_serializing_if = "Option::is_none")]
1176    pub result: Option<serde_json::Value>,
1177    #[serde(default, skip_serializing_if = "Option::is_none")]
1178    pub error: Option<JsonRpcError>,
1179}
1180
1181impl JsonRpcResponse {
1182    /// Create a successful response.
1183    pub fn success(id: Option<serde_json::Value>, result: serde_json::Value) -> Self {
1184        Self {
1185            jsonrpc: "2.0".to_string(),
1186            id,
1187            result: Some(result),
1188            error: None,
1189        }
1190    }
1191
1192    /// Create an error response.
1193    pub fn error(id: Option<serde_json::Value>, code: i64, message: impl Into<String>) -> Self {
1194        Self {
1195            jsonrpc: "2.0".to_string(),
1196            id,
1197            result: None,
1198            error: Some(JsonRpcError {
1199                code,
1200                message: message.into(),
1201                data: None,
1202            }),
1203        }
1204    }
1205
1206    /// Create a method-not-found error response.
1207    pub fn method_not_found(id: Option<serde_json::Value>, method: &str) -> Self {
1208        Self::error(id, -32601, format!("Method not found: {method}"))
1209    }
1210
1211    /// Create an internal error response.
1212    pub fn internal_error(id: Option<serde_json::Value>, message: impl Into<String>) -> Self {
1213        Self::error(id, -32603, message)
1214    }
1215}
1216
1217/// A JSON-RPC 2.0 error object.
1218#[derive(Debug, Clone, Serialize, Deserialize)]
1219pub struct JsonRpcError {
1220    pub code: i64,
1221    pub message: String,
1222    #[serde(default, skip_serializing_if = "Option::is_none")]
1223    pub data: Option<serde_json::Value>,
1224}
1225
1226/// Dispatch a JSON-RPC request to the appropriate handler.
1227///
1228/// Supports the MCP methods needed for tool proxy:
1229/// - `initialize` / `notifications/initialized`
1230/// - `tools/list` / `tools/call`
1231/// - `resources/list` / `resources/read`
1232/// - `prompts/list` / `prompts/get`
1233///
1234/// Returns a JSON-RPC response. Notifications (no `id`) return `None`.
1235pub async fn dispatch_request(
1236    request: &JsonRpcRequest,
1237    client: &mut McpClient,
1238) -> Option<JsonRpcResponse> {
1239    let id = request.id.clone();
1240
1241    match request.method.as_str() {
1242        "initialize" => {
1243            // The client is already initialized by McpClient::spawn, so we
1244            // return a canned response matching what the server would expect.
1245            let result = serde_json::json!({
1246                "protocolVersion": "2024-11-05",
1247                "capabilities": {
1248                    "tools": true,
1249                    "resources": true,
1250                    "prompts": true
1251                },
1252                "serverInfo": {
1253                    "name": "recursive-agent",
1254                    "version": "0.1.0"
1255                }
1256            });
1257            Some(JsonRpcResponse::success(id, result))
1258        }
1259        "notifications/initialized" => {
1260            // No response expected for notifications.
1261            None
1262        }
1263        "tools/list" => match client.list_tools().await {
1264            Ok(tools) => {
1265                let tools_arr: Vec<serde_json::Value> = tools
1266                    .into_iter()
1267                    .map(|t| {
1268                        serde_json::json!({
1269                            "name": t.name,
1270                            "description": t.description,
1271                            "inputSchema": t.input_schema,
1272                        })
1273                    })
1274                    .collect();
1275                Some(JsonRpcResponse::success(
1276                    id,
1277                    serde_json::json!({ "tools": tools_arr }),
1278                ))
1279            }
1280            Err(e) => Some(JsonRpcResponse::internal_error(id, e.to_string())),
1281        },
1282        "tools/call" => {
1283            let name = request
1284                .params
1285                .get("name")
1286                .and_then(|v| v.as_str())
1287                .unwrap_or("");
1288            let arguments = request.params.get("arguments").cloned().unwrap_or_default();
1289
1290            match client.call_tool(name, arguments).await {
1291                Ok(text) => {
1292                    let result = serde_json::json!({
1293                        "content": [{"type": "text", "text": text}]
1294                    });
1295                    Some(JsonRpcResponse::success(id, result))
1296                }
1297                Err(e) => {
1298                    // Return the error as a tool-level isError result, not a
1299                    // JSON-RPC error, so the client can handle it gracefully.
1300                    let result = serde_json::json!({
1301                        "isError": true,
1302                        "content": [{"type": "text", "text": e.to_string()}]
1303                    });
1304                    Some(JsonRpcResponse::success(id, result))
1305                }
1306            }
1307        }
1308        "resources/list" => match client.list_resources().await {
1309            Ok(resources) => {
1310                let resources_arr: Vec<serde_json::Value> = resources
1311                    .into_iter()
1312                    .map(|r| {
1313                        serde_json::json!({
1314                            "uri": r.uri,
1315                            "name": r.name,
1316                            "description": r.description,
1317                            "mimeType": r.mime_type,
1318                        })
1319                    })
1320                    .collect();
1321                Some(JsonRpcResponse::success(
1322                    id,
1323                    serde_json::json!({ "resources": resources_arr }),
1324                ))
1325            }
1326            Err(e) => Some(JsonRpcResponse::internal_error(id, e.to_string())),
1327        },
1328        "resources/read" => {
1329            let uri = request
1330                .params
1331                .get("uri")
1332                .and_then(|v| v.as_str())
1333                .unwrap_or("");
1334
1335            match client.read_resource(uri).await {
1336                Ok(contents) => {
1337                    let contents_arr: Vec<serde_json::Value> = contents
1338                        .into_iter()
1339                        .map(|c| {
1340                            serde_json::json!({
1341                                "uri": c.uri,
1342                                "mimeType": c.mime_type,
1343                                "text": c.text,
1344                                "blob": c.blob,
1345                            })
1346                        })
1347                        .collect();
1348                    Some(JsonRpcResponse::success(
1349                        id,
1350                        serde_json::json!({ "contents": contents_arr }),
1351                    ))
1352                }
1353                Err(e) => Some(JsonRpcResponse::internal_error(id, e.to_string())),
1354            }
1355        }
1356        "prompts/list" => match client.list_prompts().await {
1357            Ok(prompts) => {
1358                let prompts_arr: Vec<serde_json::Value> = prompts
1359                    .into_iter()
1360                    .map(|p| {
1361                        serde_json::json!({
1362                            "name": p.name,
1363                            "description": p.description,
1364                            "arguments": p.arguments,
1365                        })
1366                    })
1367                    .collect();
1368                Some(JsonRpcResponse::success(
1369                    id,
1370                    serde_json::json!({ "prompts": prompts_arr }),
1371                ))
1372            }
1373            Err(e) => Some(JsonRpcResponse::internal_error(id, e.to_string())),
1374        },
1375        "prompts/get" => {
1376            let name = request
1377                .params
1378                .get("name")
1379                .and_then(|v| v.as_str())
1380                .unwrap_or("");
1381            let arguments = request
1382                .params
1383                .get("arguments")
1384                .and_then(|v| serde_json::from_value::<HashMap<String, String>>(v.clone()).ok());
1385
1386            match client.get_prompt(name, arguments).await {
1387                Ok(messages) => {
1388                    let messages_arr: Vec<serde_json::Value> = messages
1389                        .into_iter()
1390                        .map(|m| {
1391                            serde_json::json!({
1392                                "role": m.role,
1393                                "content": {"type": "text", "text": m.content},
1394                            })
1395                        })
1396                        .collect();
1397                    Some(JsonRpcResponse::success(
1398                        id,
1399                        serde_json::json!({ "messages": messages_arr }),
1400                    ))
1401                }
1402                Err(e) => Some(JsonRpcResponse::internal_error(id, e.to_string())),
1403            }
1404        }
1405        _ => Some(JsonRpcResponse::method_not_found(id, &request.method)),
1406    }
1407}
1408
1409fn parse_elicitation_from_error(server: &str, message: &str) -> ElicitationRequest {
1410    // message format: "error (code -32042): <msg>" — optional JSON data suffix
1411    let human = message
1412        .split_once(": ")
1413        .map(|(_, rest)| rest.to_string())
1414        .unwrap_or_else(|| message.to_string());
1415    ElicitationRequest {
1416        mcp_server_name: server.to_string(),
1417        message: human,
1418        mode: Some("url".into()),
1419        url: None,
1420        elicitation_id: None,
1421        requested_schema: None,
1422        title: None,
1423        display_name: None,
1424        description: None,
1425    }
1426}
1427
1428// ---------------------------------------------------------------------------
1429// McpTool — implements the Tool trait by delegating to an McpClient
1430// ---------------------------------------------------------------------------
1431
1432/// A tool that wraps an MCP server tool. Registered with a namespaced name
1433/// `mcp__<server_name>__<tool_name>`.
1434pub struct McpTool {
1435    client: Arc<Mutex<McpClient>>,
1436    spec: McpToolSpec,
1437    server_name: String,
1438    /// Trust level of the originating server. Controls whether annotation
1439    /// hints are used to derive [`ToolSideEffect`].
1440    trust: McpServerTrust,
1441}
1442
1443impl McpTool {
1444    pub fn new(
1445        client: Arc<Mutex<McpClient>>,
1446        spec: McpToolSpec,
1447        server_name: impl Into<String>,
1448    ) -> Self {
1449        Self {
1450            client,
1451            spec,
1452            server_name: server_name.into(),
1453            trust: McpServerTrust::Untrusted,
1454        }
1455    }
1456
1457    /// Builder: set the trust level for this tool's server.
1458    pub fn with_trust(mut self, trust: McpServerTrust) -> Self {
1459        self.trust = trust;
1460        self
1461    }
1462}
1463
1464#[async_trait]
1465impl Tool for McpTool {
1466    fn is_deferred(&self) -> bool {
1467        true
1468    }
1469
1470    fn spec(&self) -> ToolSpec {
1471        ToolSpec {
1472            name: format!("mcp__{}__{}", self.server_name, self.spec.name),
1473            description: format!("[mcp:{}] {}", self.server_name, self.spec.description),
1474            parameters: self.spec.input_schema.clone(),
1475        }
1476    }
1477
1478    fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
1479        use crate::tools::ToolSideEffect;
1480        // Conservative default when server is untrusted or annotations missing.
1481        let Some(ann) = &self.spec.annotations else {
1482            return ToolSideEffect::External;
1483        };
1484        if self.trust != McpServerTrust::Trusted {
1485            return ToolSideEffect::External;
1486        }
1487        if ann.read_only_hint {
1488            ToolSideEffect::ReadOnly
1489        } else if ann.open_world_hint {
1490            ToolSideEffect::External
1491        } else {
1492            ToolSideEffect::Mutating
1493        }
1494    }
1495
1496    async fn execute(&self, arguments: Value) -> Result<String> {
1497        let mut client = self.client.lock().await;
1498        client.call_tool(&self.spec.name, arguments).await
1499    }
1500}
1501
1502// ---------------------------------------------------------------------------
1503// Config loading
1504// ---------------------------------------------------------------------------
1505
1506/// Load MCP server configurations from a JSON file.
1507/// Expected format:
1508/// ```json
1509/// { "servers": [ { "name": "...", "command": "...", "args": [...] }, { "name": "...", "url": "http://..." } ] }
1510/// ```
1511pub fn load_mcp_config(path: &std::path::Path) -> Result<Vec<McpServer>> {
1512    let contents = std::fs::read_to_string(path).map_err(|e| Error::Mcp {
1513        server: "config".into(),
1514        message: format!("failed to read config `{}`: {e}", path.display()),
1515    })?;
1516    let parsed: McpConfigFile = serde_json::from_str(&contents).map_err(|e| Error::Mcp {
1517        server: "config".into(),
1518        message: format!("failed to parse config `{}`: {e}", path.display()),
1519    })?;
1520    Ok(parsed.servers)
1521}
1522
1523#[derive(Debug, Deserialize)]
1524struct McpConfigFile {
1525    servers: Vec<McpServer>,
1526}
1527
1528// ---------------------------------------------------------------------------
1529// Workspace discovery (Claude Code .mcp.json format)
1530// ---------------------------------------------------------------------------
1531
1532/// Top-level structure of a Claude Code `.mcp.json` file.
1533#[derive(Debug, Deserialize)]
1534#[serde(deny_unknown_fields)]
1535struct McpDiscoveryFile {
1536    #[serde(rename = "mcpServers")]
1537    mcp_servers: HashMap<String, McpServerConfig>,
1538}
1539
1540/// Discover MCP server configurations from the workspace.
1541///
1542/// Looks for (in priority order):
1543/// 1. `<workspace>/.mcp.json` (Claude Code format)
1544/// 2. `<workspace>/.recursive/mcp.json` (alternative location)
1545///
1546/// Returns an empty vec if neither file exists (not an error).
1547pub async fn discover_mcp_servers(workspace: &Path) -> Result<Vec<McpServer>> {
1548    // Priority 1: workspace root .mcp.json
1549    let primary = workspace.join(".mcp.json");
1550    if primary.exists() {
1551        let configs = load_mcp_discovery_config(&primary).await?;
1552        if !configs.is_empty() {
1553            return Ok(configs);
1554        }
1555    }
1556
1557    // Priority 2: .recursive/mcp.json
1558    let fallback = workspace.join(".recursive").join("mcp.json");
1559    if fallback.exists() {
1560        let configs = load_mcp_discovery_config(&fallback).await?;
1561        return Ok(configs);
1562    }
1563
1564    Ok(Vec::new())
1565}
1566
1567/// Parse a Claude Code `.mcp.json` file into `Vec<McpServer>`.
1568///
1569/// Expected format:
1570/// ```json
1571/// {
1572///   "mcpServers": {
1573///     "server-name": {
1574///       "command": "path/to/server",
1575///       "args": ["--flag"],
1576///       "env": { "KEY": "value" }
1577///     }
1578///   }
1579/// }
1580/// ```
1581/// Or for HTTP+SSE:
1582/// ```json
1583/// {
1584///   "mcpServers": {
1585///     "server-name": {
1586///       "url": "http://localhost:3000/sse"
1587///     }
1588///   }
1589/// }
1590/// ```
1591async fn load_mcp_discovery_config(path: &Path) -> Result<Vec<McpServer>> {
1592    let contents = tokio::fs::read_to_string(path)
1593        .await
1594        .map_err(|e| Error::Mcp {
1595            server: "discovery".into(),
1596            message: format!("failed to read discovery config `{}`: {e}", path.display()),
1597        })?;
1598
1599    // Handle empty file gracefully
1600    if contents.trim().is_empty() {
1601        return Ok(Vec::new());
1602    }
1603
1604    let parsed: McpDiscoveryFile = serde_json::from_str(&contents).map_err(|e| Error::Mcp {
1605        server: "discovery".into(),
1606        message: format!("failed to parse discovery config `{}`: {e}", path.display()),
1607    })?;
1608
1609    let servers: Vec<McpServer> = parsed
1610        .mcp_servers
1611        .into_iter()
1612        .map(McpServer::from)
1613        .collect();
1614
1615    Ok(servers)
1616}
1617
1618// ---------------------------------------------------------------------------
1619// Kill-gracefully: SIGTERM → grace period → SIGKILL (S2-E15)
1620// ---------------------------------------------------------------------------
1621
1622/// Result of a graceful process kill.
1623#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1624pub enum KillOutcome {
1625    /// Process exited gracefully after SIGTERM.
1626    Graceful,
1627    /// Process did not exit in time; SIGKILL was sent.
1628    Timeout,
1629}
1630
1631/// Kill a child process gracefully: send SIGTERM, wait for the grace
1632/// period, then send SIGKILL if still alive.
1633///
1634/// On non-Unix platforms (Windows), falls back to `child.kill()` immediately
1635/// since SIGTERM is not well-defined.
1636///
1637/// # Arguments
1638///
1639/// * `child` - The child process to kill.
1640/// * `grace_period` - How long to wait after SIGTERM before sending SIGKILL.
1641/// * `server_name` - Name of the server (for logging).
1642///
1643/// # Returns
1644///
1645/// The [`KillOutcome`] indicating whether the process exited gracefully
1646/// or had to be killed.
1647pub async fn kill_gracefully(
1648    child: &mut tokio::process::Child,
1649    grace_period: std::time::Duration,
1650    server_name: &str,
1651) -> KillOutcome {
1652    #[cfg(unix)]
1653    {
1654        use std::os::unix::process::ExitStatusExt;
1655        use tokio::time::timeout;
1656
1657        // Phase 1: send SIGTERM
1658        tracing::info!(server = %server_name, "kill-gracefully: sending SIGTERM");
1659        let _ = child.start_kill(); // sends SIGTERM on Unix
1660
1661        // Phase 2: wait for grace period
1662        match timeout(grace_period, child.wait()).await {
1663            Ok(Ok(status)) => {
1664                if let Some(signal) = status.signal() {
1665                    tracing::info!(
1666                        server = %server_name,
1667                        signal = signal,
1668                        "kill-gracefully: process exited via signal {signal}"
1669                    );
1670                } else {
1671                    tracing::info!(
1672                        server = %server_name,
1673                        "kill-gracefully: process exited gracefully"
1674                    );
1675                }
1676                KillOutcome::Graceful
1677            }
1678            Ok(Err(e)) => {
1679                // wait() error -- try SIGKILL as fallback
1680                tracing::warn!(
1681                    server = %server_name,
1682                    error = %e,
1683                    "kill-gracefully: wait() error after SIGTERM, sending SIGKILL"
1684                );
1685                let _ = child.start_kill();
1686                let _ = child.wait().await;
1687                KillOutcome::Timeout
1688            }
1689            Err(_elapsed) => {
1690                // Phase 3: timeout -- send SIGKILL
1691                tracing::warn!(
1692                    server = %server_name,
1693                    grace_ms = grace_period.as_millis(),
1694                    "kill-gracefully: SIGTERM timeout, sending SIGKILL"
1695                );
1696                // On Unix, start_kill sends SIGKILL after a SIGTERM for a process
1697                // that hasn't exited. We need to forcefully kill.
1698                let pid = child.id().unwrap_or(0);
1699                let _ = std::process::Command::new("kill")
1700                    .args(["-9", &pid.to_string()])
1701                    .status();
1702                let _ = child.wait().await;
1703                KillOutcome::Timeout
1704            }
1705        }
1706    }
1707
1708    #[cfg(not(unix))]
1709    {
1710        let _ = (grace_period, server_name);
1711        tracing::info!("kill-gracefully: non-Unix, sending kill immediately");
1712        let _ = child.start_kill();
1713        let _ = child.wait().await;
1714        KillOutcome::Graceful
1715    }
1716}
1717
1718/// Default grace period for MCP subprocess shutdown (5 seconds).
1719pub const DEFAULT_KILL_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_secs(5);
1720
1721// ---------------------------------------------------------------------------
1722// MCP tool routing (S2-E17)
1723// ---------------------------------------------------------------------------
1724
1725/// A mapping from tool name prefix to MCP server name.
1726///
1727/// When the agent issues a `tool_call`, the routing layer checks this
1728/// mapping to determine which MCP server should handle the call. Tools
1729/// that don't match any prefix are treated as native (non-MCP) tools
1730/// and are handled by the local ToolRegistry.
1731#[derive(Debug, Clone, Default)]
1732pub struct McpToolRouter {
1733    /// Maps server name → list of tool name prefixes registered by that server.
1734    /// Lookup is O(n * m) where n = servers, m = prefixes per server,
1735    /// but the number of servers is small (typically < 10).
1736    server_prefixes: std::collections::HashMap<String, Vec<String>>,
1737}
1738
1739impl McpToolRouter {
1740    /// Create a new empty router.
1741    pub fn new() -> Self {
1742        Self::default()
1743    }
1744
1745    /// Register tools from a server. The `tool_names` are the raw tool names
1746    /// (without the `mcp__server__` prefix), and they are stored as-is for
1747    /// exact-match routing. Callers should also register prefixed names
1748    /// (e.g. `mcp__server__tool_name`) for MCP-prefixed dispatch.
1749    pub fn register_server(&mut self, server_name: &str, tool_names: &[String]) {
1750        let entry = self.server_prefixes.entry(server_name.to_string());
1751        let prefixes = entry.or_default();
1752        for name in tool_names {
1753            // Store both the raw name and the MCP-prefixed name.
1754            let prefixed = format!("mcp__{}__{}", server_name, name);
1755            if !prefixes.contains(name) {
1756                prefixes.push(name.clone());
1757            }
1758            if !prefixes.contains(&prefixed) {
1759                prefixes.push(prefixed);
1760            }
1761        }
1762    }
1763
1764    /// Route a tool name to the server that handles it.
1765    ///
1766    /// Returns `Some(server_name)` if the tool matches any registered prefix.
1767    /// Returns `None` if no server handles this tool (it's a native tool).
1768    pub fn route(&self, tool_name: &str) -> Option<&str> {
1769        // Check MCP-prefixed names first: `mcp__<server>__<tool>`
1770        for (server, prefixes) in &self.server_prefixes {
1771            if prefixes.iter().any(|p| p == tool_name) {
1772                return Some(server.as_str());
1773            }
1774        }
1775        // Check raw names (exact match)
1776        for (server, prefixes) in &self.server_prefixes {
1777            if prefixes.iter().any(|p| p == tool_name) {
1778                return Some(server.as_str());
1779            }
1780        }
1781        None
1782    }
1783}
1784
1785// ---------------------------------------------------------------------------
1786// Tests
1787// ---------------------------------------------------------------------------
1788
1789#[cfg(test)]
1790mod tests {
1791    use super::*;
1792
1793    // NOTE: end-to-end tests against a mock MCP server live in
1794    // `tests/mcp_e2e.rs`. They cannot live here because the
1795    // `CARGO_BIN_EXE_mock_mcp_server` env var that resolves the mock
1796    // server binary's path is only injected by cargo for integration
1797    // tests under `tests/`, not for unit tests inside `src/`.
1798
1799    #[test]
1800    fn load_mcp_config_parses_correctly() {
1801        let dir = tempfile::TempDir::new().unwrap();
1802        let path = dir.path().join("mcp.json");
1803        std::fs::write(
1804            &path,
1805            r#"{
1806                "servers": [
1807                    {"name": "fs", "command": "mcp-fs", "args": ["--root", "."]},
1808                    {"name": "github", "command": "mcp-gh", "args": []}
1809                ]
1810            }"#,
1811        )
1812        .unwrap();
1813
1814        let servers = load_mcp_config(&path).unwrap();
1815        assert_eq!(servers.len(), 2);
1816        assert_eq!(servers[0].name, "fs");
1817        assert_eq!(servers[0].command, "mcp-fs");
1818        assert_eq!(servers[0].args, vec!["--root", "."]);
1819        assert_eq!(servers[1].name, "github");
1820        assert_eq!(servers[1].command, "mcp-gh");
1821        assert!(servers[1].args.is_empty());
1822    }
1823
1824    #[test]
1825    fn load_mcp_config_empty_servers() {
1826        let dir = tempfile::TempDir::new().unwrap();
1827        let path = dir.path().join("empty.json");
1828        std::fs::write(&path, r#"{"servers": []}"#).unwrap();
1829
1830        let servers = load_mcp_config(&path).unwrap();
1831        assert!(servers.is_empty());
1832    }
1833
1834    #[test]
1835    fn load_mcp_config_missing_file_errors() {
1836        let dir = tempfile::TempDir::new().unwrap();
1837        let path = dir.path().join("nonexistent.json");
1838        let err = load_mcp_config(&path).unwrap_err();
1839        assert!(err.to_string().contains("failed to read config"));
1840    }
1841
1842    // -----------------------------------------------------------------------
1843    // Discovery tests
1844    // -----------------------------------------------------------------------
1845
1846    #[tokio::test]
1847    async fn discover_finds_dot_mcp_json_in_workspace_root() {
1848        let dir = tempfile::TempDir::new().unwrap();
1849        let mcp_path = dir.path().join(".mcp.json");
1850        tokio::fs::write(
1851            &mcp_path,
1852            r#"{
1853                "mcpServers": {
1854                    "fs": {
1855                        "command": "mcp-fs",
1856                        "args": ["--root", "."]
1857                    }
1858                }
1859            }"#,
1860        )
1861        .await
1862        .unwrap();
1863
1864        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1865        assert_eq!(servers.len(), 1);
1866        assert_eq!(servers[0].name, "fs");
1867        assert_eq!(servers[0].command, "mcp-fs");
1868        assert_eq!(servers[0].args, vec!["--root", "."]);
1869    }
1870
1871    #[tokio::test]
1872    async fn discover_returns_empty_vec_when_no_config_file() {
1873        let dir = tempfile::TempDir::new().unwrap();
1874        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1875        assert!(servers.is_empty());
1876    }
1877
1878    #[tokio::test]
1879    async fn discover_parses_claude_code_format_with_env() {
1880        let dir = tempfile::TempDir::new().unwrap();
1881        let mcp_path = dir.path().join(".mcp.json");
1882        tokio::fs::write(
1883            &mcp_path,
1884            r#"{
1885                "mcpServers": {
1886                    "github": {
1887                        "command": "mcp-gh",
1888                        "args": [],
1889                        "env": {
1890                            "GITHUB_TOKEN": "abc123"
1891                        }
1892                    },
1893                    "filesystem": {
1894                        "command": "mcp-fs",
1895                        "args": ["--root", "/tmp"]
1896                    }
1897                }
1898            }"#,
1899        )
1900        .await
1901        .unwrap();
1902
1903        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1904        assert_eq!(servers.len(), 2);
1905
1906        let gh = servers.iter().find(|s| s.name == "github").unwrap();
1907        assert_eq!(gh.command, "mcp-gh");
1908        assert!(gh.args.is_empty());
1909
1910        let fs = servers.iter().find(|s| s.name == "filesystem").unwrap();
1911        assert_eq!(fs.command, "mcp-fs");
1912        assert_eq!(fs.args, vec!["--root", "/tmp"]);
1913    }
1914
1915    #[tokio::test]
1916    async fn discover_finds_dot_recursive_mcp_json_as_fallback() {
1917        let dir = tempfile::TempDir::new().unwrap();
1918        let recursive_dir = dir.path().join(".recursive");
1919        tokio::fs::create_dir(&recursive_dir).await.unwrap();
1920        let mcp_path = recursive_dir.join("mcp.json");
1921        tokio::fs::write(
1922            &mcp_path,
1923            r#"{
1924                "mcpServers": {
1925                    "db": {
1926                        "command": "mcp-db",
1927                        "args": ["--port", "5432"]
1928                    }
1929                }
1930            }"#,
1931        )
1932        .await
1933        .unwrap();
1934
1935        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1936        assert_eq!(servers.len(), 1);
1937        assert_eq!(servers[0].name, "db");
1938        assert_eq!(servers[0].command, "mcp-db");
1939    }
1940
1941    #[tokio::test]
1942    async fn discover_dot_mcp_json_takes_priority_over_dot_recursive() {
1943        let dir = tempfile::TempDir::new().unwrap();
1944
1945        // Primary .mcp.json
1946        tokio::fs::write(
1947            dir.path().join(".mcp.json"),
1948            r#"{
1949                "mcpServers": {
1950                    "primary": {
1951                        "command": "primary-server",
1952                        "args": []
1953                    }
1954                }
1955            }"#,
1956        )
1957        .await
1958        .unwrap();
1959
1960        // Fallback .recursive/mcp.json
1961        let recursive_dir = dir.path().join(".recursive");
1962        tokio::fs::create_dir(&recursive_dir).await.unwrap();
1963        tokio::fs::write(
1964            recursive_dir.join("mcp.json"),
1965            r#"{
1966                "mcpServers": {
1967                    "fallback": {
1968                        "command": "fallback-server",
1969                        "args": []
1970                    }
1971                }
1972            }"#,
1973        )
1974        .await
1975        .unwrap();
1976
1977        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1978        assert_eq!(servers.len(), 1);
1979        assert_eq!(servers[0].name, "primary");
1980    }
1981
1982    #[tokio::test]
1983    async fn discover_malformed_json_returns_descriptive_error() {
1984        let dir = tempfile::TempDir::new().unwrap();
1985        tokio::fs::write(dir.path().join(".mcp.json"), "not valid json")
1986            .await
1987            .unwrap();
1988
1989        let err = discover_mcp_servers(dir.path()).await.unwrap_err();
1990        let msg = err.to_string();
1991        assert!(
1992            msg.contains("failed to parse discovery config"),
1993            "error should mention parsing failure: {msg}"
1994        );
1995    }
1996
1997    #[tokio::test]
1998    async fn discover_empty_file_returns_empty_vec() {
1999        let dir = tempfile::TempDir::new().unwrap();
2000        tokio::fs::write(dir.path().join(".mcp.json"), "")
2001            .await
2002            .unwrap();
2003
2004        let servers = discover_mcp_servers(dir.path()).await.unwrap();
2005        assert!(servers.is_empty());
2006    }
2007
2008    #[tokio::test]
2009    async fn discover_empty_mcp_servers_object_returns_empty_vec() {
2010        let dir = tempfile::TempDir::new().unwrap();
2011        tokio::fs::write(dir.path().join(".mcp.json"), r#"{"mcpServers": {}}"#)
2012            .await
2013            .unwrap();
2014
2015        let servers = discover_mcp_servers(dir.path()).await.unwrap();
2016        assert!(servers.is_empty());
2017    }
2018
2019    // -----------------------------------------------------------------------
2020    // HTTP+SSE transport tests
2021    // -----------------------------------------------------------------------
2022
2023    #[tokio::test]
2024    async fn test_http_sse_discovery_with_url() {
2025        let dir = tempfile::TempDir::new().unwrap();
2026        let mcp_path = dir.path().join(".mcp.json");
2027        tokio::fs::write(
2028            &mcp_path,
2029            r#"{
2030                "mcpServers": {
2031                    "remote": {
2032                        "url": "http://example.com/sse"
2033                    }
2034                }
2035            }"#,
2036        )
2037        .await
2038        .unwrap();
2039
2040        let servers = discover_mcp_servers(dir.path()).await.unwrap();
2041        assert_eq!(servers.len(), 1);
2042        assert_eq!(servers[0].name, "remote");
2043        assert_eq!(servers[0].command, "");
2044        assert!(servers[0].args.is_empty());
2045        assert_eq!(servers[0].url.as_deref(), Some("http://example.com/sse"));
2046    }
2047
2048    #[test]
2049    fn test_parse_sse_endpoint() {
2050        let buffer = "event: endpoint\ndata: http://localhost:3000/message\n\n";
2051        assert_eq!(
2052            parse_sse_endpoint(buffer),
2053            Some("http://localhost:3000/message".to_string())
2054        );
2055
2056        // No endpoint event
2057        let buffer = "event: message\ndata: {\"key\": \"value\"}\n\n";
2058        assert_eq!(parse_sse_endpoint(buffer), None);
2059
2060        // Empty data
2061        let buffer = "event: endpoint\ndata: \n\n";
2062        assert_eq!(parse_sse_endpoint(buffer), None);
2063    }
2064
2065    #[test]
2066    fn test_parse_sse_response() {
2067        let buffer = "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"key\":\"value\"}}\n\n";
2068        let result = parse_sse_response(buffer, 1, "test");
2069        assert!(result.is_some());
2070        let result = result.unwrap().unwrap();
2071        assert_eq!(result.get("key").and_then(|v| v.as_str()), Some("value"));
2072
2073        // Wrong id
2074        let result = parse_sse_response(buffer, 2, "test");
2075        assert!(result.is_none());
2076
2077        // Error response
2078        let buffer =
2079            "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32601,\"message\":\"Method not found\"}}\n\n";
2080        let result = parse_sse_response(buffer, 1, "test");
2081        assert!(result.is_some());
2082        assert!(result.unwrap().is_err());
2083    }
2084
2085    #[test]
2086    fn test_parse_sse_response_multiline_data() {
2087        let buffer =
2088            "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\"\ndata: :{\"key\":\"value\"}}\n\n";
2089        let result = parse_sse_response(buffer, 1, "test");
2090        assert!(result.is_some());
2091        let result = result.unwrap().unwrap();
2092        assert_eq!(result.get("key").and_then(|v| v.as_str()), Some("value"));
2093    }
2094
2095    #[test]
2096    fn test_parse_sse_response_empty_data_lines() {
2097        // Empty data: lines interspersed — they should be skipped (contribute nothing)
2098        // and the valid JSON line should still be parsed correctly.
2099        let buffer =
2100            "data: \ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\ndata: \n\n";
2101        let result = parse_sse_response(buffer, 1, "test");
2102        assert!(result.is_some());
2103        let result = result.unwrap().unwrap();
2104        assert_eq!(result.get("ok").and_then(|v| v.as_bool()), Some(true));
2105    }
2106
2107    #[test]
2108    fn test_parse_sse_response_invalid_json() {
2109        // data line with completely invalid JSON — should not panic, should return None
2110        // (the parse fails silently and current_data is cleared on the blank line).
2111        let buffer = "data: not-json-at-all\n\n";
2112        let result = parse_sse_response(buffer, 1, "test");
2113        // No valid JSON-RPC response could be extracted → None
2114        assert!(result.is_none());
2115    }
2116
2117    #[test]
2118    fn test_parse_sse_response_no_data_prefix() {
2119        // Lines without `data:` prefix should be ignored entirely.
2120        let buffer = "some random line\nanother line\n\n";
2121        let result = parse_sse_response(buffer, 1, "test");
2122        assert!(result.is_none());
2123    }
2124
2125    #[tokio::test]
2126    async fn test_http_sse_config_url_takes_priority() {
2127        // When both `command` and `url` are set in the config, the url-based
2128        // transport should be selected (url takes priority per spawn logic).
2129        let dir = tempfile::TempDir::new().unwrap();
2130        let mcp_path = dir.path().join(".mcp.json");
2131        tokio::fs::write(
2132            &mcp_path,
2133            r#"{
2134                "mcpServers": {
2135                    "hybrid": {
2136                        "command": "some-binary",
2137                        "args": ["--flag"],
2138                        "url": "http://example.com/sse"
2139                    }
2140                }
2141            }"#,
2142        )
2143        .await
2144        .unwrap();
2145
2146        let servers = discover_mcp_servers(dir.path()).await.unwrap();
2147        assert_eq!(servers.len(), 1);
2148        assert_eq!(servers[0].name, "hybrid");
2149        // url is present → spawn will choose HTTP+SSE transport
2150        assert_eq!(servers[0].url.as_deref(), Some("http://example.com/sse"));
2151        // command is also stored but url takes priority in spawn()
2152        assert_eq!(servers[0].command, "some-binary");
2153
2154        // Verify the spawn logic: url.is_some() → spawn_http_sse path
2155        let server = &servers[0];
2156        assert!(
2157            server.url.is_some(),
2158            "url should be present, meaning HTTP+SSE transport is selected"
2159        );
2160    }
2161
2162    #[test]
2163    fn test_load_mcp_config_with_url() {
2164        let dir = tempfile::TempDir::new().unwrap();
2165        let path = dir.path().join("mcp.json");
2166        std::fs::write(
2167            &path,
2168            r#"{
2169                "servers": [
2170                    {"name": "local", "command": "mcp-fs", "args": ["--root", "."]},
2171                    {"name": "remote", "url": "http://localhost:3000/sse"}
2172                ]
2173            }"#,
2174        )
2175        .unwrap();
2176
2177        let servers = load_mcp_config(&path).unwrap();
2178        assert_eq!(servers.len(), 2);
2179        assert_eq!(servers[0].name, "local");
2180        assert_eq!(servers[0].command, "mcp-fs");
2181        assert!(servers[0].url.is_none());
2182        assert_eq!(servers[1].name, "remote");
2183        assert_eq!(servers[1].command, "");
2184        assert_eq!(servers[1].url.as_deref(), Some("http://localhost:3000/sse"));
2185    }
2186
2187    // ── parse_sse_endpoint edge cases ───────────────────────────────────────
2188
2189    #[test]
2190    fn parse_sse_endpoint_data_before_event_header_returns_none() {
2191        // data: line before event: endpoint must NOT return Some
2192        // (kills && → || mutant at line 1000)
2193        let buffer = "data: http://localhost:3000/message\nevent: endpoint\n\n";
2194        // The data: line arrived before event: endpoint was seen → should not match
2195        // (endpoint event must precede its data:)
2196        // Note: SSE spec says event type applies to the whole event block; our parser
2197        // sets current_event before the data: check. So this test verifies the
2198        // "event before data" invariant within a single event block.
2199        let result = parse_sse_endpoint(buffer);
2200        // With && → || mutant: data before event triggers match → Some("http://...")
2201        // With correct &&: current_event is None when data: is seen → no match
2202        assert!(
2203            result.is_none(),
2204            "data: before event: endpoint must not match; got {result:?}"
2205        );
2206    }
2207
2208    #[test]
2209    fn parse_sse_endpoint_non_endpoint_event_ignored() {
2210        // event: other should NOT trigger data: as endpoint
2211        // (kills == → != mutant at line 1000)
2212        let buffer = "event: message\ndata: http://localhost:3000/endpoint-data\n\n";
2213        assert_eq!(
2214            parse_sse_endpoint(buffer),
2215            None,
2216            "event: message must not expose data: as endpoint"
2217        );
2218    }
2219
2220    #[test]
2221    fn parse_sse_endpoint_event_then_empty_data_returns_none() {
2222        // data: with whitespace-only content must not return Some
2223        // (kills delete ! in is_empty check)
2224        let buffer = "event: endpoint\ndata:    \n\n";
2225        assert_eq!(
2226            parse_sse_endpoint(buffer),
2227            None,
2228            "empty data after endpoint event must return None"
2229        );
2230    }
2231
2232    // ── parse_sse_response edge cases ──────────────────────────────────────
2233
2234    #[test]
2235    fn parse_sse_response_error_code_minus_one_when_absent() {
2236        // Error response missing "code" field → should use -1, not 1
2237        // (kills delete - mutant at line 1036)
2238        let buffer = "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"message\":\"oops\"}}\n\n";
2239        let result = parse_sse_response(buffer, 1, "srv");
2240        assert!(result.is_some(), "must parse error response");
2241        let err_msg = result.unwrap().unwrap_err().to_string();
2242        assert!(
2243            err_msg.contains("-1"),
2244            "missing code should default to -1; got: {err_msg}"
2245        );
2246    }
2247
2248    #[test]
2249    fn parse_sse_response_no_trailing_newline_still_parses() {
2250        // Buffer without trailing \n — uses the fallback path (line 1069)
2251        // (kills delete ! in the if !current_data.is_empty() at 1069)
2252        let buffer = "data: {\"jsonrpc\":\"2.0\",\"id\":5,\"result\":{\"v\":42}}";
2253        let result = parse_sse_response(buffer, 5, "srv");
2254        assert!(result.is_some(), "no trailing newline must still parse");
2255        let val = result.unwrap().unwrap();
2256        assert_eq!(val.get("v").and_then(|v| v.as_i64()), Some(42));
2257    }
2258
2259    #[test]
2260    fn parse_sse_response_id_field_mismatch_returns_none() {
2261        // id in response != expected_id → None
2262        // (kills == → != mutant at line 1030 and 1072)
2263        let buffer = "data: {\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"ok\":true}}\n\n";
2264        assert!(
2265            parse_sse_response(buffer, 99, "srv").is_none(),
2266            "wrong id must return None"
2267        );
2268        // Right id must return Some
2269        assert!(
2270            parse_sse_response(buffer, 3, "srv").is_some(),
2271            "correct id must return Some"
2272        );
2273    }
2274
2275    #[test]
2276    fn parse_sse_response_sse_field_prefix_lines_do_not_reset_data() {
2277        // Lines starting with ':', id:, retry: must NOT clear data accumulator
2278        // (kills the && → || mutations at lines 1059-1061)
2279        let buffer = "data: {\"jsonrpc\":\"2.0\",\"id\":7,\"result\"\n: comment ignored\ndata: :{\"v\":7}}\n\n";
2280        let result = parse_sse_response(buffer, 7, "srv");
2281        assert!(
2282            result.is_some(),
2283            "comment line must not reset data accumulator"
2284        );
2285    }
2286
2287    #[test]
2288    fn parse_sse_response_unknown_line_resets_data_accumulator() {
2289        // An unrecognised field (not event:, data:, id:, retry:, :) should clear
2290        // the data accumulator, causing the JSON to not parse.
2291        // (kills the delete ! mutations at lines 1053-1055)
2292        let buffer =
2293            "data: {\"jsonrpc\":\"2.0\",\"id\":8\nunknown: stuff\ndata: ,\"result\":{}}\n\n";
2294        // After "unknown: stuff" resets data, the next data: fragment is incomplete JSON
2295        let result = parse_sse_response(buffer, 8, "srv");
2296        // Incomplete/invalid JSON after reset → None
2297        assert!(
2298            result.is_none(),
2299            "unknown SSE field must reset accumulator; incomplete JSON should yield None"
2300        );
2301    }
2302
2303    // ── JsonRpcResponse builder tests ─────────────────────────────────────────
2304
2305    #[test]
2306    fn method_not_found_uses_negative_error_code() {
2307        // kills `delete - in JsonRpcResponse::method_not_found` (line 1149)
2308        // Mutant changes -32601 → 32601; this test catches it.
2309        let resp = JsonRpcResponse::method_not_found(Some(serde_json::json!(1)), "unknown_method");
2310        let err_obj = resp
2311            .error
2312            .expect("method_not_found must set the error field");
2313        assert_eq!(
2314            err_obj.code, -32601,
2315            "method_not_found must use the JSON-RPC -32601 error code (negative)"
2316        );
2317        assert!(
2318            err_obj.message.contains("unknown_method"),
2319            "error message must include the method name: {}",
2320            err_obj.message
2321        );
2322    }
2323
2324    #[test]
2325    fn internal_error_uses_negative_error_code() {
2326        // kills `delete - in JsonRpcResponse::internal_error` (analogous to line 1149)
2327        let resp = JsonRpcResponse::internal_error(None, "something failed");
2328        let err_obj = resp.error.expect("internal_error must set the error field");
2329        assert_eq!(
2330            err_obj.code, -32603,
2331            "internal_error must use the JSON-RPC -32603 error code (negative)"
2332        );
2333    }
2334}