1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::collections::BTreeMap;
4use std::fmt;
5use std::io::{BufRead, BufReader, Read, Write};
6use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
7
8#[derive(Debug, Clone)]
9pub struct McpToolSpec {
10 pub name: String,
11 pub description: Option<String>,
12 pub input_schema: Value,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "camelCase")]
17pub struct McpResource {
18 pub uri: String,
19 pub name: String,
20 pub mime_type: Option<String>,
21 pub description: Option<String>,
22 pub server: String,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "camelCase")]
27pub struct McpResourceContent {
28 pub uri: String,
29 pub mime_type: Option<String>,
30 pub text: Option<String>,
31 pub blob: Option<String>,
32}
33
34const CLAUDEAI_SERVER_PREFIX: &str = "claude.ai ";
35
36pub fn normalize_name_for_mcp(name: &str) -> String {
37 let mut normalized = String::with_capacity(name.len());
38 for c in name.chars() {
39 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
40 normalized.push(c);
41 } else {
42 normalized.push('_');
43 }
44 }
45 if name.starts_with(CLAUDEAI_SERVER_PREFIX) {
46 let mut collapsed = String::new();
47 let mut last_was_underscore = false;
48 for c in normalized.chars() {
49 if c == '_' {
50 if !last_was_underscore {
51 collapsed.push(c);
52 last_was_underscore = true;
53 }
54 } else {
55 collapsed.push(c);
56 last_was_underscore = false;
57 }
58 }
59 let trimmed = collapsed.trim_matches('_');
60 if trimmed.is_empty() {
61 normalized
62 } else {
63 trimmed.to_string()
64 }
65 } else {
66 normalized
67 }
68}
69
70pub fn make_mcp_tool_name(server_name: &str, tool_name: &str) -> String {
71 let prefix = format!("mcp__{}__", normalize_name_for_mcp(server_name));
72 format!("{}{}", prefix, normalize_name_for_mcp(tool_name))
73}
74
75struct SyncIoBridge {
76 child: Child,
77 stdin: ChildStdin,
78 stdout: BufReader<ChildStdout>,
79}
80
81impl SyncIoBridge {
82 fn new(command: &str, args: &[String], env: &BTreeMap<String, String>) -> Result<Self, String> {
83 let mut cmd = Command::new(command);
84 cmd.args(args);
85 for (k, v) in env {
86 cmd.env(k, v);
87 }
88 cmd.stdin(Stdio::piped());
89 cmd.stdout(Stdio::piped());
90 cmd.stderr(Stdio::piped());
91
92 let mut child = cmd
93 .spawn()
94 .map_err(|e| format!("failed to spawn {}: {e}", command))?;
95 let stdin = child.stdin.take().ok_or("missing child stdin")?;
96 let stdout = child.stdout.take().ok_or("missing child stdout")?;
97 Ok(Self {
98 child,
99 stdin,
100 stdout: BufReader::new(stdout),
101 })
102 }
103
104 fn stdin(&mut self) -> &mut ChildStdin {
105 &mut self.stdin
106 }
107
108 fn stdout(&mut self) -> &mut BufReader<ChildStdout> {
109 &mut self.stdout
110 }
111
112 fn kill(&mut self) {
113 let _ = self.child.kill();
114 }
115}
116
117pub struct McpStdioClient {
118 server_name: String,
119 io: SyncIoBridge,
120 initialized: bool,
121 next_id: u64,
122}
123
124impl fmt::Debug for McpStdioClient {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 f.debug_struct("McpStdioClient")
127 .field("server_name", &self.server_name)
128 .field("initialized", &self.initialized)
129 .field("next_id", &self.next_id)
130 .finish()
131 }
132}
133
134impl McpStdioClient {
135 pub fn new(
136 server_name: String,
137 command: &str,
138 args: &[String],
139 env: &BTreeMap<String, String>,
140 ) -> Result<Self, String> {
141 let io = SyncIoBridge::new(command, args, env)?;
142 let mut client = Self {
143 server_name,
144 io,
145 initialized: false,
146 next_id: 1,
147 };
148 client.initialize()?;
149 Ok(client)
150 }
151
152 fn next_request_id(&mut self) -> u64 {
153 let id = self.next_id;
154 self.next_id += 1;
155 id
156 }
157
158 fn initialize(&mut self) -> Result<(), String> {
159 let request = serde_json::json!({
160 "jsonrpc": "2.0",
161 "id": self.next_request_id(),
162 "method": "initialize",
163 "params": {
164 "protocolVersion": "2024-11-05",
165 "capabilities": {},
166 "clientInfo": {
167 "name": "clawedcode",
168 "version": "0.0.3"
169 }
170 }
171 });
172
173 self.send_json(&request)?;
174 let _response = self.read_json()?;
175
176 let notif = serde_json::json!({
177 "jsonrpc": "2.0",
178 "method": "notifications/initialized",
179 "params": {}
180 });
181 self.send_json(¬if)?;
182
183 self.initialized = true;
184 Ok(())
185 }
186
187 fn request(&mut self, method: &str, params: Value) -> Result<Value, String> {
188 let request = serde_json::json!({
189 "jsonrpc": "2.0",
190 "id": self.next_request_id(),
191 "method": method,
192 "params": params,
193 });
194
195 self.send_json(&request)?;
196 let response = self.read_json()?;
197
198 if let Some(error) = response.get("error") {
199 let message = error
200 .get("message")
201 .and_then(|value| value.as_str())
202 .unwrap_or("unknown MCP error");
203 return Err(message.to_string());
204 }
205
206 Ok(response.get("result").cloned().unwrap_or(Value::Null))
207 }
208
209 fn send_json(&mut self, value: &serde_json::Value) -> Result<(), String> {
210 let bytes = serde_json::to_vec(value).map_err(|e| format!("serialize error: {e}"))?;
211 let stdin = self.io.stdin();
212 write!(stdin, "Content-Length: {}\r\n\r\n", bytes.len())
213 .map_err(|e| format!("write header error: {e}"))?;
214 stdin
215 .write_all(&bytes)
216 .map_err(|e| format!("write error: {e}"))?;
217 stdin.flush().map_err(|e| format!("flush error: {e}"))?;
218 Ok(())
219 }
220
221 fn read_json(&mut self) -> Result<Value, String> {
222 let stdout = self.io.stdout();
223 let mut content_length = None;
224 let mut line = String::new();
225
226 loop {
227 line.clear();
228 stdout
229 .read_line(&mut line)
230 .map_err(|e| format!("read header error: {e}"))?;
231
232 if line.is_empty() {
233 return Err("unexpected EOF while reading MCP headers".into());
234 }
235
236 if line == "\r\n" || line == "\n" {
237 break;
238 }
239
240 let trimmed = line.trim();
241 if let Some(value) = trimmed.strip_prefix("Content-Length:") {
242 content_length = Some(
243 value
244 .trim()
245 .parse()
246 .map_err(|e| format!("parse Content-Length error: {e}"))?,
247 );
248 }
249 }
250
251 let content_length = content_length.ok_or("missing Content-Length header")?;
252
253 let mut body = vec![0u8; content_length];
254 stdout
255 .read_exact(&mut body)
256 .map_err(|e| format!("read body error: {e}"))?;
257
258 serde_json::from_slice(&body).map_err(|e| format!("parse error: {e}"))
259 }
260
261 pub fn list_tools(&mut self) -> Result<Vec<McpToolSpec>, String> {
262 if !self.initialized {
263 return Err("not initialized".into());
264 }
265 let result = self.request("tools/list", serde_json::json!({}))?;
266
267 let tools = result
268 .get("tools")
269 .and_then(|t| t.as_array())
270 .map(|arr| {
271 arr.iter()
272 .filter_map(|t| {
273 let name = t.get("name")?.as_str()?.to_string();
274 let description = t
275 .get("description")
276 .and_then(|d| d.as_str())
277 .map(String::from);
278 let input_schema = t
279 .get("inputSchema")
280 .cloned()
281 .unwrap_or(serde_json::json!({}));
282 Some(McpToolSpec {
283 name,
284 description,
285 input_schema,
286 })
287 })
288 .collect()
289 })
290 .unwrap_or_default();
291
292 Ok(tools)
293 }
294
295 pub fn call_tool(&mut self, tool_name: &str, arguments: Value) -> Result<String, String> {
296 if !self.initialized {
297 return Err("not initialized".into());
298 }
299 let result = self.request(
300 "tools/call",
301 serde_json::json!({
302 "name": tool_name,
303 "arguments": arguments
304 }),
305 )?;
306
307 result
308 .get("content")
309 .and_then(|c| c.as_array())
310 .and_then(|arr| arr.first())
311 .and_then(|item| item.get("text"))
312 .and_then(|t| t.as_str())
313 .map(String::from)
314 .ok_or_else(|| "invalid response format".into())
315 }
316
317 pub fn list_resources(&mut self) -> Result<Vec<McpResource>, String> {
318 if !self.initialized {
319 return Err("not initialized".into());
320 }
321
322 let result = self.request("resources/list", serde_json::json!({}))?;
323 let resources = result
324 .get("resources")
325 .and_then(|value| value.as_array())
326 .map(|items| {
327 items
328 .iter()
329 .filter_map(|item| {
330 Some(McpResource {
331 uri: item.get("uri")?.as_str()?.to_string(),
332 name: item.get("name")?.as_str()?.to_string(),
333 mime_type: item
334 .get("mimeType")
335 .and_then(|value| value.as_str())
336 .map(str::to_string),
337 description: item
338 .get("description")
339 .and_then(|value| value.as_str())
340 .map(str::to_string),
341 server: self.server_name.clone(),
342 })
343 })
344 .collect()
345 })
346 .unwrap_or_default();
347
348 Ok(resources)
349 }
350
351 pub fn read_resource(&mut self, uri: &str) -> Result<Vec<McpResourceContent>, String> {
352 if !self.initialized {
353 return Err("not initialized".into());
354 }
355
356 let result = self.request("resources/read", serde_json::json!({ "uri": uri }))?;
357 let contents = result
358 .get("contents")
359 .and_then(|value| value.as_array())
360 .map(|items| {
361 items
362 .iter()
363 .filter_map(|item| {
364 Some(McpResourceContent {
365 uri: item.get("uri")?.as_str()?.to_string(),
366 mime_type: item
367 .get("mimeType")
368 .and_then(|value| value.as_str())
369 .map(str::to_string),
370 text: item
371 .get("text")
372 .and_then(|value| value.as_str())
373 .map(str::to_string),
374 blob: item
375 .get("blob")
376 .and_then(|value| value.as_str())
377 .map(str::to_string),
378 })
379 })
380 .collect()
381 })
382 .unwrap_or_default();
383
384 Ok(contents)
385 }
386
387 pub fn server_name(&self) -> &str {
388 &self.server_name
389 }
390
391 pub fn is_initialized(&self) -> bool {
392 self.initialized
393 }
394}
395
396impl Drop for McpStdioClient {
397 fn drop(&mut self) {
398 self.io.kill();
399 }
400}
401
402pub fn discover_mcp_tools_sync(
403 servers: &BTreeMap<String, McpServerConfig>,
404) -> BTreeMap<String, Vec<McpToolSpec>> {
405 let mut result: BTreeMap<String, Vec<McpToolSpec>> = BTreeMap::new();
406
407 for (name, config) in servers {
408 if let McpServerConfig::Stdio {
409 command, args, env, ..
410 } = config
411 {
412 match McpStdioClient::new(name.clone(), command, args, env) {
413 Ok(ref mut client) => {
414 if let Ok(tools) = client.list_tools() {
415 result.insert(name.clone(), tools);
416 }
417 }
418 Err(e) => {
419 eprintln!("failed to connect to MCP server {}: {}", name, e);
420 }
421 }
422 }
423 }
424
425 result
426}
427
428pub fn run_mcp_tool_sync(
429 server_name: &str,
430 command: &str,
431 args: &[String],
432 env: &BTreeMap<String, String>,
433 tool_name: &str,
434 arguments: Value,
435) -> Result<String, String> {
436 let mut client = McpStdioClient::new(server_name.to_string(), command, args, env)?;
437 client.call_tool(tool_name, arguments)
438}
439
440pub fn discover_mcp_resources_sync(
441 servers: &BTreeMap<String, McpServerConfig>,
442) -> BTreeMap<String, Vec<McpResource>> {
443 let mut result = BTreeMap::new();
444
445 for (name, config) in servers {
446 if let McpServerConfig::Stdio {
447 command, args, env, ..
448 } = config
449 {
450 match McpStdioClient::new(name.clone(), command, args, env) {
451 Ok(ref mut client) => {
452 if let Ok(resources) = client.list_resources() {
453 result.insert(name.clone(), resources);
454 }
455 }
456 Err(e) => {
457 eprintln!("failed to connect to MCP server {}: {}", name, e);
458 }
459 }
460 }
461 }
462
463 result
464}
465
466pub fn read_mcp_resource_sync(
467 server_name: &str,
468 command: &str,
469 args: &[String],
470 env: &BTreeMap<String, String>,
471 uri: &str,
472) -> Result<Vec<McpResourceContent>, String> {
473 let mut client = McpStdioClient::new(server_name.to_string(), command, args, env)?;
474 client.read_resource(uri)
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize)]
478#[serde(untagged)]
479pub enum McpServerConfig {
480 Stdio {
481 #[serde(default)]
482 r#type: Option<String>,
483 command: String,
484 #[serde(default)]
485 args: Vec<String>,
486 #[serde(default)]
487 env: BTreeMap<String, String>,
488 },
489 Sse {
490 #[serde(rename = "type")]
491 r#type: String,
492 url: String,
493 #[serde(default)]
494 headers: BTreeMap<String, String>,
495 },
496 Http {
497 #[serde(rename = "type")]
498 r#type: String,
499 url: String,
500 #[serde(default)]
501 headers: BTreeMap<String, String>,
502 },
503 Ws {
504 #[serde(rename = "type")]
505 r#type: String,
506 url: String,
507 #[serde(default)]
508 headers: BTreeMap<String, String>,
509 },
510 Sdk {
511 #[serde(rename = "type")]
512 r#type: String,
513 name: String,
514 },
515}
516
517impl McpServerConfig {
518 pub fn command(&self) -> Option<String> {
519 match self {
520 McpServerConfig::Stdio { command, .. } => Some(command.clone()),
521 _ => None,
522 }
523 }
524
525 pub fn args(&self) -> &[String] {
526 match self {
527 McpServerConfig::Stdio { args, .. } => args,
528 _ => &[],
529 }
530 }
531}
532
533pub fn discover_mcp_servers(settings: &Value) -> BTreeMap<String, McpServerConfig> {
534 settings
535 .get("mcpServers")
536 .and_then(|value| serde_json::from_value(value.clone()).ok())
537 .unwrap_or_default()
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 fn temp_python_mcp_server() -> std::path::PathBuf {
545 let script = r#"
546import sys
547import json
548
549def send(obj):
550 content = json.dumps(obj).encode('utf-8')
551 header = ('Content-Length: %d\r\n\r\n' % len(content)).encode('ascii')
552 sys.stdout.buffer.write(header)
553 sys.stdout.buffer.write(content)
554 sys.stdout.buffer.flush()
555
556def read_request():
557 content_length = None
558 while True:
559 header = sys.stdin.buffer.readline()
560 if not header:
561 return None
562 if header in (b'\r\n', b'\n'):
563 break
564 if header.startswith(b'Content-Length:'):
565 content_length = int(header.split(b':', 1)[1].strip())
566 if content_length is None:
567 return None
568 body = sys.stdin.buffer.read(content_length)
569 if not body:
570 return None
571 return json.loads(body)
572
573while True:
574 msg = read_request()
575 if msg is None:
576 break
577 method = msg.get("method", "")
578 id = msg.get("id")
579
580 if method == "initialize":
581 send({
582 "jsonrpc": "2.0",
583 "id": id,
584 "result": {
585 "protocolVersion": "2024-11-05",
586 "capabilities": {"tools": {}},
587 "serverInfo": {"name": "test-server", "version": "1.0.0"}
588 }
589 })
590 elif method == "notifications/initialized":
591 pass
592 elif method == "tools/list":
593 send({
594 "jsonrpc": "2.0",
595 "id": id,
596 "result": {
597 "tools": [
598 {
599 "name": "test_tool",
600 "description": "A test MCP tool",
601 "inputSchema": {
602 "type": "object",
603 "properties": {
604 "message": {"type": "string"}
605 },
606 "required": ["message"]
607 }
608 },
609 {
610 "name": "echo",
611 "description": "Echo back the input",
612 "inputSchema": {"type": "object"}
613 }
614 ]
615 }
616 })
617 elif method == "tools/call":
618 params = msg.get("params", {})
619 tool_name = params.get("name", "")
620 arguments = params.get("arguments", {})
621 if tool_name == "test_tool":
622 msg_text = arguments.get("message", "default")
623 send({
624 "jsonrpc": "2.0",
625 "id": id,
626 "result": {
627 "content": [{"type": "text", "text": f"Received: {msg_text}"}]
628 }
629 })
630 elif tool_name == "echo":
631 send({
632 "jsonrpc": "2.0",
633 "id": id,
634 "result": {
635 "content": [{"type": "text", "text": json.dumps(arguments)}]
636 }
637 })
638 else:
639 send({
640 "jsonrpc": "2.0",
641 "id": id,
642 "error": {"code": -32601, "message": f"Unknown tool: {tool_name}"}
643 })
644 elif method == "resources/list":
645 send({
646 "jsonrpc": "2.0",
647 "id": id,
648 "result": {
649 "resources": [
650 {
651 "uri": "resource://test/hello",
652 "name": "hello.txt",
653 "mimeType": "text/plain",
654 "description": "Test text resource"
655 }
656 ]
657 }
658 })
659 elif method == "resources/read":
660 params = msg.get("params", {})
661 uri = params.get("uri", "")
662 if uri == "resource://test/hello":
663 send({
664 "jsonrpc": "2.0",
665 "id": id,
666 "result": {
667 "contents": [
668 {
669 "uri": uri,
670 "mimeType": "text/plain",
671 "text": "Hello from MCP resource"
672 }
673 ]
674 }
675 })
676 else:
677 send({
678 "jsonrpc": "2.0",
679 "id": id,
680 "error": {"code": -32602, "message": f"Unknown resource: {uri}"}
681 })
682"#;
683
684 let temp_dir = std::env::temp_dir();
685 let now = std::time::SystemTime::now()
686 .duration_since(std::time::UNIX_EPOCH)
687 .unwrap()
688 .as_nanos();
689 let script_path = temp_dir.join(format!("fake_mcp_server_{}.py", now));
690 std::fs::write(&script_path, script).expect("failed to write test script");
691 script_path
692 }
693
694 #[test]
695 fn normalize_name_for_mcp_basic() {
696 assert_eq!(normalize_name_for_mcp("hello"), "hello");
697 assert_eq!(normalize_name_for_mcp("hello-world"), "hello-world");
698 assert_eq!(normalize_name_for_mcp("hello.world"), "hello_world");
699 assert_eq!(normalize_name_for_mcp("hello world"), "hello_world");
700 assert_eq!(
701 normalize_name_for_mcp("hello.world.test"),
702 "hello_world_test"
703 );
704 }
705
706 #[test]
707 fn normalize_name_for_mcp_claudeai_prefix() {
708 assert_eq!(
709 normalize_name_for_mcp("claude.ai server"),
710 "claude_ai_server"
711 );
712 assert_eq!(
713 normalize_name_for_mcp("claude.ai server"),
714 "claude_ai_server"
715 );
716 assert_eq!(
717 normalize_name_for_mcp("claude.ai server__tool"),
718 "claude_ai_server_tool"
719 );
720 assert_eq!(
721 normalize_name_for_mcp("_claude.ai server_"),
722 "_claude_ai_server_"
723 );
724 }
725
726 #[test]
727 fn make_mcp_tool_name_basic() {
728 assert_eq!(
729 make_mcp_tool_name("my-server", "my_tool"),
730 "mcp__my-server__my_tool"
731 );
732 assert_eq!(
733 make_mcp_tool_name("server-with-dashes", "tool-with-dashes"),
734 "mcp__server-with-dashes__tool-with-dashes"
735 );
736 }
737
738 #[test]
739 fn make_mcp_tool_name_preserves_valid_names() {
740 assert_eq!(
741 make_mcp_tool_name("server123", "tool456"),
742 "mcp__server123__tool456"
743 );
744 }
745
746 #[test]
747 fn make_mcp_tool_name_claudeai() {
748 assert_eq!(
749 make_mcp_tool_name("claude.ai github", "create_issue"),
750 "mcp__claude_ai_github__create_issue"
751 );
752 }
753
754 #[test]
755 fn mcp_stdio_client_connects_and_lists_tools() {
756 let script_path = temp_python_mcp_server();
757 let mut client = McpStdioClient::new(
758 "test-server".to_string(),
759 "python3",
760 &[script_path.to_str().unwrap().to_string()],
761 &BTreeMap::new(),
762 )
763 .expect("failed to connect");
764
765 assert!(client.is_initialized());
766 let tools = client.list_tools().expect("failed to list tools");
767 assert_eq!(tools.len(), 2);
768 assert_eq!(tools[0].name, "test_tool");
769 assert_eq!(tools[1].name, "echo");
770
771 std::fs::remove_file(script_path).ok();
772 }
773
774 #[test]
775 fn mcp_stdio_client_calls_tool() {
776 let script_path = temp_python_mcp_server();
777 let mut client = McpStdioClient::new(
778 "test-server".to_string(),
779 "python3",
780 &[script_path.to_str().unwrap().to_string()],
781 &BTreeMap::new(),
782 )
783 .expect("failed to connect");
784
785 let result = client
786 .call_tool("test_tool", serde_json::json!({"message": "hello"}))
787 .expect("failed to call tool");
788 assert_eq!(result, "Received: hello");
789
790 std::fs::remove_file(script_path).ok();
791 }
792
793 #[test]
794 fn run_mcp_tool_sync_integration() {
795 let script_path = temp_python_mcp_server();
796 let result = run_mcp_tool_sync(
797 "test-server",
798 "python3",
799 &[script_path.to_str().unwrap().to_string()],
800 &BTreeMap::new(),
801 "echo",
802 serde_json::json!({"foo": "bar"}),
803 )
804 .expect("failed to run tool");
805 assert!(result.contains("foo"));
806
807 std::fs::remove_file(script_path).ok();
808 }
809
810 #[test]
811 fn discover_mcp_tools_sync_with_single_server() {
812 let script_path = temp_python_mcp_server();
813 let mut servers = BTreeMap::new();
814 servers.insert(
815 "test".to_string(),
816 McpServerConfig::Stdio {
817 r#type: Some("stdio".to_string()),
818 command: "python3".to_string(),
819 args: vec![script_path.to_str().unwrap().to_string()],
820 env: BTreeMap::new(),
821 },
822 );
823
824 let discovered = discover_mcp_tools_sync(&servers);
825 assert!(discovered.contains_key("test"));
826 let tools = discovered.get("test").unwrap();
827 assert_eq!(tools.len(), 2);
828
829 std::fs::remove_file(script_path).ok();
830 }
831
832 #[test]
833 fn mcp_stdio_client_lists_resources() {
834 let script_path = temp_python_mcp_server();
835 let mut client = McpStdioClient::new(
836 "test-server".to_string(),
837 "python3",
838 &[script_path.to_str().unwrap().to_string()],
839 &BTreeMap::new(),
840 )
841 .expect("failed to connect");
842
843 let resources = client.list_resources().expect("failed to list resources");
844 assert_eq!(resources.len(), 1);
845 assert_eq!(resources[0].server, "test-server");
846 assert_eq!(resources[0].uri, "resource://test/hello");
847
848 std::fs::remove_file(script_path).ok();
849 }
850
851 #[test]
852 fn mcp_stdio_client_reads_resource() {
853 let script_path = temp_python_mcp_server();
854 let mut client = McpStdioClient::new(
855 "test-server".to_string(),
856 "python3",
857 &[script_path.to_str().unwrap().to_string()],
858 &BTreeMap::new(),
859 )
860 .expect("failed to connect");
861
862 let contents = client
863 .read_resource("resource://test/hello")
864 .expect("failed to read resource");
865 assert_eq!(contents.len(), 1);
866 assert_eq!(contents[0].text.as_deref(), Some("Hello from MCP resource"));
867
868 std::fs::remove_file(script_path).ok();
869 }
870
871 #[test]
872 fn discover_mcp_resources_sync_with_single_server() {
873 let script_path = temp_python_mcp_server();
874 let mut servers = BTreeMap::new();
875 servers.insert(
876 "test".to_string(),
877 McpServerConfig::Stdio {
878 r#type: Some("stdio".to_string()),
879 command: "python3".to_string(),
880 args: vec![script_path.to_str().unwrap().to_string()],
881 env: BTreeMap::new(),
882 },
883 );
884
885 let discovered = discover_mcp_resources_sync(&servers);
886 assert!(discovered.contains_key("test"));
887 let resources = discovered.get("test").unwrap();
888 assert_eq!(resources.len(), 1);
889 assert_eq!(resources[0].uri, "resource://test/hello");
890
891 std::fs::remove_file(script_path).ok();
892 }
893
894 #[test]
895 fn read_mcp_resource_sync_integration() {
896 let script_path = temp_python_mcp_server();
897 let contents = read_mcp_resource_sync(
898 "test-server",
899 "python3",
900 &[script_path.to_str().unwrap().to_string()],
901 &BTreeMap::new(),
902 "resource://test/hello",
903 )
904 .expect("failed to read resource");
905 assert_eq!(contents.len(), 1);
906 assert_eq!(contents[0].text.as_deref(), Some("Hello from MCP resource"));
907
908 std::fs::remove_file(script_path).ok();
909 }
910
911 mod transport_failure_tests {
912 use super::*;
913
914 fn temp_exiting_mcp_server() -> std::path::PathBuf {
915 let now = std::time::SystemTime::now()
916 .duration_since(std::time::UNIX_EPOCH)
917 .unwrap()
918 .as_nanos();
919 let script_path = std::env::temp_dir().join(format!("exiting_mcp_{}.sh", now));
920 let script = r#"#!/bin/sh
921echo 'Content-Length: 44\r\n\r\n{"jsonrpc":"2.0","id":1,"result":{}}' >&2
922exit 0
923"#;
924 std::fs::write(&script_path, script).unwrap();
925
926 #[cfg(unix)]
927 {
928 use std::os::unix::fs::PermissionsExt;
929 let mut perms = std::fs::metadata(&script_path).unwrap().permissions();
930 perms.set_mode(0o755);
931 std::fs::set_permissions(&script_path, perms).unwrap();
932 }
933
934 script_path
935 }
936
937 #[test]
938 fn mcp_connection_failure_returns_clean_error() {
939 let result = McpStdioClient::new(
940 "nonexistent-server".to_string(),
941 "/path/to/nonexistent/server",
942 &[],
943 &BTreeMap::new(),
944 );
945
946 assert!(result.is_err());
947 let error = result.unwrap_err();
948 assert!(error.contains("failed to spawn") || error.contains("No such file"));
949 }
950
951 #[test]
952 fn discover_mcp_sync_skips_failed_servers() {
953 let exiting_script = temp_exiting_mcp_server();
954 let normal_script = temp_python_mcp_server();
955
956 let mut servers = BTreeMap::new();
957 servers.insert(
958 "exiting-server".to_string(),
959 McpServerConfig::Stdio {
960 r#type: Some("stdio".to_string()),
961 command: exiting_script.to_str().unwrap().to_string(),
962 args: vec![],
963 env: BTreeMap::new(),
964 },
965 );
966 servers.insert(
967 "working-server".to_string(),
968 McpServerConfig::Stdio {
969 r#type: Some("stdio".to_string()),
970 command: "python3".to_string(),
971 args: vec![normal_script.to_str().unwrap().to_string()],
972 env: BTreeMap::new(),
973 },
974 );
975
976 let tools = discover_mcp_tools_sync(&servers);
977
978 assert!(
979 tools.contains_key("working-server"),
980 "working server should be discovered"
981 );
982 let working_tools = tools.get("working-server").unwrap();
983 assert!(
984 !working_tools.is_empty(),
985 "working server should have tools"
986 );
987
988 std::fs::remove_file(&exiting_script).ok();
989 std::fs::remove_file(&normal_script).ok();
990 }
991
992 #[test]
993 fn mcp_tool_execution_returns_clean_error_on_transport_failure() {
994 let exiting_script = temp_exiting_mcp_server();
995
996 let result = run_mcp_tool_sync(
997 "exiting-server",
998 exiting_script.to_str().unwrap(),
999 &[],
1000 &BTreeMap::new(),
1001 "test_tool",
1002 serde_json::json!({}),
1003 );
1004
1005 assert!(result.is_err());
1006 let error = result.unwrap_err();
1007 assert!(!error.is_empty(), "error message should be present");
1008
1009 std::fs::remove_file(&exiting_script).ok();
1010 }
1011
1012 #[test]
1013 fn mcp_connection_succeeds_after_previous_failure() {
1014 let normal_script = temp_python_mcp_server();
1015
1016 let _failed = McpStdioClient::new(
1017 "will-fail".to_string(),
1018 "/nonexistent/path/server",
1019 &[],
1020 &BTreeMap::new(),
1021 );
1022 assert!(_failed.is_err(), "first connection should fail");
1023
1024 let mut client = McpStdioClient::new(
1025 "working-server".to_string(),
1026 "python3",
1027 &[normal_script.to_str().unwrap().to_string()],
1028 &BTreeMap::new(),
1029 )
1030 .expect("second connection should succeed");
1031
1032 assert!(client.is_initialized());
1033 let tools = client.list_tools().expect("tools should work");
1034 assert!(!tools.is_empty());
1035
1036 std::fs::remove_file(&normal_script).ok();
1037 }
1038
1039 #[test]
1040 fn discover_mcp_resources_sync_handles_missing_server() {
1041 let mut servers = BTreeMap::new();
1042 servers.insert(
1043 "missing-server".to_string(),
1044 McpServerConfig::Stdio {
1045 r#type: Some("stdio".to_string()),
1046 command: "/nonexistent/server".to_string(),
1047 args: vec![],
1048 env: BTreeMap::new(),
1049 },
1050 );
1051
1052 let resources = discover_mcp_resources_sync(&servers);
1053
1054 assert!(
1055 resources.is_empty() || !resources.contains_key("missing-server"),
1056 "missing server should not appear in resources"
1057 );
1058 }
1059
1060 #[test]
1061 fn mcp_read_resource_handles_missing_server() {
1062 let result = read_mcp_resource_sync(
1063 "missing-server",
1064 "/nonexistent/server",
1065 &[],
1066 &BTreeMap::new(),
1067 "resource://test/data",
1068 );
1069
1070 assert!(result.is_err());
1071 assert!(!result.unwrap_err().is_empty());
1072 }
1073
1074 #[test]
1075 fn make_mcp_tool_name_normalizes_special_characters() {
1076 assert_eq!(
1077 make_mcp_tool_name("my server", "get_data"),
1078 "mcp__my_server__get_data"
1079 );
1080 assert_eq!(
1081 make_mcp_tool_name("my-server", "get.data"),
1082 "mcp__my-server__get_data"
1083 );
1084 assert_eq!(
1085 make_mcp_tool_name("My Server", "GetData"),
1086 "mcp__My_Server__GetData"
1087 );
1088 }
1089
1090 #[test]
1091 fn mcp_resource_content_deserialization() {
1092 let json = r#"{"uri":"file://test","name":"test.txt","mimeType":"text/plain","description":"A test file","server":"test-server"}"#;
1093 let resource: McpResource = serde_json::from_str(json).unwrap();
1094 assert_eq!(resource.uri, "file://test");
1095 assert_eq!(resource.name, "test.txt");
1096 assert_eq!(resource.mime_type, Some("text/plain".to_string()));
1097 assert_eq!(resource.server, "test-server");
1098 }
1099
1100 #[test]
1101 fn mcp_resource_content_fields() {
1102 let json = r#"{"uri":"file://test","mimeType":"text/plain","text":"hello world"}"#;
1103 let content: McpResourceContent = serde_json::from_str(json).unwrap();
1104 assert_eq!(content.uri, "file://test");
1105 assert_eq!(content.text, Some("hello world".to_string()));
1106 assert!(content.blob.is_none());
1107 }
1108 }
1109}