1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct McpServerConfig {
37 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub url: Option<String>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct McpServer {
52 pub name: String,
53 #[serde(default)]
55 pub command: String,
56 #[serde(default)]
57 pub args: Vec<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub url: Option<String>,
61}
62
63#[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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct McpPromptMessage {
121 pub role: String,
122 pub content: String,
123}
124
125enum McpTransport {
131 Stdio {
133 stdin: ChildStdin,
134 reader: BufReader<ChildStdout>,
135 child: Option<Child>,
136 },
137 HttpSse {
139 client: reqwest::Client,
140 sse_url: String,
142 post_url: Option<String>,
144 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
164pub struct McpClient {
166 transport: McpTransport,
167 next_id: u64,
168 capabilities: ServerCapabilities,
170 server_name: String,
172}
173
174#[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 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 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 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 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 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 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 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, 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 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 if let Some(server_proto) = init_result.get("protocolVersion").and_then(|v| v.as_str()) {
367 if server_proto != "2024-11-05" {
368 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 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 self.send_notification("notifications/initialized", serde_json::json!({}))
393 .await?;
394
395 Ok(())
396 }
397
398 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 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 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 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 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 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 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 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 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 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(¬ification).await
683 }
684
685 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 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 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 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 !buffer.is_empty() {
839 if let Some(result) = parse_sse_response(buffer, expected_id, server_name) {
840 return result;
841 }
842 }
843
844 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
900fn 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 if line.is_empty() {
921 current_event = None;
922 }
923 }
924
925 None
926}
927
928fn 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 if let Ok(parsed) = serde_json::from_str::<Value>(¤t_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 if !trimmed.starts_with(':')
973 && !trimmed.starts_with("id:")
974 && !trimmed.starts_with("retry:")
975 {
976 current_data.clear();
977 }
978 }
979 }
980
981 if !current_data.is_empty() {
983 if let Ok(parsed) = serde_json::from_str::<Value>(¤t_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
1008pub 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
1050pub 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#[derive(Debug, Deserialize)]
1082#[serde(deny_unknown_fields)]
1083struct McpDiscoveryFile {
1084 #[serde(rename = "mcpServers")]
1085 mcp_servers: HashMap<String, McpServerConfig>,
1086}
1087
1088pub async fn discover_mcp_servers(workspace: &Path) -> Result<Vec<McpServer>> {
1096 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 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
1115async 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 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#[cfg(test)]
1176mod tests {
1177 use super::*;
1178 use std::sync::Arc;
1179 use tokio::sync::Mutex;
1180
1181 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 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] 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] 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] async fn test_b_malformed_server_errors_cleanly() {
1277 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] 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] async fn test_b_server_timeout_errors_cleanly() {
1314 let script = r#"
1316while true; do
1317 read line
1318 # Never write anything
1319done
1320"#;
1321 let result = spawn_mock_server(script).await;
1322 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] async fn test_a_call_tool_with_error_response() {
1334 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 #[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 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 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 #[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 let buffer = "event: message\ndata: {\"key\": \"value\"}\n\n";
1641 assert_eq!(parse_sse_endpoint(buffer), None);
1642
1643 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 let result = parse_sse_response(buffer, 2, "test");
1658 assert!(result.is_none());
1659
1660 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 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 let buffer = "data: not-json-at-all\n\n";
1695 let result = parse_sse_response(buffer, 1, "test");
1696 assert!(result.is_none());
1698 }
1699
1700 #[test]
1701 fn test_parse_sse_response_no_data_prefix() {
1702 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 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 assert_eq!(servers[0].url.as_deref(), Some("http://example.com/sse"));
1734 assert_eq!(servers[0].command, "some-binary");
1736
1737 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 #[tokio::test]
1775 #[ignore] 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] 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] 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 #[tokio::test]
1859 #[ignore] 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] 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] 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 #[tokio::test]
1944 #[ignore] async fn test_resources_capability_not_advertised() {
1946 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 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] async fn test_prompts_get_with_missing_name() {
1992 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 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] async fn test_resources_read_empty_content() {
2043 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 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 #[tokio::test]
2097 #[ignore] async fn test_capability_not_advertised_returns_error() {
2099 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] async fn test_capability_not_advertised_returns_error_for_prompts() {
2154 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}