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