1#![allow(missing_docs)]
3
4use crate::tools::Tool;
5use async_trait::async_trait;
6use rust_mcp_sdk::macros;
7use rust_mcp_sdk::schema::CallToolError;
8use serde::{Deserialize, Serialize};
9use std::sync::Arc;
10
11#[macros::mcp_tool(
13 name = "search_crates",
14 title = "Search Crates",
15 description = "Search for Rust crates from crates.io. Returns a list of matching crates, including name, description, version, downloads, etc. Suitable for discovering and comparing available Rust libraries.",
16 destructive_hint = false,
17 idempotent_hint = true,
18 open_world_hint = false,
19 read_only_hint = true,
20 execution(task_support = "optional"),
21 icons = [
22 (src = "https://crates.io/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "light"),
23 (src = "https://crates.io/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "dark")
24 ]
25)]
26#[derive(Debug, Clone, Deserialize, Serialize, macros::JsonSchema)]
27pub struct SearchCratesTool {
28 #[json_schema(
30 title = "Search Query",
31 description = "Search keywords, e.g.: web framework, async, http client, serialization"
32 )]
33 pub query: String,
34
35 #[json_schema(
37 title = "Result Limit",
38 description = "Maximum number of results to return, range 1-100",
39 minimum = 1,
40 maximum = 100,
41 default = 10
42 )]
43 pub limit: Option<u32>,
44
45 #[json_schema(
47 title = "Sort Order",
48 description = "Sort order: relevance (default), downloads, recent-downloads, recent-updates, new",
49 default = "relevance"
50 )]
51 pub sort: Option<String>,
52
53 #[json_schema(
55 title = "Output Format",
56 description = "Output format: markdown (default), text (plain text), json (raw JSON)",
57 default = "markdown"
58 )]
59 pub format: Option<String>,
60}
61
62const DEFAULT_SEARCH_SORT: &str = "relevance";
63const VALID_SEARCH_SORTS: &[&str] = &[
64 DEFAULT_SEARCH_SORT,
65 "downloads",
66 "recent-downloads",
67 "recent-updates",
68 "new",
69];
70
71pub struct SearchCratesToolImpl {
72 service: Arc<super::DocService>,
73}
74
75fn normalize_search_sort(sort: Option<&str>) -> std::result::Result<String, CallToolError> {
76 match sort {
77 Some(sort) if VALID_SEARCH_SORTS.contains(&sort) => Ok(sort.to_string()),
78 Some(sort) => Err(CallToolError::invalid_arguments(
79 "search_crates",
80 Some(format!(
81 "Invalid sort option '{sort}', expected one of: {}",
82 VALID_SEARCH_SORTS.join(", ")
83 )),
84 )),
85 None => Ok(DEFAULT_SEARCH_SORT.to_string()),
86 }
87}
88
89impl SearchCratesToolImpl {
90 #[must_use]
92 pub fn new(service: Arc<super::DocService>) -> Self {
93 Self { service }
94 }
95
96 async fn search_crates(
98 &self,
99 query: &str,
100 limit: u32,
101 sort: &str,
102 ) -> std::result::Result<Vec<CrateInfo>, CallToolError> {
103 let cache_key = format!("search:{query}:{sort}:{limit}");
104
105 if let Some(cached) = self.service.cache().get(&cache_key).await {
106 return serde_json::from_str(&cached)
107 .map_err(|e| CallToolError::from_message(format!("Cache parsing failed: {e}")));
108 }
109
110 let url = format!(
111 "https://crates.io/api/v1/crates?q={}&per_page={}&sort={}",
112 urlencoding::encode(query),
113 limit,
114 urlencoding::encode(sort)
115 );
116
117 let response = self
118 .service
119 .client()
120 .get(&url)
121 .header("User-Agent", format!("CratesDocsMCP/{}", crate::VERSION))
122 .send()
123 .await
124 .map_err(|e| CallToolError::from_message(format!("HTTP request failed: {e}")))?;
125
126 if !response.status().is_success() {
127 return Err(CallToolError::from_message(format!(
128 "Search failed, status code: {}",
129 response.status()
130 )));
131 }
132
133 let json: serde_json::Value = response
134 .json()
135 .await
136 .map_err(|e| CallToolError::from_message(format!("JSON parsing failed: {e}")))?;
137
138 let crates = parse_crates_response(&json, limit as usize);
139
140 let cache_value = serde_json::to_string(&crates)
141 .map_err(|e| CallToolError::from_message(format!("Serialization failed: {e}")))?;
142
143 self.service
144 .cache()
145 .set(
146 cache_key,
147 cache_value,
148 Some(std::time::Duration::from_secs(300)),
149 )
150 .await
151 .map_err(|e| CallToolError::from_message(format!("Cache set failed: {e}")))?;
152
153 Ok(crates)
154 }
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158struct CrateInfo {
159 name: String,
160 description: Option<String>,
161 version: String,
162 downloads: u64,
163 repository: Option<String>,
164 documentation: Option<String>,
165}
166
167#[inline]
168fn parse_crates_response(json: &serde_json::Value, limit: usize) -> Vec<CrateInfo> {
169 let Some(crates_array) = json.get("crates").and_then(|c| c.as_array()) else {
170 return Vec::new();
171 };
172
173 crates_array
174 .iter()
175 .take(limit)
176 .map(|crate_item| CrateInfo {
177 name: crate_item
178 .get("name")
179 .and_then(|n| n.as_str())
180 .unwrap_or("Unknown")
181 .to_string(),
182 description: crate_item
183 .get("description")
184 .and_then(|d| d.as_str())
185 .map(std::string::ToString::to_string),
186 version: crate_item
187 .get("max_version")
188 .and_then(|v| v.as_str())
189 .unwrap_or("0.0.0")
190 .to_string(),
191 downloads: crate_item
192 .get("downloads")
193 .and_then(serde_json::Value::as_u64)
194 .unwrap_or(0),
195 repository: crate_item
196 .get("repository")
197 .and_then(|r| r.as_str())
198 .map(std::string::ToString::to_string),
199 documentation: crate_item
200 .get("documentation")
201 .and_then(|d| d.as_str())
202 .map(std::string::ToString::to_string),
203 })
204 .collect()
205}
206
207#[inline]
208fn format_search_results(crates: &[CrateInfo], format: &str) -> String {
209 match format {
210 "json" => serde_json::to_string_pretty(crates).unwrap_or_else(|_| "[]".to_string()),
211 "text" => format_text_results(crates),
212 _ => format_markdown_results(crates),
213 }
214}
215
216fn format_markdown_results(crates: &[CrateInfo]) -> String {
217 use std::fmt::Write;
218 let estimated_size = crates.len() * 200 + 20;
219 let mut output = String::with_capacity(estimated_size);
220 output.push_str("# Search Results\n\n");
221
222 for (i, crate_info) in crates.iter().enumerate() {
223 writeln!(output, "## {}. {}", i + 1, crate_info.name).unwrap();
224 writeln!(output, "**Version**: {}", crate_info.version).unwrap();
225 writeln!(output, "**Downloads**: {}", crate_info.downloads).unwrap();
226
227 if let Some(desc) = &crate_info.description {
228 writeln!(output, "**Description**: {desc}").unwrap();
229 }
230
231 if let Some(repo) = &crate_info.repository {
232 writeln!(output, "**Repository**: [Link]({repo})").unwrap();
233 }
234
235 if let Some(docs) = &crate_info.documentation {
236 writeln!(output, "**Documentation**: [Link]({docs})").unwrap();
237 }
238
239 writeln!(
240 output,
241 "**Docs.rs**: [https://docs.rs/{}/](https://docs.rs/{}/)\n",
242 crate_info.name, crate_info.name
243 )
244 .unwrap();
245 }
246
247 output
248}
249
250fn format_text_results(crates: &[CrateInfo]) -> String {
251 use std::fmt::Write;
252 let estimated_size = crates.len() * 100;
253 let mut output = String::with_capacity(estimated_size);
254
255 for (i, crate_info) in crates.iter().enumerate() {
256 writeln!(output, "{}. {}", i + 1, crate_info.name).unwrap();
257 writeln!(output, " Version: {}", crate_info.version).unwrap();
258 writeln!(output, " Downloads: {}", crate_info.downloads).unwrap();
259
260 if let Some(desc) = &crate_info.description {
261 writeln!(output, " Description: {desc}").unwrap();
262 }
263
264 writeln!(output, " Docs.rs: https://docs.rs/{}/", crate_info.name).unwrap();
265 writeln!(output).unwrap();
266 }
267
268 output
269}
270
271#[async_trait]
272impl Tool for SearchCratesToolImpl {
273 fn definition(&self) -> rust_mcp_sdk::schema::Tool {
274 SearchCratesTool::tool()
275 }
276
277 async fn execute(
278 &self,
279 arguments: serde_json::Value,
280 ) -> std::result::Result<
281 rust_mcp_sdk::schema::CallToolResult,
282 rust_mcp_sdk::schema::CallToolError,
283 > {
284 let params: SearchCratesTool = serde_json::from_value(arguments).map_err(|e| {
285 rust_mcp_sdk::schema::CallToolError::invalid_arguments(
286 "search_crates",
287 Some(format!("Parameter parsing failed: {e}")),
288 )
289 })?;
290
291 let limit = params.limit.unwrap_or(10).min(100);
292 let sort = normalize_search_sort(params.sort.as_deref())?;
293 let crates = self.search_crates(¶ms.query, limit, &sort).await?;
294
295 let format = params.format.unwrap_or_else(|| "markdown".to_string());
296 let content = format_search_results(&crates, &format);
297
298 Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
299 content.into(),
300 ]))
301 }
302}
303
304impl Default for SearchCratesToolImpl {
305 fn default() -> Self {
306 Self::new(Arc::new(super::DocService::default()))
307 }
308}