Init
This commit is contained in:
commit
dcf78edfca
12 changed files with 1158 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
48
Cargo.toml
Normal 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
21
LICENSE
Normal 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
116
README.md
Normal 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
138
docs/development.md
Normal 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
107
docs/usage.md
Normal 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
144
examples/client.rs
Normal 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
156
src/bin/axum_docs.rs
Normal 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
35
src/bin/doc_server.rs
Normal 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
340
src/docs.rs
Normal 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
|
||||
)))
|
||||
})
|
||||
}
|
||||
}
|
24
src/jsonrpc_frame_codec.rs
Normal file
24
src/jsonrpc_frame_codec.rs
Normal 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
5
src/lib.rs
Normal file
|
@ -0,0 +1,5 @@
|
|||
pub mod docs;
|
||||
pub mod jsonrpc_frame_codec;
|
||||
|
||||
// Re-export key components for easier access
|
||||
pub use docs::DocRouter;
|
Loading…
Add table
Add a link
Reference in a new issue