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