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 = "查找 Crate 项目文档",
16 description = "从 docs.rs 获取 Rust crate 中特定项目(函数、结构体、trait、模块等)的文档。适用于查找特定 API 的详细用法和签名。支持搜索路径如 serde::Serialize、std::collections::HashMap 等。",
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 名称",
33 description = "要查找的 Crate name,例如:serde、tokio、std"
34 )]
35 pub crate_name: String,
36
37 #[json_schema(
39 title = "项目路径",
40 description = "要查找的项目路径,格式为 '模块::子模块::项目名'。例如:serde::Serialize、tokio::runtime::Runtime、std::collections::HashMap"
41 )]
42 pub item_path: String,
43
44 #[json_schema(
46 title = "版本号",
47 description = "指定 crate 版本号。不指定则使用最新版本"
48 )]
49 pub version: Option<String>,
50
51 #[json_schema(
53 title = "输出格式",
54 description = "Documentation 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 if !response.status().is_success() {
92 return Err(CallToolError::from_message(format!(
93 "Failed to get item documentation: HTTP {} - {}",
94 response.status(),
95 response.text().await.unwrap_or_default()
96 )));
97 }
98
99 response
100 .text()
101 .await
102 .map_err(|e| CallToolError::from_message(format!("Failed to read response: {e}")))
103 }
104
105 async fn fetch_item_docs(
107 &self,
108 crate_name: &str,
109 item_path: &str,
110 version: Option<&str>,
111 ) -> std::result::Result<String, CallToolError> {
112 if let Some(cached) = self
114 .service
115 .doc_cache()
116 .get_item_docs(crate_name, item_path, version)
117 .await
118 {
119 return Ok(cached);
120 }
121
122 let url = Self::build_search_url(crate_name, item_path, version);
124 let html = self.fetch_html(&url).await?;
125
126 let docs = html::extract_search_results(&html, item_path);
128
129 self.service
131 .doc_cache()
132 .set_item_docs(crate_name, item_path, version, docs.clone())
133 .await
134 .map_err(|e| CallToolError::from_message(format!("Cache set failed: {e}")))?;
135
136 Ok(docs)
137 }
138
139 async fn fetch_item_docs_as_text(
141 &self,
142 crate_name: &str,
143 item_path: &str,
144 version: Option<&str>,
145 ) -> std::result::Result<String, CallToolError> {
146 let url = Self::build_search_url(crate_name, item_path, version);
147 let html = self.fetch_html(&url).await?;
148 Ok(format!(
149 "搜索结果: {}\n\n{}",
150 item_path,
151 html::html_to_text(&html)
152 ))
153 }
154
155 async fn fetch_item_docs_as_html(
157 &self,
158 crate_name: &str,
159 item_path: &str,
160 version: Option<&str>,
161 ) -> std::result::Result<String, CallToolError> {
162 let url = Self::build_search_url(crate_name, item_path, version);
163 self.fetch_html(&url).await
164 }
165}
166
167#[async_trait]
168impl Tool for LookupItemToolImpl {
169 fn definition(&self) -> rust_mcp_sdk::schema::Tool {
170 LookupItemTool::tool()
171 }
172
173 async fn execute(
174 &self,
175 arguments: serde_json::Value,
176 ) -> std::result::Result<
177 rust_mcp_sdk::schema::CallToolResult,
178 rust_mcp_sdk::schema::CallToolError,
179 > {
180 let params: LookupItemTool = serde_json::from_value(arguments).map_err(|e| {
181 rust_mcp_sdk::schema::CallToolError::invalid_arguments(
182 "lookup_item",
183 Some(format!("Parameter parsing failed: {e}")),
184 )
185 })?;
186
187 let format = params.format.as_deref().unwrap_or("markdown");
188 let content = match format {
189 "text" => {
190 self.fetch_item_docs_as_text(
191 ¶ms.crate_name,
192 ¶ms.item_path,
193 params.version.as_deref(),
194 )
195 .await?
196 }
197 "html" => {
198 self.fetch_item_docs_as_html(
199 ¶ms.crate_name,
200 ¶ms.item_path,
201 params.version.as_deref(),
202 )
203 .await?
204 }
205 _ => {
206 self.fetch_item_docs(
208 ¶ms.crate_name,
209 ¶ms.item_path,
210 params.version.as_deref(),
211 )
212 .await?
213 }
214 };
215
216 Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
217 content.into(),
218 ]))
219 }
220}
221
222impl Default for LookupItemToolImpl {
223 fn default() -> Self {
224 Self::new(Arc::new(super::DocService::default()))
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn test_build_search_url_without_version() {
234 let url = LookupItemToolImpl::build_search_url("serde", "Serialize", None);
235 assert_eq!(url, "https://docs.rs/serde/?search=Serialize");
236 }
237
238 #[test]
239 fn test_build_search_url_with_version() {
240 let url = LookupItemToolImpl::build_search_url("serde", "Serialize", Some("1.0.0"));
241 assert_eq!(url, "https://docs.rs/serde/1.0.0/?search=Serialize");
242 }
243
244 #[test]
245 fn test_build_search_url_encodes_special_chars() {
246 let url = LookupItemToolImpl::build_search_url("std", "collections::HashMap", None);
247 assert!(url.contains("collections%3A%3AHashMap"));
248 }
249}