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