commit dcf78edfca5e5bb0955d96aa7285cb8c1bff63b1 Author: Danielle Jenkins Date: Thu Mar 6 22:49:18 2025 -0800 Init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8dce1e6 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cfec483 --- /dev/null +++ b/Cargo.toml @@ -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 "] +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" \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5fd649e --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a5808e --- /dev/null +++ b/README.md @@ -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 \ No newline at end of file diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..8ff4b70 --- /dev/null +++ b/docs/development.md @@ -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) -> Result { + // 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"] +``` \ No newline at end of file diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..55cf63f --- /dev/null +++ b/docs/usage.md @@ -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 \ No newline at end of file diff --git a/examples/client.rs b/examples/client.rs new file mode 100644 index 0000000..c43165a --- /dev/null +++ b/examples/client.rs @@ -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(()) +} \ No newline at end of file diff --git a/src/bin/axum_docs.rs b/src/bin/axum_docs.rs new file mode 100644 index 0000000..89e33e6 --- /dev/null +++ b/src/bin/axum_docs.rs @@ -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>>; +type SessionId = Arc; + +const BIND_ADDRESS: &str = "127.0.0.1:8080"; + +#[derive(Clone, Default)] +pub struct App { + txs: Arc>>, +} + +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::()); + 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, + Query(PostEventQuery { session_id }): Query, + body: Body, +) -> Result { + 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) -> Sse>> { + // 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 +} \ No newline at end of file diff --git a/src/bin/doc_server.rs b/src/bin/doc_server.rs new file mode 100644 index 0000000..95bc653 --- /dev/null +++ b/src/bin/doc_server.rs @@ -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?) +} \ No newline at end of file diff --git a/src/docs.rs b/src/docs.rs new file mode 100644 index 0000000..305d13f --- /dev/null +++ b/src/docs.rs @@ -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>>, +} + +impl DocCache { + fn new() -> Self { + Self { + cache: Arc::new(Mutex::new(std::collections::HashMap::new())), + } + } + + async fn get(&self, key: &str) -> Option { + 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) -> Result { + // 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) -> Result { + 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) -> Result { + // 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 { + 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, 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 { + vec![] + } + + fn read_resource( + &self, + _uri: &str, + ) -> Pin> + Send + 'static>> { + Box::pin(async move { + Err(ResourceError::NotFound("Resource not found".to_string())) + }) + } + + fn list_prompts(&self) -> Vec { + vec![] + } + + fn get_prompt( + &self, + prompt_name: &str, + ) -> Pin> + Send + 'static>> { + let prompt_name = prompt_name.to_string(); + Box::pin(async move { + Err(PromptError::NotFound(format!( + "Prompt {} not found", + prompt_name + ))) + }) + } +} \ No newline at end of file diff --git a/src/jsonrpc_frame_codec.rs b/src/jsonrpc_frame_codec.rs new file mode 100644 index 0000000..2129067 --- /dev/null +++ b/src/jsonrpc_frame_codec.rs @@ -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, 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) + } + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..63b6ad5 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +pub mod docs; +pub mod jsonrpc_frame_codec; + +// Re-export key components for easier access +pub use docs::DocRouter; \ No newline at end of file