Skip to main content

clawedcode_mcp/
lib.rs

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