Skip to main content

crates_docs/tools/docs/
search.rs

1//! Search crates tool
2#![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/// Search crates tool parameters
12#[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    /// Search query
29    #[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    /// Result count limit
36    #[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    /// Sort order
46    #[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    /// Output format
54    #[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    /// Create a new tool instance
91    #[must_use]
92    pub fn new(service: Arc<super::DocService>) -> Self {
93        Self { service }
94    }
95
96    /// Search crates
97    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    // SAFETY: writeln! to String never fails (writes to memory buffer). unwrap() is safe here.
221    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    // SAFETY: writeln! to String never fails (writes to memory buffer). unwrap() is safe here.
256    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(&params.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}