1#![allow(missing_docs)]
8
9use crate::tools::docs::html;
10use crate::tools::docs::DocService;
11use crate::tools::Tool;
12use async_trait::async_trait;
13use rust_mcp_sdk::schema::CallToolError;
14use serde::{Deserialize, Serialize};
15use std::sync::Arc;
16
17const TOOL_NAME: &str = "lookup_item";
18
19#[rust_mcp_sdk::macros::mcp_tool(
23 name = "lookup_item",
24 title = "Lookup Item Documentation",
25 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.",
26 destructive_hint = false,
27 idempotent_hint = true,
28 open_world_hint = false,
29 read_only_hint = true,
30 execution(task_support = "optional"),
31 icons = [
32 (src = "https://docs.rs/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "light"),
33 (src = "https://docs.rs/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "dark")
34 ]
35)]
36#[derive(Debug, Clone, Deserialize, Serialize, rust_mcp_sdk::macros::JsonSchema)]
41pub struct LookupItemTool {
42 #[json_schema(
44 title = "Crate Name",
45 description = "Crate name to lookup, e.g.: serde, tokio, std"
46 )]
47 pub crate_name: String,
48
49 #[json_schema(
51 title = "Item Path",
52 description = "Item path in format 'module::submodule::item', e.g.: serde::Serialize, tokio::runtime::Runtime, std::collections::HashMap"
53 )]
54 pub item_path: String,
55
56 #[json_schema(
58 title = "Version",
59 description = "Crate version. Uses latest version if not specified"
60 )]
61 pub version: Option<String>,
62
63 #[json_schema(
65 title = "Output Format",
66 description = "Output format: markdown (default), text (plain text), html",
67 default = "markdown"
68 )]
69 pub format: Option<String>,
70}
71
72pub struct LookupItemToolImpl {
77 service: Arc<DocService>,
79}
80
81impl LookupItemToolImpl {
82 #[must_use]
84 pub fn new(service: Arc<DocService>) -> Self {
85 Self { service }
86 }
87
88 fn build_search_url(crate_name: &str, item_path: &str, version: Option<&str>) -> String {
90 super::build_docs_item_url(crate_name, version, item_path)
91 }
92
93 async fn fetch_item_html(
94 &self,
95 crate_name: &str,
96 item_path: &str,
97 version: Option<&str>,
98 ) -> std::result::Result<String, CallToolError> {
99 if let Some(cached) = self
100 .service
101 .doc_cache()
102 .get_item_html(crate_name, item_path, version)
103 .await
104 {
105 return Ok(cached.to_string());
106 }
107
108 let url = Self::build_search_url(crate_name, item_path, version);
109 let html = self.service.fetch_html(&url, Some(TOOL_NAME)).await?;
110
111 self.service
112 .doc_cache()
113 .set_item_html(crate_name, item_path, version, html.clone())
114 .await
115 .map_err(|e| {
116 CallToolError::from_message(format!("[{TOOL_NAME}] Cache set failed: {e}"))
117 })?;
118
119 Ok(html)
120 }
121
122 async fn fetch_item_docs(
127 &self,
128 crate_name: &str,
129 item_path: &str,
130 version: Option<&str>,
131 ) -> std::result::Result<Arc<str>, CallToolError> {
132 if let Some(cached) = self
134 .service
135 .doc_cache()
136 .get_item_docs(crate_name, item_path, version)
137 .await
138 {
139 return Ok(cached);
140 }
141
142 let html = self.fetch_item_html(crate_name, item_path, version).await?;
143
144 let docs: Arc<str> =
146 Arc::from(html::extract_search_results(&html, item_path).into_boxed_str());
147
148 self.service
150 .doc_cache()
151 .set_item_docs(crate_name, item_path, version, docs.to_string())
152 .await
153 .map_err(|e| {
154 CallToolError::from_message(format!("[{TOOL_NAME}] Cache set failed: {e}"))
155 })?;
156
157 Ok(docs)
158 }
159
160 async fn fetch_item_docs_as_text(
162 &self,
163 crate_name: &str,
164 item_path: &str,
165 version: Option<&str>,
166 ) -> std::result::Result<String, CallToolError> {
167 let html = self.fetch_item_html(crate_name, item_path, version).await?;
168 Ok(format!(
169 "Search results: {}\n\n{}",
170 item_path,
171 html::html_to_text(&html)
172 ))
173 }
174
175 async fn fetch_item_docs_as_html(
177 &self,
178 crate_name: &str,
179 item_path: &str,
180 version: Option<&str>,
181 ) -> std::result::Result<String, CallToolError> {
182 self.fetch_item_html(crate_name, item_path, version).await
183 }
184}
185
186#[async_trait]
187impl Tool for LookupItemToolImpl {
188 fn definition(&self) -> rust_mcp_sdk::schema::Tool {
189 LookupItemTool::tool()
190 }
191
192 async fn execute(
193 &self,
194 arguments: serde_json::Value,
195 ) -> std::result::Result<
196 rust_mcp_sdk::schema::CallToolResult,
197 rust_mcp_sdk::schema::CallToolError,
198 > {
199 let params: LookupItemTool = serde_json::from_value(arguments).map_err(|e| {
200 rust_mcp_sdk::schema::CallToolError::invalid_arguments(
201 "lookup_item",
202 Some(format!("Parameter parsing failed: {e}")),
203 )
204 })?;
205
206 let format = super::parse_format(params.format.as_deref()).map_err(|_| {
207 rust_mcp_sdk::schema::CallToolError::invalid_arguments(
208 "lookup_item",
209 Some("Invalid format".to_string()),
210 )
211 })?;
212 let content = match format {
213 super::Format::Text => {
214 self.fetch_item_docs_as_text(
215 ¶ms.crate_name,
216 ¶ms.item_path,
217 params.version.as_deref(),
218 )
219 .await?
220 }
221 super::Format::Html => {
222 self.fetch_item_docs_as_html(
223 ¶ms.crate_name,
224 ¶ms.item_path,
225 params.version.as_deref(),
226 )
227 .await?
228 }
229 super::Format::Json => {
230 return Err(rust_mcp_sdk::schema::CallToolError::invalid_arguments(
231 "lookup_item",
232 Some("JSON format is not supported by this tool".to_string()),
233 ))
234 }
235 super::Format::Markdown => self
236 .fetch_item_docs(
237 ¶ms.crate_name,
238 ¶ms.item_path,
239 params.version.as_deref(),
240 )
241 .await
242 .map(|arc| arc.to_string())?,
243 };
244
245 Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
246 content.into(),
247 ]))
248 }
249}
250
251impl Default for LookupItemToolImpl {
252 fn default() -> Self {
253 Self::new(Arc::new(super::DocService::default()))
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use serial_test::serial;
261
262 #[test]
263 #[serial]
264 fn test_build_search_url_without_version() {
265 std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
266 let url = LookupItemToolImpl::build_search_url("serde", "Serialize", None);
267 assert_eq!(url, "https://docs.rs/serde/?search=Serialize");
268 std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
269 }
270
271 #[test]
272 #[serial]
273 fn test_build_search_url_with_version() {
274 std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
275 let url = LookupItemToolImpl::build_search_url("serde", "Serialize", Some("1.0.0"));
276 assert_eq!(url, "https://docs.rs/serde/1.0.0/?search=Serialize");
277 std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
278 }
279
280 #[test]
281 #[serial]
282 fn test_build_search_url_encodes_special_chars() {
283 std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
284 let url = LookupItemToolImpl::build_search_url("std", "collections::HashMap", None);
285 assert!(url.contains("collections%3A%3AHashMap"));
286 std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
287 }
288}