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 "{}/api/v1/crates?q={}&per_page={}&sort={}",
112 super::crates_io_base_url(),
113 urlencoding::encode(query),
114 limit,
115 urlencoding::encode(sort)
116 );
117
118 let response = self
119 .service
120 .client()
121 .get(&url)
122 .header("User-Agent", format!("CratesDocsMCP/{}", crate::VERSION))
123 .send()
124 .await
125 .map_err(|e| CallToolError::from_message(format!("HTTP request failed: {e}")))?;
126
127 if !response.status().is_success() {
128 return Err(CallToolError::from_message(format!(
129 "Search failed, status code: {}",
130 response.status()
131 )));
132 }
133
134 let json: serde_json::Value = response
135 .json()
136 .await
137 .map_err(|e| CallToolError::from_message(format!("JSON parsing failed: {e}")))?;
138
139 let crates = parse_crates_response(&json, limit as usize);
140
141 let cache_value = serde_json::to_string(&crates)
142 .map_err(|e| CallToolError::from_message(format!("Serialization failed: {e}")))?;
143
144 self.service
145 .cache()
146 .set(
147 cache_key,
148 cache_value,
149 Some(std::time::Duration::from_secs(300)),
150 )
151 .await
152 .map_err(|e| CallToolError::from_message(format!("Cache set failed: {e}")))?;
153
154 Ok(crates)
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
159struct CrateInfo {
160 name: String,
161 description: Option<String>,
162 version: String,
163 downloads: u64,
164 repository: Option<String>,
165 documentation: Option<String>,
166}
167
168#[inline]
169fn parse_crates_response(json: &serde_json::Value, limit: usize) -> Vec<CrateInfo> {
170 let Some(crates_array) = json.get("crates").and_then(|c| c.as_array()) else {
171 return Vec::new();
172 };
173
174 crates_array
175 .iter()
176 .take(limit)
177 .map(|crate_item| CrateInfo {
178 name: crate_item
179 .get("name")
180 .and_then(|n| n.as_str())
181 .unwrap_or("Unknown")
182 .to_string(),
183 description: crate_item
184 .get("description")
185 .and_then(|d| d.as_str())
186 .map(std::string::ToString::to_string),
187 version: crate_item
188 .get("max_version")
189 .and_then(|v| v.as_str())
190 .unwrap_or("0.0.0")
191 .to_string(),
192 downloads: crate_item
193 .get("downloads")
194 .and_then(serde_json::Value::as_u64)
195 .unwrap_or(0),
196 repository: crate_item
197 .get("repository")
198 .and_then(|r| r.as_str())
199 .map(std::string::ToString::to_string),
200 documentation: crate_item
201 .get("documentation")
202 .and_then(|d| d.as_str())
203 .map(std::string::ToString::to_string),
204 })
205 .collect()
206}
207
208#[inline]
209fn format_search_results(crates: &[CrateInfo], format: super::Format) -> String {
210 match format {
211 super::Format::Json => {
212 serde_json::to_string_pretty(crates).unwrap_or_else(|_| "[]".to_string())
213 }
214 super::Format::Text => format_text_results(crates),
215 _ => format_markdown_results(crates),
216 }
217}
218
219fn format_markdown_results(crates: &[CrateInfo]) -> String {
220 use std::fmt::Write;
222 let estimated_size = crates.len() * 200 + 20;
223 let mut output = String::with_capacity(estimated_size);
224 output.push_str("# Search Results\n\n");
225
226 for (i, crate_info) in crates.iter().enumerate() {
227 writeln!(output, "## {}. {}", i + 1, crate_info.name).unwrap();
228 writeln!(output, "**Version**: {}", crate_info.version).unwrap();
229 writeln!(output, "**Downloads**: {}", crate_info.downloads).unwrap();
230
231 if let Some(desc) = &crate_info.description {
232 writeln!(output, "**Description**: {desc}").unwrap();
233 }
234
235 if let Some(repo) = &crate_info.repository {
236 writeln!(output, "**Repository**: [Link]({repo})").unwrap();
237 }
238
239 if let Some(docs) = &crate_info.documentation {
240 writeln!(output, "**Documentation**: [Link]({docs})").unwrap();
241 }
242
243 writeln!(
244 output,
245 "**Docs.rs**: [https://docs.rs/{}/](https://docs.rs/{}/)\n",
246 crate_info.name, crate_info.name
247 )
248 .unwrap();
249 }
250
251 output
252}
253
254fn format_text_results(crates: &[CrateInfo]) -> String {
255 use std::fmt::Write;
257 let estimated_size = crates.len() * 100;
258 let mut output = String::with_capacity(estimated_size);
259
260 for (i, crate_info) in crates.iter().enumerate() {
261 writeln!(output, "{}. {}", i + 1, crate_info.name).unwrap();
262 writeln!(output, " Version: {}", crate_info.version).unwrap();
263 writeln!(output, " Downloads: {}", crate_info.downloads).unwrap();
264
265 if let Some(desc) = &crate_info.description {
266 writeln!(output, " Description: {desc}").unwrap();
267 }
268
269 writeln!(output, " Docs.rs: https://docs.rs/{}/", crate_info.name).unwrap();
270 writeln!(output).unwrap();
271 }
272
273 output
274}
275
276#[async_trait]
277impl Tool for SearchCratesToolImpl {
278 fn definition(&self) -> rust_mcp_sdk::schema::Tool {
279 SearchCratesTool::tool()
280 }
281
282 async fn execute(
283 &self,
284 arguments: serde_json::Value,
285 ) -> std::result::Result<
286 rust_mcp_sdk::schema::CallToolResult,
287 rust_mcp_sdk::schema::CallToolError,
288 > {
289 let params: SearchCratesTool = serde_json::from_value(arguments).map_err(|e| {
290 rust_mcp_sdk::schema::CallToolError::invalid_arguments(
291 "search_crates",
292 Some(format!("Parameter parsing failed: {e}")),
293 )
294 })?;
295
296 let limit = params.limit.unwrap_or(10).min(100);
297 let sort = normalize_search_sort(params.sort.as_deref())?;
298 let crates = self.search_crates(¶ms.query, limit, &sort).await?;
299
300 let format = super::parse_format(params.format.as_deref()).map_err(|_| {
301 rust_mcp_sdk::schema::CallToolError::invalid_arguments(
302 "search_crates",
303 Some("Invalid format".to_string()),
304 )
305 })?;
306 let content = format_search_results(&crates, format);
307
308 Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
309 content.into(),
310 ]))
311 }
312}
313
314impl Default for SearchCratesToolImpl {
315 fn default() -> Self {
316 Self::new(Arc::new(super::DocService::default()))
317 }
318}