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}
62
63/// Optional 2025-03-26 MCP tool annotations that carry hints about
64/// the tool's side-effects. All fields default to `false` / `None`.
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct McpToolAnnotations {
68    /// Hints the tool only reads data without side-effects.
69    #[serde(default)]
70    pub read_only_hint: bool,
71    /// Hints the tool may produce destructive side-effects.
72    #[serde(default)]
73    pub destructive_hint: bool,
74    /// Hints the tool is idempotent.
75    #[serde(default)]
76    pub idempotent_hint: bool,
77    /// Hints the tool interacts with the open world (network, I/O).
78    #[serde(default)]
79    pub open_world_hint: bool,
80}
81
82/// Per-MCP-server trust level. Controls whether annotation hints from
83/// a server are believed when classifying side-effects.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum McpServerTrust {
87    /// Annotations are trusted and used to derive [`ToolSideEffect`].
88    Trusted,
89    /// Annotations are ignored; all tools default to `External`.
90    /// This is the conservative default.
91    #[default]
92    Untrusted,
93}
94
95/// A tool spec as returned by the MCP server's `tools/list`.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct McpToolSpec {
98    pub name: String,
99    #[serde(default)]
100    pub description: String,
101    #[serde(default)]
102    pub input_schema: Value,
103    /// Optional 2025-03-26 annotations field.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub annotations: Option<McpToolAnnotations>,
106}
107
108/// A resource exposed by an MCP server.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct McpResource {
111    pub uri: String,
112    pub name: String,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub description: Option<String>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    #[serde(rename = "mimeType")]
117    pub mime_type: Option<String>,
118}
119
120/// Content returned from reading an MCP resource.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct McpResourceContent {
123    pub uri: String,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    #[serde(rename = "mimeType")]
126    pub mime_type: Option<String>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub text: Option<String>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub blob: Option<String>,
131}
132
133/// A prompt template exposed by an MCP server.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct McpPrompt {
136    pub name: String,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub description: Option<String>,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub arguments: Option<Vec<McpPromptArgument>>,
141}
142
143/// An argument to an MCP prompt template.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct McpPromptArgument {
146    pub name: String,
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub description: Option<String>,
149    #[serde(default)]
150    pub required: bool,
151}
152
153/// A message in an MCP prompt response.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct McpPromptMessage {
156    pub role: String,
157    pub content: String,
158}
159
160// ---------------------------------------------------------------------------
161// Transport abstraction
162// ---------------------------------------------------------------------------
163
164/// The underlying transport for an MCP client.
165#[allow(clippy::large_enum_variant)]
166enum McpTransport {
167    /// Stdio subprocess transport.
168    Stdio {
169        stdin: ChildStdin,
170        reader: BufReader<ChildStdout>,
171        child: Option<Child>,
172    },
173    /// HTTP+SSE transport.
174    HttpSse {
175        client: reqwest::Client,
176        /// Base SSE endpoint URL (the one that returns the event stream).
177        sse_url: String,
178        /// URL template for POST requests (from the `endpoint` event).
179        post_url: Option<String>,
180        /// Buffer for accumulating SSE data between reads.
181        buffer: String,
182    },
183}
184
185impl fmt::Debug for McpTransport {
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        match self {
188            Self::Stdio { .. } => f.debug_struct("Stdio").finish(),
189            Self::HttpSse {
190                sse_url, post_url, ..
191            } => f
192                .debug_struct("HttpSse")
193                .field("sse_url", sse_url)
194                .field("post_url", post_url)
195                .finish(),
196        }
197    }
198}
199
200/// An MCP client owns a transport and manages JSON-RPC communication.
201pub struct McpClient {
202    transport: McpTransport,
203    next_id: u64,
204    /// Capabilities advertised by the server during initialization.
205    capabilities: ServerCapabilities,
206    /// Name of the MCP server (for error reporting).
207    server_name: String,
208    /// How long to wait for a single line of stdio output before
209    /// declaring the server hung. Defaults to
210    /// [`DEFAULT_STDIO_READ_TIMEOUT`]; tests use a shorter value to
211    /// keep the suite fast.
212    read_timeout: Duration,
213}
214
215/// Default timeout for reading a single JSON-RPC line from a stdio
216/// MCP server. Generous on purpose — real servers can pause briefly
217/// during large tool runs without being broken.
218pub const DEFAULT_STDIO_READ_TIMEOUT: Duration = Duration::from_secs(10);
219
220/// Capabilities advertised by an MCP server during the initialize handshake.
221#[derive(Debug, Clone, Default)]
222pub struct ServerCapabilities {
223    pub tools: bool,
224    pub resources: bool,
225    pub prompts: bool,
226}
227
228impl fmt::Debug for McpClient {
229    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230        f.debug_struct("McpClient")
231            .field("transport", &self.transport)
232            .field("next_id", &self.next_id)
233            .field("capabilities", &self.capabilities)
234            .finish()
235    }
236}
237
238impl Drop for McpClient {
239    fn drop(&mut self) {
240        if let McpTransport::Stdio { ref mut child, .. } = &mut self.transport {
241            if let Some(mut child) = child.take() {
242                let _ = child.start_kill();
243            }
244        }
245    }
246}
247
248impl McpClient {
249    /// Spawn an MCP server (stdio or HTTP+SSE), perform the initialize
250    /// handshake, and return a ready-to-use client.
251    ///
252    /// If `server.url` is set, uses HTTP+SSE transport. Otherwise uses stdio.
253    pub async fn spawn(server: &McpServer) -> Result<Self> {
254        Self::spawn_with_timeout(server, DEFAULT_STDIO_READ_TIMEOUT).await
255    }
256
257    /// Like [`spawn`], but with a custom stdio read timeout. Tests use
258    /// a short value (e.g. 500ms) to keep the suite fast; production
259    /// callers should stick to [`spawn`].
260    pub async fn spawn_with_timeout(server: &McpServer, read_timeout: Duration) -> Result<Self> {
261        if let Some(url) = &server.url {
262            // HTTP+SSE has its own internal timeouts that are independent
263            // of stdio handshake behavior; keep its existing semantics.
264            Self::spawn_http_sse(server, url).await
265        } else {
266            Self::spawn_stdio(server, read_timeout).await
267        }
268    }
269
270    /// Spawn via stdio subprocess.
271    async fn spawn_stdio(server: &McpServer, read_timeout: Duration) -> Result<Self> {
272        let mut child = Command::new(&server.command)
273            .args(&server.args)
274            .stdin(std::process::Stdio::piped())
275            .stdout(std::process::Stdio::piped())
276            .stderr(std::process::Stdio::null())
277            .spawn()
278            .map_err(|e| Error::Mcp {
279                server: server.name.clone(),
280                message: format!("failed to spawn: {e}"),
281            })?;
282
283        let stdin = child.stdin.take().ok_or_else(|| Error::Mcp {
284            server: server.name.clone(),
285            message: "failed to open stdin".into(),
286        })?;
287        let stdout = child.stdout.take().ok_or_else(|| Error::Mcp {
288            server: server.name.clone(),
289            message: "failed to open stdout".into(),
290        })?;
291        let reader = BufReader::new(stdout);
292
293        let mut client = Self {
294            transport: McpTransport::Stdio {
295                stdin,
296                reader,
297                child: Some(child),
298            },
299            next_id: 1,
300            capabilities: ServerCapabilities::default(),
301            server_name: server.name.clone(),
302            read_timeout,
303        };
304
305        client.do_initialize(&server.name).await?;
306        Ok(client)
307    }
308
309    /// Spawn via HTTP+SSE transport.
310    async fn spawn_http_sse(server: &McpServer, url: &str) -> Result<Self> {
311        let http_client = reqwest::Client::builder()
312            .timeout(Duration::from_secs(30))
313            .build()
314            .map_err(|e| Error::Mcp {
315                server: server.name.clone(),
316                message: format!("failed to build HTTP client: {e}"),
317            })?;
318
319        // Connect to the SSE endpoint and read the initial event to discover
320        // the message endpoint.
321        let response = http_client
322            .get(url)
323            .header("Accept", "text/event-stream")
324            .send()
325            .await
326            .map_err(|e| Error::Mcp {
327                server: server.name.clone(),
328                message: format!("failed to connect to SSE endpoint `{url}`: {e}"),
329            })?;
330
331        if !response.status().is_success() {
332            return Err(Error::Mcp {
333                server: server.name.clone(),
334                message: format!("SSE endpoint `{url}` returned HTTP {}", response.status()),
335            });
336        }
337
338        // Read the SSE stream to find the `endpoint` event (which tells us
339        // where to POST JSON-RPC messages). We buffer the stream for later use.
340        let mut stream = response.bytes_stream();
341        let mut sse_buffer = String::new();
342        let mut post_url: Option<String> = None;
343        let mut found_endpoint = false;
344
345        // Read up to 64KB to find the endpoint event
346        let mut total_read = 0usize;
347        let max_read = 65536;
348
349        while total_read < max_read {
350            match timeout(Duration::from_secs(10), stream.next()).await {
351                Ok(Some(Ok(chunk))) => {
352                    let chunk_str = String::from_utf8_lossy(&chunk);
353                    total_read += chunk_str.len();
354                    sse_buffer.push_str(&chunk_str);
355
356                    // Parse SSE events from the buffer
357                    if let Some(endpoint) = parse_sse_endpoint(&sse_buffer) {
358                        post_url = Some(endpoint);
359                        found_endpoint = true;
360                        break;
361                    }
362                }
363                Ok(Some(Err(e))) => {
364                    return Err(Error::Mcp {
365                        server: server.name.clone(),
366                        message: format!("error reading SSE stream from `{url}`: {e}"),
367                    });
368                }
369                Ok(None) => break, // Stream ended
370                Err(_) => {
371                    return Err(Error::Mcp {
372                        server: server.name.clone(),
373                        message: format!("timeout reading SSE stream from `{url}`"),
374                    });
375                }
376            }
377        }
378
379        if !found_endpoint {
380            return Err(Error::Mcp {
381                server: server.name.clone(),
382                message: format!(
383                    "SSE endpoint `{url}` did not send an `endpoint` event. Received data: {}",
384                    &sse_buffer[..sse_buffer.len().min(200)]
385                ),
386            });
387        }
388
389        let mut client = Self {
390            transport: McpTransport::HttpSse {
391                client: http_client,
392                sse_url: url.to_string(),
393                post_url,
394                buffer: sse_buffer,
395            },
396            next_id: 1,
397            capabilities: ServerCapabilities::default(),
398            server_name: server.name.clone(),
399            // SSE transport reads its own stream with internal timeouts;
400            // this field is unused for HTTP+SSE but must be set.
401            read_timeout: DEFAULT_STDIO_READ_TIMEOUT,
402        };
403
404        client.do_initialize(&server.name).await?;
405        Ok(client)
406    }
407
408    /// Perform the MCP initialize handshake (common to both transports).
409    async fn do_initialize(&mut self, server_name: &str) -> Result<()> {
410        let init_result: Value = self
411            .send_request(
412                "initialize",
413                serde_json::json!({
414                    "protocolVersion": "2024-11-05",
415                    "capabilities": {},
416                    "clientInfo": {
417                        "name": "recursive-agent",
418                        "version": "0.1.0"
419                    }
420                }),
421            )
422            .await?;
423
424        // Check protocol version in response
425        if let Some(server_proto) = init_result.get("protocolVersion").and_then(|v| v.as_str()) {
426            if server_proto != "2024-11-05" {
427                // Non-fatal: log but continue
428                tracing::warn!(
429                    target: "recursive::mcp",
430                    server = %server_name,
431                    server_protocol = %server_proto,
432                    "MCP server protocol version mismatch"
433                );
434            }
435        }
436
437        // Parse capabilities from the server response
438        if let Some(caps) = init_result.get("capabilities") {
439            self.capabilities.tools = caps.get("tools").and_then(|v| v.as_bool()).unwrap_or(false);
440            self.capabilities.resources = caps
441                .get("resources")
442                .and_then(|v| v.as_bool())
443                .unwrap_or(false);
444            self.capabilities.prompts = caps
445                .get("prompts")
446                .and_then(|v| v.as_bool())
447                .unwrap_or(false);
448        }
449
450        // Send initialized notification (no response expected)
451        self.send_notification("notifications/initialized", serde_json::json!({}))
452            .await?;
453
454        Ok(())
455    }
456
457    /// Call `tools/list` and return the discovered tool specs.
458    pub async fn list_tools(&mut self) -> Result<Vec<McpToolSpec>> {
459        let result: Value = self
460            .send_request("tools/list", serde_json::json!({}))
461            .await?;
462
463        let tools_arr = result
464            .get("tools")
465            .and_then(|v| v.as_array())
466            .ok_or_else(|| Error::Mcp {
467                server: self.server_name.clone(),
468                message: "`tools/list` response missing `tools` array".into(),
469            })?;
470
471        let mut specs = Vec::with_capacity(tools_arr.len());
472        for item in tools_arr {
473            let name = item
474                .get("name")
475                .and_then(|v| v.as_str())
476                .ok_or_else(|| Error::Mcp {
477                    server: self.server_name.clone(),
478                    message: "tool entry missing `name`".into(),
479                })?;
480            let description = item
481                .get("description")
482                .and_then(|v| v.as_str())
483                .unwrap_or("");
484            let input_schema = item
485                .get("inputSchema")
486                .cloned()
487                .unwrap_or(serde_json::json!({"type": "object"}));
488            // Try to deserialize optional annotations field.
489            let annotations = item
490                .get("annotations")
491                .and_then(|v| serde_json::from_value::<McpToolAnnotations>(v.clone()).ok());
492            specs.push(McpToolSpec {
493                name: name.to_string(),
494                description: description.to_string(),
495                input_schema,
496                annotations,
497            });
498        }
499
500        Ok(specs)
501    }
502
503    /// Call a tool by name with the given arguments.
504    /// Returns the textual content of the first `content[].text` block.
505    /// Errors if `isError` is true in the response.
506    pub async fn call_tool(&mut self, name: &str, arguments: Value) -> Result<String> {
507        let result: Value = self
508            .send_request(
509                "tools/call",
510                serde_json::json!({
511                    "name": name,
512                    "arguments": arguments,
513                }),
514            )
515            .await?;
516
517        // Check for error
518        if result
519            .get("isError")
520            .and_then(|v| v.as_bool())
521            .unwrap_or(false)
522        {
523            let error_msg = result
524                .get("content")
525                .and_then(|v| v.as_array())
526                .and_then(|arr| arr.first())
527                .and_then(|c| c.get("text"))
528                .and_then(|v| v.as_str())
529                .unwrap_or("unknown error");
530            return Err(Error::Tool {
531                name: name.to_string(),
532                message: error_msg.to_string(),
533            });
534        }
535
536        // Extract text content
537        let content = result
538            .get("content")
539            .and_then(|v| v.as_array())
540            .map(|arr| {
541                arr.iter()
542                    .filter_map(|c| {
543                        if c.get("type").and_then(|v| v.as_str()) == Some("text") {
544                            c.get("text").and_then(|v| v.as_str())
545                        } else {
546                            None
547                        }
548                    })
549                    .collect::<Vec<_>>()
550                    .join("\n")
551            })
552            .unwrap_or_default();
553
554        Ok(content)
555    }
556
557    /// Call `resources/list` and return the discovered resources.
558    pub async fn list_resources(&mut self) -> Result<Vec<McpResource>> {
559        if !self.capabilities.resources {
560            return Err(Error::Mcp {
561                server: self.server_name.clone(),
562                message: "server does not advertise `resources` capability".into(),
563            });
564        }
565
566        let result: Value = self
567            .send_request("resources/list", serde_json::json!({}))
568            .await?;
569
570        let resources_arr = result
571            .get("resources")
572            .and_then(|v| v.as_array())
573            .ok_or_else(|| Error::Mcp {
574                server: self.server_name.clone(),
575                message: "`resources/list` response missing `resources` array".into(),
576            })?;
577
578        let mut resources = Vec::with_capacity(resources_arr.len());
579        for item in resources_arr {
580            let resource: McpResource =
581                serde_json::from_value(item.clone()).map_err(|e| Error::Mcp {
582                    server: self.server_name.clone(),
583                    message: format!("failed to parse resource: {e}"),
584                })?;
585            resources.push(resource);
586        }
587
588        Ok(resources)
589    }
590
591    /// Call `resources/read` for a specific resource URI.
592    /// Returns the list of content items.
593    pub async fn read_resource(&mut self, uri: &str) -> Result<Vec<McpResourceContent>> {
594        if !self.capabilities.resources {
595            return Err(Error::Mcp {
596                server: self.server_name.clone(),
597                message: "server does not advertise `resources` capability".into(),
598            });
599        }
600
601        let result: Value = self
602            .send_request("resources/read", serde_json::json!({ "uri": uri }))
603            .await?;
604
605        let contents_arr = result
606            .get("contents")
607            .and_then(|v| v.as_array())
608            .ok_or_else(|| Error::Mcp {
609                server: self.server_name.clone(),
610                message: "`resources/read` response missing `contents` array".into(),
611            })?;
612
613        let mut contents = Vec::with_capacity(contents_arr.len());
614        for item in contents_arr {
615            let content: McpResourceContent =
616                serde_json::from_value(item.clone()).map_err(|e| Error::Mcp {
617                    server: self.server_name.clone(),
618                    message: format!("failed to parse resource content: {e}"),
619                })?;
620            contents.push(content);
621        }
622
623        Ok(contents)
624    }
625
626    /// Call `prompts/list` and return the discovered prompts.
627    pub async fn list_prompts(&mut self) -> Result<Vec<McpPrompt>> {
628        if !self.capabilities.prompts {
629            return Err(Error::Mcp {
630                server: self.server_name.clone(),
631                message: "server does not advertise `prompts` capability".into(),
632            });
633        }
634
635        let result: Value = self
636            .send_request("prompts/list", serde_json::json!({}))
637            .await?;
638
639        let prompts_arr = result
640            .get("prompts")
641            .and_then(|v| v.as_array())
642            .ok_or_else(|| Error::Mcp {
643                server: self.server_name.clone(),
644                message: "`prompts/list` response missing `prompts` array".into(),
645            })?;
646
647        let mut prompts = Vec::with_capacity(prompts_arr.len());
648        for item in prompts_arr {
649            let prompt: McpPrompt =
650                serde_json::from_value(item.clone()).map_err(|e| Error::Mcp {
651                    server: self.server_name.clone(),
652                    message: format!("failed to parse prompt: {e}"),
653                })?;
654            prompts.push(prompt);
655        }
656
657        Ok(prompts)
658    }
659
660    /// Call `prompts/get` for a specific prompt name with optional arguments.
661    /// Returns the list of messages.
662    pub async fn get_prompt(
663        &mut self,
664        name: &str,
665        arguments: Option<HashMap<String, String>>,
666    ) -> Result<Vec<McpPromptMessage>> {
667        if !self.capabilities.prompts {
668            return Err(Error::Mcp {
669                server: self.server_name.clone(),
670                message: "server does not advertise `prompts` capability".into(),
671            });
672        }
673
674        let mut params = serde_json::json!({ "name": name });
675        if let Some(args) = arguments {
676            params["arguments"] = serde_json::to_value(args).map_err(|e| Error::Mcp {
677                server: self.server_name.clone(),
678                message: format!("failed to serialize prompt arguments: {e}"),
679            })?;
680        }
681
682        let result: Value = self.send_request("prompts/get", params).await?;
683
684        let messages_arr = result
685            .get("messages")
686            .and_then(|v| v.as_array())
687            .ok_or_else(|| Error::Mcp {
688                server: self.server_name.clone(),
689                message: "`prompts/get` response missing `messages` array".into(),
690            })?;
691
692        let mut messages = Vec::with_capacity(messages_arr.len());
693        for item in messages_arr {
694            let role = item
695                .get("role")
696                .and_then(|v| v.as_str())
697                .ok_or_else(|| Error::Mcp {
698                    server: self.server_name.clone(),
699                    message: "prompt message missing `role`".into(),
700                })?;
701            let content = item
702                .get("content")
703                .and_then(|v| v.get("text"))
704                .and_then(|v| v.as_str())
705                .ok_or_else(|| Error::Mcp {
706                    server: self.server_name.clone(),
707                    message: "prompt message missing `content.text`".into(),
708                })?;
709            messages.push(McpPromptMessage {
710                role: role.to_string(),
711                content: content.to_string(),
712            });
713        }
714
715        Ok(messages)
716    }
717
718    // -----------------------------------------------------------------------
719    // JSON-RPC 2.0 internals
720    // -----------------------------------------------------------------------
721
722    /// Send a JSON-RPC request and await the matching response.
723    async fn send_request(&mut self, method: &str, params: Value) -> Result<Value> {
724        let id = self.next_id;
725        self.next_id += 1;
726
727        let request = serde_json::json!({
728            "jsonrpc": "2.0",
729            "id": id,
730            "method": method,
731            "params": params,
732        });
733
734        self.write_line(&request).await?;
735        self.read_response(id).await
736    }
737
738    /// Send a JSON-RPC notification (no response expected).
739    async fn send_notification(&mut self, method: &str, params: Value) -> Result<()> {
740        let notification = serde_json::json!({
741            "jsonrpc": "2.0",
742            "method": method,
743            "params": params,
744        });
745
746        self.write_line(&notification).await
747    }
748
749    /// Write a JSON-RPC message via the active transport.
750    async fn write_line(&mut self, value: &Value) -> Result<()> {
751        match &mut self.transport {
752            McpTransport::Stdio { stdin, .. } => {
753                let line = serde_json::to_string(value)?;
754                let mut full = line.into_bytes();
755                full.push(b'\n');
756                stdin.write_all(&full).await.map_err(|e| Error::Mcp {
757                    server: self.server_name.clone(),
758                    message: format!("write error: {e}"),
759                })?;
760                stdin.flush().await.map_err(|e| Error::Mcp {
761                    server: self.server_name.clone(),
762                    message: format!("flush error: {e}"),
763                })?;
764                Ok(())
765            }
766            McpTransport::HttpSse {
767                client, post_url, ..
768            } => {
769                let url = post_url.as_ref().ok_or_else(|| Error::Mcp {
770                    server: self.server_name.clone(),
771                    message: "HTTP transport: no POST endpoint available".into(),
772                })?;
773                let body = serde_json::to_string(value)?;
774                let response = client
775                    .post(url)
776                    .header("Content-Type", "application/json")
777                    .body(body)
778                    .send()
779                    .await
780                    .map_err(|e| Error::Mcp {
781                        server: self.server_name.clone(),
782                        message: format!("HTTP POST error: {e}"),
783                    })?;
784                if !response.status().is_success() {
785                    return Err(Error::Mcp {
786                        server: self.server_name.clone(),
787                        message: format!(
788                            "HTTP POST to `{url}` returned HTTP {}",
789                            response.status()
790                        ),
791                    });
792                }
793                Ok(())
794            }
795        }
796    }
797
798    /// Read a JSON-RPC response matching the given id from the active transport.
799    async fn read_response(&mut self, expected_id: u64) -> Result<Value> {
800        match &mut self.transport {
801            McpTransport::Stdio { reader, .. } => {
802                Self::read_stdio_response(reader, expected_id, &self.server_name, self.read_timeout)
803                    .await
804            }
805            McpTransport::HttpSse {
806                client,
807                sse_url,
808                post_url,
809                buffer,
810            } => {
811                Self::read_sse_response(
812                    client,
813                    sse_url,
814                    post_url,
815                    buffer,
816                    expected_id,
817                    &self.server_name,
818                )
819                .await
820            }
821        }
822    }
823
824    /// Read a JSON-RPC response from stdio.
825    async fn read_stdio_response(
826        reader: &mut BufReader<ChildStdout>,
827        expected_id: u64,
828        server_name: &str,
829        read_timeout: Duration,
830    ) -> Result<Value> {
831        let mut line_buf = String::new();
832
833        loop {
834            line_buf.clear();
835
836            let read_future = reader.read_line(&mut line_buf);
837            match timeout(read_timeout, read_future).await {
838                Ok(Ok(0)) => {
839                    return Err(Error::Mcp {
840                        server: server_name.to_string(),
841                        message: "server closed stdout unexpectedly".into(),
842                    });
843                }
844                Ok(Ok(_)) => {
845                    let trimmed = line_buf.trim();
846                    if trimmed.is_empty() {
847                        continue;
848                    }
849
850                    let parsed: Value = serde_json::from_str(trimmed).map_err(|e| Error::Mcp {
851                        server: server_name.to_string(),
852                        message: format!("server returned non-JSON line: {e}; line: {trimmed}"),
853                    })?;
854
855                    if let Some(resp_id) = parsed.get("id") {
856                        if resp_id.as_u64() == Some(expected_id) {
857                            if let Some(err) = parsed.get("error") {
858                                let msg = err
859                                    .get("message")
860                                    .and_then(|v| v.as_str())
861                                    .unwrap_or("unknown error");
862                                let code = err.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
863                                return Err(Error::Mcp {
864                                    server: server_name.to_string(),
865                                    message: format!("error (code {code}): {msg}"),
866                                });
867                            }
868                            if let Some(result) = parsed.get("result") {
869                                return Ok(result.clone());
870                            }
871                            return Err(Error::Mcp {
872                                server: server_name.to_string(),
873                                message: "response missing both `result` and `error`".into(),
874                            });
875                        }
876                    }
877                }
878                Ok(Err(e)) => {
879                    return Err(Error::Mcp {
880                        server: server_name.to_string(),
881                        message: format!("read error: {e}"),
882                    });
883                }
884                Err(_) => {
885                    return Err(Error::Mcp {
886                        server: server_name.to_string(),
887                        message: "server timed out (no response within 10s)".into(),
888                    });
889                }
890            }
891        }
892    }
893
894    /// Read a JSON-RPC response from an SSE stream.
895    async fn read_sse_response(
896        client: &reqwest::Client,
897        sse_url: &str,
898        _post_url: &Option<String>,
899        buffer: &mut String,
900        expected_id: u64,
901        server_name: &str,
902    ) -> Result<Value> {
903        // If we have buffered data, try to parse a response from it first.
904        if !buffer.is_empty() {
905            if let Some(result) = parse_sse_response(buffer, expected_id, server_name) {
906                return result;
907            }
908        }
909
910        // Otherwise, reconnect to the SSE stream and read more events.
911        let response = client
912            .get(sse_url)
913            .header("Accept", "text/event-stream")
914            .send()
915            .await
916            .map_err(|e| Error::Mcp {
917                server: server_name.to_string(),
918                message: format!("failed to reconnect to SSE endpoint `{sse_url}`: {e}"),
919            })?;
920
921        if !response.status().is_success() {
922            return Err(Error::Mcp {
923                server: server_name.to_string(),
924                message: format!(
925                    "SSE endpoint `{sse_url}` returned HTTP {}",
926                    response.status()
927                ),
928            });
929        }
930
931        let mut stream = response.bytes_stream();
932
933        loop {
934            match timeout(Duration::from_secs(10), stream.next()).await {
935                Ok(Some(Ok(chunk))) => {
936                    let chunk_str = String::from_utf8_lossy(&chunk);
937                    buffer.push_str(&chunk_str);
938
939                    if let Some(result) = parse_sse_response(buffer, expected_id, server_name) {
940                        return result;
941                    }
942                }
943                Ok(Some(Err(e))) => {
944                    return Err(Error::Mcp {
945                        server: server_name.to_string(),
946                        message: format!("error reading SSE stream from `{sse_url}`: {e}"),
947                    });
948                }
949                Ok(None) => {
950                    return Err(Error::Mcp {
951                        server: server_name.to_string(),
952                        message: format!("SSE stream ended without response for id {expected_id}"),
953                    });
954                }
955                Err(_) => {
956                    return Err(Error::Mcp {
957                        server: server_name.to_string(),
958                        message: format!("SSE timed out waiting for response for id {expected_id}"),
959                    });
960                }
961            }
962        }
963    }
964}
965
966// ---------------------------------------------------------------------------
967// SSE parsing helpers
968// ---------------------------------------------------------------------------
969
970/// Parse an SSE stream buffer looking for an `event: endpoint` followed by
971/// `data: <url>`. Returns the URL if found.
972fn parse_sse_endpoint(buffer: &str) -> Option<String> {
973    let mut current_event: Option<&str> = None;
974
975    for line in buffer.lines() {
976        let line = line.trim();
977        if line.starts_with("event:") {
978            current_event = Some(line.strip_prefix("event:").unwrap_or("").trim());
979        } else if line.starts_with("data:") && current_event == Some("endpoint") {
980            let data = line.strip_prefix("data:").unwrap_or("").trim();
981            if !data.is_empty() {
982                return Some(data.to_string());
983            }
984        }
985        // Reset event if we see a blank line (end of event)
986        if line.is_empty() {
987            current_event = None;
988        }
989    }
990
991    None
992}
993
994/// Parse an SSE stream buffer looking for a JSON-RPC response with the
995/// given id. Returns `Some(Ok(result))` or `Some(Err(...))` if found,
996/// or `None` if not yet available.
997fn parse_sse_response(buffer: &str, expected_id: u64, server_name: &str) -> Option<Result<Value>> {
998    let mut current_data = String::new();
999
1000    for line in buffer.lines() {
1001        let trimmed = line.trim();
1002        if trimmed.starts_with("data:") {
1003            let data = trimmed.strip_prefix("data:").unwrap_or("").trim();
1004            current_data.push_str(data);
1005        } else if trimmed.is_empty() && !current_data.is_empty() {
1006            // End of an SSE event — try to parse the accumulated data as JSON
1007            if let Ok(parsed) = serde_json::from_str::<Value>(&current_data) {
1008                if let Some(resp_id) = parsed.get("id") {
1009                    if resp_id.as_u64() == Some(expected_id) {
1010                        if let Some(err) = parsed.get("error") {
1011                            let msg = err
1012                                .get("message")
1013                                .and_then(|v| v.as_str())
1014                                .unwrap_or("unknown error");
1015                            let code = err.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
1016                            return Some(Err(Error::Mcp {
1017                                server: server_name.to_string(),
1018                                message: format!("error (code {code}): {msg}"),
1019                            }));
1020                        }
1021                        if let Some(result) = parsed.get("result") {
1022                            return Some(Ok(result.clone()));
1023                        }
1024                        return Some(Err(Error::Mcp {
1025                            server: server_name.to_string(),
1026                            message: "response missing both `result` and `error`".into(),
1027                        }));
1028                    }
1029                }
1030            }
1031            current_data.clear();
1032        } else if !trimmed.starts_with("event:")
1033            && !trimmed.starts_with("data:")
1034            && !trimmed.is_empty()
1035        {
1036            // Non-data line that's not event/data — reset data accumulator
1037            // (SSE spec: unknown fields are ignored, but data is per-event)
1038            if !trimmed.starts_with(':')
1039                && !trimmed.starts_with("id:")
1040                && !trimmed.starts_with("retry:")
1041            {
1042                current_data.clear();
1043            }
1044        }
1045    }
1046
1047    // Also check if the buffer ends with a complete JSON object (no trailing newline)
1048    if !current_data.is_empty() {
1049        if let Ok(parsed) = serde_json::from_str::<Value>(&current_data) {
1050            if let Some(resp_id) = parsed.get("id") {
1051                if resp_id.as_u64() == Some(expected_id) {
1052                    if let Some(err) = parsed.get("error") {
1053                        let msg = err
1054                            .get("message")
1055                            .and_then(|v| v.as_str())
1056                            .unwrap_or("unknown error");
1057                        let code = err.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
1058                        return Some(Err(Error::Mcp {
1059                            server: server_name.to_string(),
1060                            message: format!("error (code {code}): {msg}"),
1061                        }));
1062                    }
1063                    if let Some(result) = parsed.get("result") {
1064                        return Some(Ok(result.clone()));
1065                    }
1066                }
1067            }
1068        }
1069    }
1070
1071    None
1072}
1073
1074// ---------------------------------------------------------------------------
1075// JSON-RPC 2.0 types for stdio MCP server mode
1076// ---------------------------------------------------------------------------
1077
1078/// A JSON-RPC 2.0 request.
1079#[derive(Debug, Clone, Serialize, Deserialize)]
1080pub struct JsonRpcRequest {
1081    pub jsonrpc: String,
1082    #[serde(default, skip_serializing_if = "Option::is_none")]
1083    pub id: Option<serde_json::Value>,
1084    pub method: String,
1085    #[serde(default)]
1086    pub params: serde_json::Value,
1087}
1088
1089/// A JSON-RPC 2.0 response.
1090#[derive(Debug, Clone, Serialize, Deserialize)]
1091pub struct JsonRpcResponse {
1092    pub jsonrpc: String,
1093    #[serde(default, skip_serializing_if = "Option::is_none")]
1094    pub id: Option<serde_json::Value>,
1095    #[serde(default, skip_serializing_if = "Option::is_none")]
1096    pub result: Option<serde_json::Value>,
1097    #[serde(default, skip_serializing_if = "Option::is_none")]
1098    pub error: Option<JsonRpcError>,
1099}
1100
1101impl JsonRpcResponse {
1102    /// Create a successful response.
1103    pub fn success(id: Option<serde_json::Value>, result: serde_json::Value) -> Self {
1104        Self {
1105            jsonrpc: "2.0".to_string(),
1106            id,
1107            result: Some(result),
1108            error: None,
1109        }
1110    }
1111
1112    /// Create an error response.
1113    pub fn error(id: Option<serde_json::Value>, code: i64, message: impl Into<String>) -> Self {
1114        Self {
1115            jsonrpc: "2.0".to_string(),
1116            id,
1117            result: None,
1118            error: Some(JsonRpcError {
1119                code,
1120                message: message.into(),
1121                data: None,
1122            }),
1123        }
1124    }
1125
1126    /// Create a method-not-found error response.
1127    pub fn method_not_found(id: Option<serde_json::Value>, method: &str) -> Self {
1128        Self::error(id, -32601, format!("Method not found: {method}"))
1129    }
1130
1131    /// Create an internal error response.
1132    pub fn internal_error(id: Option<serde_json::Value>, message: impl Into<String>) -> Self {
1133        Self::error(id, -32603, message)
1134    }
1135}
1136
1137/// A JSON-RPC 2.0 error object.
1138#[derive(Debug, Clone, Serialize, Deserialize)]
1139pub struct JsonRpcError {
1140    pub code: i64,
1141    pub message: String,
1142    #[serde(default, skip_serializing_if = "Option::is_none")]
1143    pub data: Option<serde_json::Value>,
1144}
1145
1146/// Dispatch a JSON-RPC request to the appropriate handler.
1147///
1148/// Supports the MCP methods needed for tool proxy:
1149/// - `initialize` / `notifications/initialized`
1150/// - `tools/list` / `tools/call`
1151/// - `resources/list` / `resources/read`
1152/// - `prompts/list` / `prompts/get`
1153///
1154/// Returns a JSON-RPC response. Notifications (no `id`) return `None`.
1155pub async fn dispatch_request(
1156    request: &JsonRpcRequest,
1157    client: &mut McpClient,
1158) -> Option<JsonRpcResponse> {
1159    let id = request.id.clone();
1160
1161    match request.method.as_str() {
1162        "initialize" => {
1163            // The client is already initialized by McpClient::spawn, so we
1164            // return a canned response matching what the server would expect.
1165            let result = serde_json::json!({
1166                "protocolVersion": "2024-11-05",
1167                "capabilities": {
1168                    "tools": true,
1169                    "resources": true,
1170                    "prompts": true
1171                },
1172                "serverInfo": {
1173                    "name": "recursive-agent",
1174                    "version": "0.1.0"
1175                }
1176            });
1177            Some(JsonRpcResponse::success(id, result))
1178        }
1179        "notifications/initialized" => {
1180            // No response expected for notifications.
1181            None
1182        }
1183        "tools/list" => match client.list_tools().await {
1184            Ok(tools) => {
1185                let tools_arr: Vec<serde_json::Value> = tools
1186                    .into_iter()
1187                    .map(|t| {
1188                        serde_json::json!({
1189                            "name": t.name,
1190                            "description": t.description,
1191                            "inputSchema": t.input_schema,
1192                        })
1193                    })
1194                    .collect();
1195                Some(JsonRpcResponse::success(
1196                    id,
1197                    serde_json::json!({ "tools": tools_arr }),
1198                ))
1199            }
1200            Err(e) => Some(JsonRpcResponse::internal_error(id, e.to_string())),
1201        },
1202        "tools/call" => {
1203            let name = request
1204                .params
1205                .get("name")
1206                .and_then(|v| v.as_str())
1207                .unwrap_or("");
1208            let arguments = request.params.get("arguments").cloned().unwrap_or_default();
1209
1210            match client.call_tool(name, arguments).await {
1211                Ok(text) => {
1212                    let result = serde_json::json!({
1213                        "content": [{"type": "text", "text": text}]
1214                    });
1215                    Some(JsonRpcResponse::success(id, result))
1216                }
1217                Err(e) => {
1218                    // Return the error as a tool-level isError result, not a
1219                    // JSON-RPC error, so the client can handle it gracefully.
1220                    let result = serde_json::json!({
1221                        "isError": true,
1222                        "content": [{"type": "text", "text": e.to_string()}]
1223                    });
1224                    Some(JsonRpcResponse::success(id, result))
1225                }
1226            }
1227        }
1228        "resources/list" => match client.list_resources().await {
1229            Ok(resources) => {
1230                let resources_arr: Vec<serde_json::Value> = resources
1231                    .into_iter()
1232                    .map(|r| {
1233                        serde_json::json!({
1234                            "uri": r.uri,
1235                            "name": r.name,
1236                            "description": r.description,
1237                            "mimeType": r.mime_type,
1238                        })
1239                    })
1240                    .collect();
1241                Some(JsonRpcResponse::success(
1242                    id,
1243                    serde_json::json!({ "resources": resources_arr }),
1244                ))
1245            }
1246            Err(e) => Some(JsonRpcResponse::internal_error(id, e.to_string())),
1247        },
1248        "resources/read" => {
1249            let uri = request
1250                .params
1251                .get("uri")
1252                .and_then(|v| v.as_str())
1253                .unwrap_or("");
1254
1255            match client.read_resource(uri).await {
1256                Ok(contents) => {
1257                    let contents_arr: Vec<serde_json::Value> = contents
1258                        .into_iter()
1259                        .map(|c| {
1260                            serde_json::json!({
1261                                "uri": c.uri,
1262                                "mimeType": c.mime_type,
1263                                "text": c.text,
1264                                "blob": c.blob,
1265                            })
1266                        })
1267                        .collect();
1268                    Some(JsonRpcResponse::success(
1269                        id,
1270                        serde_json::json!({ "contents": contents_arr }),
1271                    ))
1272                }
1273                Err(e) => Some(JsonRpcResponse::internal_error(id, e.to_string())),
1274            }
1275        }
1276        "prompts/list" => match client.list_prompts().await {
1277            Ok(prompts) => {
1278                let prompts_arr: Vec<serde_json::Value> = prompts
1279                    .into_iter()
1280                    .map(|p| {
1281                        serde_json::json!({
1282                            "name": p.name,
1283                            "description": p.description,
1284                            "arguments": p.arguments,
1285                        })
1286                    })
1287                    .collect();
1288                Some(JsonRpcResponse::success(
1289                    id,
1290                    serde_json::json!({ "prompts": prompts_arr }),
1291                ))
1292            }
1293            Err(e) => Some(JsonRpcResponse::internal_error(id, e.to_string())),
1294        },
1295        "prompts/get" => {
1296            let name = request
1297                .params
1298                .get("name")
1299                .and_then(|v| v.as_str())
1300                .unwrap_or("");
1301            let arguments = request
1302                .params
1303                .get("arguments")
1304                .and_then(|v| serde_json::from_value::<HashMap<String, String>>(v.clone()).ok());
1305
1306            match client.get_prompt(name, arguments).await {
1307                Ok(messages) => {
1308                    let messages_arr: Vec<serde_json::Value> = messages
1309                        .into_iter()
1310                        .map(|m| {
1311                            serde_json::json!({
1312                                "role": m.role,
1313                                "content": {"type": "text", "text": m.content},
1314                            })
1315                        })
1316                        .collect();
1317                    Some(JsonRpcResponse::success(
1318                        id,
1319                        serde_json::json!({ "messages": messages_arr }),
1320                    ))
1321                }
1322                Err(e) => Some(JsonRpcResponse::internal_error(id, e.to_string())),
1323            }
1324        }
1325        _ => Some(JsonRpcResponse::method_not_found(id, &request.method)),
1326    }
1327}
1328
1329// ---------------------------------------------------------------------------
1330// McpTool — implements the Tool trait by delegating to an McpClient
1331// ---------------------------------------------------------------------------
1332
1333/// A tool that wraps an MCP server tool. Registered with a namespaced name
1334/// `mcp__<server_name>__<tool_name>`.
1335pub struct McpTool {
1336    client: Arc<Mutex<McpClient>>,
1337    spec: McpToolSpec,
1338    server_name: String,
1339    /// Trust level of the originating server. Controls whether annotation
1340    /// hints are used to derive [`ToolSideEffect`].
1341    trust: McpServerTrust,
1342}
1343
1344impl McpTool {
1345    pub fn new(
1346        client: Arc<Mutex<McpClient>>,
1347        spec: McpToolSpec,
1348        server_name: impl Into<String>,
1349    ) -> Self {
1350        Self {
1351            client,
1352            spec,
1353            server_name: server_name.into(),
1354            trust: McpServerTrust::Untrusted,
1355        }
1356    }
1357
1358    /// Builder: set the trust level for this tool's server.
1359    pub fn with_trust(mut self, trust: McpServerTrust) -> Self {
1360        self.trust = trust;
1361        self
1362    }
1363}
1364
1365#[async_trait]
1366impl Tool for McpTool {
1367    fn spec(&self) -> ToolSpec {
1368        ToolSpec {
1369            name: format!("mcp__{}__{}", self.server_name, self.spec.name),
1370            description: format!("[mcp:{}] {}", self.server_name, self.spec.description),
1371            parameters: self.spec.input_schema.clone(),
1372        }
1373    }
1374
1375    fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
1376        use crate::tools::ToolSideEffect;
1377        // Conservative default when server is untrusted or annotations missing.
1378        let Some(ann) = &self.spec.annotations else {
1379            return ToolSideEffect::External;
1380        };
1381        if self.trust != McpServerTrust::Trusted {
1382            return ToolSideEffect::External;
1383        }
1384        if ann.read_only_hint {
1385            ToolSideEffect::ReadOnly
1386        } else if ann.open_world_hint {
1387            ToolSideEffect::External
1388        } else {
1389            ToolSideEffect::Mutating
1390        }
1391    }
1392
1393    async fn execute(&self, arguments: Value) -> Result<String> {
1394        let mut client = self.client.lock().await;
1395        client.call_tool(&self.spec.name, arguments).await
1396    }
1397}
1398
1399// ---------------------------------------------------------------------------
1400// Config loading
1401// ---------------------------------------------------------------------------
1402
1403/// Load MCP server configurations from a JSON file.
1404/// Expected format:
1405/// ```json
1406/// { "servers": [ { "name": "...", "command": "...", "args": [...] }, { "name": "...", "url": "http://..." } ] }
1407/// ```
1408pub fn load_mcp_config(path: &std::path::Path) -> Result<Vec<McpServer>> {
1409    let contents = std::fs::read_to_string(path).map_err(|e| Error::Mcp {
1410        server: "config".into(),
1411        message: format!("failed to read config `{}`: {e}", path.display()),
1412    })?;
1413    let parsed: McpConfigFile = serde_json::from_str(&contents).map_err(|e| Error::Mcp {
1414        server: "config".into(),
1415        message: format!("failed to parse config `{}`: {e}", path.display()),
1416    })?;
1417    Ok(parsed.servers)
1418}
1419
1420#[derive(Debug, Deserialize)]
1421struct McpConfigFile {
1422    servers: Vec<McpServer>,
1423}
1424
1425// ---------------------------------------------------------------------------
1426// Workspace discovery (Claude Code .mcp.json format)
1427// ---------------------------------------------------------------------------
1428
1429/// Top-level structure of a Claude Code `.mcp.json` file.
1430#[derive(Debug, Deserialize)]
1431#[serde(deny_unknown_fields)]
1432struct McpDiscoveryFile {
1433    #[serde(rename = "mcpServers")]
1434    mcp_servers: HashMap<String, McpServerConfig>,
1435}
1436
1437/// Discover MCP server configurations from the workspace.
1438///
1439/// Looks for (in priority order):
1440/// 1. `<workspace>/.mcp.json` (Claude Code format)
1441/// 2. `<workspace>/.recursive/mcp.json` (alternative location)
1442///
1443/// Returns an empty vec if neither file exists (not an error).
1444pub async fn discover_mcp_servers(workspace: &Path) -> Result<Vec<McpServer>> {
1445    // Priority 1: workspace root .mcp.json
1446    let primary = workspace.join(".mcp.json");
1447    if primary.exists() {
1448        let configs = load_mcp_discovery_config(&primary).await?;
1449        if !configs.is_empty() {
1450            return Ok(configs);
1451        }
1452    }
1453
1454    // Priority 2: .recursive/mcp.json
1455    let fallback = workspace.join(".recursive").join("mcp.json");
1456    if fallback.exists() {
1457        let configs = load_mcp_discovery_config(&fallback).await?;
1458        return Ok(configs);
1459    }
1460
1461    Ok(Vec::new())
1462}
1463
1464/// Parse a Claude Code `.mcp.json` file into `Vec<McpServer>`.
1465///
1466/// Expected format:
1467/// ```json
1468/// {
1469///   "mcpServers": {
1470///     "server-name": {
1471///       "command": "path/to/server",
1472///       "args": ["--flag"],
1473///       "env": { "KEY": "value" }
1474///     }
1475///   }
1476/// }
1477/// ```
1478/// Or for HTTP+SSE:
1479/// ```json
1480/// {
1481///   "mcpServers": {
1482///     "server-name": {
1483///       "url": "http://localhost:3000/sse"
1484///     }
1485///   }
1486/// }
1487/// ```
1488async fn load_mcp_discovery_config(path: &Path) -> Result<Vec<McpServer>> {
1489    let contents = tokio::fs::read_to_string(path)
1490        .await
1491        .map_err(|e| Error::Mcp {
1492            server: "discovery".into(),
1493            message: format!("failed to read discovery config `{}`: {e}", path.display()),
1494        })?;
1495
1496    // Handle empty file gracefully
1497    if contents.trim().is_empty() {
1498        return Ok(Vec::new());
1499    }
1500
1501    let parsed: McpDiscoveryFile = serde_json::from_str(&contents).map_err(|e| Error::Mcp {
1502        server: "discovery".into(),
1503        message: format!("failed to parse discovery config `{}`: {e}", path.display()),
1504    })?;
1505
1506    let servers: Vec<McpServer> = parsed
1507        .mcp_servers
1508        .into_iter()
1509        .map(|(name, config)| McpServer {
1510            name,
1511            command: config.command,
1512            args: config.args,
1513            url: config.url,
1514        })
1515        .collect();
1516
1517    Ok(servers)
1518}
1519
1520// ---------------------------------------------------------------------------
1521// Tests
1522// ---------------------------------------------------------------------------
1523
1524#[cfg(test)]
1525mod tests {
1526    use super::*;
1527
1528    // NOTE: end-to-end tests against a mock MCP server live in
1529    // `tests/mcp_e2e.rs`. They cannot live here because the
1530    // `CARGO_BIN_EXE_mock_mcp_server` env var that resolves the mock
1531    // server binary's path is only injected by cargo for integration
1532    // tests under `tests/`, not for unit tests inside `src/`.
1533
1534    #[test]
1535    fn load_mcp_config_parses_correctly() {
1536        let dir = tempfile::TempDir::new().unwrap();
1537        let path = dir.path().join("mcp.json");
1538        std::fs::write(
1539            &path,
1540            r#"{
1541                "servers": [
1542                    {"name": "fs", "command": "mcp-fs", "args": ["--root", "."]},
1543                    {"name": "github", "command": "mcp-gh", "args": []}
1544                ]
1545            }"#,
1546        )
1547        .unwrap();
1548
1549        let servers = load_mcp_config(&path).unwrap();
1550        assert_eq!(servers.len(), 2);
1551        assert_eq!(servers[0].name, "fs");
1552        assert_eq!(servers[0].command, "mcp-fs");
1553        assert_eq!(servers[0].args, vec!["--root", "."]);
1554        assert_eq!(servers[1].name, "github");
1555        assert_eq!(servers[1].command, "mcp-gh");
1556        assert!(servers[1].args.is_empty());
1557    }
1558
1559    #[test]
1560    fn load_mcp_config_empty_servers() {
1561        let dir = tempfile::TempDir::new().unwrap();
1562        let path = dir.path().join("empty.json");
1563        std::fs::write(&path, r#"{"servers": []}"#).unwrap();
1564
1565        let servers = load_mcp_config(&path).unwrap();
1566        assert!(servers.is_empty());
1567    }
1568
1569    #[test]
1570    fn load_mcp_config_missing_file_errors() {
1571        let dir = tempfile::TempDir::new().unwrap();
1572        let path = dir.path().join("nonexistent.json");
1573        let err = load_mcp_config(&path).unwrap_err();
1574        assert!(err.to_string().contains("failed to read config"));
1575    }
1576
1577    // -----------------------------------------------------------------------
1578    // Discovery tests
1579    // -----------------------------------------------------------------------
1580
1581    #[tokio::test]
1582    async fn discover_finds_dot_mcp_json_in_workspace_root() {
1583        let dir = tempfile::TempDir::new().unwrap();
1584        let mcp_path = dir.path().join(".mcp.json");
1585        tokio::fs::write(
1586            &mcp_path,
1587            r#"{
1588                "mcpServers": {
1589                    "fs": {
1590                        "command": "mcp-fs",
1591                        "args": ["--root", "."]
1592                    }
1593                }
1594            }"#,
1595        )
1596        .await
1597        .unwrap();
1598
1599        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1600        assert_eq!(servers.len(), 1);
1601        assert_eq!(servers[0].name, "fs");
1602        assert_eq!(servers[0].command, "mcp-fs");
1603        assert_eq!(servers[0].args, vec!["--root", "."]);
1604    }
1605
1606    #[tokio::test]
1607    async fn discover_returns_empty_vec_when_no_config_file() {
1608        let dir = tempfile::TempDir::new().unwrap();
1609        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1610        assert!(servers.is_empty());
1611    }
1612
1613    #[tokio::test]
1614    async fn discover_parses_claude_code_format_with_env() {
1615        let dir = tempfile::TempDir::new().unwrap();
1616        let mcp_path = dir.path().join(".mcp.json");
1617        tokio::fs::write(
1618            &mcp_path,
1619            r#"{
1620                "mcpServers": {
1621                    "github": {
1622                        "command": "mcp-gh",
1623                        "args": [],
1624                        "env": {
1625                            "GITHUB_TOKEN": "abc123"
1626                        }
1627                    },
1628                    "filesystem": {
1629                        "command": "mcp-fs",
1630                        "args": ["--root", "/tmp"]
1631                    }
1632                }
1633            }"#,
1634        )
1635        .await
1636        .unwrap();
1637
1638        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1639        assert_eq!(servers.len(), 2);
1640
1641        let gh = servers.iter().find(|s| s.name == "github").unwrap();
1642        assert_eq!(gh.command, "mcp-gh");
1643        assert!(gh.args.is_empty());
1644
1645        let fs = servers.iter().find(|s| s.name == "filesystem").unwrap();
1646        assert_eq!(fs.command, "mcp-fs");
1647        assert_eq!(fs.args, vec!["--root", "/tmp"]);
1648    }
1649
1650    #[tokio::test]
1651    async fn discover_finds_dot_recursive_mcp_json_as_fallback() {
1652        let dir = tempfile::TempDir::new().unwrap();
1653        let recursive_dir = dir.path().join(".recursive");
1654        tokio::fs::create_dir(&recursive_dir).await.unwrap();
1655        let mcp_path = recursive_dir.join("mcp.json");
1656        tokio::fs::write(
1657            &mcp_path,
1658            r#"{
1659                "mcpServers": {
1660                    "db": {
1661                        "command": "mcp-db",
1662                        "args": ["--port", "5432"]
1663                    }
1664                }
1665            }"#,
1666        )
1667        .await
1668        .unwrap();
1669
1670        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1671        assert_eq!(servers.len(), 1);
1672        assert_eq!(servers[0].name, "db");
1673        assert_eq!(servers[0].command, "mcp-db");
1674    }
1675
1676    #[tokio::test]
1677    async fn discover_dot_mcp_json_takes_priority_over_dot_recursive() {
1678        let dir = tempfile::TempDir::new().unwrap();
1679
1680        // Primary .mcp.json
1681        tokio::fs::write(
1682            dir.path().join(".mcp.json"),
1683            r#"{
1684                "mcpServers": {
1685                    "primary": {
1686                        "command": "primary-server",
1687                        "args": []
1688                    }
1689                }
1690            }"#,
1691        )
1692        .await
1693        .unwrap();
1694
1695        // Fallback .recursive/mcp.json
1696        let recursive_dir = dir.path().join(".recursive");
1697        tokio::fs::create_dir(&recursive_dir).await.unwrap();
1698        tokio::fs::write(
1699            recursive_dir.join("mcp.json"),
1700            r#"{
1701                "mcpServers": {
1702                    "fallback": {
1703                        "command": "fallback-server",
1704                        "args": []
1705                    }
1706                }
1707            }"#,
1708        )
1709        .await
1710        .unwrap();
1711
1712        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1713        assert_eq!(servers.len(), 1);
1714        assert_eq!(servers[0].name, "primary");
1715    }
1716
1717    #[tokio::test]
1718    async fn discover_malformed_json_returns_descriptive_error() {
1719        let dir = tempfile::TempDir::new().unwrap();
1720        tokio::fs::write(dir.path().join(".mcp.json"), "not valid json")
1721            .await
1722            .unwrap();
1723
1724        let err = discover_mcp_servers(dir.path()).await.unwrap_err();
1725        let msg = err.to_string();
1726        assert!(
1727            msg.contains("failed to parse discovery config"),
1728            "error should mention parsing failure: {msg}"
1729        );
1730    }
1731
1732    #[tokio::test]
1733    async fn discover_empty_file_returns_empty_vec() {
1734        let dir = tempfile::TempDir::new().unwrap();
1735        tokio::fs::write(dir.path().join(".mcp.json"), "")
1736            .await
1737            .unwrap();
1738
1739        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1740        assert!(servers.is_empty());
1741    }
1742
1743    #[tokio::test]
1744    async fn discover_empty_mcp_servers_object_returns_empty_vec() {
1745        let dir = tempfile::TempDir::new().unwrap();
1746        tokio::fs::write(dir.path().join(".mcp.json"), r#"{"mcpServers": {}}"#)
1747            .await
1748            .unwrap();
1749
1750        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1751        assert!(servers.is_empty());
1752    }
1753
1754    // -----------------------------------------------------------------------
1755    // HTTP+SSE transport tests
1756    // -----------------------------------------------------------------------
1757
1758    #[tokio::test]
1759    async fn test_http_sse_discovery_with_url() {
1760        let dir = tempfile::TempDir::new().unwrap();
1761        let mcp_path = dir.path().join(".mcp.json");
1762        tokio::fs::write(
1763            &mcp_path,
1764            r#"{
1765                "mcpServers": {
1766                    "remote": {
1767                        "url": "http://example.com/sse"
1768                    }
1769                }
1770            }"#,
1771        )
1772        .await
1773        .unwrap();
1774
1775        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1776        assert_eq!(servers.len(), 1);
1777        assert_eq!(servers[0].name, "remote");
1778        assert_eq!(servers[0].command, "");
1779        assert!(servers[0].args.is_empty());
1780        assert_eq!(servers[0].url.as_deref(), Some("http://example.com/sse"));
1781    }
1782
1783    #[test]
1784    fn test_parse_sse_endpoint() {
1785        let buffer = "event: endpoint\ndata: http://localhost:3000/message\n\n";
1786        assert_eq!(
1787            parse_sse_endpoint(buffer),
1788            Some("http://localhost:3000/message".to_string())
1789        );
1790
1791        // No endpoint event
1792        let buffer = "event: message\ndata: {\"key\": \"value\"}\n\n";
1793        assert_eq!(parse_sse_endpoint(buffer), None);
1794
1795        // Empty data
1796        let buffer = "event: endpoint\ndata: \n\n";
1797        assert_eq!(parse_sse_endpoint(buffer), None);
1798    }
1799
1800    #[test]
1801    fn test_parse_sse_response() {
1802        let buffer = "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"key\":\"value\"}}\n\n";
1803        let result = parse_sse_response(buffer, 1, "test");
1804        assert!(result.is_some());
1805        let result = result.unwrap().unwrap();
1806        assert_eq!(result.get("key").and_then(|v| v.as_str()), Some("value"));
1807
1808        // Wrong id
1809        let result = parse_sse_response(buffer, 2, "test");
1810        assert!(result.is_none());
1811
1812        // Error response
1813        let buffer =
1814            "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32601,\"message\":\"Method not found\"}}\n\n";
1815        let result = parse_sse_response(buffer, 1, "test");
1816        assert!(result.is_some());
1817        assert!(result.unwrap().is_err());
1818    }
1819
1820    #[test]
1821    fn test_parse_sse_response_multiline_data() {
1822        let buffer =
1823            "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\"\ndata: :{\"key\":\"value\"}}\n\n";
1824        let result = parse_sse_response(buffer, 1, "test");
1825        assert!(result.is_some());
1826        let result = result.unwrap().unwrap();
1827        assert_eq!(result.get("key").and_then(|v| v.as_str()), Some("value"));
1828    }
1829
1830    #[test]
1831    fn test_parse_sse_response_empty_data_lines() {
1832        // Empty data: lines interspersed — they should be skipped (contribute nothing)
1833        // and the valid JSON line should still be parsed correctly.
1834        let buffer =
1835            "data: \ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\ndata: \n\n";
1836        let result = parse_sse_response(buffer, 1, "test");
1837        assert!(result.is_some());
1838        let result = result.unwrap().unwrap();
1839        assert_eq!(result.get("ok").and_then(|v| v.as_bool()), Some(true));
1840    }
1841
1842    #[test]
1843    fn test_parse_sse_response_invalid_json() {
1844        // data line with completely invalid JSON — should not panic, should return None
1845        // (the parse fails silently and current_data is cleared on the blank line).
1846        let buffer = "data: not-json-at-all\n\n";
1847        let result = parse_sse_response(buffer, 1, "test");
1848        // No valid JSON-RPC response could be extracted → None
1849        assert!(result.is_none());
1850    }
1851
1852    #[test]
1853    fn test_parse_sse_response_no_data_prefix() {
1854        // Lines without `data:` prefix should be ignored entirely.
1855        let buffer = "some random line\nanother line\n\n";
1856        let result = parse_sse_response(buffer, 1, "test");
1857        assert!(result.is_none());
1858    }
1859
1860    #[tokio::test]
1861    async fn test_http_sse_config_url_takes_priority() {
1862        // When both `command` and `url` are set in the config, the url-based
1863        // transport should be selected (url takes priority per spawn logic).
1864        let dir = tempfile::TempDir::new().unwrap();
1865        let mcp_path = dir.path().join(".mcp.json");
1866        tokio::fs::write(
1867            &mcp_path,
1868            r#"{
1869                "mcpServers": {
1870                    "hybrid": {
1871                        "command": "some-binary",
1872                        "args": ["--flag"],
1873                        "url": "http://example.com/sse"
1874                    }
1875                }
1876            }"#,
1877        )
1878        .await
1879        .unwrap();
1880
1881        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1882        assert_eq!(servers.len(), 1);
1883        assert_eq!(servers[0].name, "hybrid");
1884        // url is present → spawn will choose HTTP+SSE transport
1885        assert_eq!(servers[0].url.as_deref(), Some("http://example.com/sse"));
1886        // command is also stored but url takes priority in spawn()
1887        assert_eq!(servers[0].command, "some-binary");
1888
1889        // Verify the spawn logic: url.is_some() → spawn_http_sse path
1890        let server = &servers[0];
1891        assert!(
1892            server.url.is_some(),
1893            "url should be present, meaning HTTP+SSE transport is selected"
1894        );
1895    }
1896
1897    #[test]
1898    fn test_load_mcp_config_with_url() {
1899        let dir = tempfile::TempDir::new().unwrap();
1900        let path = dir.path().join("mcp.json");
1901        std::fs::write(
1902            &path,
1903            r#"{
1904                "servers": [
1905                    {"name": "local", "command": "mcp-fs", "args": ["--root", "."]},
1906                    {"name": "remote", "url": "http://localhost:3000/sse"}
1907                ]
1908            }"#,
1909        )
1910        .unwrap();
1911
1912        let servers = load_mcp_config(&path).unwrap();
1913        assert_eq!(servers.len(), 2);
1914        assert_eq!(servers[0].name, "local");
1915        assert_eq!(servers[0].command, "mcp-fs");
1916        assert!(servers[0].url.is_none());
1917        assert_eq!(servers[1].name, "remote");
1918        assert_eq!(servers[1].command, "");
1919        assert_eq!(servers[1].url.as_deref(), Some("http://localhost:3000/sse"));
1920    }
1921}