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 = "结果限制",
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 = "排序方式",
48 description = "Sort order for crates.io search: relevance (default), downloads, recent-downloads, recent-updates, new",
49 default = "relevance"
50 )]
51 pub sort: Option<String>,
52
53 #[json_schema(
55 title = "输出格式",
56 description = "Search result 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 {
73 service: Arc<super::DocService>,
74}
75
76fn normalize_search_sort(sort: Option<&str>) -> std::result::Result<String, CallToolError> {
77 match sort {
78 Some(sort) if VALID_SEARCH_SORTS.contains(&sort) => Ok(sort.to_string()),
79 Some(sort) => Err(CallToolError::invalid_arguments(
80 "search_crates",
81 Some(format!(
82 "Invalid sort option '{sort}', expected one of: {}",
83 VALID_SEARCH_SORTS.join(", ")
84 )),
85 )),
86 None => Ok(DEFAULT_SEARCH_SORT.to_string()),
87 }
88}
89
90impl SearchCratesToolImpl {
91 #[must_use]
93 pub fn new(service: Arc<super::DocService>) -> Self {
94 Self { service }
95 }
96
97 async fn search_crates(
99 &self,
100 query: &str,
101 limit: u32,
102 sort: &str,
103 ) -> std::result::Result<Vec<CrateInfo>, CallToolError> {
104 let cache_key = format!("search:{query}:{sort}:{limit}");
106
107 if let Some(cached) = self.service.cache().get(&cache_key).await {
109 return serde_json::from_str(&cached)
110 .map_err(|e| CallToolError::from_message(format!("Cache parsing failed: {e}")));
111 }
112
113 let url = format!(
115 "https://crates.io/api/v1/crates?q={}&per_page={}&sort={}",
116 urlencoding::encode(query),
117 limit,
118 urlencoding::encode(sort)
119 );
120
121 let response = self
123 .service
124 .client()
125 .get(&url)
126 .header("User-Agent", format!("CratesDocsMCP/{}", crate::VERSION))
127 .send()
128 .await
129 .map_err(|e| CallToolError::from_message(format!("HTTP 请求失败: {e}")))?;
130
131 if !response.status().is_success() {
132 return Err(CallToolError::from_message(format!(
133 "Search failed, status code: {}",
134 response.status()
135 )));
136 }
137
138 let json: serde_json::Value = response
139 .json()
140 .await
141 .map_err(|e| CallToolError::from_message(format!("JSON 解析失败: {e}")))?;
142
143 let crates = parse_crates_response(&json, limit as usize);
145
146 let cache_value = serde_json::to_string(&crates)
148 .map_err(|e| CallToolError::from_message(format!("序列化失败: {e}")))?;
149
150 self.service
151 .cache()
152 .set(
153 cache_key,
154 cache_value,
155 Some(std::time::Duration::from_secs(300)),
156 )
157 .await
158 .map_err(|e| CallToolError::from_message(format!("Cache set failed: {e}")))?;
159
160 Ok(crates)
161 }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166struct CrateInfo {
167 name: String,
168 description: Option<String>,
169 version: String,
170 downloads: u64,
171 repository: Option<String>,
172 documentation: Option<String>,
173}
174
175fn parse_crates_response(json: &serde_json::Value, limit: usize) -> Vec<CrateInfo> {
177 let mut crates = Vec::new();
178
179 if let Some(crates_array) = json.get("crates").and_then(|c| c.as_array()) {
180 for crate_item in crates_array.iter().take(limit) {
181 let name = crate_item
182 .get("name")
183 .and_then(|n| n.as_str())
184 .unwrap_or("Unknown")
185 .to_string();
186
187 let description = crate_item
188 .get("description")
189 .and_then(|d| d.as_str())
190 .map(std::string::ToString::to_string);
191
192 let version = crate_item
193 .get("max_version")
194 .and_then(|v| v.as_str())
195 .unwrap_or("0.0.0")
196 .to_string();
197
198 let downloads = crate_item
199 .get("downloads")
200 .and_then(serde_json::Value::as_u64)
201 .unwrap_or(0);
202
203 let repository = crate_item
204 .get("repository")
205 .and_then(|r| r.as_str())
206 .map(std::string::ToString::to_string);
207
208 let documentation = crate_item
209 .get("documentation")
210 .and_then(|d| d.as_str())
211 .map(std::string::ToString::to_string);
212
213 crates.push(CrateInfo {
214 name,
215 description,
216 version,
217 downloads,
218 repository,
219 documentation,
220 });
221 }
222 }
223
224 crates
225}
226
227fn format_search_results(crates: &[CrateInfo], format: &str) -> String {
229 match format {
230 "json" => serde_json::to_string_pretty(crates).unwrap_or_else(|_| "[]".to_string()),
231 "markdown" => {
232 use std::fmt::Write;
233 let mut output = String::from("# 搜索结果\n\n");
234
235 for (i, crate_info) in crates.iter().enumerate() {
236 writeln!(output, "## {}. {}", i + 1, crate_info.name).unwrap();
237 writeln!(output, "**版本**: {}", crate_info.version).unwrap();
238 writeln!(output, "**下载量**: {}", crate_info.downloads).unwrap();
239
240 if let Some(desc) = &crate_info.description {
241 writeln!(output, "**描述**: {desc}").unwrap();
242 }
243
244 if let Some(repo) = &crate_info.repository {
245 writeln!(output, "**仓库**: [链接]({repo})").unwrap();
246 }
247
248 if let Some(docs) = &crate_info.documentation {
249 writeln!(output, "**文档**: [链接]({docs})").unwrap();
250 }
251
252 writeln!(
253 output,
254 "**Docs.rs**: [https://docs.rs/{}/](https://docs.rs/{}/)\n",
255 crate_info.name, crate_info.name
256 )
257 .unwrap();
258 }
259
260 output
261 }
262 "text" => {
263 use std::fmt::Write;
264 let mut output = String::new();
265
266 for (i, crate_info) in crates.iter().enumerate() {
267 writeln!(output, "{}. {}", i + 1, crate_info.name).unwrap();
268 writeln!(output, " 版本: {}", crate_info.version).unwrap();
269 writeln!(output, " 下载量: {}", crate_info.downloads).unwrap();
270
271 if let Some(desc) = &crate_info.description {
272 writeln!(output, " 描述: {desc}").unwrap();
273 }
274
275 writeln!(output, " Docs.rs: https://docs.rs/{}/", crate_info.name).unwrap();
276 writeln!(output).unwrap();
277 }
278
279 output
280 }
281 _ => {
282 format_search_results(crates, "markdown")
284 }
285 }
286}
287
288#[async_trait]
289impl Tool for SearchCratesToolImpl {
290 fn definition(&self) -> rust_mcp_sdk::schema::Tool {
291 SearchCratesTool::tool()
292 }
293
294 async fn execute(
295 &self,
296 arguments: serde_json::Value,
297 ) -> std::result::Result<
298 rust_mcp_sdk::schema::CallToolResult,
299 rust_mcp_sdk::schema::CallToolError,
300 > {
301 let params: SearchCratesTool = serde_json::from_value(arguments).map_err(|e| {
302 rust_mcp_sdk::schema::CallToolError::invalid_arguments(
303 "search_crates",
304 Some(format!("参数解析失败: {e}")),
305 )
306 })?;
307
308 let limit = params.limit.unwrap_or(10).min(100); let sort = normalize_search_sort(params.sort.as_deref())?;
310 let crates = self.search_crates(¶ms.query, limit, &sort).await?;
311
312 let format = params.format.unwrap_or_else(|| "markdown".to_string());
313 let content = format_search_results(&crates, &format);
314
315 Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
316 content.into(),
317 ]))
318 }
319}
320
321impl Default for SearchCratesToolImpl {
322 fn default() -> Self {
323 Self::new(Arc::new(super::DocService::default()))
324 }
325}