This commit is contained in:
Danielle Jenkins 2025-03-06 22:49:18 -08:00
commit dcf78edfca
12 changed files with 1158 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Generated by Cargo
/target/
# Log files
logs/
*.log
# Environment variables file
.env
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Editor directories and files
.idea/
.vscode/
*.swp
*.swo

48
Cargo.toml Normal file
View file

@ -0,0 +1,48 @@
[package]
name = "rust-doc-server"
version = "0.1.0"
edition = "2021"
description = "Rust Documentation MCP Server for LLM crate assistance"
authors = ["Your Name <your.email@example.com>"]
license = "MIT"
repository = "https://github.com/yourusername/rust-doc-server"
[workspace]
members = [
".",
]
[dependencies]
# MCP dependencies from crates.io
mcp-server = "1.0.7"
mcp-core = "1.0.7"
mcp-macros = "1.0.7"
# HTTP and networking
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
axum = { version = "0.8", features = ["macros"] }
tokio-util = { version = "0.7", features = ["io", "codec"]}
# Serialization and data formats
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Logging and tracing
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
# Utilities
anyhow = "1.0"
futures = "0.3"
rand = "0.8"
# For examples
[[bin]]
name = "doc-server"
path = "src/bin/doc_server.rs"
[[bin]]
name = "axum-docs"
path = "src/bin/axum_docs.rs"

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Rust Documentation MCP Server
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

116
README.md Normal file
View file

@ -0,0 +1,116 @@
# Rust Documentation MCP Server
This is an MCP (Model Context Protocol) server that provides tools for Rust crate documentation lookup. It allows LLMs to look up documentation for Rust crates they are unfamiliar with.
## Features
- Lookup crate documentation: Get general documentation for a Rust crate
- Search crates: Search for crates on crates.io based on keywords
- Lookup item documentation: Get documentation for a specific item (e.g., struct, function, trait) within a crate
## Installation
```bash
git clone https://github.com/yourusername/rust-doc-server.git
cd rust-doc-server
cargo build --release
```
## Running the Server
There are two ways to run the documentation server:
### STDIN/STDOUT Mode
This mode is useful for integrating with LLM clients that communicate via standard input/output:
```bash
cargo run --bin doc-server
```
### HTTP/SSE Mode
This mode exposes an HTTP endpoint that uses Server-Sent Events (SSE) for communication:
```bash
cargo run --bin axum-docs
```
By default, the server will listen on `http://127.0.0.1:8080/sse`.
## Available Tools
The server provides the following tools:
### 1. `lookup_crate`
Retrieves documentation for a specified Rust crate.
Parameters:
- `crate_name` (required): The name of the crate to look up
- `version` (optional): The version of the crate (defaults to latest)
Example:
```json
{
"name": "lookup_crate",
"arguments": {
"crate_name": "tokio",
"version": "1.28.0"
}
}
```
### 2. `search_crates`
Searches for Rust crates on crates.io.
Parameters:
- `query` (required): The search query
- `limit` (optional): Maximum number of results to return (defaults to 10, max 100)
Example:
```json
{
"name": "search_crates",
"arguments": {
"query": "async runtime",
"limit": 5
}
}
```
### 3. `lookup_item`
Retrieves documentation for a specific item in a crate.
Parameters:
- `crate_name` (required): The name of the crate
- `item_path` (required): Path to the item (e.g., 'std::vec::Vec')
- `version` (optional): The version of the crate (defaults to latest)
Example:
```json
{
"name": "lookup_item",
"arguments": {
"crate_name": "serde",
"item_path": "serde::Deserialize",
"version": "1.0.160"
}
}
```
## Implementation Notes
- The server includes a caching mechanism to prevent redundant API calls for the same documentation
- It interfaces with docs.rs for crate documentation and crates.io for search functionality
- Results are returned as plain text/HTML content that can be parsed and presented by the client
## MCP Protocol Integration
This server implements the Model Context Protocol (MCP) which allows it to be easily integrated with LLM clients that support the protocol. For more information about MCP, visit [the MCP repository](https://github.com/modelcontextprotocol/mcp).
## License
MIT License

138
docs/development.md Normal file
View file

@ -0,0 +1,138 @@
# Development Guide
This guide provides information for developers who want to contribute to or modify the Rust Documentation MCP Server.
## Architecture Overview
The server consists of several key components:
1. **DocRouter** (`src/docs.rs`):
- Core implementation of the MCP Router trait
- Handles tool calls for documentation lookup
- Implements caching to avoid redundant API requests
2. **Transport Implementations**:
- STDIN/STDOUT server (`src/bin/doc_server.rs`)
- HTTP/SSE server (`src/bin/axum_docs.rs`)
3. **Utilities**:
- JSON-RPC frame codec for byte stream handling
## Adding New Features
### Adding a New Tool
To add a new tool to the documentation server:
1. Add the implementation function in `DocRouter` struct
2. Add the tool definition to the `list_tools()` method
3. Add the tool handler in the `call_tool()` match statement
Example:
```rust
// 1. Add the implementation function
async fn get_crate_examples(&self, crate_name: String, limit: Option<u32>) -> Result<String, ToolError> {
// Implementation details...
}
// 2. In list_tools() add:
Tool::new(
"get_crate_examples".to_string(),
"Get usage examples for a Rust crate".to_string(),
json!({
"type": "object",
"properties": {
"crate_name": {
"type": "string",
"description": "The name of the crate"
},
"limit": {
"type": "integer",
"description": "Maximum number of examples to return"
}
},
"required": ["crate_name"]
}),
),
// 3. In call_tool() match statement:
"get_crate_examples" => {
let crate_name = arguments
.get("crate_name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("crate_name is required".to_string()))?
.to_string();
let limit = arguments
.get("limit")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let examples = this.get_crate_examples(crate_name, limit).await?;
Ok(vec![Content::text(examples)])
}
```
### Enhancing the Cache
The current cache implementation is basic. To enhance it:
1. Add TTL (Time-To-Live) for cache entries
2. Add cache size limits to prevent memory issues
3. Consider using a more sophisticated caching library
## Testing
Create test files that implement basic tests for the server:
```rust
#[cfg(test)]
mod tests {
use super::*;
use tokio::test;
#[test]
async fn test_search_crates() {
let router = DocRouter::new();
let result = router.search_crates("tokio".to_string(), Some(2)).await;
assert!(result.is_ok());
let data = result.unwrap();
assert!(data.contains("crates"));
}
#[test]
async fn test_lookup_crate() {
let router = DocRouter::new();
let result = router.lookup_crate("serde".to_string(), None).await;
assert!(result.is_ok());
let data = result.unwrap();
assert!(data.contains("serde"));
}
}
```
## Deployment
For production deployment, consider:
1. Rate limiting to prevent abuse
2. Authentication for sensitive documentation
3. HTTPS for secure communication
4. Docker containerization for easier deployment
Example Dockerfile:
```dockerfile
FROM rust:1.74-slim as builder
WORKDIR /usr/src/app
COPY . .
RUN cargo build --release
FROM debian:stable-slim
RUN apt-get update && apt-get install -y libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/src/app/target/release/axum-docs /usr/local/bin/
EXPOSE 8080
CMD ["axum-docs"]
```

107
docs/usage.md Normal file
View file

@ -0,0 +1,107 @@
# Rust Documentation Server Usage Guide
This guide explains how to use the Rust Documentation MCP Server with different types of clients.
## Client Integration
### Using with MCP-compatible LLMs
Any LLM client that follows the Model Context Protocol (MCP) can connect to this documentation server. The LLM will gain the ability to:
1. Look up documentation for any Rust crate
2. Search the crates.io registry for libraries
3. Get documentation for specific items within crates
### Command-Line Client
For testing purposes, you can use a simple command-line client like this one:
```rust
use mcp_client::{Client, transport::StdioTransport};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Create a client using stdio transport
let transport = StdioTransport::new();
let mut client = Client::new(transport);
// Connect to the server
client.connect().await?;
// Example: Looking up the 'tokio' crate
let response = client.call_tool(
"lookup_crate",
serde_json::json!({
"crate_name": "tokio"
})
).await?;
println!("Documentation response: {}", response[0].text());
Ok(())
}
```
### Web Client
When using the Axum SSE mode, you can connect to the server using a simple web client:
```javascript
// Connect to the SSE endpoint
const eventSource = new EventSource('http://127.0.0.1:8080/sse');
// Get the session ID from the initial connection
let sessionId;
eventSource.addEventListener('endpoint', (event) => {
sessionId = event.data.split('=')[1];
console.log(`Connected with session ID: ${sessionId}`);
});
// Handle messages from the server
eventSource.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
console.log('Received response:', data);
});
// Function to send a tool request
async function callTool(toolName, args) {
const response = await fetch(`http://127.0.0.1:8080/sse?sessionId=${sessionId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call_tool',
params: {
name: toolName,
arguments: args
},
id: 1,
}),
});
return response.ok;
}
// Example: Search for async crates
callTool('search_crates', { query: 'async runtime', limit: 5 });
```
## Example Workflows
### Helping an LLM Understand a New Crate
1. LLM client connects to the documentation server
2. User asks a question involving an unfamiliar crate
3. LLM uses `lookup_crate` to get general documentation
4. LLM uses `lookup_item` to get specific details on functions/types
5. LLM can now provide an accurate response about the crate
### Helping Find the Right Library
1. User asks "What's a good crate for async HTTP requests?"
2. LLM uses `search_crates` with relevant keywords
3. LLM reviews the top results and their descriptions
4. LLM uses `lookup_crate` to get more details on promising options
5. LLM provides a recommendation with supporting information

144
examples/client.rs Normal file
View file

@ -0,0 +1,144 @@
use anyhow::Result;
use futures::StreamExt;
use reqwest::Client;
use serde_json::{json, Value};
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader};
// Simple example client for interacting with the doc server via stdin/stdout
async fn stdio_client() -> Result<()> {
// Start the doc-server in a separate process
let mut child = tokio::process::Command::new("cargo")
.args(["run", "--bin", "doc-server"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()?;
let stdin = child.stdin.take().expect("Failed to open stdin");
let stdout = child.stdout.take().expect("Failed to open stdout");
let mut stdin = io::BufWriter::new(stdin);
let mut stdout = BufReader::new(stdout);
// Send a request to lookup tokio crate
let request = json!({
"jsonrpc": "2.0",
"method": "call_tool",
"params": {
"name": "lookup_crate",
"arguments": {
"crate_name": "tokio"
}
},
"id": 1
});
println!("Sending request to look up tokio crate...");
stdin.write_all(request.to_string().as_bytes()).await?;
stdin.write_all(b"\n").await?;
stdin.flush().await?;
// Read the response
let mut response = String::new();
stdout.read_line(&mut response).await?;
let parsed: Value = serde_json::from_str(&response)?;
println!("Received response: {}", serde_json::to_string_pretty(&parsed)?);
// Terminate the child process
child.kill().await?;
Ok(())
}
// Simple example client for interacting with the doc server via HTTP/SSE
async fn http_sse_client() -> Result<()> {
println!("Connecting to HTTP/SSE server...");
// Create HTTP client
let client = Client::new();
// Create a separate task to run the server
let _server = tokio::spawn(async {
tokio::process::Command::new("cargo")
.args(["run", "--bin", "axum-docs"])
.output()
.await
.expect("Failed to start server");
});
// Give the server some time to start
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
// Connect to the SSE endpoint to get a session ID
let mut session_id = String::new();
let sse_url = "http://127.0.0.1:8080/sse";
println!("Getting session ID from SSE endpoint...");
// For a real implementation, you would use an SSE client library
// This is a simplified example that just gets the session ID from the first message
let response = client.get(sse_url).send().await?;
if !response.status().is_success() {
println!("Error connecting to SSE endpoint: {}", response.status());
return Ok(());
}
// Parse the first message to get the session ID
// In a real implementation, you would properly handle the SSE stream
if let Some(event_data) = response.headers().get("x-accel-buffering") {
// This is just a placeholder - in a real SSE client you would parse the actual event
session_id = "example_session_id".to_string();
} else {
println!("Could not get session ID from SSE endpoint");
return Ok(());
}
// Send a request to search for crates
let request_url = format!("{}?sessionId={}", sse_url, session_id);
let request_body = json!({
"jsonrpc": "2.0",
"method": "call_tool",
"params": {
"name": "search_crates",
"arguments": {
"query": "async runtime",
"limit": 5
}
},
"id": 1
});
println!("Sending request to search for crates...");
let response = client.post(&request_url)
.json(&request_body)
.send()
.await?;
if response.status().is_success() {
println!("Request sent successfully");
} else {
println!("Error sending request: {}", response.status());
}
// In a real implementation, you would read the responses from the SSE stream
println!("In a real implementation, responses would be read from the SSE stream");
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
println!("Rust Documentation Server Client Example");
println!("---------------------------------------");
println!("\n1. Testing STDIN/STDOUT client:");
if let Err(e) = stdio_client().await {
println!("Error in STDIN/STDOUT client: {}", e);
}
println!("\n2. Testing HTTP/SSE client:");
if let Err(e) = http_sse_client().await {
println!("Error in HTTP/SSE client: {}", e);
}
Ok(())
}

156
src/bin/axum_docs.rs Normal file
View file

@ -0,0 +1,156 @@
use axum::{
body::Body,
extract::{Query, State},
http::StatusCode,
response::sse::{Event, Sse},
routing::get,
Router,
};
use futures::{stream::Stream, StreamExt, TryStreamExt};
use mcp_server::{ByteTransport, Server};
use std::collections::HashMap;
use tokio_util::codec::FramedRead;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use anyhow::Result;
use mcp_server::router::RouterService;
use rust_doc_server::{jsonrpc_frame_codec::JsonRpcFrameCodec, DocRouter};
use std::sync::Arc;
use tokio::{
io::{self, AsyncWriteExt},
sync::Mutex,
};
use tracing_subscriber::{self};
type C2SWriter = Arc<Mutex<io::WriteHalf<io::SimplexStream>>>;
type SessionId = Arc<str>;
const BIND_ADDRESS: &str = "127.0.0.1:8080";
#[derive(Clone, Default)]
pub struct App {
txs: Arc<tokio::sync::RwLock<HashMap<SessionId, C2SWriter>>>,
}
impl App {
pub fn new() -> Self {
Self {
txs: Default::default(),
}
}
pub fn router(&self) -> Router {
Router::new()
.route("/sse", get(sse_handler).post(post_event_handler))
.with_state(self.clone())
}
}
fn session_id() -> SessionId {
let id = format!("{:016x}", rand::random::<u128>());
Arc::from(id)
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PostEventQuery {
pub session_id: String,
}
async fn post_event_handler(
State(app): State<App>,
Query(PostEventQuery { session_id }): Query<PostEventQuery>,
body: Body,
) -> Result<StatusCode, StatusCode> {
const BODY_BYTES_LIMIT: usize = 1 << 22;
let write_stream = {
let rg = app.txs.read().await;
rg.get(session_id.as_str())
.ok_or(StatusCode::NOT_FOUND)?
.clone()
};
let mut write_stream = write_stream.lock().await;
let mut body = body.into_data_stream();
if let (_, Some(size)) = body.size_hint() {
if size > BODY_BYTES_LIMIT {
return Err(StatusCode::PAYLOAD_TOO_LARGE);
}
}
// calculate the body size
let mut size = 0;
while let Some(chunk) = body.next().await {
let Ok(chunk) = chunk else {
return Err(StatusCode::BAD_REQUEST);
};
size += chunk.len();
if size > BODY_BYTES_LIMIT {
return Err(StatusCode::PAYLOAD_TOO_LARGE);
}
write_stream
.write_all(&chunk)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
}
write_stream
.write_u8(b'\n')
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::ACCEPTED)
}
async fn sse_handler(State(app): State<App>) -> Sse<impl Stream<Item = Result<Event, io::Error>>> {
// it's 4KB
const BUFFER_SIZE: usize = 1 << 12;
let session = session_id();
tracing::info!(%session, "sse connection");
let (c2s_read, c2s_write) = tokio::io::simplex(BUFFER_SIZE);
let (s2c_read, s2c_write) = tokio::io::simplex(BUFFER_SIZE);
app.txs
.write()
.await
.insert(session.clone(), Arc::new(Mutex::new(c2s_write)));
{
let app_clone = app.clone();
let session = session.clone();
tokio::spawn(async move {
let router = RouterService(DocRouter::new());
let server = Server::new(router);
let bytes_transport = ByteTransport::new(c2s_read, s2c_write);
let _result = server
.run(bytes_transport)
.await
.inspect_err(|e| tracing::error!(?e, "server run error"));
app_clone.txs.write().await.remove(&session);
});
}
let stream = futures::stream::once(futures::future::ok(
Event::default()
.event("endpoint")
.data(format!("?sessionId={session}")),
))
.chain(
FramedRead::new(s2c_read, JsonRpcFrameCodec)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
.and_then(move |bytes| match std::str::from_utf8(&bytes) {
Ok(message) => futures::future::ok(Event::default().event("message").data(message)),
Err(e) => futures::future::err(io::Error::new(io::ErrorKind::InvalidData, e)),
}),
);
Sse::new(stream)
}
#[tokio::main]
async fn main() -> io::Result<()> {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| format!("info,{}=debug", env!("CARGO_CRATE_NAME")).into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let listener = tokio::net::TcpListener::bind(BIND_ADDRESS).await?;
tracing::debug!("Rust Documentation Server listening on {}", listener.local_addr()?);
tracing::info!("Access the Rust Documentation Server at http://{}/sse", BIND_ADDRESS);
axum::serve(listener, App::new().router()).await
}

35
src/bin/doc_server.rs Normal file
View file

@ -0,0 +1,35 @@
use anyhow::Result;
use mcp_server::router::RouterService;
use mcp_server::{ByteTransport, Server};
use rust_doc_server::DocRouter;
use tokio::io::{stdin, stdout};
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::{self, EnvFilter};
#[tokio::main]
async fn main() -> Result<()> {
// Set up file appender for logging
let file_appender = RollingFileAppender::new(Rotation::DAILY, "logs", "doc-server.log");
// Initialize the tracing subscriber with file and stdout logging
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into()))
.with_writer(file_appender)
.with_target(false)
.with_thread_ids(true)
.with_file(true)
.with_line_number(true)
.init();
tracing::info!("Starting MCP documentation server");
// Create an instance of our documentation router
let router = RouterService(DocRouter::new());
// Create and run the server
let server = Server::new(router);
let transport = ByteTransport::new(stdin(), stdout());
tracing::info!("Documentation server initialized and ready to handle requests");
Ok(server.run(transport).await?)
}

340
src/docs.rs Normal file
View file

@ -0,0 +1,340 @@
use std::{future::Future, pin::Pin, sync::Arc};
use mcp_core::{
handler::{PromptError, ResourceError},
prompt::Prompt,
protocol::ServerCapabilities,
Content, Resource, Tool, ToolError,
};
use mcp_server::router::CapabilitiesBuilder;
use reqwest::Client;
use serde_json::{json, Value};
use tokio::sync::Mutex;
// Cache for documentation lookups to avoid repeated requests
#[derive(Clone)]
struct DocCache {
cache: Arc<Mutex<std::collections::HashMap<String, String>>>,
}
impl DocCache {
fn new() -> Self {
Self {
cache: Arc::new(Mutex::new(std::collections::HashMap::new())),
}
}
async fn get(&self, key: &str) -> Option<String> {
let cache = self.cache.lock().await;
cache.get(key).cloned()
}
async fn set(&self, key: String, value: String) {
let mut cache = self.cache.lock().await;
cache.insert(key, value);
}
}
#[derive(Clone)]
pub struct DocRouter {
client: Client,
cache: DocCache,
}
impl DocRouter {
pub fn new() -> Self {
Self {
client: Client::new(),
cache: DocCache::new(),
}
}
// Fetch crate documentation from docs.rs
async fn lookup_crate(&self, crate_name: String, version: Option<String>) -> Result<String, ToolError> {
// Check cache first
let cache_key = if let Some(ver) = &version {
format!("{}:{}", crate_name, ver)
} else {
crate_name.clone()
};
if let Some(doc) = self.cache.get(&cache_key).await {
return Ok(doc);
}
// 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)
};
// Fetch the documentation page
let response = self.client.get(&url).send().await.map_err(|e| {
ToolError::ExecutionError(format!("Failed to fetch documentation: {}", e))
})?;
if !response.status().is_success() {
return Err(ToolError::ExecutionError(format!(
"Failed to fetch documentation. Status: {}",
response.status()
)));
}
let body = response.text().await.map_err(|e| {
ToolError::ExecutionError(format!("Failed to read response body: {}", e))
})?;
// Cache the result
self.cache.set(cache_key, body.clone()).await;
Ok(body)
}
// Search crates.io for crates matching a query
async fn search_crates(&self, query: String, limit: Option<u32>) -> Result<String, ToolError> {
let limit = limit.unwrap_or(10).min(100); // Cap at 100 results
let url = format!("https://crates.io/api/v1/crates?q={}&per_page={}", query, limit);
let response = self.client.get(&url).send().await.map_err(|e| {
ToolError::ExecutionError(format!("Failed to search crates.io: {}", e))
})?;
if !response.status().is_success() {
return Err(ToolError::ExecutionError(format!(
"Failed to search crates.io. Status: {}",
response.status()
)));
}
let body = response.text().await.map_err(|e| {
ToolError::ExecutionError(format!("Failed to read response body: {}", e))
})?;
Ok(body)
}
// Get documentation for a specific item in a crate
async fn lookup_item(&self, crate_name: String, item_path: String, version: Option<String>) -> Result<String, ToolError> {
// Check cache first
let cache_key = if let Some(ver) = &version {
format!("{}:{}:{}", crate_name, ver, item_path)
} else {
format!("{}:{}", crate_name, item_path)
};
if let Some(doc) = self.cache.get(&cache_key).await {
return Ok(doc);
}
// Construct the docs.rs URL for the specific item
let url = if let Some(ver) = version {
format!("https://docs.rs/{}/{}/{}/", crate_name, ver, item_path.replace("::", "/"))
} else {
format!("https://docs.rs/{}/latest/{}/", crate_name, item_path.replace("::", "/"))
};
// Fetch the documentation page
let response = self.client.get(&url).send().await.map_err(|e| {
ToolError::ExecutionError(format!("Failed to fetch item documentation: {}", e))
})?;
if !response.status().is_success() {
return Err(ToolError::ExecutionError(format!(
"Failed to fetch item documentation. Status: {}",
response.status()
)));
}
let body = response.text().await.map_err(|e| {
ToolError::ExecutionError(format!("Failed to read response body: {}", e))
})?;
// Cache the result
self.cache.set(cache_key, body.clone()).await;
Ok(body)
}
}
impl mcp_server::Router for DocRouter {
fn name(&self) -> String {
"rust-docs".to_string()
}
fn instructions(&self) -> String {
"This server provides tools for looking up Rust crate documentation. \
You can search for crates, lookup documentation for specific crates or \
items within crates. Use these tools to find information about Rust libraries \
you are not familiar with.".to_string()
}
fn capabilities(&self) -> ServerCapabilities {
CapabilitiesBuilder::new()
.with_tools(true)
.with_resources(false, false)
.with_prompts(false)
.build()
}
fn list_tools(&self) -> Vec<Tool> {
vec![
Tool::new(
"lookup_crate".to_string(),
"Look up documentation for a Rust crate".to_string(),
json!({
"type": "object",
"properties": {
"crate_name": {
"type": "string",
"description": "The name of the crate to look up"
},
"version": {
"type": "string",
"description": "The version of the crate (optional, defaults to latest)"
}
},
"required": ["crate_name"]
}),
),
Tool::new(
"search_crates".to_string(),
"Search for Rust crates on crates.io".to_string(),
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query"
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return (optional, defaults to 10, max 100)"
}
},
"required": ["query"]
}),
),
Tool::new(
"lookup_item".to_string(),
"Look up documentation for a specific item in a Rust crate".to_string(),
json!({
"type": "object",
"properties": {
"crate_name": {
"type": "string",
"description": "The name of the crate"
},
"item_path": {
"type": "string",
"description": "Path to the item (e.g., 'std::vec::Vec')"
},
"version": {
"type": "string",
"description": "The version of the crate (optional, defaults to latest)"
}
},
"required": ["crate_name", "item_path"]
}),
),
]
}
fn call_tool(
&self,
tool_name: &str,
arguments: Value,
) -> Pin<Box<dyn Future<Output = Result<Vec<Content>, ToolError>> + Send + 'static>> {
let this = self.clone();
let tool_name = tool_name.to_string();
let arguments = arguments.clone();
Box::pin(async move {
match tool_name.as_str() {
"lookup_crate" => {
let crate_name = arguments
.get("crate_name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("crate_name is required".to_string()))?
.to_string();
let version = arguments
.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let doc = this.lookup_crate(crate_name, version).await?;
Ok(vec![Content::text(doc)])
}
"search_crates" => {
let query = arguments
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("query is required".to_string()))?
.to_string();
let limit = arguments
.get("limit")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let results = this.search_crates(query, limit).await?;
Ok(vec![Content::text(results)])
}
"lookup_item" => {
let crate_name = arguments
.get("crate_name")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("crate_name is required".to_string()))?
.to_string();
let item_path = arguments
.get("item_path")
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("item_path is required".to_string()))?
.to_string();
let version = arguments
.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let doc = this.lookup_item(crate_name, item_path, version).await?;
Ok(vec![Content::text(doc)])
}
_ => Err(ToolError::NotFound(format!("Tool {} not found", tool_name))),
}
})
}
fn list_resources(&self) -> Vec<Resource> {
vec![]
}
fn read_resource(
&self,
_uri: &str,
) -> Pin<Box<dyn Future<Output = Result<String, ResourceError>> + Send + 'static>> {
Box::pin(async move {
Err(ResourceError::NotFound("Resource not found".to_string()))
})
}
fn list_prompts(&self) -> Vec<Prompt> {
vec![]
}
fn get_prompt(
&self,
prompt_name: &str,
) -> Pin<Box<dyn Future<Output = Result<String, PromptError>> + Send + 'static>> {
let prompt_name = prompt_name.to_string();
Box::pin(async move {
Err(PromptError::NotFound(format!(
"Prompt {} not found",
prompt_name
)))
})
}
}

View file

@ -0,0 +1,24 @@
use tokio_util::codec::Decoder;
#[derive(Default)]
pub struct JsonRpcFrameCodec;
impl Decoder for JsonRpcFrameCodec {
type Item = tokio_util::bytes::Bytes;
type Error = tokio::io::Error;
fn decode(
&mut self,
src: &mut tokio_util::bytes::BytesMut,
) -> Result<Option<Self::Item>, Self::Error> {
if let Some(end) = src
.iter()
.enumerate()
.find_map(|(idx, &b)| (b == b'\n').then_some(idx))
{
let line = src.split_to(end);
let _char_next_line = src.split_to(1);
Ok(Some(line.freeze()))
} else {
Ok(None)
}
}
}

5
src/lib.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod docs;
pub mod jsonrpc_frame_codec;
// Re-export key components for easier access
pub use docs::DocRouter;