1use crate::prelude::*;
2use html2md::parse_html;
3use readability_rust::Readability;
4use reqwest::Client;
5use robotstxt::DefaultMatcher;
6use std::time::Duration;
7use url::Url;
8
9#[derive(Debug, Deserialize, JsonSchema)]
11pub struct FetchInput {
12 pub url: String,
14
15 #[serde(default = "default_max_length")]
17 pub max_length: Option<usize>,
18
19 #[serde(default)]
21 pub start_index: Option<usize>,
22
23 #[serde(default)]
25 pub raw: bool,
26
27 #[serde(default)]
29 pub force: bool,
30
31 #[serde(default = "default_user_agent")]
33 pub user_agent: String,
34
35 #[serde(default = "default_timeout")]
37 pub timeout_seconds: u64,
38}
39
40fn default_max_length() -> Option<usize> {
41 Some(5000)
42}
43
44fn default_user_agent() -> String {
45 "mixtape-bot/1.0 (+https://github.com/your-repo/mixtape)".to_string()
46}
47
48fn default_timeout() -> u64 {
49 30
50}
51
52pub struct FetchTool {
54 client: Client,
55}
56
57impl FetchTool {
58 pub fn new() -> Self {
59 let client = Client::builder()
60 .timeout(Duration::from_secs(60))
61 .build()
62 .expect("Failed to create HTTP client");
63
64 Self { client }
65 }
66
67 async fn check_robots_txt(
69 &self,
70 url: &Url,
71 user_agent: &str,
72 ) -> std::result::Result<bool, ToolError> {
73 let host = url
74 .host_str()
75 .ok_or_else(|| ToolError::from("Invalid host"))?;
76 let robots_url = format!("{}://{}/robots.txt", url.scheme(), host);
77
78 let robots_response =
80 match tokio::time::timeout(Duration::from_secs(5), self.client.get(&robots_url).send())
81 .await
82 {
83 Ok(Ok(response)) => response,
84 Ok(Err(_)) => return Ok(true), Err(_) => return Ok(true), };
87
88 if !robots_response.status().is_success() {
89 return Ok(true); }
91
92 let robots_content = match robots_response.text().await {
93 Ok(content) => content,
94 Err(e) => return Err(format!("Failed to read robots.txt: {}", e).into()),
95 };
96
97 let mut matcher = DefaultMatcher::default();
99 let url_str = url.as_str();
100
101 Ok(matcher.one_agent_allowed_by_robots(&robots_content, user_agent, url_str))
102 }
103
104 fn extract_content(&self, html: &str, _url: &str) -> (Option<String>, String) {
106 match Readability::new(html, None) {
108 Ok(mut parser) => {
109 if let Some(article) = parser.parse() {
110 let content = article.content.unwrap_or_else(|| html.to_string());
112 return (article.title, content);
113 }
114 }
115 Err(_) => {
116 }
118 }
119
120 (None, html.to_string())
122 }
123
124 fn html_to_markdown(&self, html: &str) -> String {
126 parse_html(html)
127 }
128
129 fn paginate_content(
131 &self,
132 content: String,
133 start_index: Option<usize>,
134 max_length: Option<usize>,
135 ) -> (String, bool, usize) {
136 let total_length = content.len();
137 let start = start_index.unwrap_or(0);
138
139 if start >= total_length {
140 return (String::new(), false, total_length);
141 }
142
143 if let Some(max_len) = max_length {
144 let end = (start + max_len).min(total_length);
145 let truncated_content = content[start..end].to_string();
146 let is_truncated = end < total_length;
147 (truncated_content, is_truncated, total_length)
148 } else {
149 let truncated_content = content[start..].to_string();
150 (truncated_content, false, total_length)
151 }
152 }
153}
154
155impl Default for FetchTool {
156 fn default() -> Self {
157 Self::new()
158 }
159}
160
161impl Tool for FetchTool {
162 type Input = FetchInput;
163
164 fn name(&self) -> &str {
165 "fetch"
166 }
167
168 fn description(&self) -> &str {
169 "Fetch content from a URL with robots.txt compliance, content extraction, and Markdown conversion. \
170 Supports pagination for large documents."
171 }
172
173 fn format_output_plain(&self, result: &ToolResult) -> String {
174 let output = result.as_text();
175 let (metadata, content) = parse_fetch_header(&output);
176
177 if metadata.is_empty() {
178 return output.to_string();
179 }
180
181 let mut out = String::new();
182 out.push_str(&"─".repeat(60));
183 out.push('\n');
184
185 for (key, value) in &metadata {
186 let icon = match *key {
187 "URL" => "[>]",
188 "Title" => "[#]",
189 "Content Length" => "[=]",
190 "Showing" => "[~]",
191 _ => " ",
192 };
193 out.push_str(&format!("{} {:15} {}\n", icon, key, value));
194 }
195
196 out.push_str(&"─".repeat(60));
197 out.push_str("\n\n");
198 out.push_str(content);
199 out
200 }
201
202 fn format_output_ansi(&self, result: &ToolResult) -> String {
203 let output = result.as_text();
204 let (metadata, content) = parse_fetch_header(&output);
205
206 if metadata.is_empty() {
207 return output.to_string();
208 }
209
210 let mut out = String::new();
211 out.push_str(&format!("\x1b[2m{}\x1b[0m\n", "─".repeat(60)));
212
213 for (key, value) in &metadata {
214 let (icon, color) = match *key {
215 "URL" => ("\x1b[34m\x1b[0m", "\x1b[34m"),
216 "Title" => ("\x1b[33m\x1b[0m", "\x1b[1m"),
217 "Content Length" => ("\x1b[32m\x1b[0m", "\x1b[32m"),
218 "Showing" => ("\x1b[36m\x1b[0m", "\x1b[36m"),
219 _ => (" ", "\x1b[0m"),
220 };
221 out.push_str(&format!(
222 "{} \x1b[2m{:15}\x1b[0m {}{}\x1b[0m\n",
223 icon, key, color, value
224 ));
225 }
226
227 out.push_str(&format!("\x1b[2m{}\x1b[0m\n\n", "─".repeat(60)));
228 out.push_str(content);
229 out
230 }
231
232 fn format_output_markdown(&self, result: &ToolResult) -> String {
233 let output = result.as_text();
234 let (metadata, content) = parse_fetch_header(&output);
235
236 if metadata.is_empty() {
237 return output.to_string();
238 }
239
240 let mut out = String::new();
241 let title = metadata
242 .iter()
243 .find(|(k, _)| *k == "Title")
244 .map(|(_, v)| *v);
245
246 if let Some(t) = title {
247 out.push_str(&format!("## {}\n\n", t));
248 }
249
250 for (key, value) in &metadata {
251 if *key != "Title" {
252 out.push_str(&format!("- **{}**: {}\n", key, value));
253 }
254 }
255
256 out.push_str("\n---\n\n");
257 out.push_str(content);
258 out
259 }
260
261 async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
262 let url =
264 Url::parse(&input.url).map_err(|e| ToolError::from(format!("Invalid URL: {}", e)))?;
265
266 if !input.force {
268 let allowed = self
269 .check_robots_txt(&url, &input.user_agent)
270 .await
271 .map_err(|e| ToolError::from(format!("Robots.txt check failed: {}", e)))?;
272
273 if !allowed {
274 return Err(format!(
275 "Access to {} is disallowed by robots.txt for user-agent '{}'",
276 input.url, input.user_agent
277 )
278 .into());
279 }
280 }
281
282 let response = tokio::time::timeout(
284 Duration::from_secs(input.timeout_seconds),
285 self.client
286 .get(input.url.clone())
287 .header("User-Agent", &input.user_agent)
288 .send(),
289 )
290 .await
291 .map_err(|_| format!("Request timed out after {} seconds", input.timeout_seconds))?
292 .map_err(|e| ToolError::from(format!("Failed to fetch URL: {}", e)))?;
293
294 if !response.status().is_success() {
296 return Err(format!(
297 "HTTP error: {} {}",
298 response.status().as_u16(),
299 response.status().canonical_reason().unwrap_or("Unknown")
300 )
301 .into());
302 }
303
304 let html = response
306 .text()
307 .await
308 .map_err(|e| ToolError::from(format!("Failed to read response body: {}", e)))?;
309
310 let (title, content_html) = self.extract_content(&html, &input.url);
312
313 let processed_content = if input.raw {
315 content_html
316 } else {
317 self.html_to_markdown(&content_html)
318 };
319
320 let (final_content, is_truncated, total_length) =
322 self.paginate_content(processed_content, input.start_index, input.max_length);
323
324 let mut result = String::new();
326 result.push_str(&format!("URL: {}\n", input.url));
327
328 if let Some(page_title) = title {
329 result.push_str(&format!("Title: {}\n", page_title.trim()));
330 }
331
332 result.push_str(&format!("Content Length: {} characters\n", total_length));
333
334 if is_truncated {
335 let start = input.start_index.unwrap_or(0);
336 let end = start + final_content.len();
337 result.push_str(&format!(
338 "Showing: characters {}-{} (truncated)\n",
339 start, end
340 ));
341 }
342
343 result.push_str("\n---\n\n");
344 result.push_str(&final_content);
345
346 Ok(result.into())
347 }
348}
349
350fn parse_fetch_header(output: &str) -> (Vec<(&str, &str)>, &str) {
352 let mut metadata = Vec::new();
353 let mut content_start = 0;
354
355 for (i, line) in output.lines().enumerate() {
356 if line == "---" {
357 let lines: Vec<&str> = output.lines().collect();
359 if i + 1 < lines.len() {
360 let header_len: usize = lines[..=i].iter().map(|l| l.len() + 1).sum();
362 content_start = header_len;
363 }
364 break;
365 }
366
367 if let Some(colon_idx) = line.find(": ") {
368 let key = &line[..colon_idx];
369 let value = &line[colon_idx + 2..];
370 metadata.push((key, value));
371 }
372 }
373
374 let content = if content_start < output.len() {
375 output[content_start..].trim_start_matches('\n')
376 } else {
377 ""
378 };
379
380 (metadata, content)
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use wiremock::{
387 matchers::{method, path},
388 Mock, MockServer, ResponseTemplate,
389 };
390
391 fn test_input(url: impl Into<String>) -> FetchInput {
393 FetchInput {
394 url: url.into(),
395 user_agent: "test-agent".to_string(),
396 timeout_seconds: 30,
397 raw: false,
398 force: false,
399 start_index: None,
400 max_length: None,
401 }
402 }
403
404 #[test]
407 fn test_default() {
408 let tool: FetchTool = Default::default();
409 assert_eq!(tool.name(), "fetch");
410 }
411
412 #[test]
413 fn test_tool_name() {
414 let tool = FetchTool::new();
415 assert_eq!(tool.name(), "fetch");
416 }
417
418 #[test]
419 fn test_tool_description() {
420 let tool = FetchTool::new();
421 assert!(!tool.description().is_empty());
422 assert!(tool.description().contains("Fetch"));
423 }
424
425 #[test]
428 fn test_default_max_length() {
429 assert_eq!(default_max_length(), Some(5000));
430 }
431
432 #[test]
433 fn test_default_user_agent() {
434 let ua = default_user_agent();
435 assert!(ua.contains("mixtape"));
436 }
437
438 #[test]
439 fn test_default_timeout() {
440 assert_eq!(default_timeout(), 30);
441 }
442
443 #[test]
446 fn test_parse_fetch_header_complete() {
447 let output = "URL: https://example.com\nTitle: Test Page\nContent Length: 1000 characters\nShowing: characters 0-500 (truncated)\n\n---\n\nThis is the content.";
448 let (metadata, content) = parse_fetch_header(output);
449
450 assert_eq!(metadata.len(), 4);
451 assert_eq!(metadata[0], ("URL", "https://example.com"));
452 assert_eq!(metadata[1], ("Title", "Test Page"));
453 assert_eq!(metadata[2], ("Content Length", "1000 characters"));
454 assert_eq!(metadata[3], ("Showing", "characters 0-500 (truncated)"));
455 assert!(content.contains("This is the content"));
456 }
457
458 #[test]
459 fn test_parse_fetch_header_no_separator() {
460 let output = "Just plain content without headers";
461 let (metadata, content) = parse_fetch_header(output);
462
463 assert!(metadata.is_empty());
466 assert_eq!(content, output);
468 }
469
470 #[test]
471 fn test_parse_fetch_header_with_metadata_no_separator() {
472 let output = "URL: https://example.com\nTitle: Test";
474 let (metadata, content) = parse_fetch_header(output);
475
476 assert_eq!(metadata.len(), 2);
478 assert_eq!(metadata[0], ("URL", "https://example.com"));
479 assert!(content.contains("URL:"));
481 }
482
483 #[test]
484 fn test_parse_fetch_header_minimal() {
485 let output = "URL: https://example.com\n\n---\n\nContent";
486 let (metadata, content) = parse_fetch_header(output);
487
488 assert_eq!(metadata.len(), 1);
489 assert_eq!(metadata[0], ("URL", "https://example.com"));
490 assert!(content.contains("Content"));
491 }
492
493 #[test]
494 fn test_parse_fetch_header_empty() {
495 let output = "";
496 let (metadata, content) = parse_fetch_header(output);
497
498 assert!(metadata.is_empty());
499 assert_eq!(content, "");
500 }
501
502 #[test]
503 fn test_parse_fetch_header_no_content_after_separator() {
504 let output = "URL: https://example.com\n---";
508 let (metadata, content) = parse_fetch_header(output);
509
510 assert_eq!(metadata.len(), 1);
511 assert!(content.contains("URL:"));
514 }
515
516 #[test]
517 fn test_parse_fetch_header_content_after_separator() {
518 let output = "URL: https://example.com\n---\nBody content here";
520 let (metadata, content) = parse_fetch_header(output);
521
522 assert_eq!(metadata.len(), 1);
523 assert_eq!(metadata[0], ("URL", "https://example.com"));
524 assert_eq!(content, "Body content here");
526 assert!(!content.contains("URL:"));
527 }
528
529 #[test]
532 fn test_format_output_plain_no_metadata() {
533 let tool = FetchTool::new();
534 let result: ToolResult = "Plain content without headers".into();
535
536 let formatted = tool.format_output_plain(&result);
537 assert_eq!(formatted, "Plain content without headers");
538 }
539
540 #[test]
541 fn test_format_output_plain_with_metadata() {
542 let tool = FetchTool::new();
543 let result: ToolResult = "URL: https://example.com\nTitle: Test\nContent Length: 100 characters\n\n---\n\nContent here".into();
544
545 let formatted = tool.format_output_plain(&result);
546
547 assert!(formatted.contains("─"));
549 assert!(
551 formatted.contains("[>]") || formatted.contains("[#]") || formatted.contains("[=]")
552 );
553 assert!(formatted.contains("Content here"));
555 }
556
557 #[test]
558 fn test_format_output_plain_icons() {
559 let tool = FetchTool::new();
560 let result: ToolResult = "URL: https://example.com\nTitle: Test Title\nContent Length: 500 characters\nShowing: 0-100\n\n---\n\nBody".into();
561
562 let formatted = tool.format_output_plain(&result);
563
564 assert!(formatted.contains("[>]")); assert!(formatted.contains("[#]")); assert!(formatted.contains("[=]")); assert!(formatted.contains("[~]")); }
570
571 #[test]
574 fn test_format_output_ansi_no_metadata() {
575 let tool = FetchTool::new();
576 let result: ToolResult = "Plain content".into();
577
578 let formatted = tool.format_output_ansi(&result);
579 assert_eq!(formatted, "Plain content");
580 }
581
582 #[test]
583 fn test_format_output_ansi_with_metadata() {
584 let tool = FetchTool::new();
585 let result: ToolResult = "URL: https://example.com\nTitle: Test\n\n---\n\nContent".into();
586
587 let formatted = tool.format_output_ansi(&result);
588
589 assert!(formatted.contains("\x1b["));
591 assert!(formatted.contains("\x1b[2m"));
593 }
594
595 #[test]
596 fn test_format_output_ansi_colors() {
597 let tool = FetchTool::new();
598 let result: ToolResult = "URL: https://example.com\nTitle: Test\nContent Length: 100 characters\nShowing: 0-50\n\n---\n\nBody".into();
599
600 let formatted = tool.format_output_ansi(&result);
601
602 assert!(formatted.contains("\x1b[34m"));
604 assert!(formatted.contains("\x1b[1m"));
606 assert!(formatted.contains("\x1b[32m"));
608 assert!(formatted.contains("\x1b[36m"));
610 }
611
612 #[test]
615 fn test_format_output_markdown_no_metadata() {
616 let tool = FetchTool::new();
617 let result: ToolResult = "Plain content".into();
618
619 let formatted = tool.format_output_markdown(&result);
620 assert_eq!(formatted, "Plain content");
621 }
622
623 #[test]
624 fn test_format_output_markdown_with_title() {
625 let tool = FetchTool::new();
626 let result: ToolResult =
627 "URL: https://example.com\nTitle: My Page Title\n\n---\n\nContent".into();
628
629 let formatted = tool.format_output_markdown(&result);
630
631 assert!(formatted.contains("## My Page Title"));
633 }
634
635 #[test]
636 fn test_format_output_markdown_metadata_as_list() {
637 let tool = FetchTool::new();
638 let result: ToolResult =
639 "URL: https://example.com\nTitle: Test\nContent Length: 500 characters\n\n---\n\nBody"
640 .into();
641
642 let formatted = tool.format_output_markdown(&result);
643
644 assert!(formatted.contains("- **URL**: https://example.com"));
646 assert!(formatted.contains("- **Content Length**: 500 characters"));
647 assert!(!formatted.contains("- **Title**"));
649 }
650
651 #[test]
652 fn test_format_output_markdown_separator() {
653 let tool = FetchTool::new();
654 let result: ToolResult = "URL: https://example.com\n\n---\n\nBody content".into();
655
656 let formatted = tool.format_output_markdown(&result);
657
658 assert!(formatted.contains("---"));
660 assert!(formatted.contains("Body content"));
662 }
663
664 #[test]
667 fn test_paginate_content_start_beyond_length() {
668 let tool = FetchTool::new();
669 let content = "Short".to_string();
670
671 let (result, truncated, total) = tool.paginate_content(content, Some(100), Some(10));
672
673 assert_eq!(result, "");
674 assert!(!truncated);
675 assert_eq!(total, 5);
676 }
677
678 #[test]
679 fn test_paginate_content_exact_length() {
680 let tool = FetchTool::new();
681 let content = "12345".to_string();
682
683 let (result, truncated, total) = tool.paginate_content(content, Some(0), Some(5));
684
685 assert_eq!(result, "12345");
686 assert!(!truncated);
687 assert_eq!(total, 5);
688 }
689
690 #[test]
693 fn test_extract_content() {
694 let tool = FetchTool::new();
695 let html = r#"
696 <html>
697 <head><title>Test Page</title></head>
698 <body>
699 <nav>Navigation</nav>
700 <article>
701 <h1>Main Content</h1>
702 <p>This is the article content.</p>
703 </article>
704 <footer>Footer</footer>
705 </body>
706 </html>
707 "#;
708
709 let (title, content) = tool.extract_content(html, "https://example.com/test");
710 assert_eq!(title, Some("Main Content".to_string()));
712 assert!(content.contains("This is the article content"));
714 assert!(!content.contains("Navigation") || content.len() < html.len());
716 }
717
718 #[test]
719 fn test_paginate_content() {
720 let tool = FetchTool::new();
721 let content = "0123456789".to_string();
722
723 let (result, truncated, total) = tool.paginate_content(content.clone(), None, None);
725 assert_eq!(result, "0123456789");
726 assert!(!truncated);
727 assert_eq!(total, 10);
728
729 let (result, truncated, total) = tool.paginate_content(content.clone(), Some(2), Some(5));
731 assert_eq!(result, "23456");
732 assert!(truncated);
733 assert_eq!(total, 10);
734
735 let (result, truncated, total) = tool.paginate_content(content.clone(), Some(5), Some(10));
737 assert_eq!(result, "56789");
738 assert!(!truncated);
739 assert_eq!(total, 10);
740 }
741
742 #[test]
743 fn test_html_to_markdown() {
744 let tool = FetchTool::new();
745 let html = "<h1>Title</h1><p>Paragraph with <strong>bold</strong> text.</p>";
746 let markdown = tool.html_to_markdown(html);
747
748 assert!(markdown.contains("Title"));
749 assert!(markdown.contains("Paragraph"));
750 assert!(markdown.contains("bold"));
751 }
752
753 #[tokio::test]
756 async fn test_fetch_successful_html() {
757 let mock_server = MockServer::start().await;
758
759 let html_body = r#"
760 <html>
761 <head><title>Test Article</title></head>
762 <body>
763 <article>
764 <h1>Main Heading</h1>
765 <p>This is the main content of the article.</p>
766 </article>
767 </body>
768 </html>
769 "#;
770
771 Mock::given(method("GET"))
772 .and(path("/test"))
773 .respond_with(ResponseTemplate::new(200).set_body_string(html_body))
774 .mount(&mock_server)
775 .await;
776
777 let tool = FetchTool::new();
778 let input = test_input(format!("{}/test", mock_server.uri()));
779
780 let result = tool.execute(input).await.unwrap();
781 let output = result.as_text();
782
783 assert!(output.contains("URL:"));
784 assert!(output.contains("Title: Main Heading"));
785 assert!(output.contains("main content"));
786 }
787
788 #[tokio::test]
789 async fn test_fetch_404_error() {
790 let mock_server = MockServer::start().await;
791
792 Mock::given(method("GET"))
793 .and(path("/notfound"))
794 .respond_with(ResponseTemplate::new(404))
795 .mount(&mock_server)
796 .await;
797
798 let tool = FetchTool::new();
799 let input = test_input(format!("{}/notfound", mock_server.uri()));
800
801 let result = tool.execute(input).await;
802 assert!(result.is_err());
803 let err = result.unwrap_err().to_string();
804 assert!(err.contains("HTTP error") || err.contains("404"));
805 }
806
807 #[tokio::test]
808 async fn test_fetch_timeout() {
809 let mock_server = MockServer::start().await;
810
811 Mock::given(method("GET"))
812 .and(path("/slow"))
813 .respond_with(
814 ResponseTemplate::new(200)
815 .set_delay(std::time::Duration::from_millis(1500))
816 .set_body_string("<html><body>Slow</body></html>"),
817 )
818 .mount(&mock_server)
819 .await;
820
821 let tool = FetchTool::new();
822 let mut input = test_input(format!("{}/slow", mock_server.uri()));
823 input.timeout_seconds = 1; let result = tool.execute(input).await;
826 assert!(result.is_err());
827 let err = result.unwrap_err().to_string();
828 assert!(err.contains("timeout") || err.contains("timed out"));
829 }
830
831 #[tokio::test]
832 async fn test_fetch_raw_mode() {
833 let mock_server = MockServer::start().await;
834
835 let html_body = "<html><body><h1>Raw HTML</h1><p>Content</p></body></html>";
836
837 Mock::given(method("GET"))
838 .and(path("/raw"))
839 .respond_with(ResponseTemplate::new(200).set_body_string(html_body))
840 .mount(&mock_server)
841 .await;
842
843 let tool = FetchTool::new();
844 let mut input = test_input(format!("{}/raw", mock_server.uri()));
845 input.raw = true;
846
847 let result = tool.execute(input).await.unwrap();
848 let output = result.as_text();
849
850 assert!(output.contains("<h1>") || output.contains("Raw HTML"));
852 }
853
854 #[tokio::test]
855 async fn test_fetch_with_pagination() {
856 let mock_server = MockServer::start().await;
857
858 let html_body = r#"
859 <html>
860 <body>
861 <article>
862 <p>This is a very long article with lots of content that will be paginated.</p>
863 </article>
864 </body>
865 </html>
866 "#;
867
868 Mock::given(method("GET"))
869 .and(path("/paginated"))
870 .respond_with(ResponseTemplate::new(200).set_body_string(html_body))
871 .mount(&mock_server)
872 .await;
873
874 let tool = FetchTool::new();
875 let mut input = test_input(format!("{}/paginated", mock_server.uri()));
876 input.start_index = Some(0);
877 input.max_length = Some(50);
878
879 let result = tool.execute(input).await.unwrap();
880 let output = result.as_text();
881
882 assert!(output.contains("Showing:") || output.contains("truncated"));
883 }
884
885 #[tokio::test]
886 async fn test_fetch_invalid_url() {
887 let tool = FetchTool::new();
888 let input = test_input("not-a-valid-url");
889
890 let result = tool.execute(input).await;
891 assert!(result.is_err());
892 let err = result.unwrap_err().to_string();
893 assert!(err.contains("Invalid URL") || err.contains("scheme"));
894 }
895
896 #[tokio::test]
897 async fn test_fetch_disallowed_scheme() {
898 let tool = FetchTool::new();
899 let input = test_input("file:///etc/passwd");
900
901 let result = tool.execute(input).await;
902 assert!(result.is_err());
903 }
906}