diff --git a/README.md b/README.md index a273c50..33c98c3 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,22 @@ cargo build --release ### CLI Commands ```bash -rust-docs-mcp # Start MCP server +rust-docs-mcp # Start MCP server (same as `serve`) +rust-docs-mcp serve # Start MCP server explicitly + +# One-shot operations (stateless – print JSON to stdout and exit) +rust-docs-mcp call cache-crate \ + --params '{"crate_name":"serde","source_type":"cratesio","version":"1.0.215"}' + +rust-docs-mcp call search-items-fuzzy \ + --params '{"crate_name":"serde","version":"1.0.215","query":"Deserialize","limit":10}' + +rust-docs-mcp call list-cached-crates + +rust-docs-mcp call list-crate-versions \ + --params '{"crate_name":"serde"}' + +# Maintenance commands rust-docs-mcp install # Install to ~/.local/bin rust-docs-mcp install --force # Force overwrite existing installation rust-docs-mcp doctor # Verify system environment and dependencies @@ -227,6 +242,19 @@ rust-docs-mcp update # Update to latest version from GitHub rust-docs-mcp --help # Show help ``` +> **Note:** `call cache-crate` is **blocking** — it downloads the crate, +> generates documentation, and builds the search index before returning. +> This is different from MCP mode where `cache_crate` returns a task ID +> immediately and completes in the background. If a one-shot tool returns +> an error JSON response, the CLI prints that JSON to stdout and exits with +> status 1. +> +> Available one-shot tools: `cache-crate`, `search-items-fuzzy`, +> `search-items-preview`, `search-items`, `list-crate-items`, +> `get-item-details`, `get-item-docs`, `get-item-source`, +> `list-cached-crates`, `list-crate-versions`, `get-dependencies`, +> `structure`. + ### Troubleshooting If you encounter issues during installation or runtime, run the doctor command diff --git a/rust-docs-mcp/src/cache/tools.rs b/rust-docs-mcp/src/cache/tools.rs index 3cc271b..aac311c 100644 --- a/rust-docs-mcp/src/cache/tools.rs +++ b/rust-docs-mcp/src/cache/tools.rs @@ -191,6 +191,69 @@ pub struct CacheTools { task_manager: Arc, } +/// Convert [`CacheCrateParams`] into a [`CrateSource`], returning a +/// user-facing error string when validation fails. +pub fn params_to_source_checked(params: &CacheCrateParams) -> Result { + match params.source_type.as_str() { + "cratesio" => { + let version = params.version.clone().ok_or_else(|| { + "Missing required parameter 'version' for source_type='cratesio'".to_string() + })?; + Ok(CrateSource::CratesIO(CacheCrateFromCratesIOParams { + crate_name: params.crate_name.clone(), + version, + members: params.members.clone(), + update: params.update, + })) + } + "github" => { + let github_url = params.github_url.clone().ok_or_else(|| { + "Missing required parameter 'github_url' for source_type='github'".to_string() + })?; + + match (¶ms.branch, ¶ms.tag) { + (Some(_), Some(_)) => { + return Err( + "Only one of 'branch' or 'tag' can be specified for source_type='github', not both" + .to_string(), + ); + } + (None, None) => { + return Err( + "Either 'branch' or 'tag' must be specified for source_type='github'" + .to_string(), + ); + } + _ => {} + } + + Ok(CrateSource::GitHub(CacheCrateFromGitHubParams { + crate_name: params.crate_name.clone(), + github_url, + branch: params.branch.clone(), + tag: params.tag.clone(), + members: params.members.clone(), + update: params.update, + })) + } + "local" => { + let path = params.path.clone().ok_or_else(|| { + "Missing required parameter 'path' for source_type='local'".to_string() + })?; + Ok(CrateSource::LocalPath(CacheCrateFromLocalParams { + crate_name: params.crate_name.clone(), + version: params.version.clone(), + path, + members: params.members.clone(), + update: params.update, + })) + } + other => Err(format!( + "Invalid source_type '{other}'. Must be one of: 'cratesio', 'github', 'local'" + )), + } +} + impl CacheTools { /// Create a new CacheTools instance pub fn new(cache: Arc>, task_manager: Arc) -> Self { @@ -571,96 +634,88 @@ impl CacheTools { } } - /// Unified cache_crate method that accepts all source types + /// Build task metadata from an already-validated source. /// - /// Validates parameters, spawns async task, and returns immediately with task ID. - /// Returns JSON-formatted [`CacheTaskStartedOutput`] for structured monitoring. - pub async fn cache_crate(&self, params: CacheCrateParams) -> String { - // Validate and extract source details for task creation - let (crate_name, version, source_details) = match params.source_type.as_str() { - "cratesio" => { - let version = match ¶ms.version { - Some(v) => v.clone(), - None => { - return "# Error\n\nMissing required parameter 'version' for source_type='cratesio'".to_string(); - } - }; - (params.crate_name.clone(), version, None) - } - "github" => { - let github_url = match ¶ms.github_url { - Some(url) => url.clone(), - None => { - return "# Error\n\nMissing required parameter 'github_url' for source_type='github'".to_string(); - } - }; - - match (¶ms.branch, ¶ms.tag) { + /// For local paths, this also resolves and stores the effective version so + /// the spawned background task does not need to rebuild the source from raw + /// params. + fn task_metadata_from_source( + source: &mut CrateSource, + ) -> Result<(String, String, String, Option), String> { + match source { + CrateSource::CratesIO(params) => Ok(( + params.crate_name.clone(), + params.version.clone(), + "cratesio".to_string(), + None, + )), + CrateSource::GitHub(params) => { + let (version, ref_type) = match (¶ms.branch, ¶ms.tag) { + (Some(branch), None) => (branch.clone(), "branch"), + (None, Some(tag)) => (tag.clone(), "tag"), (Some(_), Some(_)) => { - return "# Error\n\nOnly one of 'branch' or 'tag' can be specified for source_type='github', not both".to_string(); + return Err( + "Only one of 'branch' or 'tag' can be specified for source_type='github', not both" + .to_string(), + ); } (None, None) => { - return "# Error\n\nEither 'branch' or 'tag' must be specified for source_type='github'".to_string(); - } - _ => {} - } - - let version = params - .branch - .clone() - .or_else(|| params.tag.clone()) - .unwrap(); - let ref_type = if params.branch.is_some() { - "branch" - } else { - "tag" - }; - let details = format!("{github_url}, {ref_type}: {version}"); - (params.crate_name.clone(), version, Some(details)) - } - "local" => { - let path = match ¶ms.path { - Some(p) => p.clone(), - None => { - return "# Error\n\nMissing required parameter 'path' for source_type='local'".to_string(); + return Err( + "Either 'branch' or 'tag' must be specified for source_type='github'" + .to_string(), + ); } }; - // Resolve version synchronously before creating task (fixes bug #2) + Ok(( + params.crate_name.clone(), + version.clone(), + "github".to_string(), + Some(format!("{}, {ref_type}: {version}", params.github_url)), + )) + } + CrateSource::LocalPath(params) => { let (version, auto_detected) = - match Self::resolve_local_version(&path, params.version.as_deref()) { - Ok(result) => result, - Err(error_msg) => { - return format!("# Error\n\n{error_msg}"); - } - }; + Self::resolve_local_version(¶ms.path, params.version.as_deref())?; + params.version = Some(version.clone()); - // Add auto-detection note to source details let details = if auto_detected { - format!("{path} (version auto-detected from Cargo.toml)") + format!("{} (version auto-detected from Cargo.toml)", params.path) } else { - path + params.path.clone() }; - (params.crate_name.clone(), version, Some(details)) - } - _ => { - return format!( - "# Error\n\nInvalid source_type '{}'. Must be one of: 'cratesio', 'github', 'local'", - params.source_type - ); + Ok(( + params.crate_name.clone(), + version, + "local".to_string(), + Some(details), + )) } + } + } + + /// Unified cache_crate method that accepts all source types + /// + /// Validates parameters, spawns async task, and returns immediately with task ID. + /// Returns JSON-formatted [`CacheTaskStartedOutput`] for structured monitoring. + pub async fn cache_crate(&self, params: CacheCrateParams) -> String { + let mut crate_source = match params_to_source_checked(¶ms) { + Ok(source) => source, + Err(error) => return format!("# Error\n\n{error}"), }; + // Validate and extract source details for task creation. + let (crate_name, version, source_type, source_details) = + match Self::task_metadata_from_source(&mut crate_source) { + Ok(metadata) => metadata, + Err(error) => return format!("# Error\n\n{error}"), + }; + // Create task let task = self .task_manager - .create_task( - crate_name, - version, - params.source_type.clone(), - source_details, - ) + .create_task(crate_name, version, source_type, source_details) .await; // Update status to InProgress before returning (fixes race condition bug #1) @@ -673,12 +728,8 @@ impl CacheTools { let task_manager = self.task_manager.clone(); let task_id = task.task_id.clone(); let cancellation_token = task.cancellation_token.clone(); - let params = params.clone(); // Clone params for the spawned task tokio::spawn(async move { - // Build CrateSource from params - let crate_source = Self::params_to_source(¶ms); - // Run the caching operation let cache_guard = cache.write().await; @@ -758,32 +809,17 @@ impl CacheTools { output.to_json() } - /// Helper to convert CacheCrateParams to CrateSource - fn params_to_source(params: &CacheCrateParams) -> CrateSource { - match params.source_type.as_str() { - "cratesio" => CrateSource::CratesIO(CacheCrateFromCratesIOParams { - crate_name: params.crate_name.clone(), - version: params.version.clone().unwrap(), - members: params.members.clone(), - update: params.update, - }), - "github" => CrateSource::GitHub(CacheCrateFromGitHubParams { - crate_name: params.crate_name.clone(), - github_url: params.github_url.clone().unwrap(), - branch: params.branch.clone(), - tag: params.tag.clone(), - members: params.members.clone(), - update: params.update, - }), - "local" => CrateSource::LocalPath(CacheCrateFromLocalParams { - crate_name: params.crate_name.clone(), - version: params.version.clone(), - path: params.path.clone().unwrap(), - members: params.members.clone(), - update: params.update, - }), - _ => unreachable!("Invalid source type should have been caught earlier"), - } + /// Blocking (one-shot) cache: validate params, run the full pipeline, + /// and return the result JSON. The call does **not** return until + /// download, doc generation, and search-index creation finish. + pub async fn cache_crate_blocking(&self, params: CacheCrateParams) -> String { + let source = match params_to_source_checked(¶ms) { + Ok(source) => source, + Err(error) => return CacheCrateOutput::Error { error }.to_json(), + }; + + let cache = self.cache.write().await; + cache.cache_crate_with_source(source, None, None).await } /// Unified cache_operations method for managing and monitoring caching tasks diff --git a/rust-docs-mcp/src/cli.rs b/rust-docs-mcp/src/cli.rs new file mode 100644 index 0000000..f4f241c --- /dev/null +++ b/rust-docs-mcp/src/cli.rs @@ -0,0 +1,180 @@ +//! One-shot CLI adapter over the shared [`RustDocsRuntime`](crate::runtime::RustDocsRuntime). +//! +//! Takes a tool name and optional JSON params, runs the operation through +//! the runtime, and returns the result string to be printed on stdout. + +use std::path::PathBuf; + +use anyhow::{Context, Result, bail}; + +use crate::analysis::tools::AnalyzeCrateStructureParams; +use crate::cache::tools::{CacheCrateParams, ListCrateVersionsParams}; +use crate::deps::tools::GetDependenciesParams; +use crate::docs::tools::{ + GetItemDetailsParams, GetItemDocsParams, GetItemSourceParams, ListItemsParams, + SearchItemsParams, SearchItemsPreviewParams, +}; +use crate::runtime::RustDocsRuntime; +use crate::search::tools::SearchItemsFuzzyParams; + +/// Recognised one-shot tools (subset of MCP tools). +#[derive(Debug, Clone, clap::ValueEnum)] +#[clap(rename_all = "kebab-case")] +pub enum CliTool { + /// Blocking cache operation + CacheCrate, + /// Fuzzy search with Tantivy + SearchItemsFuzzy, + /// Exact search — preview only + SearchItemsPreview, + /// Exact search — full details + SearchItems, + /// List all items in a crate + ListCrateItems, + /// Full details for an item by id + GetItemDetails, + /// Documentation text only for an item + GetItemDocs, + /// Source code for an item + GetItemSource, + /// List all locally cached crates + ListCachedCrates, + /// List locally cached versions of a crate + ListCrateVersions, + /// Get dependency information + GetDependencies, + /// View hierarchical structure tree + Structure, +} + +/// Result of a one-shot CLI tool invocation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliCallResult { + /// Tool output to emit on stdout. + pub output: String, + /// Whether the output represents a tool-level failure. + pub failed: bool, +} + +/// Run the requested tool and return its output string. +/// +/// `params_json` may be `None` for parameterless tools; otherwise it +/// should be the JSON representation of the tool's parameter struct. +pub async fn call( + cache_dir: Option, + tool: CliTool, + params_json: Option, +) -> Result { + Ok(call_with_status(cache_dir, tool, params_json).await?.output) +} + +/// Run the requested tool and return both output and failure status. +/// +/// Tool-level failures are represented as JSON on stdout. This wrapper lets +/// binary callers preserve that stdout while still choosing a non-zero exit. +pub async fn call_with_status( + cache_dir: Option, + tool: CliTool, + params_json: Option, +) -> Result { + let output = call_output(cache_dir, tool, params_json).await?; + let failed = output_indicates_error(&output); + Ok(CliCallResult { output, failed }) +} + +async fn call_output( + cache_dir: Option, + tool: CliTool, + params_json: Option, +) -> Result { + let runtime = RustDocsRuntime::new(cache_dir)?; + + match tool { + CliTool::CacheCrate => { + let params: CacheCrateParams = parse_params("params", params_json)?; + Ok(runtime.cache_crate_blocking(params).await) + } + CliTool::SearchItemsFuzzy => { + let params: SearchItemsFuzzyParams = parse_params("params", params_json)?; + Ok(runtime.search_items_fuzzy(params).await) + } + CliTool::SearchItemsPreview => { + let params: SearchItemsPreviewParams = parse_params("params", params_json)?; + Ok(runtime.search_items_preview(params).await) + } + CliTool::SearchItems => { + let params: SearchItemsParams = parse_params("params", params_json)?; + Ok(runtime.search_items(params).await) + } + CliTool::ListCrateItems => { + let params: ListItemsParams = parse_params("params", params_json)?; + Ok(runtime.list_crate_items(params).await) + } + CliTool::GetItemDetails => { + let params: GetItemDetailsParams = parse_params("params", params_json)?; + Ok(runtime.get_item_details(params).await) + } + CliTool::GetItemDocs => { + let params: GetItemDocsParams = parse_params("params", params_json)?; + Ok(runtime.get_item_docs(params).await) + } + CliTool::GetItemSource => { + let params: GetItemSourceParams = parse_params("params", params_json)?; + Ok(runtime.get_item_source(params).await) + } + CliTool::ListCachedCrates => { + ensure_empty_params("list-cached-crates", params_json)?; + Ok(runtime.list_cached_crates().await) + } + CliTool::ListCrateVersions => { + let params: ListCrateVersionsParams = parse_params("params", params_json)?; + Ok(runtime.list_crate_versions(params).await) + } + CliTool::GetDependencies => { + let params: GetDependenciesParams = parse_params("params", params_json)?; + Ok(runtime.get_dependencies(params).await) + } + CliTool::Structure => { + let params: AnalyzeCrateStructureParams = parse_params("params", params_json)?; + Ok(runtime.structure(params).await) + } + } +} + +/// Parse JSON params (or `{}` if missing) into `T`, with a helpful +/// error message wrapping the parameter name. +fn parse_params(name: &str, json: Option) -> Result { + let json = json.unwrap_or_else(|| "{}".to_string()); + serde_json::from_str::(&json) + .with_context(|| format!("Failed to parse {name} as {}", std::any::type_name::())) +} + +fn ensure_empty_params(tool_name: &str, json: Option) -> Result<()> { + let Some(json) = json else { + return Ok(()); + }; + + let value: serde_json::Value = serde_json::from_str(&json) + .with_context(|| format!("Failed to parse params for {tool_name}"))?; + + match value { + serde_json::Value::Object(map) if map.is_empty() => Ok(()), + _ => bail!("{tool_name} does not accept parameters; omit --params or pass {{}}"), + } +} + +/// Return true when a tool output JSON object represents an error. +/// +/// The existing output types encode failures either as `{ "error": ... }` +/// or as a tagged cache response `{ "status": "error", ... }`. +pub fn output_indicates_error(output: &str) -> bool { + let Ok(serde_json::Value::Object(object)) = serde_json::from_str(output) else { + return false; + }; + + object + .get("status") + .and_then(serde_json::Value::as_str) + .is_some_and(|status| status == "error") + || object.contains_key("error") +} diff --git a/rust-docs-mcp/src/lib.rs b/rust-docs-mcp/src/lib.rs index 20c26b9..de89d13 100644 --- a/rust-docs-mcp/src/lib.rs +++ b/rust-docs-mcp/src/lib.rs @@ -1,10 +1,13 @@ pub mod analysis; pub mod cache; +pub mod cli; pub mod deps; pub mod docs; +pub mod runtime; pub mod rustdoc; pub mod search; pub mod service; pub mod util; +pub use runtime::RustDocsRuntime; pub use service::RustDocsService; diff --git a/rust-docs-mcp/src/main.rs b/rust-docs-mcp/src/main.rs index 0ddc0a7..fcd0549 100644 --- a/rust-docs-mcp/src/main.rs +++ b/rust-docs-mcp/src/main.rs @@ -1,6 +1,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; use rmcp::{ServiceExt, transport::stdio}; +use std::io::Write; use std::path::PathBuf; use std::process; use tracing_subscriber::EnvFilter; @@ -23,6 +24,22 @@ struct Args { #[derive(Subcommand, Debug)] enum Commands { + /// Start the MCP stdio server (default mode) + Serve, + /// Execute a one-shot tool and print the result + Call { + /// Tool to invoke + #[arg(value_enum)] + tool: rust_docs_mcp::cli::CliTool, + + /// JSON parameters for the tool (inline) + #[arg(long, conflicts_with = "params_file")] + params: Option, + + /// Path to a file containing JSON parameters + #[arg(long, conflicts_with = "params")] + params_file: Option, + }, /// Install the current executable to a directory in PATH Install { /// Target directory to install to (defaults to ~/.local/bin) @@ -58,8 +75,14 @@ async fn main() -> Result<()> { let args = Args::parse(); // Handle subcommands - if let Some(command) = args.command { - return handle_command(command, args.cache_dir).await; + match &args.command { + // MCP serve: explicit `serve` or no subcommand at all. + None | Some(Commands::Serve) => { + // If there are other subcommand args we skip tracing init. + } + Some(_) => { + return handle_command(args.command.unwrap(), args.cache_dir).await; + } } // Initialize tracing to stderr to avoid conflicts with stdio transport @@ -87,8 +110,43 @@ async fn main() -> Result<()> { Ok(()) } +/// Read params from `--params` or `--params-file`, returning the raw +/// JSON string (or `None` when neither is provided). +fn read_params(inline: Option, file: Option) -> Result> { + match (inline, file) { + (Some(json), _) => Ok(Some(json)), + (_, Some(path)) => { + let content = std::fs::read_to_string(&path).map_err(|e| { + anyhow::anyhow!("Failed to read params file {}: {e}", path.display()) + })?; + Ok(Some(content)) + } + (None, None) => Ok(None), + } +} + async fn handle_command(command: Commands, cache_dir: Option) -> Result<()> { match command { + Commands::Serve => { + // Handled in main before tracing init; unreachable here. + unreachable!("Serve is handled in main") + } + Commands::Call { + tool, + params, + params_file, + } => { + let params_json = read_params(params, params_file)?; + let result = rust_docs_mcp::cli::call_with_status(cache_dir, tool, params_json).await?; + println!("{}", result.output); + std::io::stdout().flush()?; + + if result.failed { + process::exit(1); + } + + Ok(()) + } Commands::Install { target_dir, force } => install_executable(target_dir, force).await, Commands::Update { target_dir, diff --git a/rust-docs-mcp/src/runtime.rs b/rust-docs-mcp/src/runtime.rs new file mode 100644 index 0000000..eff1023 --- /dev/null +++ b/rust-docs-mcp/src/runtime.rs @@ -0,0 +1,184 @@ +//! Transport-free application runtime shared by MCP and CLI adapters. +//! +//! [`RustDocsRuntime`] owns the cache, docs, search, deps, and analysis tool +//! instances and exposes one method per logical operation. Each method returns +//! the final `String` intended for stdout (CLI) or raw MCP response. + +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; + +use anyhow::Result; + +use crate::analysis::tools::{AnalysisTools, AnalyzeCrateStructureParams}; +use crate::cache::{ + CrateCache, + task_manager::TaskManager, + tools::{ + CacheCrateParams, CacheOperationsParams, CacheTools, GetCratesMetadataParams, + ListCrateVersionsParams, RemoveCrateParams, + }, +}; +use crate::deps::tools::{DepsTools, GetDependenciesParams}; +use crate::docs::tools::{ + DocsTools, GetItemDetailsParams, GetItemDocsParams, GetItemSourceParams, ListItemsParams, + SearchItemsParams, SearchItemsPreviewParams, +}; +use crate::search::tools::{SearchItemsFuzzyParams, SearchTools}; + +/// Shared application runtime owning all tool facets. +/// +/// Constructed once per invocation; each public method performs the +/// underlying operation synchronously from the caller's perspective +/// and returns a ready-to-emit `String`. +#[derive(Debug, Clone)] +pub struct RustDocsRuntime { + cache_tools: CacheTools, + docs_tools: DocsTools, + deps_tools: DepsTools, + analysis_tools: AnalysisTools, + search_tools: SearchTools, +} + +impl RustDocsRuntime { + /// Create a new runtime with the given cache directory. + /// + /// `cache_dir: None` uses the default `~/.rust-docs-mcp/cache`. + pub fn new(cache_dir: Option) -> Result { + let cache = Arc::new(RwLock::new(CrateCache::new(cache_dir)?)); + let task_manager = Arc::new(TaskManager::new()); + + Ok(Self { + cache_tools: CacheTools::new(cache.clone(), task_manager), + docs_tools: DocsTools::new(cache.clone()), + deps_tools: DepsTools::new(cache.clone()), + analysis_tools: AnalysisTools::new(cache.clone()), + search_tools: SearchTools::new(cache), + }) + } + + // ── Cache ───────────────────────────────────────────────────── + + /// MCP-style asynchronous (background) cache. + /// + /// Validates params, spawns a tracked background task, and returns + /// a [`CacheTaskStartedOutput`] JSON immediately. + pub async fn cache_crate_background(&self, params: CacheCrateParams) -> String { + self.cache_tools.cache_crate(params).await + } + + /// One-shot blocking cache: validates params, runs the full cache → + /// doc generation → search-index pipeline, and returns the result + /// JSON. The call does **not** return until the pipeline finishes. + pub async fn cache_crate_blocking(&self, params: CacheCrateParams) -> String { + self.cache_tools.cache_crate_blocking(params).await + } + + /// Remove a cached crate version. + pub async fn remove_crate(&self, params: RemoveCrateParams) -> String { + match self.cache_tools.remove_crate(params).await { + Ok(output) => output.to_json(), + Err(error) => error.to_json(), + } + } + + /// List all locally cached crates. + pub async fn list_cached_crates(&self) -> String { + match self.cache_tools.list_cached_crates().await { + Ok(output) => output.to_json(), + Err(error) => error.to_json(), + } + } + + /// List locally cached versions of a crate. + pub async fn list_crate_versions(&self, params: ListCrateVersionsParams) -> String { + match self.cache_tools.list_crate_versions(params).await { + Ok(output) => output.to_json(), + Err(error) => error.to_json(), + } + } + + /// Get metadata for multiple crates and their workspace members. + pub async fn get_crates_metadata(&self, params: GetCratesMetadataParams) -> String { + self.cache_tools.get_crates_metadata(params).await.to_json() + } + + /// Manage/monitor background caching tasks (MCP-only concept). + pub async fn cache_operations(&self, params: CacheOperationsParams) -> String { + self.cache_tools.cache_operations(params).await + } + + // ── Docs / listing ──────────────────────────────────────────── + + /// List all items in a crate's documentation (full details). + pub async fn list_crate_items(&self, params: ListItemsParams) -> String { + match self.docs_tools.list_crate_items(params).await { + Ok(output) => output.to_json(), + Err(error) => error.to_json(), + } + } + + /// Exact search — full details (may be large). + pub async fn search_items(&self, params: SearchItemsParams) -> String { + match self.docs_tools.search_items(params).await { + Ok(output) => output.to_json(), + Err(error) => error.to_json(), + } + } + + /// Exact search — preview only (id, name, kind, path). + pub async fn search_items_preview(&self, params: SearchItemsPreviewParams) -> String { + match self.docs_tools.search_items_preview(params).await { + Ok(output) => output.to_json(), + Err(error) => error.to_json(), + } + } + + /// Full details for a specific item by numeric id. + pub async fn get_item_details(&self, params: GetItemDetailsParams) -> String { + self.docs_tools.get_item_details(params).await.to_json() + } + + /// Documentation string only for a specific item. + pub async fn get_item_docs(&self, params: GetItemDocsParams) -> String { + match self.docs_tools.get_item_docs(params).await { + Ok(output) => output.to_json(), + Err(error) => error.to_json(), + } + } + + /// Source code for a specific item. + pub async fn get_item_source(&self, params: GetItemSourceParams) -> String { + self.docs_tools.get_item_source(params).await.to_json() + } + + // ── Deps ────────────────────────────────────────────────────── + + /// Get dependency information for a crate. + pub async fn get_dependencies(&self, params: GetDependenciesParams) -> String { + match self.deps_tools.get_dependencies(params).await { + Ok(output) => output.to_json(), + Err(error) => error.to_json(), + } + } + + // ── Analysis ────────────────────────────────────────────────── + + /// View the hierarchical structure tree of a crate. + pub async fn structure(&self, params: AnalyzeCrateStructureParams) -> String { + match self.analysis_tools.structure(params).await { + Ok(output) => output.to_json(), + Err(error) => error.to_json(), + } + } + + // ── Search (fuzzy) ──────────────────────────────────────────── + + /// Fuzzy search with Tantivy — typo tolerance. + pub async fn search_items_fuzzy(&self, params: SearchItemsFuzzyParams) -> String { + match self.search_tools.search_items_fuzzy(params).await { + Ok(output) => output.to_json(), + Err(error) => error.to_json(), + } + } +} diff --git a/rust-docs-mcp/src/service.rs b/rust-docs-mcp/src/service.rs index d448497..43f293e 100644 --- a/rust-docs-mcp/src/service.rs +++ b/rust-docs-mcp/src/service.rs @@ -1,7 +1,5 @@ use rmcp::handler::server::wrapper::Parameters; use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::RwLock; use anyhow::Result; use rmcp::schemars::{self, JsonSchema}; @@ -19,21 +17,18 @@ use rmcp::{ use serde::{Deserialize, Serialize}; -use crate::analysis::tools::{AnalysisTools, AnalyzeCrateStructureParams}; -use crate::cache::{ - CrateCache, - task_manager::TaskManager, - tools::{ - CacheCrateParams, CacheOperationsParams, CacheTools, GetCratesMetadataParams, - ListCrateVersionsParams, RemoveCrateParams, - }, +use crate::analysis::tools::AnalyzeCrateStructureParams; +use crate::cache::tools::{ + CacheCrateParams, CacheOperationsParams, GetCratesMetadataParams, ListCrateVersionsParams, + RemoveCrateParams, }; -use crate::deps::tools::{DepsTools, GetDependenciesParams}; +use crate::deps::tools::GetDependenciesParams; use crate::docs::tools::{ - DocsTools, GetItemDetailsParams, GetItemDocsParams, GetItemSourceParams, ListItemsParams, + GetItemDetailsParams, GetItemDocsParams, GetItemSourceParams, ListItemsParams, SearchItemsParams, SearchItemsPreviewParams, }; -use crate::search::tools::{SearchItemsFuzzyParams, SearchTools}; +use crate::runtime::RustDocsRuntime; +use crate::search::tools::SearchItemsFuzzyParams; #[derive(Debug, Serialize, Deserialize, JsonSchema)] struct CacheDependenciesArgs { @@ -57,27 +52,16 @@ struct CacheDependenciesArgs { pub struct RustDocsService { tool_router: ToolRouter, prompt_router: PromptRouter, - cache_tools: CacheTools, - docs_tools: DocsTools, - deps_tools: DepsTools, - analysis_tools: AnalysisTools, - search_tools: SearchTools, + runtime: RustDocsRuntime, } #[tool_router] impl RustDocsService { pub fn new(cache_dir: Option) -> Result { - let cache = Arc::new(RwLock::new(CrateCache::new(cache_dir)?)); - let task_manager = Arc::new(TaskManager::new()); - Ok(Self { tool_router: Self::tool_router(), prompt_router: Self::prompt_router(), - cache_tools: CacheTools::new(cache.clone(), task_manager), - docs_tools: DocsTools::new(cache.clone()), - deps_tools: DepsTools::new(cache.clone()), - analysis_tools: AnalysisTools::new(cache.clone()), - search_tools: SearchTools::new(cache), + runtime: RustDocsRuntime::new(cache_dir)?, }) } @@ -112,27 +96,21 @@ OPTIONAL PARAMETERS (all source types): MONITORING: Use cache_operations tool to monitor progress, cancel, or check status of caching operations." )] pub async fn cache_crate(&self, Parameters(params): Parameters) -> String { - self.cache_tools.cache_crate(params).await + self.runtime.cache_crate_background(params).await } #[tool( description = "Remove a cached crate version from local storage. Use to free up disk space or remove outdated versions. This only affects the local cache - the crate can be re-downloaded later if needed." )] pub async fn remove_crate(&self, Parameters(params): Parameters) -> String { - match self.cache_tools.remove_crate(params).await { - Ok(output) => output.to_json(), - Err(error) => error.to_json(), - } + self.runtime.remove_crate(params).await } #[tool( description = "List all locally cached crates with their versions and sizes. Use to see what crates are available offline and how much disk space they use. Shows cache metadata including when each crate was cached." )] pub async fn list_cached_crates(&self) -> String { - match self.cache_tools.list_cached_crates().await { - Ok(output) => output.to_json(), - Err(error) => error.to_json(), - } + self.runtime.list_cached_crates().await } #[tool( @@ -142,10 +120,7 @@ MONITORING: Use cache_operations tool to monitor progress, cancel, or check stat &self, Parameters(params): Parameters, ) -> String { - match self.cache_tools.list_crate_versions(params).await { - Ok(output) => output.to_json(), - Err(error) => error.to_json(), - } + self.runtime.list_crate_versions(params).await } #[tool( @@ -155,8 +130,7 @@ MONITORING: Use cache_operations tool to monitor progress, cancel, or check stat &self, Parameters(params): Parameters, ) -> String { - let output = self.cache_tools.get_crates_metadata(params).await; - output.to_json() + self.runtime.get_crates_metadata(params).await } #[tool( @@ -174,7 +148,7 @@ Usage: &self, Parameters(params): Parameters, ) -> String { - self.cache_tools.cache_operations(params).await + self.runtime.cache_operations(params).await } // Docs tools @@ -185,20 +159,14 @@ Usage: &self, Parameters(params): Parameters, ) -> String { - match self.docs_tools.list_crate_items(params).await { - Ok(output) => output.to_json(), - Err(error) => error.to_json(), - } + self.runtime.list_crate_items(params).await } #[tool( description = "Search for items by name pattern in a crate. Use when looking for specific functions, types, or modules. Returns FULL details including documentation. WARNING: May exceed token limits for large results. Use search_items_preview first for exploration, then get_item_details for specific items. For workspace crates, specify the member parameter with the member path (e.g., 'crates/rmcp')." )] pub async fn search_items(&self, Parameters(params): Parameters) -> String { - match self.docs_tools.search_items(params).await { - Ok(output) => output.to_json(), - Err(error) => error.to_json(), - } + self.runtime.search_items(params).await } #[tool( @@ -208,10 +176,7 @@ Usage: &self, Parameters(params): Parameters, ) -> String { - match self.docs_tools.search_items_preview(params).await { - Ok(output) => output.to_json(), - Err(error) => error.to_json(), - } + self.runtime.search_items_preview(params).await } #[tool( @@ -221,17 +186,14 @@ Usage: &self, Parameters(params): Parameters, ) -> String { - self.docs_tools.get_item_details(params).await.to_json() + self.runtime.get_item_details(params).await } #[tool( description = "Get ONLY the documentation string for a specific item. Use when you need just the docs without other details. More efficient than get_item_details if you only need the documentation text. Returns null if no documentation exists. For workspace crates, specify the member parameter with the member path (e.g., 'crates/rmcp')." )] pub async fn get_item_docs(&self, Parameters(params): Parameters) -> String { - match self.docs_tools.get_item_docs(params).await { - Ok(output) => output.to_json(), - Err(error) => error.to_json(), - } + self.runtime.get_item_docs(params).await } #[tool( @@ -241,7 +203,7 @@ Usage: &self, Parameters(params): Parameters, ) -> String { - self.docs_tools.get_item_source(params).await.to_json() + self.runtime.get_item_source(params).await } // Deps tools @@ -252,10 +214,7 @@ Usage: &self, Parameters(params): Parameters, ) -> String { - match self.deps_tools.get_dependencies(params).await { - Ok(output) => output.to_json(), - Err(error) => error.to_json(), - } + self.runtime.get_dependencies(params).await } // Analysis tools @@ -266,10 +225,7 @@ Usage: &self, Parameters(params): Parameters, ) -> String { - match self.analysis_tools.structure(params).await { - Ok(output) => output.to_json(), - Err(error) => error.to_json(), - } + self.runtime.structure(params).await } // Search tools @@ -280,10 +236,7 @@ Usage: &self, Parameters(params): Parameters, ) -> String { - match self.search_tools.search_items_fuzzy(params).await { - Ok(output) => output.to_json(), - Err(error) => error.to_json(), - } + self.runtime.search_items_fuzzy(params).await } } diff --git a/rust-docs-mcp/tests/integration_tests.rs b/rust-docs-mcp/tests/integration_tests.rs index 6db8039..c2ff75e 100644 --- a/rust-docs-mcp/tests/integration_tests.rs +++ b/rust-docs-mcp/tests/integration_tests.rs @@ -11,12 +11,14 @@ use rust_docs_mcp::RustDocsService; use rust_docs_mcp::analysis::outputs::StructureOutput; use rust_docs_mcp::analysis::tools::AnalyzeCrateStructureParams; use rust_docs_mcp::cache::outputs::{ - CacheTaskStartedOutput, GetCratesMetadataOutput, ListCrateVersionsOutput, + CacheCrateOutput, CacheTaskStartedOutput, GetCratesMetadataOutput, ListCachedCratesOutput, + ListCrateVersionsOutput, }; use rust_docs_mcp::cache::tools::{ CacheCrateParams, CacheOperationsParams, CrateMetadataQuery, GetCratesMetadataParams, ListCrateVersionsParams, }; +use rust_docs_mcp::cli::{self, CliTool, output_indicates_error}; use rust_docs_mcp::deps::outputs::GetDependenciesOutput; use rust_docs_mcp::deps::tools::GetDependenciesParams; use rust_docs_mcp::docs::outputs::{ @@ -27,8 +29,10 @@ use rust_docs_mcp::docs::tools::{ GetItemDetailsParams, GetItemDocsParams, GetItemSourceParams, ListItemsParams, SearchItemsParams, SearchItemsPreviewParams, }; +use rust_docs_mcp::runtime::RustDocsRuntime; use rust_docs_mcp::search::outputs::SearchItemsFuzzyOutput; use rust_docs_mcp::search::tools::SearchItemsFuzzyParams; +use std::process::Command; use std::time::Duration; use tempfile::TempDir; @@ -1553,3 +1557,315 @@ async fn test_step_tracking() -> Result<()> { Ok(()) } + +// ── CLI / Runtime one-shot tests ────────────────────────────────────── + +#[test] +fn test_cli_output_indicates_error() { + assert!(output_indicates_error(r#"{"error":"boom"}"#)); + assert!(output_indicates_error( + r#"{"status":"error","error":"boom"}"# + )); + assert!(!output_indicates_error(r#"{"status":"success"}"#)); + assert!(!output_indicates_error( + r#"{"status":"partial_success","errors":["boom"]}"# + )); +} + +#[tokio::test] +async fn test_cli_call_with_status_marks_tool_error() -> Result<()> { + let temp_dir = TempDir::new()?; + + let result = cli::call_with_status( + Some(temp_dir.path().to_path_buf()), + CliTool::CacheCrate, + Some(r#"{"crate_name":"semver","source_type":"cratesio"}"#.to_string()), + ) + .await?; + + assert!( + result.failed, + "Expected tool error for output: {}", + result.output + ); + let parsed: serde_json::Value = serde_json::from_str(&result.output)?; + assert_eq!( + parsed.get("status").and_then(serde_json::Value::as_str), + Some("error") + ); + assert!( + parsed + .get("error") + .and_then(serde_json::Value::as_str) + .is_some_and(|error| error.contains("version")), + "Expected missing version error, got: {parsed}" + ); + + Ok(()) +} + +#[test] +fn test_cli_binary_tool_error_exits_nonzero_and_prints_json() -> Result<()> { + let temp_dir = TempDir::new()?; + + let output = Command::new(env!("CARGO_BIN_EXE_rust-docs-mcp")) + .arg("--cache-dir") + .arg(temp_dir.path()) + .args([ + "call", + "cache-crate", + "--params", + r#"{"crate_name":"semver","source_type":"cratesio"}"#, + ]) + .output()?; + + assert!(!output.status.success(), "Expected non-zero exit status"); + + let stdout = String::from_utf8(output.stdout)?; + let parsed: serde_json::Value = serde_json::from_str(stdout.trim())?; + assert_eq!( + parsed.get("status").and_then(serde_json::Value::as_str), + Some("error") + ); + assert!( + parsed + .get("error") + .and_then(serde_json::Value::as_str) + .is_some_and(|error| error.contains("version")), + "Expected missing version error, got: {parsed}" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_cli_call_list_cached_crates_allows_empty_object() -> Result<()> { + let temp_dir = TempDir::new()?; + + let output = cli::call( + Some(temp_dir.path().to_path_buf()), + CliTool::ListCachedCrates, + Some("{}".to_string()), + ) + .await?; + + let parsed: ListCachedCratesOutput = + serde_json::from_str(&output).with_context(|| format!("CLI list output: {output}"))?; + assert_eq!(parsed.total_crates, 0); + + Ok(()) +} + +#[tokio::test] +async fn test_cli_call_list_cached_crates_rejects_non_empty_params() -> Result<()> { + let temp_dir = TempDir::new()?; + + let error = cli::call( + Some(temp_dir.path().to_path_buf()), + CliTool::ListCachedCrates, + Some(r#"{"unexpected":true}"#.to_string()), + ) + .await + .expect_err("list-cached-crates should reject non-empty params"); + + assert!( + error.to_string().contains("does not accept parameters"), + "Unexpected error: {error}" + ); + + Ok(()) +} + +#[tokio::test] +async fn test_cache_crate_background_rejects_invalid_params_before_task() -> Result<()> { + let (service, _temp_dir) = create_test_service()?; + + let params = CacheCrateParams { + crate_name: "semver".to_string(), + source_type: "cratesio".to_string(), + version: None, + github_url: None, + branch: None, + tag: None, + path: None, + members: None, + update: None, + }; + + let response = service.cache_crate(Parameters(params)).await; + assert!( + response.contains("Missing required parameter 'version'"), + "Expected missing version error, got: {response}" + ); + assert!( + serde_json::from_str::(&response).is_err(), + "Invalid params should not create a task: {response}" + ); + + let operations = service + .cache_operations(Parameters(CacheOperationsParams { + task_id: None, + status_filter: None, + cancel: false, + clear: false, + })) + .await; + assert!( + operations.contains("**Total Operations**: 0") + || operations.contains("No caching operations found"), + "Invalid params should not register a task: {operations}" + ); + + Ok(()) +} + +/// Verify the shared runtime can cache a crate **blocking** and the +/// result JSON reports success. +#[tokio::test] +async fn test_runtime_cache_blocking() -> Result<()> { + let temp_dir = TempDir::new()?; + let runtime = RustDocsRuntime::new(Some(temp_dir.path().to_path_buf()))?; + + let params = CacheCrateParams { + crate_name: "semver".to_string(), + source_type: "cratesio".to_string(), + version: Some("1.0.0".to_string()), + github_url: None, + branch: None, + tag: None, + path: None, + members: None, + update: None, + }; + + let output = runtime.cache_crate_blocking(params).await; + let parsed: CacheCrateOutput = serde_json::from_str(&output) + .with_context(|| format!("Failed to parse cache output: {output}"))?; + + assert!(parsed.is_success(), "Expected success, got: {parsed:?}"); + + Ok(()) +} + +/// Verify the shared runtime can do a one-shot fuzzy search against +/// an already-cached crate. +#[tokio::test] +async fn test_runtime_search_fuzzy() -> Result<()> { + let temp_dir = TempDir::new()?; + let runtime = RustDocsRuntime::new(Some(temp_dir.path().to_path_buf()))?; + + // Cache first (blocking) + let cache_params = CacheCrateParams { + crate_name: "semver".to_string(), + source_type: "cratesio".to_string(), + version: Some("1.0.0".to_string()), + github_url: None, + branch: None, + tag: None, + path: None, + members: None, + update: None, + }; + let cache_output = runtime.cache_crate_blocking(cache_params).await; + let cache_parsed: CacheCrateOutput = serde_json::from_str(&cache_output)?; + assert!(cache_parsed.is_success(), "Cache failed: {cache_parsed:?}"); + + // Now search + let search_params = SearchItemsFuzzyParams { + crate_name: "semver".to_string(), + version: "1.0.0".to_string(), + query: "Version".to_string(), + fuzzy_enabled: Some(true), + fuzzy_distance: Some(1), + limit: Some(5), + kind_filter: None, + member: None, + }; + + let output = runtime.search_items_fuzzy(search_params).await; + let parsed: SearchItemsFuzzyOutput = serde_json::from_str(&output) + .with_context(|| format!("Failed to parse search output: {output}"))?; + + assert!( + !parsed.results.is_empty(), + "Expected at least one search result" + ); + + Ok(()) +} + +/// CLI dispatch: cache_crate via the `cli::call` function. +#[tokio::test] +async fn test_cli_call_cache_crate() -> Result<()> { + let temp_dir = TempDir::new()?; + + let output = cli::call( + Some(temp_dir.path().to_path_buf()), + CliTool::CacheCrate, + Some(r#"{"crate_name":"semver","source_type":"cratesio","version":"1.0.0"}"#.to_string()), + ) + .await?; + + let parsed: CacheCrateOutput = + serde_json::from_str(&output).with_context(|| format!("CLI cache output: {output}"))?; + assert!(parsed.is_success()); + + Ok(()) +} + +/// CLI dispatch: search after cache. +#[tokio::test] +async fn test_cli_call_search_fuzzy() -> Result<()> { + let temp_dir = TempDir::new()?; + + // Cache + let _ = cli::call( + Some(temp_dir.path().to_path_buf()), + CliTool::CacheCrate, + Some(r#"{"crate_name":"semver","source_type":"cratesio","version":"1.0.0"}"#.to_string()), + ) + .await?; + + // Search + let output = cli::call( + Some(temp_dir.path().to_path_buf()), + CliTool::SearchItemsFuzzy, + Some( + r#"{"crate_name":"semver","version":"1.0.0","query":"Version","limit":5}"#.to_string(), + ), + ) + .await?; + + let parsed: SearchItemsFuzzyOutput = + serde_json::from_str(&output).with_context(|| format!("CLI search output: {output}"))?; + assert!(!parsed.results.is_empty()); + + Ok(()) +} + +/// CLI dispatch: list-cached-crates with no params. +#[tokio::test] +async fn test_cli_call_list_cached_crates() -> Result<()> { + let temp_dir = TempDir::new()?; + + // Cache a crate first + let _ = cli::call( + Some(temp_dir.path().to_path_buf()), + CliTool::CacheCrate, + Some(r#"{"crate_name":"semver","source_type":"cratesio","version":"1.0.0"}"#.to_string()), + ) + .await?; + + let output = cli::call( + Some(temp_dir.path().to_path_buf()), + CliTool::ListCachedCrates, + None, + ) + .await?; + + let parsed: ListCachedCratesOutput = + serde_json::from_str(&output).with_context(|| format!("CLI list output: {output}"))?; + assert!(parsed.total_crates >= 1); + + Ok(()) +}