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