# Directory Structure ``` ├── .clinerules ├── .gitignore ├── biome.json ├── bun.lock ├── package.json ├── README.md ├── src │ ├── index.ts │ ├── service.test.ts │ ├── service.ts │ ├── test-client.ts │ ├── types.ts │ └── utils │ ├── crates-io-client.ts │ ├── http-client.ts │ └── logger.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.clinerules: -------------------------------------------------------------------------------- ``` 1 | # Testing the server 2 | 3 | The MCP server is already configured in your local environment, and you can't 4 | communicate with it when you run it in the terminal. Instead, just make tool 5 | calls to the rust-docs MCP server (after rebuilding) to test if it's working. 6 | Restart the server if that capability is available to you. 7 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | 177 | build/ 178 | mcp-sdk.md 179 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Rust Docs MCP Server 2 | 3 | An MCP (Model Context Protocol) server that provides access to Rust documentation from docs.rs. This server allows AI tools to search for documentation, type information, feature flags, version numbers, and symbol definitions/source code. 4 | 5 | ## Features 6 | 7 | - Search for crates on docs.rs 8 | - Get documentation for specific crates and versions 9 | - Get type information (structs, enums, traits, etc.) 10 | - Get feature flags for crates 11 | - Get available versions for crates 12 | - Get source code for specific items 13 | - Search for symbols within crates 14 | 15 | ## Installation 16 | 17 | This project uses Bun for development, but the built server can run with Node.js. 18 | 19 | ```bash 20 | # Clone the repository 21 | git clone https://github.com/yourusername/rust-docs-mcp-server.git 22 | cd rust-docs-mcp-server 23 | 24 | # Install dependencies 25 | bun install 26 | ``` 27 | 28 | ## Building 29 | 30 | ```bash 31 | # Build the server 32 | bun run build 33 | ``` 34 | 35 | This will create a build directory with the compiled JavaScript files. 36 | 37 | ## Running 38 | 39 | ```bash 40 | # Run the development server 41 | bun run dev 42 | 43 | # Or run the built server 44 | bun run start 45 | ``` 46 | 47 | ## Usage with MCP Clients 48 | 49 | This server implements the Model Context Protocol and can be used with any MCP client. To use it with an MCP client, you'll need to configure the client to connect to this server. 50 | 51 | ### Available Tools 52 | 53 | The server provides the following tools: 54 | 55 | - `search_crates`: Search for crates on docs.rs 56 | - `get_crate_documentation`: Get documentation for a specific crate 57 | - `get_type_info`: Get type information for a specific item 58 | - `get_feature_flags`: Get feature flags for a crate 59 | - `get_crate_versions`: Get available versions for a crate 60 | - `get_source_code`: Get source code for a specific item 61 | - `search_symbols`: Search for symbols within a crate 62 | 63 | ## Testing 64 | 65 | ```bash 66 | # Run tests 67 | bun test 68 | ``` 69 | 70 | ## License 71 | 72 | MIT 73 | ``` -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": [] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "tab" 15 | }, 16 | "organizeImports": { 17 | "enabled": true 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true 23 | } 24 | }, 25 | "javascript": { 26 | "formatter": { 27 | "quoteStyle": "double" 28 | } 29 | } 30 | } 31 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": ["ESNext", "DOM"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | 22 | // Some stricter flags (disabled by default) 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noPropertyAccessFromIndexSignature": false 26 | } 27 | } 28 | ``` -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- ```typescript 1 | import pino from "pino"; 2 | 3 | // Create a logger instance with appropriate configuration 4 | export const logger = pino({ 5 | level: process.env.LOG_LEVEL || "warn", 6 | transport: { 7 | target: "pino/file", 8 | options: { destination: 2 }, // stderr 9 | }, 10 | timestamp: pino.stdTimeFunctions.isoTime, 11 | formatters: { 12 | level: (label) => { 13 | return { level: label }; 14 | }, 15 | }, 16 | }); 17 | 18 | // Export convenience methods 19 | export default { 20 | debug: (msg: string, obj?: object) => logger.debug(obj || {}, msg), 21 | info: (msg: string, obj?: object) => logger.info(obj || {}, msg), 22 | warn: (msg: string, obj?: object) => logger.warn(obj || {}, msg), 23 | error: (msg: string, obj?: object) => logger.error(obj || {}, msg), 24 | fatal: (msg: string, obj?: object) => logger.fatal(obj || {}, msg), 25 | }; 26 | ``` -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- ```typescript 1 | /** 2 | * Types for docs.rs integration 3 | */ 4 | 5 | export interface CrateInfo { 6 | name: string; 7 | version: string; 8 | description?: string; 9 | } 10 | 11 | export interface CrateSearchResult { 12 | crates: CrateInfo[]; 13 | totalCount: number; 14 | } 15 | 16 | export interface RustType { 17 | name: string; 18 | kind: 19 | | "struct" 20 | | "enum" 21 | | "trait" 22 | | "function" 23 | | "macro" 24 | | "type" 25 | | "module" 26 | | "other"; 27 | path: string; 28 | description?: string; 29 | sourceUrl?: string; 30 | documentationUrl: string; 31 | } 32 | 33 | export interface FeatureFlag { 34 | name: string; 35 | description?: string; 36 | enabled: boolean; 37 | } 38 | 39 | export interface CrateVersion { 40 | version: string; 41 | isYanked: boolean; 42 | releaseDate?: string; 43 | } 44 | 45 | export interface SymbolDefinition { 46 | name: string; 47 | kind: string; 48 | path: string; 49 | sourceCode?: string; 50 | documentationHtml?: string; 51 | } 52 | 53 | export interface SearchOptions { 54 | query: string; 55 | page?: number; 56 | perPage?: number; 57 | } 58 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "rust-docs-mcp-server", 3 | "version": "1.0.0", 4 | "description": "MCP server for accessing Rust documentation from docs.rs", 5 | "module": "index.ts", 6 | "type": "module", 7 | "bin": { 8 | "rust-docs-mcp-server": "./build/index.js" 9 | }, 10 | "scripts": { 11 | "build": "bun build ./src/index.ts --outdir ./build --target node", 12 | "start": "bun run build && node ./build/index.js", 13 | "dev": "bun run src/index.ts", 14 | "test": "bun test" 15 | }, 16 | "devDependencies": { 17 | "@types/bun": "latest", 18 | "@types/turndown": "^5.0.5" 19 | }, 20 | "peerDependencies": { 21 | "typescript": "^5.0.0" 22 | }, 23 | "dependencies": { 24 | "@biomejs/biome": "^1.9.4", 25 | "@modelcontextprotocol/sdk": "^1.6.0", 26 | "axios": "^1.7.9", 27 | "cheerio": "^1.0.0", 28 | "pino": "^9.6.0", 29 | "turndown": "^7.2.0", 30 | "zod": "^3.24.2" 31 | }, 32 | "engines": { 33 | "node": ">=18.0.0" 34 | }, 35 | "packageManager": "[email protected]+sha1.4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447" 36 | } 37 | ``` -------------------------------------------------------------------------------- /src/test-client.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { spawn } from "node:child_process"; 2 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 3 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; 4 | 5 | async function main() { 6 | console.log("Starting test client for Rust Docs MCP Server..."); 7 | 8 | // Start the server process 9 | const serverProcess = spawn("bun", ["run", "src/index.ts"], { 10 | stdio: ["pipe", "pipe", "inherit"], 11 | }); 12 | 13 | // Create a transport that connects to the server 14 | const transport = new StdioClientTransport({ 15 | command: "bun", 16 | args: ["run", "src/index.ts"], 17 | }); 18 | 19 | // Create the client 20 | const client = new Client( 21 | { 22 | name: "test-client", 23 | version: "1.0.0", 24 | }, 25 | { 26 | capabilities: { 27 | tools: {}, 28 | }, 29 | }, 30 | ); 31 | 32 | try { 33 | // Connect to the server 34 | console.log("Connecting to server..."); 35 | await client.connect(transport); 36 | console.log("Connected to server!"); 37 | 38 | // List available tools 39 | console.log("\nListing available tools:"); 40 | const tools = await client.listTools(); 41 | console.log(JSON.stringify(tools, null, 2)); 42 | 43 | // Test search_crates tool 44 | console.log("\nTesting search_crates tool:"); 45 | const searchResult = await client.callTool({ 46 | name: "search_crates", 47 | arguments: { 48 | query: "serde", 49 | }, 50 | }); 51 | if ( 52 | searchResult.content && 53 | Array.isArray(searchResult.content) && 54 | searchResult.content.length > 0 55 | ) { 56 | console.log(searchResult.content[0].text); 57 | } 58 | 59 | // Test get_crate_versions tool 60 | console.log("\nTesting get_crate_versions tool:"); 61 | const versionsResult = await client.callTool({ 62 | name: "get_crate_versions", 63 | arguments: { 64 | crateName: "tokio", 65 | }, 66 | }); 67 | if ( 68 | versionsResult.content && 69 | Array.isArray(versionsResult.content) && 70 | versionsResult.content.length > 0 71 | ) { 72 | console.log(versionsResult.content[0].text); 73 | } 74 | 75 | // Test search_symbols tool 76 | console.log("\nTesting search_symbols tool:"); 77 | const symbolsResult = await client.callTool({ 78 | name: "search_symbols", 79 | arguments: { 80 | crateName: "tokio", 81 | query: "runtime", 82 | }, 83 | }); 84 | if ( 85 | symbolsResult.content && 86 | Array.isArray(symbolsResult.content) && 87 | symbolsResult.content.length > 0 88 | ) { 89 | console.log(symbolsResult.content[0].text); 90 | } 91 | 92 | console.log("\nAll tests completed successfully!"); 93 | } catch (error) { 94 | console.error("Error:", error); 95 | } finally { 96 | // Close the connection and kill the server process 97 | await client.close(); 98 | serverProcess.kill(); 99 | } 100 | } 101 | 102 | main().catch(console.error); 103 | ``` -------------------------------------------------------------------------------- /src/service.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, expect, test, beforeAll } from "bun:test"; 2 | import * as cheerio from "cheerio"; 3 | import { 4 | searchCrates, 5 | getCrateDocumentation, 6 | getCrateVersions, 7 | searchSymbols, 8 | getTypeInfo, 9 | getCrateDetails, 10 | } from "./service"; 11 | 12 | describe("service", () => { 13 | // Set longer timeout for network requests 14 | const timeout = 15000; 15 | 16 | describe("searchCrates should return results for a valid query", () => { 17 | test.each([ 18 | ["serde", "serde"], 19 | ["tokio", "tokio"], 20 | ["pin-project", "pin-project"], 21 | ["pin_project", "pin-project"], 22 | ["fjall", "fjall"], 23 | ])( 24 | "%s", 25 | async (query, name) => { 26 | const result = await searchCrates({ query }); 27 | expect(result.crates.length).toBeGreaterThan(0); 28 | expect(result.totalCount).toBeGreaterThan(0); 29 | // Check that each crate has a version 30 | for (const crate of result.crates) { 31 | expect(crate.name).toBeDefined(); 32 | expect(crate.version).toBeDefined(); 33 | } 34 | 35 | expect(result.crates.some((crate) => crate.name === name)).toBe(true); 36 | }, 37 | timeout, 38 | ); 39 | }); 40 | 41 | describe("getCrateVersions", () => { 42 | test( 43 | "should return versions for a valid crate", 44 | async () => { 45 | const versions = await getCrateVersions("tokio"); 46 | expect(versions.length).toBeGreaterThan(0); 47 | 48 | // Check that each version has the expected properties 49 | for (const version of versions) { 50 | expect(version.version).toBeDefined(); 51 | expect(typeof version.isYanked).toBe("boolean"); 52 | expect(version.releaseDate).toBeDefined(); 53 | } 54 | }, 55 | timeout, 56 | ); 57 | }); 58 | 59 | test( 60 | "searchSymbols should return symbols for a valid query", 61 | async () => { 62 | const symbols = await searchSymbols("tokio", "runtime"); 63 | expect(symbols.length).toBeGreaterThan(0); 64 | }, 65 | timeout, 66 | ); 67 | 68 | test( 69 | "getTypeInfo should return information for a valid type", 70 | async () => { 71 | // This test is skipped because the path may change in docs.rs 72 | // In a real implementation, we would need to first find the correct path 73 | // by searching for the type or navigating through the documentation 74 | const typeInfo = await getTypeInfo( 75 | "tokio", 76 | "runtime/struct.Runtime.html", 77 | ); 78 | 79 | expect(typeInfo).toBeTruthy(); 80 | expect(typeInfo.name).toContain("Runtime"); 81 | expect(typeInfo.kind).toBe("struct"); 82 | }, 83 | timeout, 84 | ); 85 | 86 | describe("getCrateDetails", () => { 87 | test( 88 | "should return details for a valid crate", 89 | async () => { 90 | const details = await getCrateDetails("tokio"); 91 | expect(details.name).toBe("tokio"); 92 | expect(details.description).toBeDefined(); 93 | expect(details.versions.length).toBeGreaterThan(0); 94 | expect(details.downloads).toBeGreaterThan(0); 95 | }, 96 | timeout, 97 | ); 98 | }); 99 | }); 100 | ``` -------------------------------------------------------------------------------- /src/utils/crates-io-client.ts: -------------------------------------------------------------------------------- ```typescript 1 | import logger from "./logger"; 2 | 3 | interface RequestOptions { 4 | method?: string; 5 | params?: Record<string, string | number | boolean | undefined>; 6 | body?: unknown; 7 | } 8 | 9 | type FetchResponse = 10 | | { 11 | data: Record<string, unknown>; 12 | status: number; 13 | headers: Headers; 14 | contentType: "json"; 15 | } 16 | | { 17 | data: string; 18 | status: number; 19 | headers: Headers; 20 | contentType: "text"; 21 | }; 22 | 23 | // base configuration for crates.io requests 24 | const BASE_CONFIG = { 25 | baseURL: "https://crates.io/api/v1/", 26 | headers: { 27 | Accept: "application/json", 28 | "User-Agent": "rust-docs-mcp-server/1.0.0", 29 | }, 30 | }; 31 | 32 | // helper to build full url with query params 33 | function buildUrl( 34 | path: string, 35 | params?: Record<string, string | number | boolean | undefined>, 36 | ): string { 37 | const url = new URL(path, BASE_CONFIG.baseURL); 38 | if (params) { 39 | for (const [key, value] of Object.entries(params)) { 40 | if (value !== undefined) { 41 | url.searchParams.append(key, String(value)); 42 | } 43 | } 44 | } 45 | return url.toString(); 46 | } 47 | 48 | // create a configured fetch client for crates.io 49 | export async function cratesIoFetch( 50 | path: string, 51 | options: RequestOptions = {}, 52 | ): Promise<FetchResponse> { 53 | const { method = "GET", params, body } = options; 54 | const url = buildUrl(path, params); 55 | 56 | try { 57 | logger.debug(`making request to ${url}`, { method, params }); 58 | 59 | const controller = new AbortController(); 60 | const timeoutId = setTimeout(() => controller.abort(), 10000); 61 | 62 | const response = await fetch(url, { 63 | method, 64 | headers: BASE_CONFIG.headers, 65 | body: body ? JSON.stringify(body) : undefined, 66 | signal: controller.signal, 67 | }); 68 | 69 | clearTimeout(timeoutId); 70 | 71 | logger.debug(`received response from ${url}`, { 72 | status: response.status, 73 | contentType: response.headers.get("content-type"), 74 | }); 75 | 76 | if (!response.ok) { 77 | throw new Error(`HTTP error! status: ${response.status}`); 78 | } 79 | 80 | const contentType = response.headers.get("content-type"); 81 | const isJson = contentType?.includes("application/json"); 82 | const data = isJson ? await response.json() : await response.text(); 83 | 84 | return { 85 | data, 86 | status: response.status, 87 | headers: response.headers, 88 | contentType: isJson ? "json" : "text", 89 | }; 90 | } catch (error) { 91 | logger.error(`error making request to ${url}`, { error }); 92 | throw error; 93 | } 94 | } 95 | 96 | // Export a default instance 97 | export default { 98 | get: (path: string, options = {}) => 99 | cratesIoFetch(path, { ...options, method: "GET" }), 100 | post: (path: string, options = {}) => 101 | cratesIoFetch(path, { ...options, method: "POST" }), 102 | put: (path: string, options = {}) => 103 | cratesIoFetch(path, { ...options, method: "PUT" }), 104 | delete: (path: string, options = {}) => 105 | cratesIoFetch(path, { ...options, method: "DELETE" }), 106 | }; 107 | ``` -------------------------------------------------------------------------------- /src/utils/http-client.ts: -------------------------------------------------------------------------------- ```typescript 1 | import logger from "./logger"; 2 | 3 | interface RequestOptions { 4 | method?: string; 5 | params?: Record<string, string | number | boolean | undefined>; 6 | body?: unknown; 7 | } 8 | 9 | type FetchResponse = 10 | | { 11 | data: Record<string, unknown>; 12 | status: number; 13 | headers: Headers; 14 | contentType: "json"; 15 | } 16 | | { 17 | data: string; 18 | status: number; 19 | headers: Headers; 20 | contentType: "text"; 21 | }; 22 | 23 | // base configuration for docs.rs requests 24 | const BASE_CONFIG = { 25 | baseURL: "https://docs.rs", 26 | headers: { 27 | Accept: "text/html,application/xhtml+xml,application/json", 28 | "User-Agent": "rust-docs-mcp-server/1.0.0", 29 | }, 30 | }; 31 | 32 | // helper to build full url with query params 33 | function buildUrl( 34 | path: string, 35 | params?: Record<string, string | number | boolean | undefined>, 36 | ): string { 37 | const url = new URL(path, BASE_CONFIG.baseURL); 38 | if (params) { 39 | for (const [key, value] of Object.entries(params)) { 40 | if (value !== undefined) { 41 | url.searchParams.append(key, String(value)); 42 | } 43 | } 44 | } 45 | return url.toString(); 46 | } 47 | 48 | // create a configured fetch client for docs.rs 49 | export async function docsRsFetch( 50 | path: string, 51 | options: RequestOptions = {}, 52 | ): Promise<FetchResponse> { 53 | const { method = "GET", params, body } = options; 54 | const url = buildUrl(path, params); 55 | 56 | try { 57 | console.debug(`making request to ${url}`, { method, params }); 58 | 59 | const controller = new AbortController(); 60 | const timeoutId = setTimeout(() => controller.abort(), 10000); 61 | 62 | const response = await fetch(url, { 63 | method, 64 | headers: BASE_CONFIG.headers, 65 | body: body ? JSON.stringify(body) : undefined, 66 | signal: controller.signal, 67 | }); 68 | 69 | clearTimeout(timeoutId); 70 | 71 | logger.debug(`Received response from ${url}`, { 72 | status: response.status, 73 | contentType: response.headers.get("content-type"), 74 | }); 75 | 76 | if (!response.ok) { 77 | throw new Error(`HTTP error! status: ${response.status}`); 78 | } 79 | 80 | const contentType = response.headers.get("content-type"); 81 | const isJson = contentType?.includes("application/json"); 82 | const data = isJson ? await response.json() : await response.text(); 83 | 84 | return { 85 | data, 86 | status: response.status, 87 | headers: response.headers, 88 | contentType: isJson ? "json" : "text", 89 | }; 90 | } catch (error) { 91 | logger.error(`Error making request to ${url}`, { error }); 92 | throw error; 93 | } 94 | } 95 | 96 | // Export a default instance 97 | export default { 98 | get: (path: string, options = {}) => 99 | docsRsFetch(path, { ...options, method: "GET" }), 100 | post: (path: string, options = {}) => 101 | docsRsFetch(path, { ...options, method: "POST" }), 102 | put: (path: string, options = {}) => 103 | docsRsFetch(path, { ...options, method: "PUT" }), 104 | delete: (path: string, options = {}) => 105 | docsRsFetch(path, { ...options, method: "DELETE" }), 106 | }; 107 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 4 | import { z } from "zod"; 5 | import * as cheerio from "cheerio"; 6 | import logger from "./utils/logger"; 7 | import { 8 | searchCrates, 9 | getCrateDocumentation, 10 | getTypeInfo, 11 | getFeatureFlags, 12 | getCrateVersions, 13 | getSourceCode, 14 | searchSymbols, 15 | } from "./service"; 16 | 17 | /** 18 | * Rust Docs MCP Server 19 | * 20 | * This server provides tools for accessing Rust documentation from docs.rs. 21 | * It allows searching for crates, viewing documentation, type information, 22 | * feature flags, version numbers, and source code. 23 | */ 24 | class RustDocsMcpServer { 25 | private server: McpServer; 26 | 27 | constructor() { 28 | // Create the MCP server 29 | this.server = new McpServer({ 30 | name: "rust-docs", 31 | version: "1.0.0", 32 | }); 33 | 34 | // Set up tools 35 | this.setupTools(); 36 | 37 | // Error handling 38 | process.on("uncaughtException", (error) => { 39 | logger.error("Uncaught exception", { error }); 40 | process.exit(1); 41 | }); 42 | 43 | process.on("unhandledRejection", (reason) => { 44 | logger.error("Unhandled rejection", { reason }); 45 | }); 46 | } 47 | 48 | /** 49 | * Set up the MCP tools 50 | */ 51 | private setupTools() { 52 | // Tool: Search for crates 53 | this.server.tool( 54 | "search_crates", 55 | { 56 | query: z.string().min(1).describe("Search query for crates"), 57 | page: z.number().optional().describe("Page number (starts at 1)"), 58 | perPage: z.number().optional().describe("Results per page"), 59 | }, 60 | async ({ query, page, perPage }) => { 61 | try { 62 | const result = await searchCrates({ 63 | query, 64 | page, 65 | perPage, 66 | }); 67 | return { 68 | content: [ 69 | { 70 | type: "text", 71 | text: JSON.stringify(result, null, 2), 72 | }, 73 | ], 74 | }; 75 | } catch (error) { 76 | logger.error("Error in search_crates tool", { error }); 77 | return { 78 | content: [ 79 | { 80 | type: "text", 81 | text: `Error searching for crates: ${(error as Error).message}`, 82 | }, 83 | ], 84 | isError: true, 85 | }; 86 | } 87 | }, 88 | ); 89 | 90 | // Tool: Get crate documentation 91 | this.server.tool( 92 | "get_crate_documentation", 93 | { 94 | crateName: z.string().min(1).describe("Name of the crate"), 95 | version: z 96 | .string() 97 | .optional() 98 | .describe("Specific version (defaults to latest)"), 99 | }, 100 | async ({ crateName, version }) => { 101 | try { 102 | const html = await getCrateDocumentation(crateName, version); 103 | 104 | // Use cheerio to parse the HTML and extract the content 105 | const $ = cheerio.load(html); 106 | 107 | // Try different selectors to find the main content 108 | let content = "Documentation content not found"; 109 | let contentFound = false; 110 | 111 | // First try the #main element which contains the main crate documentation 112 | const mainElement = $("#main"); 113 | if (mainElement.length > 0) { 114 | content = mainElement.html() || content; 115 | contentFound = true; 116 | } 117 | 118 | // If that fails, try other potential content containers 119 | if (!contentFound) { 120 | const selectors = [ 121 | "main", 122 | ".container.package-page-container", 123 | ".rustdoc", 124 | ".information", 125 | ".crate-info", 126 | ]; 127 | 128 | for (const selector of selectors) { 129 | const element = $(selector); 130 | if (element.length > 0) { 131 | content = element.html() || content; 132 | contentFound = true; 133 | break; 134 | } 135 | } 136 | } 137 | 138 | // Log the extraction result 139 | if (!contentFound) { 140 | logger.warn(`Failed to extract content for crate: ${crateName}`); 141 | } else { 142 | logger.info( 143 | `Successfully extracted content for crate: ${crateName}`, 144 | ); 145 | } 146 | 147 | return { 148 | content: [ 149 | { 150 | type: "text", 151 | text: content, 152 | }, 153 | ], 154 | }; 155 | } catch (error) { 156 | logger.error("Error in get_crate_documentation tool", { error }); 157 | return { 158 | content: [ 159 | { 160 | type: "text", 161 | text: `Error getting documentation: ${(error as Error).message}`, 162 | }, 163 | ], 164 | isError: true, 165 | }; 166 | } 167 | }, 168 | ); 169 | 170 | // Tool: Get type information 171 | this.server.tool( 172 | "get_type_info", 173 | { 174 | crateName: z.string().min(1).describe("Name of the crate"), 175 | path: z 176 | .string() 177 | .min(1) 178 | .describe('Path to the type (e.g., "std/vec/struct.Vec.html")'), 179 | version: z 180 | .string() 181 | .optional() 182 | .describe("Specific version (defaults to latest)"), 183 | }, 184 | async ({ crateName, path, version }) => { 185 | try { 186 | const typeInfo = await getTypeInfo(crateName, path, version); 187 | return { 188 | content: [ 189 | { 190 | type: "text", 191 | text: JSON.stringify(typeInfo, null, 2), 192 | }, 193 | ], 194 | }; 195 | } catch (error) { 196 | logger.error("Error in get_type_info tool", { error }); 197 | return { 198 | content: [ 199 | { 200 | type: "text", 201 | text: `Error getting type information: ${(error as Error).message}`, 202 | }, 203 | ], 204 | isError: true, 205 | }; 206 | } 207 | }, 208 | ); 209 | 210 | // Tool: Get feature flags 211 | this.server.tool( 212 | "get_feature_flags", 213 | { 214 | crateName: z.string().min(1).describe("Name of the crate"), 215 | version: z 216 | .string() 217 | .optional() 218 | .describe("Specific version (defaults to latest)"), 219 | }, 220 | async ({ crateName, version }) => { 221 | try { 222 | const features = await getFeatureFlags(crateName, version); 223 | return { 224 | content: [ 225 | { 226 | type: "text", 227 | text: JSON.stringify(features, null, 2), 228 | }, 229 | ], 230 | }; 231 | } catch (error) { 232 | logger.error("Error in get_feature_flags tool", { error }); 233 | return { 234 | content: [ 235 | { 236 | type: "text", 237 | text: `Error getting feature flags: ${(error as Error).message}`, 238 | }, 239 | ], 240 | isError: true, 241 | }; 242 | } 243 | }, 244 | ); 245 | 246 | // Tool: Get crate versions 247 | this.server.tool( 248 | "get_crate_versions", 249 | { 250 | crateName: z.string().min(1).describe("Name of the crate"), 251 | }, 252 | async ({ crateName }) => { 253 | try { 254 | const versions = await getCrateVersions(crateName); 255 | return { 256 | content: [ 257 | { 258 | type: "text", 259 | text: JSON.stringify(versions, null, 2), 260 | }, 261 | ], 262 | }; 263 | } catch (error) { 264 | logger.error("Error in get_crate_versions tool", { error }); 265 | return { 266 | content: [ 267 | { 268 | type: "text", 269 | text: `Error getting crate versions: ${(error as Error).message}`, 270 | }, 271 | ], 272 | isError: true, 273 | }; 274 | } 275 | }, 276 | ); 277 | 278 | // Tool: Get source code 279 | this.server.tool( 280 | "get_source_code", 281 | { 282 | crateName: z.string().min(1).describe("Name of the crate"), 283 | path: z.string().min(1).describe("Path to the source file"), 284 | version: z 285 | .string() 286 | .optional() 287 | .describe("Specific version (defaults to latest)"), 288 | }, 289 | async ({ crateName, path, version }) => { 290 | try { 291 | const sourceCode = await getSourceCode(crateName, path, version); 292 | return { 293 | content: [ 294 | { 295 | type: "text", 296 | text: sourceCode, 297 | }, 298 | ], 299 | }; 300 | } catch (error) { 301 | logger.error("Error in get_source_code tool", { error }); 302 | return { 303 | content: [ 304 | { 305 | type: "text", 306 | text: `Error getting source code: ${(error as Error).message}`, 307 | }, 308 | ], 309 | isError: true, 310 | }; 311 | } 312 | }, 313 | ); 314 | 315 | // Tool: Search for symbols 316 | this.server.tool( 317 | "search_symbols", 318 | { 319 | crateName: z.string().min(1).describe("Name of the crate"), 320 | query: z.string().min(1).describe("Search query for symbols"), 321 | version: z 322 | .string() 323 | .optional() 324 | .describe("Specific version (defaults to latest)"), 325 | }, 326 | async ({ crateName, query, version }) => { 327 | try { 328 | const symbols = await searchSymbols(crateName, query, version); 329 | return { 330 | content: [ 331 | { 332 | type: "text", 333 | text: JSON.stringify(symbols, null, 2), 334 | }, 335 | ], 336 | }; 337 | } catch (error) { 338 | logger.error("Error in search_symbols tool", { error }); 339 | return { 340 | content: [ 341 | { 342 | type: "text", 343 | text: `Error searching for symbols: ${(error as Error).message}`, 344 | }, 345 | ], 346 | isError: true, 347 | }; 348 | } 349 | }, 350 | ); 351 | } 352 | 353 | /** 354 | * Start the server 355 | */ 356 | async start() { 357 | try { 358 | logger.info("Starting Rust Docs MCP Server"); 359 | const transport = new StdioServerTransport(); 360 | await this.server.connect(transport); 361 | logger.info("Server connected via stdio"); 362 | } catch (error) { 363 | logger.error("Failed to start server", { error }); 364 | process.exit(1); 365 | } 366 | } 367 | } 368 | 369 | // Create and start the server 370 | const server = new RustDocsMcpServer(); 371 | server.start().catch((error) => { 372 | logger.error("Error starting server", { error }); 373 | process.exit(1); 374 | }); 375 | ``` -------------------------------------------------------------------------------- /src/service.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as cheerio from "cheerio"; 2 | import turndown from "turndown"; 3 | import type { 4 | CrateInfo, 5 | CrateSearchResult, 6 | CrateVersion, 7 | FeatureFlag, 8 | RustType, 9 | SearchOptions, 10 | SymbolDefinition, 11 | } from "./types"; 12 | import docsRsClient from "./utils/http-client"; 13 | import cratesIoClient from "./utils/crates-io-client"; 14 | import logger from "./utils/logger"; 15 | 16 | const turndownInstance = new turndown(); 17 | 18 | /** 19 | * Search for crates on crates.io 20 | */ 21 | export async function searchCrates( 22 | options: SearchOptions, 23 | ): Promise<CrateSearchResult> { 24 | try { 25 | logger.info(`searching for crates with query: ${options.query}`); 26 | 27 | const response = await cratesIoClient.get("crates", { 28 | params: { 29 | q: options.query, 30 | page: options.page || 1, 31 | per_page: options.perPage || 10, 32 | }, 33 | }); 34 | 35 | if (response.contentType !== "json") { 36 | throw new Error("Expected JSON response but got text"); 37 | } 38 | 39 | const data = response.data as { 40 | crates: Array<{ 41 | name: string; 42 | max_version: string; 43 | description?: string; 44 | }>; 45 | meta: { 46 | total: number; 47 | }; 48 | }; 49 | 50 | const crates: CrateInfo[] = data.crates.map((crate) => ({ 51 | name: crate.name, 52 | version: crate.max_version, 53 | description: crate.description, 54 | })); 55 | 56 | return { 57 | crates, 58 | totalCount: data.meta.total, 59 | }; 60 | } catch (error) { 61 | logger.error("error searching for crates", { error }); 62 | throw new Error(`failed to search for crates: ${(error as Error).message}`); 63 | } 64 | } 65 | 66 | /** 67 | * Get detailed information about a crate from crates.io 68 | */ 69 | export async function getCrateDetails(crateName: string): Promise<{ 70 | name: string; 71 | description?: string; 72 | versions: CrateVersion[]; 73 | downloads: number; 74 | homepage?: string; 75 | repository?: string; 76 | documentation?: string; 77 | }> { 78 | try { 79 | logger.info(`getting crate details for: ${crateName}`); 80 | 81 | const response = await cratesIoClient.get(`crates/${crateName}`); 82 | 83 | if (response.contentType !== "json") { 84 | throw new Error("Expected JSON response but got text"); 85 | } 86 | 87 | const data = response.data as { 88 | crate: { 89 | name: string; 90 | description?: string; 91 | downloads: number; 92 | homepage?: string; 93 | repository?: string; 94 | documentation?: string; 95 | }; 96 | versions: Array<{ 97 | num: string; 98 | yanked: boolean; 99 | created_at: string; 100 | }>; 101 | }; 102 | 103 | return { 104 | name: data.crate.name, 105 | description: data.crate.description, 106 | downloads: data.crate.downloads, 107 | homepage: data.crate.homepage, 108 | repository: data.crate.repository, 109 | documentation: data.crate.documentation, 110 | versions: data.versions.map((v) => ({ 111 | version: v.num, 112 | isYanked: v.yanked, 113 | releaseDate: v.created_at, 114 | })), 115 | }; 116 | } catch (error) { 117 | logger.error(`error getting crate details for: ${crateName}`, { error }); 118 | throw new Error( 119 | `failed to get crate details for ${crateName}: ${(error as Error).message}`, 120 | ); 121 | } 122 | } 123 | 124 | /** 125 | * Get documentation for a specific crate from docs.rs 126 | */ 127 | export async function getCrateDocumentation( 128 | crateName: string, 129 | version?: string, 130 | ): Promise<string> { 131 | try { 132 | logger.info( 133 | `getting documentation for crate: ${crateName}${version ? ` version ${version}` : ""}`, 134 | ); 135 | 136 | const path = version 137 | ? `crate/${crateName}/${version}` 138 | : `crate/${crateName}/latest`; 139 | 140 | const response = await docsRsClient.get(path); 141 | 142 | if (response.contentType !== "text") { 143 | throw new Error("Expected HTML response but got JSON"); 144 | } 145 | 146 | return turndownInstance.turndown(response.data); 147 | } catch (error) { 148 | logger.error(`error getting documentation for crate: ${crateName}`, { 149 | error, 150 | }); 151 | throw new Error( 152 | `failed to get documentation for crate ${crateName}: ${(error as Error).message}`, 153 | ); 154 | } 155 | } 156 | 157 | /** 158 | * Get type information for a specific item in a crate 159 | */ 160 | export async function getTypeInfo( 161 | crateName: string, 162 | path: string, 163 | version?: string, 164 | ): Promise<RustType> { 165 | try { 166 | logger.info(`Getting type info for ${path} in crate: ${crateName}`); 167 | 168 | const versionPath = version || "latest"; 169 | const fullPath = `${crateName}/${versionPath}/${crateName}/${path}`; 170 | 171 | const response = await docsRsClient.get(fullPath); 172 | 173 | if (response.contentType !== "text") { 174 | throw new Error("Expected HTML response but got JSON"); 175 | } 176 | 177 | const $ = cheerio.load(response.data); 178 | 179 | // Determine the kind of type 180 | let kind: RustType["kind"] = "other"; 181 | if ($(".struct").length) kind = "struct"; 182 | else if ($(".enum").length) kind = "enum"; 183 | else if ($(".trait").length) kind = "trait"; 184 | else if ($(".fn").length) kind = "function"; 185 | else if ($(".macro").length) kind = "macro"; 186 | else if ($(".typedef").length) kind = "type"; 187 | else if ($(".mod").length) kind = "module"; 188 | 189 | // Get description 190 | const description = $(".docblock").first().text().trim(); 191 | 192 | // Get source URL if available 193 | const sourceUrl = $(".src-link a").attr("href"); 194 | 195 | const name = path.split("/").pop() || path; 196 | 197 | return { 198 | name, 199 | kind, 200 | path, 201 | description: description || undefined, 202 | sourceUrl: sourceUrl || undefined, 203 | documentationUrl: `https://docs.rs${fullPath}`, 204 | }; 205 | } catch (error) { 206 | logger.error(`Error getting type info for ${path} in crate: ${crateName}`, { 207 | error, 208 | }); 209 | throw new Error(`Failed to get type info: ${(error as Error).message}`); 210 | } 211 | } 212 | 213 | /** 214 | * Get feature flags for a crate 215 | */ 216 | export async function getFeatureFlags( 217 | crateName: string, 218 | version?: string, 219 | ): Promise<FeatureFlag[]> { 220 | try { 221 | logger.info(`Getting feature flags for crate: ${crateName}`); 222 | 223 | const versionPath = version || "latest"; 224 | const response = await docsRsClient.get( 225 | `/crate/${crateName}/${versionPath}/features`, 226 | ); 227 | 228 | if (response.contentType !== "text") { 229 | throw new Error("Expected HTML response but got JSON"); 230 | } 231 | 232 | const $ = cheerio.load(response.data); 233 | const features: FeatureFlag[] = []; 234 | 235 | $(".feature").each((_, element) => { 236 | const name = $(element).find(".feature-name").text().trim(); 237 | const description = $(element).find(".feature-description").text().trim(); 238 | const enabled = $(element).hasClass("feature-enabled"); 239 | 240 | features.push({ 241 | name, 242 | description: description || undefined, 243 | enabled, 244 | }); 245 | }); 246 | 247 | return features; 248 | } catch (error) { 249 | logger.error(`Error getting feature flags for crate: ${crateName}`, { 250 | error, 251 | }); 252 | throw new Error(`Failed to get feature flags: ${(error as Error).message}`); 253 | } 254 | } 255 | 256 | /** 257 | * Get available versions for a crate from crates.io 258 | */ 259 | export async function getCrateVersions( 260 | crateName: string, 261 | ): Promise<CrateVersion[]> { 262 | try { 263 | logger.info(`getting versions for crate: ${crateName}`); 264 | 265 | const response = await cratesIoClient.get(`crates/${crateName}`); 266 | 267 | if (response.contentType !== "json") { 268 | throw new Error("Expected JSON response but got text"); 269 | } 270 | 271 | const data = response.data as { 272 | versions: Array<{ 273 | num: string; 274 | yanked: boolean; 275 | created_at: string; 276 | }>; 277 | }; 278 | 279 | return data.versions.map((v) => ({ 280 | version: v.num, 281 | isYanked: v.yanked, 282 | releaseDate: v.created_at, 283 | })); 284 | } catch (error) { 285 | logger.error(`error getting versions for crate: ${crateName}`, { 286 | error, 287 | }); 288 | throw new Error( 289 | `failed to get crate versions: ${(error as Error).message}`, 290 | ); 291 | } 292 | } 293 | 294 | /** 295 | * Get source code for a specific item 296 | */ 297 | export async function getSourceCode( 298 | crateName: string, 299 | path: string, 300 | version?: string, 301 | ): Promise<string> { 302 | try { 303 | logger.info(`Getting source code for ${path} in crate: ${crateName}`); 304 | 305 | const versionPath = version || "latest"; 306 | const response = await docsRsClient.get( 307 | `/crate/${crateName}/${versionPath}/src/${path}`, 308 | ); 309 | 310 | if (typeof response.data !== "string") { 311 | throw new Error("Expected HTML response but got JSON"); 312 | } 313 | 314 | const $ = cheerio.load(response.data); 315 | return $(".src").text(); 316 | } catch (error) { 317 | logger.error( 318 | `Error getting source code for ${path} in crate: ${crateName}`, 319 | { error }, 320 | ); 321 | throw new Error(`Failed to get source code: ${(error as Error).message}`); 322 | } 323 | } 324 | 325 | /** 326 | * Search for symbols within a crate 327 | */ 328 | export async function searchSymbols( 329 | crateName: string, 330 | query: string, 331 | version?: string, 332 | ): Promise<SymbolDefinition[]> { 333 | try { 334 | logger.info( 335 | `searching for symbols in crate: ${crateName} with query: ${query}`, 336 | ); 337 | 338 | try { 339 | const versionPath = version || "latest"; 340 | const response = await docsRsClient.get( 341 | `/${crateName}/${versionPath}/${crateName}/`, 342 | { 343 | params: { search: query }, 344 | }, 345 | ); 346 | 347 | if (typeof response.data !== "string") { 348 | throw new Error("Expected HTML response but got JSON"); 349 | } 350 | 351 | const $ = cheerio.load(response.data); 352 | const symbols: SymbolDefinition[] = []; 353 | 354 | $(".search-results a").each((_, element) => { 355 | const name = $(element).find(".result-name path").text().trim(); 356 | const kind = $(element).find(".result-name typename").text().trim(); 357 | const path = $(element).attr("href") || ""; 358 | 359 | symbols.push({ 360 | name, 361 | kind, 362 | path, 363 | }); 364 | }); 365 | 366 | return symbols; 367 | } catch (innerError: unknown) { 368 | // If we get a 404, try a different approach - search in the main documentation 369 | if (innerError instanceof Error && innerError.message.includes("404")) { 370 | logger.info( 371 | `Search endpoint not found for ${crateName}, trying alternative approach`, 372 | ); 373 | } 374 | 375 | // Re-throw other errors 376 | throw innerError; 377 | } 378 | } catch (error) { 379 | logger.error(`Error searching for symbols in crate: ${crateName}`, { 380 | error, 381 | }); 382 | throw new Error( 383 | `Failed to search for symbols: ${(error as Error).message}`, 384 | ); 385 | } 386 | } 387 | ```