1#![allow(missing_docs)]
8
9use crate::tools::Tool;
10use async_trait::async_trait;
11use rust_mcp_sdk::macros;
12use rust_mcp_sdk::schema::CallToolError;
13use serde::{Deserialize, Serialize};
14use std::sync::Arc;
15
16const DEFAULT_SEARCH_LIMIT: u32 = 10;
17const ESTIMATED_MARKDOWN_ENTRY_SIZE: usize = 200;
18const ESTIMATED_TEXT_ENTRY_SIZE: usize = 100;
19
20#[macros::mcp_tool(
24 name = "search_crates",
25 title = "Search Crates",
26 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.",
27 destructive_hint = false,
28 idempotent_hint = true,
29 open_world_hint = false,
30 read_only_hint = true,
31 execution(task_support = "optional"),
32 icons = [
33 (src = "https://crates.io/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "light"),
34 (src = "https://crates.io/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "dark")
35 ]
36)]
37#[derive(Debug, Clone, Deserialize, Serialize, macros::JsonSchema)]
42pub struct SearchCratesTool {
43 #[json_schema(
45 title = "Search Query",
46 description = "Search keywords, e.g.: web framework, async, http client, serialization"
47 )]
48 pub query: String,
49
50 #[json_schema(
52 title = "Result Limit",
53 description = "Maximum number of results to return, range 1-100",
54 minimum = 1,
55 maximum = 100,
56 default = 10
57 )]
58 pub limit: Option<u32>,
59
60 #[json_schema(
62 title = "Sort Order",
63 description = "Sort order: relevance (default), downloads, recent-downloads, recent-updates, new",
64 default = "relevance"
65 )]
66 pub sort: Option<String>,
67
68 #[json_schema(
70 title = "Output Format",
71 description = "Output format: markdown (default), text (plain text), json (raw JSON)",
72 default = "markdown"
73 )]
74 pub format: Option<String>,
75}
76
77const DEFAULT_SEARCH_SORT: &str = "relevance";
78const VALID_SEARCH_SORTS: &[&str] = &[
79 DEFAULT_SEARCH_SORT,
80 "downloads",
81 "recent-downloads",
82 "recent-updates",
83 "new",
84];
85
86#[derive(Debug, Deserialize)]
88struct SearchCratesResponse {
89 crates: Vec<SearchCrateRecord>,
90}
91
92#[derive(Debug, Deserialize)]
94struct SearchCrateRecord {
95 name: String,
96 #[serde(default)]
97 description: Option<String>,
98 #[serde(default = "default_max_version")]
99 max_version: String,
100 #[serde(default)]
101 downloads: u64,
102 #[serde(default)]
103 repository: Option<String>,
104 #[serde(default)]
105 documentation: Option<String>,
106}
107
108fn default_max_version() -> String {
109 "0.0.0".to_string()
110}
111
112pub struct SearchCratesToolImpl {
117 service: Arc<super::DocService>,
119}
120
121fn normalize_search_sort(sort: Option<&str>) -> std::result::Result<String, CallToolError> {
122 match sort {
123 Some(sort) if VALID_SEARCH_SORTS.contains(&sort) => Ok(sort.to_string()),
124 Some(sort) => Err(CallToolError::invalid_arguments(
125 "search_crates",
126 Some(format!(
127 "Invalid sort option '{sort}', expected one of: {}",
128 VALID_SEARCH_SORTS.join(", ")
129 )),
130 )),
131 None => Ok(DEFAULT_SEARCH_SORT.to_string()),
132 }
133}
134
135impl SearchCratesToolImpl {
136 #[must_use]
138 pub fn new(service: Arc<super::DocService>) -> Self {
139 Self { service }
140 }
141
142 async fn search_crates(
144 &self,
145 query: &str,
146 limit: u32,
147 sort: &str,
148 ) -> std::result::Result<Vec<CrateInfo>, CallToolError> {
149 if let Some(cached) = self
151 .service
152 .doc_cache()
153 .get_search_results(query, limit, Some(sort))
154 .await
155 {
156 return serde_json::from_str(&cached)
157 .map_err(|e| CallToolError::from_message(format!("Cache parsing failed: {e}")));
158 }
159
160 let url = super::build_crates_io_search_url(query, Some(sort), Some(limit as usize));
162
163 let response = self
164 .service
165 .client()
166 .get(&url)
167 .header("User-Agent", format!("CratesDocsMCP/{}", crate::VERSION))
168 .send()
169 .await
170 .map_err(|e| CallToolError::from_message(format!("HTTP request failed: {e}")))?;
171
172 if !response.status().is_success() {
173 return Err(CallToolError::from_message(format!(
174 "Search failed, status code: {}",
175 response.status()
176 )));
177 }
178
179 let search_response: SearchCratesResponse = response
181 .json()
182 .await
183 .map_err(|e| CallToolError::from_message(format!("JSON parsing failed: {e}")))?;
184
185 let crates = parse_crates_response(search_response, limit as usize);
186
187 let cache_value = serde_json::to_string(&crates)
188 .map_err(|e| CallToolError::from_message(format!("Serialization failed: {e}")))?;
189
190 self.service
192 .doc_cache()
193 .set_search_results(query, limit, Some(sort), cache_value)
194 .await
195 .map_err(|e| CallToolError::from_message(format!("Cache set failed: {e}")))?;
196
197 Ok(crates)
198 }
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203struct CrateInfo {
204 name: String,
206 description: Option<String>,
208 version: String,
210 downloads: u64,
212 repository: Option<String>,
214 documentation: Option<String>,
216}
217
218#[inline]
219fn parse_crates_response(response: SearchCratesResponse, limit: usize) -> Vec<CrateInfo> {
220 response
221 .crates
222 .into_iter()
223 .take(limit)
224 .map(|crate_record| CrateInfo {
225 name: crate_record.name,
226 description: crate_record.description,
227 version: crate_record.max_version,
228 downloads: crate_record.downloads,
229 repository: crate_record.repository,
230 documentation: crate_record.documentation,
231 })
232 .collect()
233}
234
235#[inline]
236fn format_search_results(crates: &[CrateInfo], format: super::Format) -> String {
237 match format {
238 super::Format::Json => {
239 serde_json::to_string_pretty(crates).unwrap_or_else(|_| "[]".to_string())
240 }
241 super::Format::Text => format_text_results(crates),
242 _ => format_markdown_results(crates),
243 }
244}
245
246fn format_markdown_results(crates: &[CrateInfo]) -> String {
247 use std::fmt::Write;
249 let estimated_size = crates.len().saturating_mul(ESTIMATED_MARKDOWN_ENTRY_SIZE) + 20;
250 let mut output = String::with_capacity(estimated_size);
251 output.push_str("# Search Results\n\n");
252
253 for (i, crate_info) in crates.iter().enumerate() {
254 writeln!(output, "## {}. {}", i + 1, crate_info.name).unwrap();
255 writeln!(output, "**Version**: {}", crate_info.version).unwrap();
256 writeln!(output, "**Downloads**: {}", crate_info.downloads).unwrap();
257
258 if let Some(desc) = &crate_info.description {
259 writeln!(output, "**Description**: {desc}").unwrap();
260 }
261
262 if let Some(repo) = &crate_info.repository {
263 writeln!(output, "**Repository**: [Link]({repo})").unwrap();
264 }
265
266 if let Some(docs) = &crate_info.documentation {
267 writeln!(output, "**Documentation**: [Link]({docs})").unwrap();
268 }
269
270 writeln!(
271 output,
272 "**Docs.rs**: [https://docs.rs/{}/](https://docs.rs/{}/)\n",
273 crate_info.name, crate_info.name
274 )
275 .unwrap();
276 }
277
278 output
279}
280
281fn format_text_results(crates: &[CrateInfo]) -> String {
282 use std::fmt::Write;
284 let estimated_size = crates.len().saturating_mul(ESTIMATED_TEXT_ENTRY_SIZE);
285 let mut output = String::with_capacity(estimated_size);
286
287 for (i, crate_info) in crates.iter().enumerate() {
288 writeln!(output, "{}. {}", i + 1, crate_info.name).unwrap();
289 writeln!(output, " Version: {}", crate_info.version).unwrap();
290 writeln!(output, " Downloads: {}", crate_info.downloads).unwrap();
291
292 if let Some(desc) = &crate_info.description {
293 writeln!(output, " Description: {desc}").unwrap();
294 }
295
296 writeln!(output, " Docs.rs: https://docs.rs/{}/", crate_info.name).unwrap();
297 writeln!(output).unwrap();
298 }
299
300 output
301}
302
303#[async_trait]
304impl Tool for SearchCratesToolImpl {
305 fn definition(&self) -> rust_mcp_sdk::schema::Tool {
306 SearchCratesTool::tool()
307 }
308
309 async fn execute(
310 &self,
311 arguments: serde_json::Value,
312 ) -> std::result::Result<
313 rust_mcp_sdk::schema::CallToolResult,
314 rust_mcp_sdk::schema::CallToolError,
315 > {
316 let params: SearchCratesTool = serde_json::from_value(arguments).map_err(|e| {
317 rust_mcp_sdk::schema::CallToolError::invalid_arguments(
318 "search_crates",
319 Some(format!("Parameter parsing failed: {e}")),
320 )
321 })?;
322
323 let limit = params.limit.unwrap_or(DEFAULT_SEARCH_LIMIT).min(100);
324 let sort = normalize_search_sort(params.sort.as_deref())?;
325 let crates = self.search_crates(¶ms.query, limit, &sort).await?;
326
327 let format = super::parse_format(params.format.as_deref())?;
328 let content = format_search_results(&crates, format);
329
330 Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
331 content.into(),
332 ]))
333 }
334}
335
336impl Default for SearchCratesToolImpl {
337 fn default() -> Self {
338 Self::new(Arc::new(super::DocService::default()))
339 }
340}