Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 49 additions & 19 deletions src/tools/docs/docs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,52 @@ impl DocRouter {
}
}

fn url_for_crate(&self, crate_name: &str, version: &Option<String>) -> String {
if crate_name == "std" {
if let Some(ver) = version {
format!("https://doc.rust-lang.org/{}/std/", ver)
} else {
format!("https://doc.rust-lang.org/stable/std/")
}
} else {
if let Some(ver) = version {
format!("https://docs.rs/crate/{}/{}/", crate_name, ver)
} else {
format!("https://docs.rs/crate/{}/", crate_name)
}
}
}

fn url_for_item(
&self,
crate_name: &str,
version: &Option<String>,
module_path: &str,
item_type: &str,
item_name: &str,
) -> String {
let mut url = self
.url_for_crate(crate_name, version)
.replace("crate/", "");

if crate_name != "std" {
if let Some(ver) = version {
url.push_str(&format!("{}/{}/", ver, crate_name));
} else {
url.push_str(&format!("latest/{}/", crate_name));
}
}

let append = if module_path.is_empty() {
format!("{}.{}.html", item_type, item_name)
} else {
format!("{}/{}.{}.html", module_path, item_type, item_name)
};

url.push_str(&append);
url
}

// Fetch crate documentation from docs.rs
async fn lookup_crate(&self, crate_name: String, version: Option<String>) -> Result<String, ToolError> {
// Check cache first
Expand All @@ -76,11 +122,7 @@ impl DocRouter {
}

// Construct the docs.rs URL for the crate
let url = if let Some(ver) = version {
format!("https://docs.rs/crate/{}/{}/", crate_name, ver)
} else {
format!("https://docs.rs/crate/{}/", crate_name)
};
let url = self.url_for_crate(&crate_name, &version);

// Fetch the documentation page
let response = self.client.get(&url)
Expand Down Expand Up @@ -189,20 +231,8 @@ impl DocRouter {

for item_type in item_types.iter() {
// Construct the docs.rs URL for the specific item
let url = if let Some(ver) = version.clone() {
if module_path.is_empty() {
format!("https://docs.rs/{}/{}/{}/{}.{}.html", crate_name, ver, crate_name, item_type, item_name)
} else {
format!("https://docs.rs/{}/{}/{}/{}/{}.{}.html", crate_name, ver, crate_name, module_path, item_type, item_name)
}
} else {
if module_path.is_empty() {
format!("https://docs.rs/{}/latest/{}/{}.{}.html", crate_name, crate_name, item_type, item_name)
} else {
format!("https://docs.rs/{}/latest/{}/{}/{}.{}.html", crate_name, crate_name, module_path, item_type, item_name)
}
};

let url = self.url_for_item(&crate_name, &version, &module_path, item_type, &item_name);

// Try to fetch the documentation page
let response = match self.client.get(&url)
.header("User-Agent", "CrateDocs/0.1.0 (https://github.com/d6e/cratedocs-mcp)")
Expand Down
101 changes: 97 additions & 4 deletions tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,106 @@ async fn test_end_to_end_crate_lookup() {
assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);
// The response should be HTML from docs.rs

// The response should be Markdown converted from docs.rs
match &content[0] {
mcp_core::Content::Text(text) => {
assert!(text.text.contains("<!DOCTYPE html>"));
assert!(text.text.contains("[Docs.rs ](/)"));
assert!(text.text.contains("serde"));
},
}
_ => panic!("Expected text content"),
}
}

// This test requires network access
#[tokio::test]
#[ignore = "Requires network access"]
async fn test_end_to_end_std_lookup() {
let router = DocRouter::new();

// Look up standard library as a crate
let result = router
.call_tool(
"lookup_crate",
json!({
"crate_name": "std"
}),
)
.await;

assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);

// The response should be Markdown converted from docs.rs
match &content[0] {
mcp_core::Content::Text(text) => {
assert!(text.text.contains("The Rust Standard Library"));
}
_ => panic!("Expected text content"),
}
}

// This test requires network access
#[tokio::test]
#[ignore = "Requires network access"]
async fn test_end_to_end_std_item_lookup() {
let router = DocRouter::new();

// Look up standard library as a crate
let result = router
.call_tool(
"lookup_item",
json!({
"crate_name": "std",
"item_path": "sync::mpsc::Sender"
}),
)
.await;

assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);

// The response should be Markdown converted from docs.rs
match &content[0] {
mcp_core::Content::Text(text) => {
assert!(text.text.contains(
r"The sending-half of Rust’s asynchronous [`channel`](fn.channel.html) type."
));
}
_ => panic!("Expected text content"),
}
}

// This test requires network access
#[tokio::test]
#[ignore = "Requires network access"]
async fn test_end_to_end_item_lookup() {
let router = DocRouter::new();

// Look up standard library as a crate
let result = router
.call_tool(
"lookup_item",
json!({
"crate_name": "tokio",
"item_path": "sync::mpsc::Sender"
}),
)
.await;

assert!(result.is_ok());
let content = result.unwrap();
assert_eq!(content.len(), 1);

// The response should be Markdown converted from docs.rs
match &content[0] {
mcp_core::Content::Text(text) => {
assert!(text
.text
.contains(r"### impl\<T\> [Sender](struct.Sender.html)\<T\> ###"));
}
_ => panic!("Expected text content"),
}
}
Expand Down