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" => match client.list_tools().await {
1118            Ok(tools) => {
1119                let tools_arr: Vec<serde_json::Value> = tools
1120                    .into_iter()
1121                    .map(|t| {
1122                        serde_json::json!({
1123                            "name": t.name,
1124                            "description": t.description,
1125                            "inputSchema": t.input_schema,
1126                        })
1127                    })
1128                    .collect();
1129                Some(JsonRpcResponse::success(
1130                    id,
1131                    serde_json::json!({ "tools": tools_arr }),
1132                ))
1133            }
1134            Err(e) => Some(JsonRpcResponse::internal_error(id, e.to_string())),
1135        },
1136        "tools/call" => {
1137            let name = request
1138                .params
1139                .get("name")
1140                .and_then(|v| v.as_str())
1141                .unwrap_or("");
1142            let arguments = request.params.get("arguments").cloned().unwrap_or_default();
1143
1144            match client.call_tool(name, arguments).await {
1145                Ok(text) => {
1146                    let result = serde_json::json!({
1147                        "content": [{"type": "text", "text": text}]
1148                    });
1149                    Some(JsonRpcResponse::success(id, result))
1150                }
1151                Err(e) => {
1152                    // Return the error as a tool-level isError result, not a
1153                    // JSON-RPC error, so the client can handle it gracefully.
1154                    let result = serde_json::json!({
1155                        "isError": true,
1156                        "content": [{"type": "text", "text": e.to_string()}]
1157                    });
1158                    Some(JsonRpcResponse::success(id, result))
1159                }
1160            }
1161        }
1162        "resources/list" => match client.list_resources().await {
1163            Ok(resources) => {
1164                let resources_arr: Vec<serde_json::Value> = resources
1165                    .into_iter()
1166                    .map(|r| {
1167                        serde_json::json!({
1168                            "uri": r.uri,
1169                            "name": r.name,
1170                            "description": r.description,
1171                            "mimeType": r.mime_type,
1172                        })
1173                    })
1174                    .collect();
1175                Some(JsonRpcResponse::success(
1176                    id,
1177                    serde_json::json!({ "resources": resources_arr }),
1178                ))
1179            }
1180            Err(e) => Some(JsonRpcResponse::internal_error(id, e.to_string())),
1181        },
1182        "resources/read" => {
1183            let uri = request
1184                .params
1185                .get("uri")
1186                .and_then(|v| v.as_str())
1187                .unwrap_or("");
1188
1189            match client.read_resource(uri).await {
1190                Ok(contents) => {
1191                    let contents_arr: Vec<serde_json::Value> = contents
1192                        .into_iter()
1193                        .map(|c| {
1194                            serde_json::json!({
1195                                "uri": c.uri,
1196                                "mimeType": c.mime_type,
1197                                "text": c.text,
1198                                "blob": c.blob,
1199                            })
1200                        })
1201                        .collect();
1202                    Some(JsonRpcResponse::success(
1203                        id,
1204                        serde_json::json!({ "contents": contents_arr }),
1205                    ))
1206                }
1207                Err(e) => Some(JsonRpcResponse::internal_error(id, e.to_string())),
1208            }
1209        }
1210        "prompts/list" => match client.list_prompts().await {
1211            Ok(prompts) => {
1212                let prompts_arr: Vec<serde_json::Value> = prompts
1213                    .into_iter()
1214                    .map(|p| {
1215                        serde_json::json!({
1216                            "name": p.name,
1217                            "description": p.description,
1218                            "arguments": p.arguments,
1219                        })
1220                    })
1221                    .collect();
1222                Some(JsonRpcResponse::success(
1223                    id,
1224                    serde_json::json!({ "prompts": prompts_arr }),
1225                ))
1226            }
1227            Err(e) => Some(JsonRpcResponse::internal_error(id, e.to_string())),
1228        },
1229        "prompts/get" => {
1230            let name = request
1231                .params
1232                .get("name")
1233                .and_then(|v| v.as_str())
1234                .unwrap_or("");
1235            let arguments = request
1236                .params
1237                .get("arguments")
1238                .and_then(|v| serde_json::from_value::<HashMap<String, String>>(v.clone()).ok());
1239
1240            match client.get_prompt(name, arguments).await {
1241                Ok(messages) => {
1242                    let messages_arr: Vec<serde_json::Value> = messages
1243                        .into_iter()
1244                        .map(|m| {
1245                            serde_json::json!({
1246                                "role": m.role,
1247                                "content": {"type": "text", "text": m.content},
1248                            })
1249                        })
1250                        .collect();
1251                    Some(JsonRpcResponse::success(
1252                        id,
1253                        serde_json::json!({ "messages": messages_arr }),
1254                    ))
1255                }
1256                Err(e) => Some(JsonRpcResponse::internal_error(id, e.to_string())),
1257            }
1258        }
1259        _ => Some(JsonRpcResponse::method_not_found(id, &request.method)),
1260    }
1261}
1262
1263// ---------------------------------------------------------------------------
1264// McpTool — implements the Tool trait by delegating to an McpClient
1265// ---------------------------------------------------------------------------
1266
1267/// A tool that wraps an MCP server tool. Registered with a namespaced name
1268/// `mcp__<server_name>__<tool_name>`.
1269pub struct McpTool {
1270    client: Arc<Mutex<McpClient>>,
1271    spec: McpToolSpec,
1272    server_name: String,
1273}
1274
1275impl McpTool {
1276    pub fn new(
1277        client: Arc<Mutex<McpClient>>,
1278        spec: McpToolSpec,
1279        server_name: impl Into<String>,
1280    ) -> Self {
1281        Self {
1282            client,
1283            spec,
1284            server_name: server_name.into(),
1285        }
1286    }
1287}
1288
1289#[async_trait]
1290impl Tool for McpTool {
1291    fn spec(&self) -> ToolSpec {
1292        ToolSpec {
1293            name: format!("mcp__{}__{}", self.server_name, self.spec.name),
1294            description: format!("[mcp:{}] {}", self.server_name, self.spec.description),
1295            parameters: self.spec.input_schema.clone(),
1296        }
1297    }
1298
1299    async fn execute(&self, arguments: Value) -> Result<String> {
1300        let mut client = self.client.lock().await;
1301        client.call_tool(&self.spec.name, arguments).await
1302    }
1303}
1304
1305// ---------------------------------------------------------------------------
1306// Config loading
1307// ---------------------------------------------------------------------------
1308
1309/// Load MCP server configurations from a JSON file.
1310/// Expected format:
1311/// ```json
1312/// { "servers": [ { "name": "...", "command": "...", "args": [...] }, { "name": "...", "url": "http://..." } ] }
1313/// ```
1314pub fn load_mcp_config(path: &std::path::Path) -> Result<Vec<McpServer>> {
1315    let contents = std::fs::read_to_string(path).map_err(|e| Error::Mcp {
1316        server: "config".into(),
1317        message: format!("failed to read config `{}`: {e}", path.display()),
1318    })?;
1319    let parsed: McpConfigFile = serde_json::from_str(&contents).map_err(|e| Error::Mcp {
1320        server: "config".into(),
1321        message: format!("failed to parse config `{}`: {e}", path.display()),
1322    })?;
1323    Ok(parsed.servers)
1324}
1325
1326#[derive(Debug, Deserialize)]
1327struct McpConfigFile {
1328    servers: Vec<McpServer>,
1329}
1330
1331// ---------------------------------------------------------------------------
1332// Workspace discovery (Claude Code .mcp.json format)
1333// ---------------------------------------------------------------------------
1334
1335/// Top-level structure of a Claude Code `.mcp.json` file.
1336#[derive(Debug, Deserialize)]
1337#[serde(deny_unknown_fields)]
1338struct McpDiscoveryFile {
1339    #[serde(rename = "mcpServers")]
1340    mcp_servers: HashMap<String, McpServerConfig>,
1341}
1342
1343/// Discover MCP server configurations from the workspace.
1344///
1345/// Looks for (in priority order):
1346/// 1. `<workspace>/.mcp.json` (Claude Code format)
1347/// 2. `<workspace>/.recursive/mcp.json` (alternative location)
1348///
1349/// Returns an empty vec if neither file exists (not an error).
1350pub async fn discover_mcp_servers(workspace: &Path) -> Result<Vec<McpServer>> {
1351    // Priority 1: workspace root .mcp.json
1352    let primary = workspace.join(".mcp.json");
1353    if primary.exists() {
1354        let configs = load_mcp_discovery_config(&primary).await?;
1355        if !configs.is_empty() {
1356            return Ok(configs);
1357        }
1358    }
1359
1360    // Priority 2: .recursive/mcp.json
1361    let fallback = workspace.join(".recursive").join("mcp.json");
1362    if fallback.exists() {
1363        let configs = load_mcp_discovery_config(&fallback).await?;
1364        return Ok(configs);
1365    }
1366
1367    Ok(Vec::new())
1368}
1369
1370/// Parse a Claude Code `.mcp.json` file into `Vec<McpServer>`.
1371///
1372/// Expected format:
1373/// ```json
1374/// {
1375///   "mcpServers": {
1376///     "server-name": {
1377///       "command": "path/to/server",
1378///       "args": ["--flag"],
1379///       "env": { "KEY": "value" }
1380///     }
1381///   }
1382/// }
1383/// ```
1384/// Or for HTTP+SSE:
1385/// ```json
1386/// {
1387///   "mcpServers": {
1388///     "server-name": {
1389///       "url": "http://localhost:3000/sse"
1390///     }
1391///   }
1392/// }
1393/// ```
1394async fn load_mcp_discovery_config(path: &Path) -> Result<Vec<McpServer>> {
1395    let contents = tokio::fs::read_to_string(path)
1396        .await
1397        .map_err(|e| Error::Mcp {
1398            server: "discovery".into(),
1399            message: format!("failed to read discovery config `{}`: {e}", path.display()),
1400        })?;
1401
1402    // Handle empty file gracefully
1403    if contents.trim().is_empty() {
1404        return Ok(Vec::new());
1405    }
1406
1407    let parsed: McpDiscoveryFile = serde_json::from_str(&contents).map_err(|e| Error::Mcp {
1408        server: "discovery".into(),
1409        message: format!("failed to parse discovery config `{}`: {e}", path.display()),
1410    })?;
1411
1412    let servers: Vec<McpServer> = parsed
1413        .mcp_servers
1414        .into_iter()
1415        .map(|(name, config)| McpServer {
1416            name,
1417            command: config.command,
1418            args: config.args,
1419            url: config.url,
1420        })
1421        .collect();
1422
1423    Ok(servers)
1424}
1425
1426// ---------------------------------------------------------------------------
1427// Tests
1428// ---------------------------------------------------------------------------
1429
1430#[cfg(test)]
1431mod tests {
1432    use super::*;
1433    use std::sync::Arc;
1434    use tokio::sync::Mutex;
1435
1436    /// Helper: spawn a mock MCP server using a shell script that reads
1437    /// JSON-RPC lines from stdin and writes canned responses to stdout.
1438    async fn spawn_mock_server(script: impl AsRef<str>) -> Result<McpClient> {
1439        let server = McpServer {
1440            name: "mock".to_string(),
1441            command: "/bin/sh".to_string(),
1442            args: vec!["-c".to_string(), script.as_ref().to_string()],
1443            url: None,
1444        };
1445        McpClient::spawn(&server).await
1446    }
1447
1448    /// Build a mock script that handles initialize + tools/list + tools/call.
1449    fn mock_script_echo() -> String {
1450        r#"
1451while IFS= read -r line; do
1452    # Parse the method from the request
1453    method=$(echo "$line" | python3 -c "
1454import sys, json
1455try:
1456    req = json.loads(sys.stdin.readline())
1457    print(req.get('method', ''))
1458except:
1459    pass
1460" 2>/dev/null <<< "$line")
1461
1462    id=$(echo "$line" | python3 -c "
1463import sys, json
1464try:
1465    req = json.loads(sys.stdin.readline())
1466    print(req.get('id', 0))
1467except:
1468    pass
1469" 2>/dev/null <<< "$line")
1470
1471    case "$method" in
1472        initialize)
1473            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"}}}'
1474            ;;
1475        notifications/initialized)
1476            # No response expected
1477            ;;
1478        tools/list)
1479            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"tools":[{"name":"echo","description":"Echo back the input","inputSchema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}]}}'
1480            ;;
1481        tools/call)
1482            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"content":[{"type":"text","text":"Echo: hello"}]}}'
1483            ;;
1484        resources/list)
1485            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"resources":[{"uri":"file:///tmp/test.txt","name":"Test File","description":"A test file","mimeType":"text/plain"}]}}'
1486            ;;
1487        resources/read)
1488            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"contents":[{"uri":"file:///tmp/test.txt","mimeType":"text/plain","text":"Hello, world!"}]}}'
1489            ;;
1490        prompts/list)
1491            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"prompts":[{"name":"greet","description":"Greet someone","arguments":[{"name":"name","description":"The name to greet","required":true}]}]}}'
1492            ;;
1493        prompts/get)
1494            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"messages":[{"role":"user","content":{"type":"text","text":"Hello, world!"}}]}}'
1495            ;;
1496        *)
1497            echo '{"jsonrpc":"2.0","id":'"$id"',"error":{"code":-32601,"message":"Method not found"}}'
1498            ;;
1499    esac
1500done
1501"#.to_string()
1502    }
1503
1504    #[tokio::test]
1505    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
1506    async fn test_a_initialize_handshake_and_list_tools() {
1507        let script = mock_script_echo();
1508        let mut client = spawn_mock_server(script).await.expect("spawn mock server");
1509
1510        let tools = client.list_tools().await.expect("list_tools");
1511        assert_eq!(tools.len(), 1);
1512        assert_eq!(tools[0].name, "echo");
1513        assert!(tools[0].description.contains("Echo"));
1514    }
1515
1516    #[tokio::test]
1517    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
1518    async fn test_a_call_tool_returns_text() {
1519        let script = mock_script_echo();
1520        let mut client = spawn_mock_server(script).await.expect("spawn mock server");
1521
1522        let result = client
1523            .call_tool("echo", serde_json::json!({"message": "hello"}))
1524            .await
1525            .expect("call_tool");
1526        assert!(result.contains("Echo: hello"));
1527    }
1528
1529    #[tokio::test]
1530    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
1531    async fn test_b_malformed_server_errors_cleanly() {
1532        // Server that outputs non-JSON
1533        let script = r#"
1534echo "not json"
1535sleep 10
1536"#;
1537        let result = spawn_mock_server(script).await;
1538        assert!(result.is_err(), "should fail on non-JSON response");
1539    }
1540
1541    #[tokio::test]
1542    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
1543    async fn test_c_mcp_tool_roundtrip() {
1544        let script = mock_script_echo();
1545        let client = spawn_mock_server(script).await.expect("spawn mock server");
1546        let client = Arc::new(Mutex::new(client));
1547
1548        let spec = McpToolSpec {
1549            name: "echo".to_string(),
1550            description: "Echo back input".to_string(),
1551            input_schema: serde_json::json!({"type":"object","properties":{"message":{"type":"string"}}}),
1552        };
1553
1554        let tool = McpTool::new(client, spec, "mock");
1555        let tool_spec = tool.spec();
1556        assert_eq!(tool_spec.name, "mcp__mock__echo");
1557        assert!(tool_spec.description.contains("[mcp:mock]"));
1558
1559        let result = tool
1560            .execute(serde_json::json!({"message": "hello"}))
1561            .await
1562            .expect("tool execute");
1563        assert!(result.contains("Echo: hello"));
1564    }
1565
1566    #[tokio::test]
1567    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
1568    async fn test_b_server_timeout_errors_cleanly() {
1569        // Server that never responds
1570        let script = r#"
1571while true; do
1572    read line
1573    # Never write anything
1574done
1575"#;
1576        let result = spawn_mock_server(script).await;
1577        // The initialize handshake should time out
1578        assert!(result.is_err(), "should fail on timeout");
1579        let err = result.unwrap_err().to_string();
1580        assert!(
1581            err.contains("timed out") || err.contains("timeout"),
1582            "error should mention timeout: {err}"
1583        );
1584    }
1585
1586    #[tokio::test]
1587    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
1588    async fn test_a_call_tool_with_error_response() {
1589        // Server that returns isError: true
1590        let script = r#"
1591while IFS= read -r line; do
1592    id=$(echo "$line" | python3 -c "
1593import sys, json
1594try:
1595    req = json.loads(sys.stdin.readline())
1596    print(req.get('id', 0))
1597except:
1598    pass
1599" 2>/dev/null <<< "$line")
1600    method=$(echo "$line" | python3 -c "
1601import sys, json
1602try:
1603    req = json.loads(sys.stdin.readline())
1604    print(req.get('method', ''))
1605except:
1606    pass
1607" 2>/dev/null <<< "$line")
1608
1609    case "$method" in
1610        initialize)
1611            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"mock-server","version":"1.0"}}}'
1612            ;;
1613        notifications/initialized)
1614            ;;
1615        tools/list)
1616            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"tools":[{"name":"failing","description":"Always fails","inputSchema":{"type":"object"}}]}}'
1617            ;;
1618        tools/call)
1619            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"isError":true,"content":[{"type":"text","text":"Something went wrong"}]}}'
1620            ;;
1621        *)
1622            echo '{"jsonrpc":"2.0","id":'"$id"',"error":{"code":-32601,"message":"Method not found"}}'
1623            ;;
1624    esac
1625done
1626"#;
1627        let mut client = spawn_mock_server(script).await.expect("spawn mock server");
1628        let err = client
1629            .call_tool("failing", serde_json::json!({}))
1630            .await
1631            .unwrap_err();
1632        assert!(matches!(err, Error::Tool { .. }));
1633        let msg = err.to_string();
1634        assert!(msg.contains("Something went wrong"), "error: {msg}");
1635    }
1636
1637    #[test]
1638    fn load_mcp_config_parses_correctly() {
1639        let dir = tempfile::TempDir::new().unwrap();
1640        let path = dir.path().join("mcp.json");
1641        std::fs::write(
1642            &path,
1643            r#"{
1644                "servers": [
1645                    {"name": "fs", "command": "mcp-fs", "args": ["--root", "."]},
1646                    {"name": "github", "command": "mcp-gh", "args": []}
1647                ]
1648            }"#,
1649        )
1650        .unwrap();
1651
1652        let servers = load_mcp_config(&path).unwrap();
1653        assert_eq!(servers.len(), 2);
1654        assert_eq!(servers[0].name, "fs");
1655        assert_eq!(servers[0].command, "mcp-fs");
1656        assert_eq!(servers[0].args, vec!["--root", "."]);
1657        assert_eq!(servers[1].name, "github");
1658        assert_eq!(servers[1].command, "mcp-gh");
1659        assert!(servers[1].args.is_empty());
1660    }
1661
1662    #[test]
1663    fn load_mcp_config_empty_servers() {
1664        let dir = tempfile::TempDir::new().unwrap();
1665        let path = dir.path().join("empty.json");
1666        std::fs::write(&path, r#"{"servers": []}"#).unwrap();
1667
1668        let servers = load_mcp_config(&path).unwrap();
1669        assert!(servers.is_empty());
1670    }
1671
1672    #[test]
1673    fn load_mcp_config_missing_file_errors() {
1674        let dir = tempfile::TempDir::new().unwrap();
1675        let path = dir.path().join("nonexistent.json");
1676        let err = load_mcp_config(&path).unwrap_err();
1677        assert!(err.to_string().contains("failed to read config"));
1678    }
1679
1680    // -----------------------------------------------------------------------
1681    // Discovery tests
1682    // -----------------------------------------------------------------------
1683
1684    #[tokio::test]
1685    async fn discover_finds_dot_mcp_json_in_workspace_root() {
1686        let dir = tempfile::TempDir::new().unwrap();
1687        let mcp_path = dir.path().join(".mcp.json");
1688        tokio::fs::write(
1689            &mcp_path,
1690            r#"{
1691                "mcpServers": {
1692                    "fs": {
1693                        "command": "mcp-fs",
1694                        "args": ["--root", "."]
1695                    }
1696                }
1697            }"#,
1698        )
1699        .await
1700        .unwrap();
1701
1702        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1703        assert_eq!(servers.len(), 1);
1704        assert_eq!(servers[0].name, "fs");
1705        assert_eq!(servers[0].command, "mcp-fs");
1706        assert_eq!(servers[0].args, vec!["--root", "."]);
1707    }
1708
1709    #[tokio::test]
1710    async fn discover_returns_empty_vec_when_no_config_file() {
1711        let dir = tempfile::TempDir::new().unwrap();
1712        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1713        assert!(servers.is_empty());
1714    }
1715
1716    #[tokio::test]
1717    async fn discover_parses_claude_code_format_with_env() {
1718        let dir = tempfile::TempDir::new().unwrap();
1719        let mcp_path = dir.path().join(".mcp.json");
1720        tokio::fs::write(
1721            &mcp_path,
1722            r#"{
1723                "mcpServers": {
1724                    "github": {
1725                        "command": "mcp-gh",
1726                        "args": [],
1727                        "env": {
1728                            "GITHUB_TOKEN": "abc123"
1729                        }
1730                    },
1731                    "filesystem": {
1732                        "command": "mcp-fs",
1733                        "args": ["--root", "/tmp"]
1734                    }
1735                }
1736            }"#,
1737        )
1738        .await
1739        .unwrap();
1740
1741        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1742        assert_eq!(servers.len(), 2);
1743
1744        let gh = servers.iter().find(|s| s.name == "github").unwrap();
1745        assert_eq!(gh.command, "mcp-gh");
1746        assert!(gh.args.is_empty());
1747
1748        let fs = servers.iter().find(|s| s.name == "filesystem").unwrap();
1749        assert_eq!(fs.command, "mcp-fs");
1750        assert_eq!(fs.args, vec!["--root", "/tmp"]);
1751    }
1752
1753    #[tokio::test]
1754    async fn discover_finds_dot_recursive_mcp_json_as_fallback() {
1755        let dir = tempfile::TempDir::new().unwrap();
1756        let recursive_dir = dir.path().join(".recursive");
1757        tokio::fs::create_dir(&recursive_dir).await.unwrap();
1758        let mcp_path = recursive_dir.join("mcp.json");
1759        tokio::fs::write(
1760            &mcp_path,
1761            r#"{
1762                "mcpServers": {
1763                    "db": {
1764                        "command": "mcp-db",
1765                        "args": ["--port", "5432"]
1766                    }
1767                }
1768            }"#,
1769        )
1770        .await
1771        .unwrap();
1772
1773        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1774        assert_eq!(servers.len(), 1);
1775        assert_eq!(servers[0].name, "db");
1776        assert_eq!(servers[0].command, "mcp-db");
1777    }
1778
1779    #[tokio::test]
1780    async fn discover_dot_mcp_json_takes_priority_over_dot_recursive() {
1781        let dir = tempfile::TempDir::new().unwrap();
1782
1783        // Primary .mcp.json
1784        tokio::fs::write(
1785            dir.path().join(".mcp.json"),
1786            r#"{
1787                "mcpServers": {
1788                    "primary": {
1789                        "command": "primary-server",
1790                        "args": []
1791                    }
1792                }
1793            }"#,
1794        )
1795        .await
1796        .unwrap();
1797
1798        // Fallback .recursive/mcp.json
1799        let recursive_dir = dir.path().join(".recursive");
1800        tokio::fs::create_dir(&recursive_dir).await.unwrap();
1801        tokio::fs::write(
1802            recursive_dir.join("mcp.json"),
1803            r#"{
1804                "mcpServers": {
1805                    "fallback": {
1806                        "command": "fallback-server",
1807                        "args": []
1808                    }
1809                }
1810            }"#,
1811        )
1812        .await
1813        .unwrap();
1814
1815        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1816        assert_eq!(servers.len(), 1);
1817        assert_eq!(servers[0].name, "primary");
1818    }
1819
1820    #[tokio::test]
1821    async fn discover_malformed_json_returns_descriptive_error() {
1822        let dir = tempfile::TempDir::new().unwrap();
1823        tokio::fs::write(dir.path().join(".mcp.json"), "not valid json")
1824            .await
1825            .unwrap();
1826
1827        let err = discover_mcp_servers(dir.path()).await.unwrap_err();
1828        let msg = err.to_string();
1829        assert!(
1830            msg.contains("failed to parse discovery config"),
1831            "error should mention parsing failure: {msg}"
1832        );
1833    }
1834
1835    #[tokio::test]
1836    async fn discover_empty_file_returns_empty_vec() {
1837        let dir = tempfile::TempDir::new().unwrap();
1838        tokio::fs::write(dir.path().join(".mcp.json"), "")
1839            .await
1840            .unwrap();
1841
1842        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1843        assert!(servers.is_empty());
1844    }
1845
1846    #[tokio::test]
1847    async fn discover_empty_mcp_servers_object_returns_empty_vec() {
1848        let dir = tempfile::TempDir::new().unwrap();
1849        tokio::fs::write(dir.path().join(".mcp.json"), r#"{"mcpServers": {}}"#)
1850            .await
1851            .unwrap();
1852
1853        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1854        assert!(servers.is_empty());
1855    }
1856
1857    // -----------------------------------------------------------------------
1858    // HTTP+SSE transport tests
1859    // -----------------------------------------------------------------------
1860
1861    #[tokio::test]
1862    async fn test_http_sse_discovery_with_url() {
1863        let dir = tempfile::TempDir::new().unwrap();
1864        let mcp_path = dir.path().join(".mcp.json");
1865        tokio::fs::write(
1866            &mcp_path,
1867            r#"{
1868                "mcpServers": {
1869                    "remote": {
1870                        "url": "http://example.com/sse"
1871                    }
1872                }
1873            }"#,
1874        )
1875        .await
1876        .unwrap();
1877
1878        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1879        assert_eq!(servers.len(), 1);
1880        assert_eq!(servers[0].name, "remote");
1881        assert_eq!(servers[0].command, "");
1882        assert!(servers[0].args.is_empty());
1883        assert_eq!(servers[0].url.as_deref(), Some("http://example.com/sse"));
1884    }
1885
1886    #[test]
1887    fn test_parse_sse_endpoint() {
1888        let buffer = "event: endpoint\ndata: http://localhost:3000/message\n\n";
1889        assert_eq!(
1890            parse_sse_endpoint(buffer),
1891            Some("http://localhost:3000/message".to_string())
1892        );
1893
1894        // No endpoint event
1895        let buffer = "event: message\ndata: {\"key\": \"value\"}\n\n";
1896        assert_eq!(parse_sse_endpoint(buffer), None);
1897
1898        // Empty data
1899        let buffer = "event: endpoint\ndata: \n\n";
1900        assert_eq!(parse_sse_endpoint(buffer), None);
1901    }
1902
1903    #[test]
1904    fn test_parse_sse_response() {
1905        let buffer = "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"key\":\"value\"}}\n\n";
1906        let result = parse_sse_response(buffer, 1, "test");
1907        assert!(result.is_some());
1908        let result = result.unwrap().unwrap();
1909        assert_eq!(result.get("key").and_then(|v| v.as_str()), Some("value"));
1910
1911        // Wrong id
1912        let result = parse_sse_response(buffer, 2, "test");
1913        assert!(result.is_none());
1914
1915        // Error response
1916        let buffer =
1917            "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32601,\"message\":\"Method not found\"}}\n\n";
1918        let result = parse_sse_response(buffer, 1, "test");
1919        assert!(result.is_some());
1920        assert!(result.unwrap().is_err());
1921    }
1922
1923    #[test]
1924    fn test_parse_sse_response_multiline_data() {
1925        let buffer =
1926            "data: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\"\ndata: :{\"key\":\"value\"}}\n\n";
1927        let result = parse_sse_response(buffer, 1, "test");
1928        assert!(result.is_some());
1929        let result = result.unwrap().unwrap();
1930        assert_eq!(result.get("key").and_then(|v| v.as_str()), Some("value"));
1931    }
1932
1933    #[test]
1934    fn test_parse_sse_response_empty_data_lines() {
1935        // Empty data: lines interspersed — they should be skipped (contribute nothing)
1936        // and the valid JSON line should still be parsed correctly.
1937        let buffer =
1938            "data: \ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"ok\":true}}\ndata: \n\n";
1939        let result = parse_sse_response(buffer, 1, "test");
1940        assert!(result.is_some());
1941        let result = result.unwrap().unwrap();
1942        assert_eq!(result.get("ok").and_then(|v| v.as_bool()), Some(true));
1943    }
1944
1945    #[test]
1946    fn test_parse_sse_response_invalid_json() {
1947        // data line with completely invalid JSON — should not panic, should return None
1948        // (the parse fails silently and current_data is cleared on the blank line).
1949        let buffer = "data: not-json-at-all\n\n";
1950        let result = parse_sse_response(buffer, 1, "test");
1951        // No valid JSON-RPC response could be extracted → None
1952        assert!(result.is_none());
1953    }
1954
1955    #[test]
1956    fn test_parse_sse_response_no_data_prefix() {
1957        // Lines without `data:` prefix should be ignored entirely.
1958        let buffer = "some random line\nanother line\n\n";
1959        let result = parse_sse_response(buffer, 1, "test");
1960        assert!(result.is_none());
1961    }
1962
1963    #[tokio::test]
1964    async fn test_http_sse_config_url_takes_priority() {
1965        // When both `command` and `url` are set in the config, the url-based
1966        // transport should be selected (url takes priority per spawn logic).
1967        let dir = tempfile::TempDir::new().unwrap();
1968        let mcp_path = dir.path().join(".mcp.json");
1969        tokio::fs::write(
1970            &mcp_path,
1971            r#"{
1972                "mcpServers": {
1973                    "hybrid": {
1974                        "command": "some-binary",
1975                        "args": ["--flag"],
1976                        "url": "http://example.com/sse"
1977                    }
1978                }
1979            }"#,
1980        )
1981        .await
1982        .unwrap();
1983
1984        let servers = discover_mcp_servers(dir.path()).await.unwrap();
1985        assert_eq!(servers.len(), 1);
1986        assert_eq!(servers[0].name, "hybrid");
1987        // url is present → spawn will choose HTTP+SSE transport
1988        assert_eq!(servers[0].url.as_deref(), Some("http://example.com/sse"));
1989        // command is also stored but url takes priority in spawn()
1990        assert_eq!(servers[0].command, "some-binary");
1991
1992        // Verify the spawn logic: url.is_some() → spawn_http_sse path
1993        let server = &servers[0];
1994        assert!(
1995            server.url.is_some(),
1996            "url should be present, meaning HTTP+SSE transport is selected"
1997        );
1998    }
1999
2000    #[test]
2001    fn test_load_mcp_config_with_url() {
2002        let dir = tempfile::TempDir::new().unwrap();
2003        let path = dir.path().join("mcp.json");
2004        std::fs::write(
2005            &path,
2006            r#"{
2007                "servers": [
2008                    {"name": "local", "command": "mcp-fs", "args": ["--root", "."]},
2009                    {"name": "remote", "url": "http://localhost:3000/sse"}
2010                ]
2011            }"#,
2012        )
2013        .unwrap();
2014
2015        let servers = load_mcp_config(&path).unwrap();
2016        assert_eq!(servers.len(), 2);
2017        assert_eq!(servers[0].name, "local");
2018        assert_eq!(servers[0].command, "mcp-fs");
2019        assert!(servers[0].url.is_none());
2020        assert_eq!(servers[1].name, "remote");
2021        assert_eq!(servers[1].command, "");
2022        assert_eq!(servers[1].url.as_deref(), Some("http://localhost:3000/sse"));
2023    }
2024
2025    // -----------------------------------------------------------------------
2026    // Resources tests
2027    // -----------------------------------------------------------------------
2028
2029    #[tokio::test]
2030    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
2031    async fn test_resources_list_resources() {
2032        let script = mock_script_echo();
2033        let mut client = spawn_mock_server(script).await.expect("spawn mock server");
2034
2035        let resources = client.list_resources().await.expect("list_resources");
2036        assert_eq!(resources.len(), 1);
2037        assert_eq!(resources[0].uri, "file:///tmp/test.txt");
2038        assert_eq!(resources[0].name, "Test File");
2039        assert_eq!(resources[0].description.as_deref(), Some("A test file"));
2040        assert_eq!(resources[0].mime_type.as_deref(), Some("text/plain"));
2041    }
2042
2043    #[tokio::test]
2044    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
2045    async fn test_resources_read_resource() {
2046        let script = mock_script_echo();
2047        let mut client = spawn_mock_server(script).await.expect("spawn mock server");
2048
2049        let contents = client
2050            .read_resource("file:///tmp/test.txt")
2051            .await
2052            .expect("read_resource");
2053        assert_eq!(contents.len(), 1);
2054        assert_eq!(contents[0].uri, "file:///tmp/test.txt");
2055        assert_eq!(contents[0].text.as_deref(), Some("Hello, world!"));
2056        assert_eq!(contents[0].mime_type.as_deref(), Some("text/plain"));
2057    }
2058
2059    #[tokio::test]
2060    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
2061    async fn test_resources_read_resource_with_blob() {
2062        let script = r#"
2063while IFS= read -r line; do
2064    id=$(echo "$line" | python3 -c "
2065import sys, json
2066try:
2067    req = json.loads(sys.stdin.readline())
2068    print(req.get('id', 0))
2069except:
2070    pass
2071" 2>/dev/null <<< "$line")
2072    method=$(echo "$line" | python3 -c "
2073import sys, json
2074try:
2075    req = json.loads(sys.stdin.readline())
2076    print(req.get('method', ''))
2077except:
2078    pass
2079" 2>/dev/null <<< "$line")
2080
2081    case "$method" in
2082        initialize)
2083            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"protocolVersion":"2024-11-05","capabilities":{"resources":true},"serverInfo":{"name":"mock-server","version":"1.0"}}}'
2084            ;;
2085        notifications/initialized)
2086            ;;
2087        resources/read)
2088            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"contents":[{"uri":"file:///tmp/image.png","mimeType":"image/png","blob":"iVBORw0KGgoAAAANSUhEUgAAAAE="}]}}'
2089            ;;
2090        *)
2091            echo '{"jsonrpc":"2.0","id":'"$id"',"error":{"code":-32601,"message":"Method not found"}}'
2092            ;;
2093    esac
2094done
2095"#;
2096        let mut client = spawn_mock_server(script).await.expect("spawn mock server");
2097
2098        let contents = client
2099            .read_resource("file:///tmp/image.png")
2100            .await
2101            .expect("read_resource");
2102        assert_eq!(contents.len(), 1);
2103        assert_eq!(contents[0].uri, "file:///tmp/image.png");
2104        assert_eq!(contents[0].mime_type.as_deref(), Some("image/png"));
2105        assert!(contents[0].blob.is_some());
2106        assert!(contents[0].text.is_none());
2107    }
2108
2109    // -----------------------------------------------------------------------
2110    // Prompts tests
2111    // -----------------------------------------------------------------------
2112
2113    #[tokio::test]
2114    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
2115    async fn test_prompts_list_prompts() {
2116        let script = mock_script_echo();
2117        let mut client = spawn_mock_server(script).await.expect("spawn mock server");
2118
2119        let prompts = client.list_prompts().await.expect("list_prompts");
2120        assert_eq!(prompts.len(), 1);
2121        assert_eq!(prompts[0].name, "greet");
2122        assert_eq!(prompts[0].description.as_deref(), Some("Greet someone"));
2123        let args = prompts[0]
2124            .arguments
2125            .as_ref()
2126            .expect("arguments should be present");
2127        assert_eq!(args.len(), 1);
2128        assert_eq!(args[0].name, "name");
2129        assert!(args[0].required);
2130    }
2131
2132    #[tokio::test]
2133    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
2134    async fn test_prompts_get_prompt() {
2135        let script = mock_script_echo();
2136        let mut client = spawn_mock_server(script).await.expect("spawn mock server");
2137
2138        let messages = client.get_prompt("greet", None).await.expect("get_prompt");
2139        assert_eq!(messages.len(), 1);
2140        assert_eq!(messages[0].role, "user");
2141        assert_eq!(messages[0].content, "Hello, world!");
2142    }
2143
2144    #[tokio::test]
2145    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
2146    async fn test_prompts_get_prompt_with_arguments() {
2147        let script = r#"
2148while IFS= read -r line; do
2149    id=$(echo "$line" | python3 -c "
2150import sys, json
2151try:
2152    req = json.loads(sys.stdin.readline())
2153    print(req.get('id', 0))
2154except:
2155    pass
2156" 2>/dev/null <<< "$line")
2157    method=$(echo "$line" | python3 -c "
2158import sys, json
2159try:
2160    req = json.loads(sys.stdin.readline())
2161    print(req.get('method', ''))
2162except:
2163    pass
2164" 2>/dev/null <<< "$line")
2165
2166    case "$method" in
2167        initialize)
2168            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"protocolVersion":"2024-11-05","capabilities":{"prompts":true},"serverInfo":{"name":"mock-server","version":"1.0"}}}'
2169            ;;
2170        notifications/initialized)
2171            ;;
2172        prompts/get)
2173            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"messages":[{"role":"user","content":{"type":"text","text":"Hello, Alice!"}}]}}'
2174            ;;
2175        *)
2176            echo '{"jsonrpc":"2.0","id":'"$id"',"error":{"code":-32601,"message":"Method not found"}}'
2177            ;;
2178    esac
2179done
2180"#;
2181        let mut client = spawn_mock_server(script).await.expect("spawn mock server");
2182
2183        let mut args = HashMap::new();
2184        args.insert("name".to_string(), "Alice".to_string());
2185        let messages = client
2186            .get_prompt("greet", Some(args))
2187            .await
2188            .expect("get_prompt");
2189        assert_eq!(messages.len(), 1);
2190        assert_eq!(messages[0].role, "user");
2191        assert_eq!(messages[0].content, "Hello, Alice!");
2192    }
2193
2194    // -----------------------------------------------------------------------
2195    // Resources/prompts edge cases
2196    // -----------------------------------------------------------------------
2197
2198    #[tokio::test]
2199    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
2200    async fn test_resources_capability_not_advertised() {
2201        // Server that advertises only tools — no resources capability.
2202        let script = r#"
2203while IFS= read -r line; do
2204    id=$(echo "$line" | python3 -c "
2205import sys, json
2206try:
2207    req = json.loads(sys.stdin.readline())
2208    print(req.get('id', 0))
2209except:
2210    pass
2211" 2>/dev/null <<< "$line")
2212    method=$(echo "$line" | python3 -c "
2213import sys, json
2214try:
2215    req = json.loads(sys.stdin.readline())
2216    print(req.get('method', ''))
2217except:
2218    pass
2219" 2>/dev/null <<< "$line")
2220
2221    case "$method" in
2222        initialize)
2223            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":true},"serverInfo":{"name":"mock-server","version":"1.0"}}}'
2224            ;;
2225        notifications/initialized)
2226            ;;
2227        *)
2228            echo '{"jsonrpc":"2.0","id":'"$id"',"error":{"code":-32601,"message":"Method not found"}}'
2229            ;;
2230    esac
2231done
2232"#;
2233        let mut client = spawn_mock_server(script).await.expect("spawn mock server");
2234
2235        // list_resources should fail because resources capability is not advertised
2236        let err = client.list_resources().await.unwrap_err();
2237        let msg = err.to_string();
2238        assert!(
2239            msg.contains("does not advertise"),
2240            "expected capability error, got: {msg}"
2241        );
2242    }
2243
2244    #[tokio::test]
2245    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
2246    async fn test_prompts_get_with_missing_name() {
2247        // Call get_prompt with an empty name string — verify it sends the request
2248        // and the server can respond (behavior check, not a client-side validation error).
2249        let script = r#"
2250while IFS= read -r line; do
2251    id=$(echo "$line" | python3 -c "
2252import sys, json
2253try:
2254    req = json.loads(sys.stdin.readline())
2255    print(req.get('id', 0))
2256except:
2257    pass
2258" 2>/dev/null <<< "$line")
2259    method=$(echo "$line" | python3 -c "
2260import sys, json
2261try:
2262    req = json.loads(sys.stdin.readline())
2263    print(req.get('method', ''))
2264except:
2265    pass
2266" 2>/dev/null <<< "$line")
2267
2268    case "$method" in
2269        initialize)
2270            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"protocolVersion":"2024-11-05","capabilities":{"prompts":true},"serverInfo":{"name":"mock-server","version":"1.0"}}}'
2271            ;;
2272        notifications/initialized)
2273            ;;
2274        prompts/get)
2275            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"messages":[{"role":"assistant","content":{"type":"text","text":"default prompt"}}]}}'
2276            ;;
2277        *)
2278            echo '{"jsonrpc":"2.0","id":'"$id"',"error":{"code":-32601,"message":"Method not found"}}'
2279            ;;
2280    esac
2281done
2282"#;
2283        let mut client = spawn_mock_server(script).await.expect("spawn mock server");
2284
2285        // Empty name — client should still send the request without panicking
2286        let messages = client
2287            .get_prompt("", None)
2288            .await
2289            .expect("get_prompt with empty name");
2290        assert_eq!(messages.len(), 1);
2291        assert_eq!(messages[0].role, "assistant");
2292        assert_eq!(messages[0].content, "default prompt");
2293    }
2294
2295    #[tokio::test]
2296    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
2297    async fn test_resources_read_empty_content() {
2298        // Server returns resources/read with an empty contents array.
2299        let script = r#"
2300while IFS= read -r line; do
2301    id=$(echo "$line" | python3 -c "
2302import sys, json
2303try:
2304    req = json.loads(sys.stdin.readline())
2305    print(req.get('id', 0))
2306except:
2307    pass
2308" 2>/dev/null <<< "$line")
2309    method=$(echo "$line" | python3 -c "
2310import sys, json
2311try:
2312    req = json.loads(sys.stdin.readline())
2313    print(req.get('method', ''))
2314except:
2315    pass
2316" 2>/dev/null <<< "$line")
2317
2318    case "$method" in
2319        initialize)
2320            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"protocolVersion":"2024-11-05","capabilities":{"resources":true},"serverInfo":{"name":"mock-server","version":"1.0"}}}'
2321            ;;
2322        notifications/initialized)
2323            ;;
2324        resources/read)
2325            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"contents":[]}}'
2326            ;;
2327        *)
2328            echo '{"jsonrpc":"2.0","id":'"$id"',"error":{"code":-32601,"message":"Method not found"}}'
2329            ;;
2330    esac
2331done
2332"#;
2333        let mut client = spawn_mock_server(script).await.expect("spawn mock server");
2334
2335        // Empty contents array — should be handled gracefully (return empty vec)
2336        let contents = client
2337            .read_resource("file:///tmp/nonexistent.txt")
2338            .await
2339            .expect("read_resource with empty contents");
2340        assert!(
2341            contents.is_empty(),
2342            "expected empty contents vec, got {} items",
2343            contents.len()
2344        );
2345    }
2346
2347    // -----------------------------------------------------------------------
2348    // Capability check tests
2349    // -----------------------------------------------------------------------
2350
2351    #[tokio::test]
2352    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
2353    async fn test_capability_not_advertised_returns_error() {
2354        // Server that only advertises tools, not resources
2355        let script = r#"
2356while IFS= read -r line; do
2357    id=$(echo "$line" | python3 -c "
2358import sys, json
2359try:
2360    req = json.loads(sys.stdin.readline())
2361    print(req.get('id', 0))
2362except:
2363    pass
2364" 2>/dev/null <<< "$line")
2365    method=$(echo "$line" | python3 -c "
2366import sys, json
2367try:
2368    req = json.loads(sys.stdin.readline())
2369    print(req.get('method', ''))
2370except:
2371    pass
2372" 2>/dev/null <<< "$line")
2373
2374    case "$method" in
2375        initialize)
2376            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":true},"serverInfo":{"name":"mock-server","version":"1.0"}}}'
2377            ;;
2378        notifications/initialized)
2379            ;;
2380        *)
2381            echo '{"jsonrpc":"2.0","id":'"$id"',"error":{"code":-32601,"message":"Method not found"}}'
2382            ;;
2383    esac
2384done
2385"#;
2386        let mut client = spawn_mock_server(script).await.expect("spawn mock server");
2387
2388        let err = client.list_resources().await.unwrap_err();
2389        let msg = err.to_string();
2390        assert!(
2391            msg.contains("does not advertise"),
2392            "error should mention capability: {msg}"
2393        );
2394
2395        let err = client
2396            .read_resource("file:///tmp/test.txt")
2397            .await
2398            .unwrap_err();
2399        let msg = err.to_string();
2400        assert!(
2401            msg.contains("does not advertise"),
2402            "error should mention capability: {msg}"
2403        );
2404    }
2405
2406    #[tokio::test]
2407    #[ignore] // bash+python3 mock server unreliable in CI (Linux)
2408    async fn test_capability_not_advertised_returns_error_for_prompts() {
2409        // Server that only advertises tools, not prompts
2410        let script = r#"
2411while IFS= read -r line; do
2412    id=$(echo "$line" | python3 -c "
2413import sys, json
2414try:
2415    req = json.loads(sys.stdin.readline())
2416    print(req.get('id', 0))
2417except:
2418    pass
2419" 2>/dev/null <<< "$line")
2420    method=$(echo "$line" | python3 -c "
2421import sys, json
2422try:
2423    req = json.loads(sys.stdin.readline())
2424    print(req.get('method', ''))
2425except:
2426    pass
2427" 2>/dev/null <<< "$line")
2428
2429    case "$method" in
2430        initialize)
2431            echo '{"jsonrpc":"2.0","id":'"$id"',"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":true},"serverInfo":{"name":"mock-server","version":"1.0"}}}'
2432            ;;
2433        notifications/initialized)
2434            ;;
2435        *)
2436            echo '{"jsonrpc":"2.0","id":'"$id"',"error":{"code":-32601,"message":"Method not found"}}'
2437            ;;
2438    esac
2439done
2440"#;
2441        let mut client = spawn_mock_server(script).await.expect("spawn mock server");
2442
2443        let err = client.list_prompts().await.unwrap_err();
2444        let msg = err.to_string();
2445        assert!(
2446            msg.contains("does not advertise"),
2447            "error should mention capability: {msg}"
2448        );
2449
2450        let err = client.get_prompt("greet", None).await.unwrap_err();
2451        let msg = err.to_string();
2452        assert!(
2453            msg.contains("does not advertise"),
2454            "error should mention capability: {msg}"
2455        );
2456    }
2457}