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