Skip to main content

crates_docs/tools/docs/
lookup_crate.rs

1//! Lookup crate documentation tool
2//!
3//! Provides functionality to retrieve complete documentation for a Rust crate
4//! from docs.rs. Returns the main documentation page content including modules,
5//! structs, functions, etc.
6
7#![allow(missing_docs)]
8
9//! Tool parameters for looking up crate documentation from docs.rs
10//!
11//! This struct defines the parameters needed to retrieve documentation
12//! for a specific Rust crate, including the crate name, optional version,
13//! and desired output format.
14
15use 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///
25/// Used to specify which crate to look up and in what format to return the documentation.
26#[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/// Parameters for the `lookup_crate` tool
40///
41/// Defines the input parameters for retrieving crate documentation,
42/// including the crate name, optional version specification, and output format.
43#[derive(Debug, Clone, Deserialize, Serialize, rust_mcp_sdk::macros::JsonSchema)]
44pub struct LookupCrateTool {
45    /// Crate name to lookup (e.g., "serde", "tokio", "reqwest")
46    #[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    /// Crate version (optional, defaults to latest)
53    #[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    /// Output format: "markdown", "text", or "html" (defaults to "markdown")
60    #[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
68/// Implementation of the lookup crate documentation tool
69///
70/// Handles the execution of crate documentation lookups, including
71/// cache management, HTTP fetching from docs.rs, and result formatting.
72pub struct LookupCrateToolImpl {
73    /// Shared document service for HTTP requests and caching
74    service: Arc<DocService>,
75}
76
77impl LookupCrateToolImpl {
78    /// Create a new lookup tool instance
79    #[must_use]
80    pub fn new(service: Arc<DocService>) -> Self {
81        Self { service }
82    }
83
84    /// Build docs.rs URL for crate
85    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        // Cache write failures must not fail the request (see fetch_crate_docs):
107        // the HTML was fetched successfully, so log and continue uncached.
108        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    /// Get crate documentation (markdown format)
121    ///
122    /// Returns `Arc<str>` to preserve shared ownership on cache hits,
123    /// avoiding unnecessary cloning of large documentation strings.
124    async fn fetch_crate_docs(
125        &self,
126        crate_name: &str,
127        version: Option<&str>,
128    ) -> std::result::Result<Arc<str>, CallToolError> {
129        // Try cache first - returns Arc<str> directly without cloning
130        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        // Extract documentation into Arc<str> for shared ownership
142        let docs: Arc<str> = Arc::from(html::extract_documentation(&html).into_boxed_str());
143
144        // Cache the result. A cache write failure (e.g. a Redis outage) must
145        // not fail the user's request: the documentation was fetched
146        // successfully, so log and continue with an uncached result.
147        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    /// Get crate documentation as plain text
160    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    /// Get crate documentation as raw HTML
170    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        // Propagate the detailed parse error (e.g. "Invalid format 'xml'. Expected
201        // one of: ...") rather than masking it with a generic message, so callers
202        // get actionable feedback.
203        super::validate_crate_name(TOOL_NAME, &params.crate_name)?;
204        super::validate_version(TOOL_NAME, params.version.as_deref())?;
205        // Normalise surrounding whitespace so it does not leak into headings or
206        // candidate URL construction (a padded name would otherwise 404).
207        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(&params.crate_name, params.version.as_deref())
216                    .await?
217            }
218            super::Format::Html => {
219                self.fetch_crate_docs_as_html(&params.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(&params.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}