1#![allow(missing_docs)]
8
9use crate::tools::docs::html;
16use crate::tools::docs::DocService;
17use crate::tools::Tool;
18use async_trait::async_trait;
19use rust_mcp_sdk::schema::CallToolError;
20use serde::{Deserialize, Serialize};
21use std::sync::Arc;
22
23const TOOL_NAME: &str = "lookup_crate";
24#[rust_mcp_sdk::macros::mcp_tool(
27 name = "lookup_crate",
28 title = "Lookup Crate Documentation",
29 description = "Get complete documentation for a Rust crate from docs.rs. Returns the main documentation page content, including modules, structs, functions, etc. Suitable for understanding the overall functionality and usage of a crate.",
30 destructive_hint = false,
31 idempotent_hint = true,
32 open_world_hint = false,
33 read_only_hint = true,
34 icons = [
35 (src = "https://docs.rs/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "light"),
36 (src = "https://docs.rs/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "dark")
37 ]
38)]
39#[derive(Debug, Clone, Deserialize, Serialize, rust_mcp_sdk::macros::JsonSchema)]
44pub struct LookupCrateTool {
45 #[json_schema(
47 title = "Crate Name",
48 description = "Crate name to lookup, e.g.: serde, tokio, reqwest"
49 )]
50 pub crate_name: String,
51
52 #[json_schema(
54 title = "Version",
55 description = "Crate version, e.g.: 1.0.0. Uses latest version if not specified"
56 )]
57 pub version: Option<String>,
58
59 #[json_schema(
61 title = "Output Format",
62 description = "Output format: markdown (default), text (plain text), html",
63 default = "markdown"
64 )]
65 pub format: Option<String>,
66}
67
68pub struct LookupCrateToolImpl {
73 service: Arc<DocService>,
75}
76
77impl LookupCrateToolImpl {
78 #[must_use]
80 pub fn new(service: Arc<DocService>) -> Self {
81 Self { service }
82 }
83
84 fn build_url(crate_name: &str, version: Option<&str>) -> String {
86 super::build_docs_url(crate_name, version)
87 }
88
89 async fn fetch_crate_html(
90 &self,
91 crate_name: &str,
92 version: Option<&str>,
93 ) -> std::result::Result<String, CallToolError> {
94 if let Some(cached) = self
95 .service
96 .doc_cache()
97 .get_crate_html(crate_name, version)
98 .await
99 {
100 return Ok(cached.to_string());
101 }
102
103 let url = Self::build_url(crate_name, version);
104 let html = self.service.fetch_html(&url, Some(TOOL_NAME)).await?;
105
106 if let Err(e) = self
109 .service
110 .doc_cache()
111 .set_crate_html(crate_name, version, html.clone())
112 .await
113 {
114 tracing::warn!("[{TOOL_NAME}] failed to cache crate HTML (continuing uncached): {e}");
115 }
116
117 Ok(html)
118 }
119
120 async fn fetch_crate_docs(
125 &self,
126 crate_name: &str,
127 version: Option<&str>,
128 ) -> std::result::Result<Arc<str>, CallToolError> {
129 if let Some(cached) = self
131 .service
132 .doc_cache()
133 .get_crate_docs(crate_name, version)
134 .await
135 {
136 return Ok(cached);
137 }
138
139 let html = self.fetch_crate_html(crate_name, version).await?;
140
141 let docs: Arc<str> = Arc::from(html::extract_documentation(&html).into_boxed_str());
143
144 if let Err(e) = self
148 .service
149 .doc_cache()
150 .set_crate_docs(crate_name, version, docs.to_string())
151 .await
152 {
153 tracing::warn!("[{TOOL_NAME}] failed to cache crate docs (continuing uncached): {e}");
154 }
155
156 Ok(docs)
157 }
158
159 async fn fetch_crate_docs_as_text(
161 &self,
162 crate_name: &str,
163 version: Option<&str>,
164 ) -> std::result::Result<String, CallToolError> {
165 let html = self.fetch_crate_html(crate_name, version).await?;
166 Ok(html::extract_documentation_as_text(&html))
167 }
168
169 async fn fetch_crate_docs_as_html(
171 &self,
172 crate_name: &str,
173 version: Option<&str>,
174 ) -> std::result::Result<String, CallToolError> {
175 let html = self.fetch_crate_html(crate_name, version).await?;
176 Ok(html::extract_documentation_html(&html))
177 }
178}
179
180#[async_trait]
181impl Tool for LookupCrateToolImpl {
182 fn definition(&self) -> rust_mcp_sdk::schema::Tool {
183 LookupCrateTool::tool()
184 }
185
186 async fn execute(
187 &self,
188 arguments: serde_json::Value,
189 ) -> std::result::Result<
190 rust_mcp_sdk::schema::CallToolResult,
191 rust_mcp_sdk::schema::CallToolError,
192 > {
193 let mut params: LookupCrateTool = serde_json::from_value(arguments).map_err(|e| {
194 rust_mcp_sdk::schema::CallToolError::invalid_arguments(
195 "lookup_crate",
196 Some(format!("Parameter parsing failed: {e}")),
197 )
198 })?;
199
200 super::validate_crate_name(TOOL_NAME, ¶ms.crate_name)?;
204 super::validate_version(TOOL_NAME, params.version.as_deref())?;
205 params.crate_name = params.crate_name.trim().to_string();
208 if let Some(version) = params.version.as_mut() {
209 *version = super::normalize_version(version);
210 }
211
212 let format = super::parse_format(TOOL_NAME, params.format.as_deref(), super::DOC_FORMATS)?;
213 let content = match format {
214 super::Format::Text => {
215 self.fetch_crate_docs_as_text(¶ms.crate_name, params.version.as_deref())
216 .await?
217 }
218 super::Format::Html => {
219 self.fetch_crate_docs_as_html(¶ms.crate_name, params.version.as_deref())
220 .await?
221 }
222 super::Format::Json => {
223 return Err(rust_mcp_sdk::schema::CallToolError::invalid_arguments(
224 "lookup_crate",
225 Some(
226 "Invalid format 'json'. This tool supports: markdown, text, html"
227 .to_string(),
228 ),
229 ))
230 }
231 super::Format::Markdown => self
232 .fetch_crate_docs(¶ms.crate_name, params.version.as_deref())
233 .await
234 .map(|arc| arc.to_string())?,
235 };
236
237 Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
238 content.into(),
239 ]))
240 }
241}
242
243impl Default for LookupCrateToolImpl {
244 fn default() -> Self {
245 Self::new(Arc::new(super::DocService::default()))
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use serial_test::serial;
253
254 #[test]
255 #[serial]
256 fn test_build_url_without_version() {
257 std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
258 let url = LookupCrateToolImpl::build_url("serde", None);
259 assert_eq!(url, "https://docs.rs/serde/");
260 std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
261 }
262
263 #[test]
264 #[serial]
265 fn test_build_url_with_version() {
266 std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
267 let url = LookupCrateToolImpl::build_url("serde", Some("1.0.0"));
268 assert_eq!(url, "https://docs.rs/serde/1.0.0/");
269 std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
270 }
271
272 #[test]
273 #[serial]
274 fn test_build_url_with_custom_base() {
275 std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "http://mock-server");
276 let url = LookupCrateToolImpl::build_url("serde", None);
277 assert_eq!(url, "http://mock-server/serde/");
278 std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
279 }
280}