#![allow(missing_docs)]
use crate::tools::docs::html;
use crate::tools::docs::DocService;
use crate::tools::Tool;
use async_trait::async_trait;
use rust_mcp_sdk::schema::CallToolError;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
const TOOL_NAME: &str = "lookup_item";
#[rust_mcp_sdk::macros::mcp_tool(
name = "lookup_item",
title = "Lookup Item Documentation",
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.",
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false,
read_only_hint = true,
icons = [
(src = "https://docs.rs/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "light"),
(src = "https://docs.rs/favicon.ico", mime_type = "image/x-icon", sizes = ["32x32"], theme = "dark")
]
)]
#[derive(Debug, Clone, Deserialize, Serialize, rust_mcp_sdk::macros::JsonSchema)]
pub struct LookupItemTool {
#[json_schema(
title = "Crate Name",
description = "Crate name to lookup, e.g.: serde, tokio, std"
)]
pub crate_name: String,
#[json_schema(
title = "Item Path",
description = "Item path in format 'module::submodule::item', e.g.: serde::Serialize, tokio::runtime::Runtime, std::collections::HashMap"
)]
pub item_path: String,
#[json_schema(
title = "Version",
description = "Crate version. Uses latest version if not specified"
)]
pub version: Option<String>,
#[json_schema(
title = "Output Format",
description = "Output format: markdown (default), text (plain text), html",
default = "markdown"
)]
pub format: Option<String>,
}
pub struct LookupItemToolImpl {
service: Arc<DocService>,
}
enum AllHtmlMemo {
Unfetched,
Fetched(Option<String>),
}
impl LookupItemToolImpl {
#[must_use]
pub fn new(service: Arc<DocService>) -> Self {
Self { service }
}
fn build_search_url(crate_name: &str, item_path: &str, version: Option<&str>) -> String {
super::build_docs_item_url(crate_name, version, item_path)
}
async fn fetch_item_html(
&self,
crate_name: &str,
item_path: &str,
version: Option<&str>,
) -> std::result::Result<String, CallToolError> {
if let Some(cached) = self
.service
.doc_cache()
.get_item_html(crate_name, item_path, version)
.await
{
return Ok(cached.to_string());
}
let html = self
.resolve_item_html(crate_name, item_path, version)
.await?;
if let Err(e) = self
.service
.doc_cache()
.set_item_html(crate_name, item_path, version, html.clone())
.await
{
tracing::warn!("[{TOOL_NAME}] failed to cache item HTML (continuing uncached): {e}");
}
Ok(html)
}
async fn resolve_item_html(
&self,
crate_name: &str,
item_path: &str,
version: Option<&str>,
) -> std::result::Result<String, CallToolError> {
let mut all_html_memo = AllHtmlMemo::Unfetched;
if let Some(html) = self
.try_resolve_item_path(crate_name, item_path, version, &mut all_html_memo)
.await?
{
return Ok(html);
}
if let Some((parent, _member)) = item_path.rsplit_once("::") {
let parent = parent.trim();
if !parent.is_empty() {
if let Some(html) = self
.try_resolve_item_path(crate_name, parent, version, &mut all_html_memo)
.await?
{
return Ok(html);
}
}
}
let url = Self::build_search_url(crate_name, item_path, version);
self.service.fetch_html(&url, Some(TOOL_NAME)).await
}
async fn try_resolve_item_path(
&self,
crate_name: &str,
item_path: &str,
version: Option<&str>,
all_html_memo: &mut AllHtmlMemo,
) -> std::result::Result<Option<String>, CallToolError> {
let candidates = super::build_docs_item_url_candidates(crate_name, version, item_path);
for url in candidates {
if let Some(html) = self
.service
.fetch_html_optional(&url, Some(TOOL_NAME))
.await?
{
return Ok(Some(html));
}
}
let item_name = item_path.rsplit("::").next().unwrap_or(item_path).trim();
if !item_name.is_empty() {
if matches!(all_html_memo, AllHtmlMemo::Unfetched) {
let all_url = super::build_docs_all_items_url(crate_name, version);
let fetched = self
.service
.fetch_html_optional(&all_url, Some(TOOL_NAME))
.await?;
*all_html_memo = AllHtmlMemo::Fetched(fetched);
}
let item_url = {
let all_html = match &*all_html_memo {
AllHtmlMemo::Fetched(html) => html.as_deref(),
AllHtmlMemo::Unfetched => None,
};
all_html.and_then(|html| {
super::find_item_url_in_all_html(crate_name, version, html, item_name)
})
};
if let Some(item_url) = item_url {
let resolved = self
.service
.fetch_html_optional(&item_url, Some(TOOL_NAME))
.await?;
if let Some(html) = resolved {
return Ok(Some(html));
}
}
}
Ok(None)
}
async fn fetch_item_docs(
&self,
crate_name: &str,
item_path: &str,
version: Option<&str>,
) -> std::result::Result<Arc<str>, CallToolError> {
if let Some(cached) = self
.service
.doc_cache()
.get_item_docs(crate_name, item_path, version)
.await
{
return Ok(cached);
}
let html = self.fetch_item_html(crate_name, item_path, version).await?;
let docs: Arc<str> =
Arc::from(html::extract_search_results(&html, item_path).into_boxed_str());
if let Err(e) = self
.service
.doc_cache()
.set_item_docs(crate_name, item_path, version, docs.to_string())
.await
{
tracing::warn!("[{TOOL_NAME}] failed to cache item docs (continuing uncached): {e}");
}
Ok(docs)
}
async fn fetch_item_docs_as_text(
&self,
crate_name: &str,
item_path: &str,
version: Option<&str>,
) -> std::result::Result<String, CallToolError> {
let html = self.fetch_item_html(crate_name, item_path, version).await?;
let body = html::extract_documentation_as_text(&html);
let note = if html::is_item_fallback_page(&html, item_path) {
format!(
"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"
)
} else {
String::new()
};
Ok(format!("Documentation: {item_path}\n\n{note}{body}"))
}
async fn fetch_item_docs_as_html(
&self,
crate_name: &str,
item_path: &str,
version: Option<&str>,
) -> std::result::Result<String, CallToolError> {
let html = self.fetch_item_html(crate_name, item_path, version).await?;
let body = html::extract_documentation_html(&html);
if html::is_item_fallback_page(&html, item_path) {
let safe_path = item_path
.replace('&', "&")
.replace('<', "<")
.replace('>', ">");
return Ok(format!(
"<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}"
));
}
Ok(body)
}
}
#[async_trait]
impl Tool for LookupItemToolImpl {
fn definition(&self) -> rust_mcp_sdk::schema::Tool {
LookupItemTool::tool()
}
async fn execute(
&self,
arguments: serde_json::Value,
) -> std::result::Result<
rust_mcp_sdk::schema::CallToolResult,
rust_mcp_sdk::schema::CallToolError,
> {
let mut params: LookupItemTool = serde_json::from_value(arguments).map_err(|e| {
rust_mcp_sdk::schema::CallToolError::invalid_arguments(
"lookup_item",
Some(format!("Parameter parsing failed: {e}")),
)
})?;
super::validate_crate_name(TOOL_NAME, ¶ms.crate_name)?;
super::validate_version(TOOL_NAME, params.version.as_deref())?;
super::validate_item_path(TOOL_NAME, ¶ms.item_path)?;
params.crate_name = params.crate_name.trim().to_string();
if let Some(version) = params.version.as_mut() {
*version = super::normalize_version(version);
}
params.item_path = params.item_path.trim().to_string();
let format = super::parse_format(TOOL_NAME, params.format.as_deref(), super::DOC_FORMATS)?;
let content = match format {
super::Format::Text => {
self.fetch_item_docs_as_text(
¶ms.crate_name,
¶ms.item_path,
params.version.as_deref(),
)
.await?
}
super::Format::Html => {
self.fetch_item_docs_as_html(
¶ms.crate_name,
¶ms.item_path,
params.version.as_deref(),
)
.await?
}
super::Format::Json => {
return Err(rust_mcp_sdk::schema::CallToolError::invalid_arguments(
"lookup_item",
Some(
"Invalid format 'json'. This tool supports: markdown, text, html"
.to_string(),
),
))
}
super::Format::Markdown => self
.fetch_item_docs(
¶ms.crate_name,
¶ms.item_path,
params.version.as_deref(),
)
.await
.map(|arc| arc.to_string())?,
};
Ok(rust_mcp_sdk::schema::CallToolResult::text_content(vec![
content.into(),
]))
}
}
impl Default for LookupItemToolImpl {
fn default() -> Self {
Self::new(Arc::new(super::DocService::default()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
#[test]
#[serial]
fn test_build_search_url_without_version() {
std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
let url = LookupItemToolImpl::build_search_url("serde", "Serialize", None);
assert_eq!(url, "https://docs.rs/serde/?search=Serialize");
std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
}
#[test]
#[serial]
fn test_build_search_url_with_version() {
std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
let url = LookupItemToolImpl::build_search_url("serde", "Serialize", Some("1.0.0"));
assert_eq!(url, "https://docs.rs/serde/1.0.0/?search=Serialize");
std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
}
#[test]
#[serial]
fn test_build_search_url_encodes_special_chars() {
std::env::set_var("CRATES_DOCS_DOCS_RS_URL", "https://docs.rs");
let url = LookupItemToolImpl::build_search_url("std", "collections::HashMap", None);
assert!(url.contains("collections%3A%3AHashMap"));
std::env::remove_var("CRATES_DOCS_DOCS_RS_URL");
}
}