#![allow(missing_docs)]
use crate::tools::Tool;
use async_trait::async_trait;
use rust_mcp_sdk::macros;
use rust_mcp_sdk::schema::CallToolError;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
const DEFAULT_SEARCH_LIMIT: u32 = 10;
const ESTIMATED_MARKDOWN_ENTRY_SIZE: usize = 200;
const ESTIMATED_TEXT_ENTRY_SIZE: usize = 100;
#[macros::mcp_tool(
name = "search_crates",
title = "Search Crates",
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.",
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false,
read_only_hint = true,
icons = [
(src = "https://crates.io/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "light"),
(src = "https://crates.io/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "dark")
]
)]
#[derive(Debug, Clone, Deserialize, Serialize, macros::JsonSchema)]
pub struct SearchCratesTool {
#[json_schema(
title = "Search Query",
description = "Search keywords, e.g.: web framework, async, http client, serialization"
)]
pub query: String,
#[json_schema(
title = "Result Limit",
description = "Maximum number of results to return, range 1-100",
minimum = 1,
maximum = 100,
default = 10
)]
pub limit: Option<u32>,
#[json_schema(
title = "Sort Order",
description = "Sort order: relevance (default), downloads, recent-downloads, recent-updates, new",
default = "relevance"
)]
pub sort: Option<String>,
#[json_schema(
title = "Output Format",
description = "Output format: markdown (default), text (plain text), json (structured JSON: name, version, downloads, recent_downloads, description, repository, documentation, docs_rs)",
default = "markdown"
)]
pub format: Option<String>,
}
const DEFAULT_SEARCH_SORT: &str = "relevance";
const VALID_SEARCH_SORTS: &[&str] = &[
DEFAULT_SEARCH_SORT,
"downloads",
"recent-downloads",
"recent-updates",
"new",
];
#[derive(Debug, Deserialize)]
struct SearchCratesResponse {
crates: Vec<SearchCrateRecord>,
}
#[derive(Debug, Deserialize)]
struct SearchCrateRecord {
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default = "default_max_version")]
max_version: String,
#[serde(default)]
max_stable_version: Option<String>,
#[serde(default)]
downloads: u64,
#[serde(default)]
recent_downloads: Option<u64>,
#[serde(default)]
repository: Option<String>,
#[serde(default)]
documentation: Option<String>,
}
fn default_max_version() -> String {
"0.0.0".to_string()
}
pub struct SearchCratesToolImpl {
service: Arc<super::DocService>,
}
fn normalize_search_sort(sort: Option<&str>) -> std::result::Result<String, CallToolError> {
match sort {
Some(raw) => {
let normalized = raw.trim().to_lowercase();
if VALID_SEARCH_SORTS.contains(&normalized.as_str()) {
Ok(normalized)
} else {
Err(CallToolError::invalid_arguments(
"search_crates",
Some(format!(
"Invalid sort option '{raw}', expected one of: {}",
VALID_SEARCH_SORTS.join(", ")
)),
))
}
}
None => Ok(DEFAULT_SEARCH_SORT.to_string()),
}
}
impl SearchCratesToolImpl {
#[must_use]
pub fn new(service: Arc<super::DocService>) -> Self {
Self { service }
}
async fn search_crates(
&self,
query: &str,
limit: u32,
sort: &str,
) -> std::result::Result<Vec<CrateInfo>, CallToolError> {
if let Some(cached) = self
.service
.doc_cache()
.get_search_results(query, limit, Some(sort))
.await
{
return serde_json::from_str(&cached).map_err(|e| {
CallToolError::from_message(format!("[search_crates] Cache parsing failed: {e}"))
});
}
let url = super::build_crates_io_search_url(query, Some(sort), Some(limit as usize));
let response = self
.service
.client()
.get(&url)
.header("User-Agent", crate::user_agent())
.send()
.await
.map_err(|e| {
CallToolError::from_message(format!("[search_crates] HTTP request failed: {e}"))
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let trimmed = body.trim();
let detail = if trimmed.is_empty()
|| trimmed.starts_with('<')
|| trimmed.to_ascii_lowercase().contains("<html")
{
String::new()
} else {
let snippet: String = trimmed.chars().take(200).collect();
format!(" - {snippet}")
};
return Err(CallToolError::from_message(format!(
"[search_crates] crates.io search failed: HTTP {status}{detail}"
)));
}
let search_response: SearchCratesResponse = response.json().await.map_err(|e| {
CallToolError::from_message(format!("[search_crates] JSON parsing failed: {e}"))
})?;
let crates = parse_crates_response(search_response, limit as usize);
let cache_value = serde_json::to_string(&crates).map_err(|e| {
CallToolError::from_message(format!("[search_crates] Serialization failed: {e}"))
})?;
if let Err(e) = self
.service
.doc_cache()
.set_search_results(query, limit, Some(sort), cache_value)
.await
{
tracing::warn!(
"[search_crates] failed to cache search results (continuing uncached): {e}"
);
}
Ok(crates)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CrateInfo {
name: String,
description: Option<String>,
version: String,
downloads: u64,
#[serde(default)]
recent_downloads: Option<u64>,
repository: Option<String>,
documentation: Option<String>,
#[serde(default)]
docs_rs: String,
}
#[inline]
fn parse_crates_response(response: SearchCratesResponse, limit: usize) -> Vec<CrateInfo> {
response
.crates
.into_iter()
.take(limit)
.map(|crate_record| {
let docs_rs = format!("https://docs.rs/{}/", crate_record.name);
CrateInfo {
name: crate_record.name,
description: crate_record.description,
version: crate_record
.max_stable_version
.unwrap_or(crate_record.max_version),
downloads: crate_record.downloads,
recent_downloads: crate_record.recent_downloads,
repository: crate_record.repository,
documentation: crate_record.documentation,
docs_rs,
}
})
.collect()
}
#[inline]
fn format_search_results(crates: &[CrateInfo], format: super::Format) -> String {
match format {
super::Format::Json => {
serde_json::to_string_pretty(crates).unwrap_or_else(|_| "[]".to_string())
}
super::Format::Text => {
if crates.is_empty() {
"No crates found matching the query.".to_string()
} else {
format_text_results(crates)
}
}
super::Format::Markdown | super::Format::Html => {
if crates.is_empty() {
"# Search Results\n\nNo crates found matching the query.".to_string()
} else {
format_markdown_results(crates)
}
}
}
}
fn normalize_description(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn escape_markdown_text(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'[' => out.push_str("\\["),
']' => out.push_str("\\]"),
'`' => out.push_str("\\`"),
'<' => out.push_str("<"),
_ => out.push(c),
}
}
out
}
fn render_markdown_url(label: &str, url: &str) -> String {
let is_http = url.starts_with("http://") || url.starts_with("https://");
let is_clean = !url.chars().any(|c| {
c.is_whitespace()
|| c.is_control()
|| matches!(c, '(' | ')' | '<' | '>' | '[' | ']' | '"' | '\\')
});
if is_http && is_clean {
format!("[{label}]({url})")
} else {
let inert: String = url
.chars()
.map(|c| if c == '`' || c.is_control() { ' ' } else { c })
.collect();
format!("`{inert}`")
}
}
fn format_markdown_results(crates: &[CrateInfo]) -> String {
use std::fmt::Write;
let estimated_size = crates.len().saturating_mul(ESTIMATED_MARKDOWN_ENTRY_SIZE) + 20;
let mut output = String::with_capacity(estimated_size);
output.push_str("# Search Results\n\n");
for (i, crate_info) in crates.iter().enumerate() {
writeln!(output, "## {}. {}", i + 1, crate_info.name).unwrap();
writeln!(output, "**Version**: {}", crate_info.version).unwrap();
writeln!(output, "**Downloads**: {}", crate_info.downloads).unwrap();
if let Some(recent) = crate_info.recent_downloads {
writeln!(output, "**Recent downloads**: {recent}").unwrap();
}
if let Some(desc) = &crate_info.description {
writeln!(
output,
"**Description**: {}",
escape_markdown_text(&normalize_description(desc))
)
.unwrap();
}
if let Some(repo) = &crate_info.repository {
writeln!(
output,
"**Repository**: {}",
render_markdown_url("Link", repo)
)
.unwrap();
}
if let Some(docs) = &crate_info.documentation {
writeln!(
output,
"**Documentation**: {}",
render_markdown_url("Link", docs)
)
.unwrap();
}
writeln!(
output,
"**Docs.rs**: {}\n",
render_markdown_url(&crate_info.docs_rs, &crate_info.docs_rs)
)
.unwrap();
}
output
}
fn format_text_results(crates: &[CrateInfo]) -> String {
use std::fmt::Write;
let estimated_size = crates.len().saturating_mul(ESTIMATED_TEXT_ENTRY_SIZE);
let mut output = String::with_capacity(estimated_size);
for (i, crate_info) in crates.iter().enumerate() {
writeln!(output, "{}. {}", i + 1, crate_info.name).unwrap();
writeln!(output, " Version: {}", crate_info.version).unwrap();
writeln!(output, " Downloads: {}", crate_info.downloads).unwrap();
if let Some(recent) = crate_info.recent_downloads {
writeln!(output, " Recent downloads: {recent}").unwrap();
}
if let Some(desc) = &crate_info.description {
writeln!(output, " Description: {}", normalize_description(desc)).unwrap();
}
if let Some(repo) = &crate_info.repository {
writeln!(output, " Repository: {repo}").unwrap();
}
if let Some(docs) = &crate_info.documentation {
writeln!(output, " Documentation: {docs}").unwrap();
}
writeln!(output, " Docs.rs: {}", crate_info.docs_rs).unwrap();
writeln!(output).unwrap();
}
output
}
#[async_trait]
impl Tool for SearchCratesToolImpl {
fn definition(&self) -> rust_mcp_sdk::schema::Tool {
SearchCratesTool::tool()
}
async fn execute(
&self,
arguments: serde_json::Value,
) -> std::result::Result<
rust_mcp_sdk::schema::CallToolResult,
rust_mcp_sdk::schema::CallToolError,
> {
let params: SearchCratesTool = serde_json::from_value(arguments).map_err(|e| {
rust_mcp_sdk::schema::CallToolError::invalid_arguments(
"search_crates",
Some(format!("Parameter parsing failed: {e}")),
)
})?;
super::validate_search_query("search_crates", ¶ms.query)?;
let limit = params.limit.unwrap_or(DEFAULT_SEARCH_LIMIT).clamp(1, 100);
let sort = normalize_search_sort(params.sort.as_deref())?;
let format = super::parse_format(
"search_crates",
params.format.as_deref(),
super::SEARCH_FORMATS,
)?;
let crates = self
.search_crates(params.query.trim(), limit, &sort)
.await?;
let content = format_search_results(&crates, format);
Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
content.into(),
]))
}
}
impl Default for SearchCratesToolImpl {
fn default() -> Self {
Self::new(Arc::new(super::DocService::default()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_search_results_empty_emits_message() {
use crate::tools::docs::Format;
let text = format_search_results(&[], Format::Text);
assert!(
text.contains("No crates found"),
"text empty should explain no matches: {text:?}"
);
let md = format_search_results(&[], Format::Markdown);
assert!(
md.contains("No crates found"),
"markdown empty should explain no matches: {md:?}"
);
let json = format_search_results(&[], Format::Json);
assert_eq!(json, "[]");
}
#[test]
fn test_recent_downloads_parsed_and_rendered() {
use crate::tools::docs::Format;
let json = r#"{"crates":[
{"name":"a","max_stable_version":"1.0.0","downloads":1000,"recent_downloads":42}
]}"#;
let resp: SearchCratesResponse = serde_json::from_str(json).unwrap();
let crates = parse_crates_response(resp, 10);
assert_eq!(crates[0].recent_downloads, Some(42));
let md = format_search_results(&crates, Format::Markdown);
assert!(md.contains("**Recent downloads**: 42"), "markdown: {md}");
let text = format_search_results(&crates, Format::Text);
assert!(text.contains("Recent downloads: 42"), "text: {text}");
}
#[test]
fn test_parse_crates_response_prefers_stable_version() {
let json = r#"{"crates":[
{"name":"a","max_version":"2.0.0-yanked","max_stable_version":"1.9.0","downloads":1},
{"name":"b","max_version":"0.3.0","downloads":2}
]}"#;
let resp: SearchCratesResponse = serde_json::from_str(json).unwrap();
let crates = parse_crates_response(resp, 10);
assert_eq!(crates[0].version, "1.9.0");
assert_eq!(crates[1].version, "0.3.0");
}
#[test]
fn test_format_text_results_includes_repository_and_documentation() {
let crates = vec![CrateInfo {
name: "demo".to_string(),
description: Some("A demo crate".to_string()),
version: "1.0.0".to_string(),
downloads: 42,
recent_downloads: None,
repository: Some("https://github.com/x/demo".to_string()),
documentation: Some("https://docs.rs/demo".to_string()),
docs_rs: "https://docs.rs/demo/".to_string(),
}];
let out = format_text_results(&crates);
assert!(
out.contains("Repository: https://github.com/x/demo"),
"{out}"
);
assert!(out.contains("Documentation: https://docs.rs/demo"), "{out}");
assert!(out.contains("Docs.rs: https://docs.rs/demo/"), "{out}");
}
#[test]
fn test_description_trailing_newline_does_not_split_record() {
let crates = vec![CrateInfo {
name: "futures-executor".to_string(),
description: Some("Runtime for the async/await macros.\n".to_string()),
version: "0.3.0".to_string(),
downloads: 1,
recent_downloads: None,
repository: Some("https://github.com/rust-lang/futures-rs".to_string()),
documentation: None,
docs_rs: "https://docs.rs/futures-executor/".to_string(),
}];
let text = format_text_results(&crates);
assert!(
text.contains("Description: Runtime for the async/await macros.\n Repository:"),
"text record split by stray blank line: {text:?}"
);
let md = format_markdown_results(&crates);
assert!(
!md.contains("macros.\n\n**Repository"),
"markdown record split by stray blank line: {md:?}"
);
}
}