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 icons = [
31 (src = "https://docs.rs/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "light"),
32 (src = "https://docs.rs/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "dark")
33 ]
34)]
35#[derive(Debug, Clone, Deserialize, Serialize, rust_mcp_sdk::macros::JsonSchema)]
40pub struct LookupItemTool {
41 #[json_schema(
43 title = "Crate Name",
44 description = "Crate name to lookup, e.g.: serde, tokio, std"
45 )]
46 pub crate_name: String,
47
48 #[json_schema(
50 title = "Item Path",
51 description = "Item path in format 'module::submodule::item', e.g.: serde::Serialize, tokio::runtime::Runtime, std::collections::HashMap"
52 )]
53 pub item_path: String,
54
55 #[json_schema(
57 title = "Version",
58 description = "Crate version. Uses latest version if not specified"
59 )]
60 pub version: Option<String>,
61
62 #[json_schema(
64 title = "Output Format",
65 description = "Output format: markdown (default), text (plain text), html",
66 default = "markdown"
67 )]
68 pub format: Option<String>,
69}
70
71pub struct LookupItemToolImpl {
76 service: Arc<DocService>,
78}
79
80enum AllHtmlMemo {
87 Unfetched,
88 Fetched(Option<String>),
89}
90
91impl LookupItemToolImpl {
92 #[must_use]
94 pub fn new(service: Arc<DocService>) -> Self {
95 Self { service }
96 }
97
98 fn build_search_url(crate_name: &str, item_path: &str, version: Option<&str>) -> String {
100 super::build_docs_item_url(crate_name, version, item_path)
101 }
102
103 async fn fetch_item_html(
104 &self,
105 crate_name: &str,
106 item_path: &str,
107 version: Option<&str>,
108 ) -> std::result::Result<String, CallToolError> {
109 if let Some(cached) = self
110 .service
111 .doc_cache()
112 .get_item_html(crate_name, item_path, version)
113 .await
114 {
115 return Ok(cached.to_string());
116 }
117
118 let html = self
119 .resolve_item_html(crate_name, item_path, version)
120 .await?;
121
122 if let Err(e) = self
125 .service
126 .doc_cache()
127 .set_item_html(crate_name, item_path, version, html.clone())
128 .await
129 {
130 tracing::warn!("[{TOOL_NAME}] failed to cache item HTML (continuing uncached): {e}");
131 }
132
133 Ok(html)
134 }
135
136 async fn resolve_item_html(
145 &self,
146 crate_name: &str,
147 item_path: &str,
148 version: Option<&str>,
149 ) -> std::result::Result<String, CallToolError> {
150 let mut all_html_memo = AllHtmlMemo::Unfetched;
155 if let Some(html) = self
156 .try_resolve_item_path(crate_name, item_path, version, &mut all_html_memo)
157 .await?
158 {
159 return Ok(html);
160 }
161
162 if let Some((parent, _member)) = item_path.rsplit_once("::") {
168 let parent = parent.trim();
169 if !parent.is_empty() {
170 if let Some(html) = self
171 .try_resolve_item_path(crate_name, parent, version, &mut all_html_memo)
172 .await?
173 {
174 return Ok(html);
175 }
176 }
177 }
178
179 let url = Self::build_search_url(crate_name, item_path, version);
181 self.service.fetch_html(&url, Some(TOOL_NAME)).await
182 }
183
184 async fn try_resolve_item_path(
188 &self,
189 crate_name: &str,
190 item_path: &str,
191 version: Option<&str>,
192 all_html_memo: &mut AllHtmlMemo,
193 ) -> std::result::Result<Option<String>, CallToolError> {
194 let candidates = super::build_docs_item_url_candidates(crate_name, version, item_path);
195 for url in candidates {
196 if let Some(html) = self
197 .service
198 .fetch_html_optional(&url, Some(TOOL_NAME))
199 .await?
200 {
201 return Ok(Some(html));
202 }
203 }
204
205 let item_name = item_path.rsplit("::").next().unwrap_or(item_path).trim();
209 if !item_name.is_empty() {
210 if matches!(all_html_memo, AllHtmlMemo::Unfetched) {
215 let all_url = super::build_docs_all_items_url(crate_name, version);
216 let fetched = self
220 .service
221 .fetch_html_optional(&all_url, Some(TOOL_NAME))
222 .await?;
223 *all_html_memo = AllHtmlMemo::Fetched(fetched);
224 }
225 let item_url = {
228 let all_html = match &*all_html_memo {
229 AllHtmlMemo::Fetched(html) => html.as_deref(),
230 AllHtmlMemo::Unfetched => None,
231 };
232 all_html.and_then(|html| {
233 super::find_item_url_in_all_html(crate_name, version, html, item_name)
234 })
235 };
236 if let Some(item_url) = item_url {
237 let resolved = self
238 .service
239 .fetch_html_optional(&item_url, Some(TOOL_NAME))
240 .await?;
241 if let Some(html) = resolved {
242 return Ok(Some(html));
243 }
244 }
245 }
246
247 Ok(None)
248 }
249
250 async fn fetch_item_docs(
255 &self,
256 crate_name: &str,
257 item_path: &str,
258 version: Option<&str>,
259 ) -> std::result::Result<Arc<str>, CallToolError> {
260 if let Some(cached) = self
262 .service
263 .doc_cache()
264 .get_item_docs(crate_name, item_path, version)
265 .await
266 {
267 return Ok(cached);
268 }
269
270 let html = self.fetch_item_html(crate_name, item_path, version).await?;
271
272 let docs: Arc<str> =
274 Arc::from(html::extract_search_results(&html, item_path).into_boxed_str());
275
276 if let Err(e) = self
280 .service
281 .doc_cache()
282 .set_item_docs(crate_name, item_path, version, docs.to_string())
283 .await
284 {
285 tracing::warn!("[{TOOL_NAME}] failed to cache item docs (continuing uncached): {e}");
286 }
287
288 Ok(docs)
289 }
290
291 async fn fetch_item_docs_as_text(
293 &self,
294 crate_name: &str,
295 item_path: &str,
296 version: Option<&str>,
297 ) -> std::result::Result<String, CallToolError> {
298 let html = self.fetch_item_html(crate_name, item_path, version).await?;
299 let body = html::extract_documentation_as_text(&html);
300 let note = if html::is_item_fallback_page(&html, item_path) {
305 format!(
306 "No dedicated documentation page was found for '{item_path}'; showing the closest available page (its containing type or the crate overview) instead. It may be a method, associated item, or trait method, or it may not exist.\n\n"
307 )
308 } else {
309 String::new()
310 };
311 Ok(format!("Documentation: {item_path}\n\n{note}{body}"))
312 }
313
314 async fn fetch_item_docs_as_html(
316 &self,
317 crate_name: &str,
318 item_path: &str,
319 version: Option<&str>,
320 ) -> std::result::Result<String, CallToolError> {
321 let html = self.fetch_item_html(crate_name, item_path, version).await?;
322 let body = html::extract_documentation_html(&html);
323 if html::is_item_fallback_page(&html, item_path) {
328 let safe_path = item_path
331 .replace('&', "&")
332 .replace('<', "<")
333 .replace('>', ">");
334 return Ok(format!(
335 "<p><em>No dedicated documentation page was found for '{safe_path}'; showing the closest available page (its containing type or the crate overview) instead. It may be a method, associated item, or trait method, or it may not exist.</em></p>\n{body}"
336 ));
337 }
338 Ok(body)
339 }
340}
341
342#[async_trait]
343impl Tool for LookupItemToolImpl {
344 fn definition(&self) -> rust_mcp_sdk::schema::Tool {
345 LookupItemTool::tool()
346 }
347
348 async fn execute(
349 &self,
350 arguments: serde_json::Value,
351 ) -> std::result::Result<
352 rust_mcp_sdk::schema::CallToolResult,
353 rust_mcp_sdk::schema::CallToolError,
354 > {
355 let mut params: LookupItemTool = serde_json::from_value(arguments).map_err(|e| {
356 rust_mcp_sdk::schema::CallToolError::invalid_arguments(
357 "lookup_item",
358 Some(format!("Parameter parsing failed: {e}")),
359 )
360 })?;
361
362 super::validate_crate_name(TOOL_NAME, ¶ms.crate_name)?;
363 super::validate_version(TOOL_NAME, params.version.as_deref())?;
364 super::validate_item_path(TOOL_NAME, ¶ms.item_path)?;
365 params.crate_name = params.crate_name.trim().to_string();
368 if let Some(version) = params.version.as_mut() {
369 *version = super::normalize_version(version);
370 }
371 params.item_path = params.item_path.trim().to_string();
372
373 let format = super::parse_format(TOOL_NAME, params.format.as_deref(), super::DOC_FORMATS)?;
377 let content = match format {
378 super::Format::Text => {
379 self.fetch_item_docs_as_text(
380 ¶ms.crate_name,
381 ¶ms.item_path,
382 params.version.as_deref(),
383 )
384 .await?
385 }
386 super::Format::Html => {
387 self.fetch_item_docs_as_html(
388 ¶ms.crate_name,
389 ¶ms.item_path,
390 params.version.as_deref(),
391 )
392 .await?
393 }
394 super::Format::Json => {
395 return Err(rust_mcp_sdk::schema::CallToolError::invalid_arguments(
396 "lookup_item",
397 Some(
398 "Invalid format 'json'. This tool supports: markdown, text, html"
399 .to_string(),
400 ),
401 ))
402 }
403 super::Format::Markdown => self
404 .fetch_item_docs(
405 ¶ms.crate_name,
406 ¶ms.item_path,
407 params.version.as_deref(),
408 )
409 .await
410 .map(|arc| arc.to_string())?,
411 };
412
413 Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
414 content.into(),
415 ]))
416 }
417}
418
419impl Default for LookupItemToolImpl {
420 fn default() -> Self {
421 Self::new(Arc::new(super::DocService::default()))
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use serial_test::serial;
429
430 #[test]
431 #[serial]
432 fn test_build_search_url_without_version() {
433 std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
434 let url = LookupItemToolImpl::build_search_url("serde", "Serialize", None);
435 assert_eq!(url, "https://docs.rs/serde/?search=Serialize");
436 std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
437 }
438
439 #[test]
440 #[serial]
441 fn test_build_search_url_with_version() {
442 std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
443 let url = LookupItemToolImpl::build_search_url("serde", "Serialize", Some("1.0.0"));
444 assert_eq!(url, "https://docs.rs/serde/1.0.0/?search=Serialize");
445 std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
446 }
447
448 #[test]
449 #[serial]
450 fn test_build_search_url_encodes_special_chars() {
451 std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
452 let url = LookupItemToolImpl::build_search_url("std", "collections::HashMap", None);
453 assert!(url.contains("collections%3A%3AHashMap"));
454 std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
455 }
456}