Skip to main content

crates_docs/tools/docs/
lookup_item.rs

1//! Lookup item documentation tool
2#![allow(missing_docs)]
3
4use crate::tools::docs::html;
5use crate::tools::docs::DocService;
6use crate::tools::Tool;
7use async_trait::async_trait;
8use rust_mcp_sdk::schema::CallToolError;
9use serde::{Deserialize, Serialize};
10use std::sync::Arc;
11
12/// Lookup item documentation tool
13#[rust_mcp_sdk::macros::mcp_tool(
14    name = "lookup_item",
15    title = "Lookup Item Documentation",
16    description = "Get documentation for a specific item (function, struct, trait, module, etc.) from a Rust crate on docs.rs. Supports search paths like serde::Serialize, std::collections::HashMap, etc.",
17    destructive_hint = false,
18    idempotent_hint = true,
19    open_world_hint = false,
20    read_only_hint = true,
21    execution(task_support = "optional"),
22    icons = [
23        (src = "https://docs.rs/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "light"),
24        (src = "https://docs.rs/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "dark")
25    ]
26)]
27#[allow(missing_docs)]
28#[derive(Debug, Clone, Deserialize, Serialize, rust_mcp_sdk::macros::JsonSchema)]
29pub struct LookupItemTool {
30    /// Crate name
31    #[json_schema(
32        title = "Crate Name",
33        description = "Crate name to lookup, e.g.: serde, tokio, std"
34    )]
35    pub crate_name: String,
36
37    /// Item path (e.g., `std::collections::HashMap`)
38    #[json_schema(
39        title = "Item Path",
40        description = "Item path in format 'module::submodule::item', e.g.: serde::Serialize, tokio::runtime::Runtime, std::collections::HashMap"
41    )]
42    pub item_path: String,
43
44    /// Version (optional, defaults to latest)
45    #[json_schema(
46        title = "Version",
47        description = "Crate version. Uses latest version if not specified"
48    )]
49    pub version: Option<String>,
50
51    /// Output format: markdown, text, or html
52    #[json_schema(
53        title = "Output Format",
54        description = "Output format: markdown (default), text (plain text), html",
55        default = "markdown"
56    )]
57    pub format: Option<String>,
58}
59
60/// Lookup item documentation tool implementation
61pub struct LookupItemToolImpl {
62    service: Arc<DocService>,
63}
64
65impl LookupItemToolImpl {
66    /// Create a new lookup item tool instance
67    #[must_use]
68    pub fn new(service: Arc<DocService>) -> Self {
69        Self { service }
70    }
71
72    /// Build docs.rs search URL for item
73    fn build_search_url(crate_name: &str, item_path: &str, version: Option<&str>) -> String {
74        let encoded_path = urlencoding::encode(item_path);
75        match version {
76            Some(ver) => format!("https://docs.rs/{crate_name}/{ver}/?search={encoded_path}"),
77            None => format!("https://docs.rs/{crate_name}/?search={encoded_path}"),
78        }
79    }
80
81    /// Fetch HTML from docs.rs
82    async fn fetch_html(&self, url: &str) -> std::result::Result<String, CallToolError> {
83        let response = self
84            .service
85            .client()
86            .get(url)
87            .send()
88            .await
89            .map_err(|e| CallToolError::from_message(format!("HTTP request failed: {e}")))?;
90
91        let status = response.status();
92        if !status.is_success() {
93            let error_body = response.text().await.map_err(|e| {
94                CallToolError::from_message(format!("Failed to read error response: {e}"))
95            })?;
96            return Err(CallToolError::from_message(format!(
97                "Failed to get item documentation: HTTP {} - {}",
98                status,
99                if error_body.is_empty() {
100                    "No error details".to_string()
101                } else {
102                    error_body
103                }
104            )));
105        }
106
107        response
108            .text()
109            .await
110            .map_err(|e| CallToolError::from_message(format!("Failed to read response: {e}")))
111    }
112
113    /// Get item documentation (markdown format)
114    async fn fetch_item_docs(
115        &self,
116        crate_name: &str,
117        item_path: &str,
118        version: Option<&str>,
119    ) -> std::result::Result<String, CallToolError> {
120        // Try cache first
121        if let Some(cached) = self
122            .service
123            .doc_cache()
124            .get_item_docs(crate_name, item_path, version)
125            .await
126        {
127            return Ok(cached);
128        }
129
130        // Fetch from docs.rs
131        let url = Self::build_search_url(crate_name, item_path, version);
132        let html = self.fetch_html(&url).await?;
133
134        // Extract search results
135        let docs = html::extract_search_results(&html, item_path);
136
137        // Cache result
138        self.service
139            .doc_cache()
140            .set_item_docs(crate_name, item_path, version, docs.clone())
141            .await
142            .map_err(|e| CallToolError::from_message(format!("Cache set failed: {e}")))?;
143
144        Ok(docs)
145    }
146
147    /// Get item documentation as plain text
148    async fn fetch_item_docs_as_text(
149        &self,
150        crate_name: &str,
151        item_path: &str,
152        version: Option<&str>,
153    ) -> std::result::Result<String, CallToolError> {
154        let url = Self::build_search_url(crate_name, item_path, version);
155        let html = self.fetch_html(&url).await?;
156        Ok(format!(
157            "搜索结果: {}\n\n{}",
158            item_path,
159            html::html_to_text(&html)
160        ))
161    }
162
163    /// Get item documentation as raw HTML
164    async fn fetch_item_docs_as_html(
165        &self,
166        crate_name: &str,
167        item_path: &str,
168        version: Option<&str>,
169    ) -> std::result::Result<String, CallToolError> {
170        let url = Self::build_search_url(crate_name, item_path, version);
171        self.fetch_html(&url).await
172    }
173}
174
175#[async_trait]
176impl Tool for LookupItemToolImpl {
177    fn definition(&self) -> rust_mcp_sdk::schema::Tool {
178        LookupItemTool::tool()
179    }
180
181    async fn execute(
182        &self,
183        arguments: serde_json::Value,
184    ) -> std::result::Result<
185        rust_mcp_sdk::schema::CallToolResult,
186        rust_mcp_sdk::schema::CallToolError,
187    > {
188        let params: LookupItemTool = serde_json::from_value(arguments).map_err(|e| {
189            rust_mcp_sdk::schema::CallToolError::invalid_arguments(
190                "lookup_item",
191                Some(format!("Parameter parsing failed: {e}")),
192            )
193        })?;
194
195        let format = params.format.as_deref().unwrap_or("markdown");
196        let content = match format {
197            "text" => {
198                self.fetch_item_docs_as_text(
199                    &params.crate_name,
200                    &params.item_path,
201                    params.version.as_deref(),
202                )
203                .await?
204            }
205            "html" => {
206                self.fetch_item_docs_as_html(
207                    &params.crate_name,
208                    &params.item_path,
209                    params.version.as_deref(),
210                )
211                .await?
212            }
213            _ => {
214                // "markdown" and other formats default to markdown
215                self.fetch_item_docs(
216                    &params.crate_name,
217                    &params.item_path,
218                    params.version.as_deref(),
219                )
220                .await?
221            }
222        };
223
224        Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
225            content.into(),
226        ]))
227    }
228}
229
230impl Default for LookupItemToolImpl {
231    fn default() -> Self {
232        Self::new(Arc::new(super::DocService::default()))
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_build_search_url_without_version() {
242        let url = LookupItemToolImpl::build_search_url("serde", "Serialize", None);
243        assert_eq!(url, "https://docs.rs/serde/?search=Serialize");
244    }
245
246    #[test]
247    fn test_build_search_url_with_version() {
248        let url = LookupItemToolImpl::build_search_url("serde", "Serialize", Some("1.0.0"));
249        assert_eq!(url, "https://docs.rs/serde/1.0.0/?search=Serialize");
250    }
251
252    #[test]
253    fn test_build_search_url_encodes_special_chars() {
254        let url = LookupItemToolImpl::build_search_url("std", "collections::HashMap", None);
255        assert!(url.contains("collections%3A%3AHashMap"));
256    }
257}