Skip to main content

clawedcode_mcp/
lib.rs

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(&notif)?;
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}