cratesio-mcp 0.1.4

MCP server for querying crates.io - the Rust package registry
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
use std::sync::Arc;
use std::time::Duration;

use clap::{Parser, ValueEnum};
use cratesio_mcp::{prompts, resources, state::AppState, tools};
use tower::ServiceBuilder;
use tower::timeout::TimeoutLayer;
use tower_mcp::protocol::{
    CallToolParams, CompleteParams, CompleteResult, Completion, CompletionReference, McpRequest,
};
use tower_mcp::router::{RouterRequest, RouterResponse};
use tower_mcp::{HttpTransport, McpRouter, McpTracingLayer, StdioTransport};
use tower_resilience::bulkhead::BulkheadLayer;
use tower_resilience::cache::SharedCacheLayer;
use tower_resilience::ratelimiter::RateLimiterLayer;

#[derive(Debug, Clone, Copy, ValueEnum)]
enum Transport {
    Stdio,
    Http,
}

#[derive(Parser, Debug)]
#[command(name = "cratesio-mcp")]
#[command(about = "MCP server for querying crates.io", long_about = None)]
struct Args {
    /// Transport to use
    #[arg(short, long, default_value = "stdio")]
    transport: Transport,

    /// Maximum concurrent requests (concurrency limit)
    #[arg(long, default_value = "10")]
    max_concurrent: usize,

    /// Rate limit interval between crates.io API calls (in milliseconds)
    #[arg(long, default_value = "1000")]
    rate_limit_ms: u64,

    /// Log level
    #[arg(short, long, default_value = "info")]
    log_level: String,

    /// HTTP host to bind to (use 0.0.0.0 for public access)
    #[arg(long, default_value = "127.0.0.1")]
    host: String,

    /// HTTP port to bind to
    #[arg(short, long, default_value = "3000")]
    port: u16,

    /// Request timeout in seconds (for HTTP transport)
    #[arg(long, default_value = "30")]
    request_timeout_secs: u64,

    /// Minimal mode - only register tools (no prompts, resources, or completions).
    /// Use this to work around Claude Code MCP tool discovery issues.
    /// See: https://github.com/anthropics/claude-code/issues/2682
    #[arg(long, default_value = "false")]
    minimal: bool,

    /// Enable response caching for tool calls (HTTP transport only)
    #[arg(long, default_value = "true")]
    cache_enabled: bool,

    /// Cache TTL in seconds (how long cached responses are valid)
    #[arg(long, default_value = "300")]
    cache_ttl_secs: u64,

    /// Maximum number of cached responses
    #[arg(long, default_value = "200")]
    cache_max_size: usize,

    /// Maximum number of cached docs.rs rustdoc JSON entries
    #[arg(long, default_value = "10")]
    docs_cache_max_entries: usize,

    /// TTL for cached docs.rs rustdoc JSON entries (in seconds)
    #[arg(long, default_value = "3600")]
    docs_cache_ttl_secs: u64,
}

#[tokio::main]
async fn main() -> Result<(), tower_mcp::BoxError> {
    let args = Args::parse();

    // Initialize tracing
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::from_default_env()
                .add_directive(format!("cratesio_mcp={}", args.log_level).parse()?)
                .add_directive(format!("tower_mcp={}", args.log_level).parse()?),
        )
        .with_writer(std::io::stderr)
        .init();

    tracing::info!(
        transport = ?args.transport,
        max_concurrent = args.max_concurrent,
        rate_limit_ms = args.rate_limit_ms,
        "Starting cratesio-mcp server"
    );

    // Create shared state with rate limiting for crates.io API
    let rate_limit = Duration::from_millis(args.rate_limit_ms);
    let docs_cache_ttl = Duration::from_secs(args.docs_cache_ttl_secs);
    let state = Arc::new(
        AppState::new(rate_limit, args.docs_cache_max_entries, docs_cache_ttl)
            .map_err(|e| format!("Failed to create state: {}", e))?,
    );

    // Build all tools
    let search_tool = tools::search::build(state.clone());
    let info_tool = tools::info::build(state.clone());
    let versions_tool = tools::versions::build(state.clone());
    let deps_tool = tools::dependencies::build(state.clone());
    let reverse_deps_tool = tools::reverse_deps::build(state.clone());
    let downloads_tool = tools::downloads::build(state.clone());
    let owners_tool = tools::owners::build(state.clone());
    let summary_tool = tools::summary::build(state.clone());
    let authors_tool = tools::authors::build(state.clone());
    let user_tool = tools::user::build(state.clone());
    let readme_tool = tools::readme::build(state.clone());
    let categories_tool = tools::categories::build(state.clone());
    let keywords_tool = tools::keywords::build(state.clone());
    let version_downloads_tool = tools::version_downloads::build(state.clone());
    let version_detail_tool = tools::version_detail::build(state.clone());
    let category_tool = tools::category::build(state.clone());
    let keyword_detail_tool = tools::keyword_detail::build(state.clone());
    let get_crate_docs_tool = tools::crate_docs::build(state.clone());
    let get_doc_item_tool = tools::doc_item::build(state.clone());
    let search_docs_tool = tools::search_docs::build(state.clone());
    let audit_tool = tools::audit::build(state.clone());
    let features_tool = tools::features::build(state.clone());
    let user_stats_tool = tools::user_stats::build(state.clone());
    let compare_tool = tools::compare::build(state.clone());
    let dependency_tree_tool = tools::dependency_tree::build(state.clone());
    let health_check_tool = tools::health_check::build(state.clone());
    let find_alternatives_tool = tools::alternatives::build(state.clone());

    // Create base router with tools (always registered)
    let instructions = if args.minimal {
        "MCP server for querying crates.io - the Rust package registry.\n\n\
         Available tools:\n\
         - search_crates: Find crates by name/keywords\n\
         - get_crate_info: Get detailed crate information\n\
         - get_crate_versions: Get version history\n\
         - get_crate_readme: Get README content for a crate\n\
         - get_dependencies: Get dependencies for a version\n\
         - get_reverse_dependencies: Find crates that depend on this crate\n\
         - get_downloads: Get download statistics\n\
         - get_owners: Get crate owners/maintainers\n\
         - get_summary: Get crates.io global statistics\n\
         - get_crate_authors: Get authors for a crate version\n\
         - get_user: Get a user's profile\n\
         - get_categories: Browse crates.io categories\n\
         - get_keywords: Browse crates.io keywords\n\
         - get_version_downloads: Daily download stats for a specific version\n\
         - get_crate_version: Detailed metadata for a specific version\n\
         - get_category: Details about a specific category\n\
         - get_keyword: Details about a specific keyword\n\
         - get_crate_docs: Browse crate documentation structure from docs.rs\n\
         - get_doc_item: Get full documentation for a specific item from docs.rs\n\
         - search_docs: Search for items by name within a crate's docs\n\
         - audit_dependencies: Check deps against OSV.dev vulnerability database\n\
         - get_crate_features: Get feature flags for a crate version\n\
         - get_user_stats: Get download statistics for a crates.io user\n\
         - compare_crates: Compare two or more crates side by side\n\
         - get_dependency_tree: Get full transitive dependency tree for a crate\n\
         - crate_health_check: Comprehensive health report for a crate\n\
         - find_alternatives: Find and compare alternative crates for a given crate\n\n\
         (Running in minimal mode - resources, prompts, and completions disabled)"
    } else {
        "MCP server for querying crates.io - the Rust package registry.\n\n\
         Available tools:\n\
         - search_crates: Find crates by name/keywords\n\
         - get_crate_info: Get detailed crate information\n\
         - get_crate_versions: Get version history\n\
         - get_crate_readme: Get README content for a crate\n\
         - get_dependencies: Get dependencies for a version\n\
         - get_reverse_dependencies: Find crates that depend on this crate\n\
         - get_downloads: Get download statistics\n\
         - get_owners: Get crate owners/maintainers\n\
         - get_summary: Get crates.io global statistics\n\
         - get_crate_authors: Get authors for a crate version\n\
         - get_user: Get a user's profile\n\
         - get_categories: Browse crates.io categories\n\
         - get_keywords: Browse crates.io keywords\n\
         - get_version_downloads: Daily download stats for a specific version\n\
         - get_crate_version: Detailed metadata for a specific version\n\
         - get_category: Details about a specific category\n\
         - get_keyword: Details about a specific keyword\n\
         - get_crate_docs: Browse crate documentation structure from docs.rs\n\
         - get_doc_item: Get full documentation for a specific item from docs.rs\n\
         - search_docs: Search for items by name within a crate's docs\n\
         - audit_dependencies: Check deps against OSV.dev vulnerability database\n\
         - get_crate_features: Get feature flags for a crate version\n\
         - get_user_stats: Get download statistics for a crates.io user\n\
         - compare_crates: Compare two or more crates side by side\n\
         - get_dependency_tree: Get full transitive dependency tree for a crate\n\
         - crate_health_check: Comprehensive health report for a crate\n\
         - find_alternatives: Find and compare alternative crates for a given crate\n\n\
         Resources:\n\
         - crates://{name}/info: Get crate info as a resource\n\
         - crates://{name}/readme: Get README content for a crate\n\
         - crates://{name}/docs: Get documentation structure for a crate\n\n\
         Use the prompts for guided analysis:\n\
         - analyze_crate: Comprehensive crate analysis\n\
         - compare_crates: Compare multiple crates\n\
         - stack_review: Evaluate a set of crates as a cohesive stack\n\
         - evaluate_dependencies: Evaluate project dependencies for health and security\n\
         - recommend_crates: Find and evaluate crates for a use case\n\
         - migration_guide: Generate a migration guide between two crates"
    };

    let mut router = McpRouter::new()
        .server_info("cratesio-mcp", env!("CARGO_PKG_VERSION"))
        .instructions(instructions)
        .tool(search_tool)
        .tool(info_tool)
        .tool(versions_tool)
        .tool(deps_tool)
        .tool(reverse_deps_tool)
        .tool(downloads_tool)
        .tool(owners_tool)
        .tool(summary_tool)
        .tool(authors_tool)
        .tool(user_tool)
        .tool(readme_tool)
        .tool(categories_tool)
        .tool(keywords_tool)
        .tool(version_downloads_tool)
        .tool(version_detail_tool)
        .tool(category_tool)
        .tool(keyword_detail_tool)
        .tool(get_crate_docs_tool)
        .tool(get_doc_item_tool)
        .tool(search_docs_tool)
        .tool(audit_tool)
        .tool(features_tool)
        .tool(user_stats_tool)
        .tool(compare_tool)
        .tool(dependency_tree_tool)
        .tool(health_check_tool)
        .tool(find_alternatives_tool);

    // Add resources, prompts, and completions unless in minimal mode
    // Minimal mode works around Claude Code MCP tool discovery issues
    // See: https://github.com/anthropics/claude-code/issues/2682
    if !args.minimal {
        // Build resources
        let recent_searches = resources::recent_searches::build(state.clone());
        let crate_info_template = resources::crate_info::build(state.clone());
        let readme_template = resources::readme::build(state.clone());
        let docs_template = resources::docs::build(state.clone());

        // Build prompts
        let analyze_prompt = prompts::analyze::build();
        let compare_prompt = prompts::compare::build();
        let stack_review_prompt = prompts::stack_review::build();
        let evaluate_dependencies_prompt = prompts::evaluate_dependencies::build();
        let recommend_prompt = prompts::recommend::build();
        let migration_guide_prompt = prompts::migration_guide::build();

        // Popular crates for completion suggestions
        let popular_crates = vec![
            "serde",
            "tokio",
            "anyhow",
            "thiserror",
            "clap",
            "tracing",
            "reqwest",
            "axum",
            "tower",
            "hyper",
            "futures",
            "async-trait",
            "rand",
            "regex",
            "chrono",
            "uuid",
            "log",
            "env_logger",
            "syn",
            "quote",
            "proc-macro2",
            "bytes",
            "http",
            "tonic",
            "prost",
            "sqlx",
            "diesel",
            "actix-web",
            "rocket",
            "warp",
            "tide",
            "poem",
            "salvo",
        ];

        router = router
            .resource(recent_searches)
            .resource_template(crate_info_template)
            .resource_template(readme_template)
            .resource_template(docs_template)
            .prompt(analyze_prompt)
            .prompt(compare_prompt)
            .prompt(stack_review_prompt)
            .prompt(evaluate_dependencies_prompt)
            .prompt(recommend_prompt)
            .prompt(migration_guide_prompt)
            // Completion handler for crate name suggestions
            .completion_handler(move |params: CompleteParams| {
                let popular = popular_crates.clone();
                async move {
                    let prefix = params.argument.value.to_lowercase();

                    // Filter popular crates by prefix
                    let suggestions: Vec<String> = popular
                        .iter()
                        .filter(|name| name.starts_with(&prefix))
                        .take(10)
                        .map(|name| name.to_string())
                        .collect();

                    // Log what we're completing for
                    match &params.reference {
                        CompletionReference::Prompt { name } => {
                            tracing::debug!(%name, %prefix, "Completing prompt argument");
                        }
                        CompletionReference::Resource { uri } => {
                            tracing::debug!(%uri, %prefix, "Completing resource URI");
                        }
                        _ => {
                            tracing::debug!(%prefix, "Completing unknown reference type");
                        }
                    }

                    Ok(CompleteResult {
                        completion: Completion {
                            values: suggestions,
                            total: None,
                            has_more: Some(false),
                        },
                        meta: None,
                    })
                }
            });

        tracing::info!("Full mode: resources, prompts, and completions enabled");
    } else {
        tracing::info!(
            "Minimal mode: only tools registered (workaround for Claude Code MCP issues)"
        );
    }

    let router = router;

    match args.transport {
        Transport::Stdio => {
            // For stdio, we serve directly without middleware since error handling
            // is more complex (would need error type conversion).
            tracing::info!("Serving over stdio");
            StdioTransport::new(router).run().await?;
        }
        Transport::Http => {
            let addr = format!("{}:{}", args.host, args.port);
            tracing::info!(
                %addr,
                cache_enabled = args.cache_enabled,
                cache_ttl_secs = args.cache_ttl_secs,
                cache_max_size = args.cache_max_size,
                "Serving over HTTP"
            );

            // Build tower middleware stack for request protection:
            //
            // 1. TimeoutLayer - Request timeout protection
            // 2. RateLimiterLayer - Limits requests per second (token bucket)
            // 3. BulkheadLayer - Limits concurrent in-flight requests
            // 4. CacheLayer - Response caching for tool calls (optional)
            //
            // These layers compose naturally with tower-mcp's Service implementation.
            // The HTTP transport's CatchError wrapper converts middleware errors
            // to JSON-RPC error responses.
            //
            // tower-resilience layers use composite error types that wrap both
            // the layer's own errors and the inner service error, making them
            // compatible with tower-mcp's Infallible error type.
            //
            // Note: CircuitBreakerLayer could be added for downstream service failures
            // (e.g., crates.io API), but McpRouter returns Infallible so the breaker
            // would need a custom failure classifier to inspect response content.
            let rate_limiter = RateLimiterLayer::builder()
                .limit_for_period(10) // 10 requests per second
                .refresh_period(Duration::from_secs(1))
                .timeout_duration(Duration::from_millis(500))
                .build();

            let bulkhead = BulkheadLayer::builder()
                .max_concurrent_calls(args.max_concurrent)
                .max_wait_duration(Duration::from_millis(500))
                .build();

            // Response caching for tool calls using SharedCacheLayer.
            // SharedCacheLayer shares the cache store across all layer() calls,
            // so all HTTP sessions share the same cache (unlike regular CacheLayer).
            // The key extractor creates cache keys only for tool calls (tools/call).
            // Other MCP methods (list_tools, initialize, ping) get unique keys
            // that never match, effectively bypassing the cache.
            let cache: SharedCacheLayer<RouterRequest, String, RouterResponse> =
                SharedCacheLayer::builder()
                    .max_size(args.cache_max_size)
                    .ttl(Duration::from_secs(args.cache_ttl_secs))
                    .key_extractor(|req: &RouterRequest| -> String {
                        // Only cache tool calls - create deterministic key from tool name + args
                        match &req.inner {
                            McpRequest::CallTool(CallToolParams {
                                name, arguments, ..
                            }) => {
                                // Serialize arguments to create stable cache key
                                let args_str = serde_json::to_string(arguments).unwrap_or_default();
                                format!("tool:{}:{}", name, args_str)
                            }
                            // For all other requests, use unique key based on request ID
                            // This ensures they're never cached (each request ID is unique)
                            _ => format!("nocache:{:?}", req.id),
                        }
                    })
                    .on_hit(|| tracing::debug!("Cache hit"))
                    .on_miss(|| tracing::debug!("Cache miss"))
                    .build();

            let builder = ServiceBuilder::new()
                // Outer layers (applied first on request, last on response)
                .layer(TimeoutLayer::new(Duration::from_secs(
                    args.request_timeout_secs,
                )))
                .layer(rate_limiter)
                .layer(bulkhead);

            // Conditionally add cache layer
            // optional_sessions() allows clients that don't carry mcp-session-id
            // forward (e.g. Codex CLI, Cursor) to still call tools.
            // See: https://github.com/joshrotenberg/cratesio-mcp/issues/61
            let transport = if args.cache_enabled {
                HttpTransport::new(router)
                    .disable_origin_validation()
                    .optional_sessions()
                    .layer(
                        builder
                            .layer(cache)
                            .layer(McpTracingLayer::new())
                            .into_inner(),
                    )
            } else {
                HttpTransport::new(router)
                    .disable_origin_validation()
                    .optional_sessions()
                    .layer(builder.layer(McpTracingLayer::new()).into_inner())
            };

            transport.serve(&addr).await?;
        }
    }

    Ok(())
}