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
12#[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 #[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 #[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 #[json_schema(
46 title = "Version",
47 description = "Crate version. Uses latest version if not specified"
48 )]
49 pub version: Option<String>,
50
51 #[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
60pub struct LookupItemToolImpl {
62 service: Arc<DocService>,
63}
64
65impl LookupItemToolImpl {
66 #[must_use]
68 pub fn new(service: Arc<DocService>) -> Self {
69 Self { service }
70 }
71
72 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 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 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 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 let url = Self::build_search_url(crate_name, item_path, version);
132 let html = self.fetch_html(&url).await?;
133
134 let docs = html::extract_search_results(&html, item_path);
136
137 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 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 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 ¶ms.crate_name,
200 ¶ms.item_path,
201 params.version.as_deref(),
202 )
203 .await?
204 }
205 "html" => {
206 self.fetch_item_docs_as_html(
207 ¶ms.crate_name,
208 ¶ms.item_path,
209 params.version.as_deref(),
210 )
211 .await?
212 }
213 _ => {
214 self.fetch_item_docs(
216 ¶ms.crate_name,
217 ¶ms.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}