Skip to main content

crates_docs/cli/
serve_cmd.rs

1//! Serve command implementation
2
3use crate::config_reload::ConfigReloader;
4use crate::server::transport::{self, HyperServerConfig};
5use crate::CratesDocsServer;
6use std::path::PathBuf;
7use std::sync::Arc;
8use tokio::time::{interval, Duration};
9
10#[cfg(feature = "api-key")]
11fn normalize_api_keys(
12    api_key_config: &crate::server::auth::ApiKeyConfig,
13    keys: Vec<String>,
14) -> Result<Vec<String>, Box<dyn std::error::Error>> {
15    keys.into_iter()
16        .map(|key| {
17            api_key_config
18                .normalize_key_material(&key)
19                .map_err(|e| format!("Failed to normalize API key material: {e}").into())
20        })
21        .collect()
22}
23
24fn load_from_env(config: &mut crate::config::AppConfig) -> Result<(), Box<dyn std::error::Error>> {
25    let env_config = match crate::config::AppConfig::from_env() {
26        Ok(config) => Some(config),
27        Err(e) if e.to_string().contains("Invalid port") => return Err(e.to_string().into()),
28        Err(_) => None,
29    };
30
31    // Using mem::take to move ownership without cloning, leaving default values in place
32    *config = crate::config::AppConfig::merge(Some(std::mem::take(config)), env_config);
33
34    #[cfg(feature = "api-key")]
35    if !config.auth.api_key.keys.is_empty() {
36        let keys = std::mem::take(&mut config.auth.api_key.keys);
37        config.auth.api_key.keys = normalize_api_keys(&config.auth.api_key, keys)?;
38    }
39
40    Ok(())
41}
42
43fn init_logging(
44    config: &crate::config::AppConfig,
45    debug: bool,
46    verbose: bool,
47) -> Result<(), Box<dyn std::error::Error>> {
48    // Both `--debug` and the global `--verbose` raise logging to debug level
49    // (the only level above the default `info` used by this server), so the
50    // documented `--verbose` flag has a real effect instead of being ignored.
51    if debug || verbose {
52        let debug_config = crate::config::LoggingConfig {
53            level: "debug".to_string(),
54            ..config.logging.clone()
55        };
56        crate::init_logging_with_config(&debug_config)
57            .map_err(|e| format!("Failed to initialize logging system: {e}"))?;
58    } else {
59        crate::init_logging_with_config(&config.logging)
60            .map_err(|e| format!("Failed to initialize logging system: {e}"))?;
61    }
62    Ok(())
63}
64
65fn start_config_reloader(config_path: &std::path::Path, server: &CratesDocsServer) {
66    let config_path_arc = Arc::from(config_path.to_path_buf().into_boxed_path());
67    let current_config = server.config().clone();
68
69    match ConfigReloader::new(config_path_arc, current_config) {
70        Ok(mut reloader) => {
71            tracing::info!(
72                "Configuration hot-reload enabled for {}",
73                config_path.display()
74            );
75
76            tokio::spawn(async move {
77                let mut check_interval = interval(Duration::from_secs(1));
78
79                loop {
80                    check_interval.tick().await;
81
82                    // Stop the task if the watcher has died; otherwise this loop
83                    // would spin every second forever (and previously spammed a
84                    // warning each tick) with no way to detect changes.
85                    if !reloader.is_watcher_alive() {
86                        tracing::warn!(
87                            "Stopping configuration hot-reload task: file watcher disconnected."
88                        );
89                        break;
90                    }
91
92                    if let Some(change) = reloader.check_for_changes() {
93                        if let Some(changes) = change.changes() {
94                            tracing::info!("Configuration file changed:");
95                            for change_desc in changes {
96                                tracing::info!(" - {}", change_desc);
97                            }
98                            tracing::warn!(
99                                "Detected configuration changes are NOT applied to the already-running server. \
100                                 Restart the server for these changes to take effect."
101                            );
102                            tracing::warn!(
103                                "Security note: API key and OAuth changes (including key removals) do NOT take \
104                                 effect until the server is restarted."
105                            );
106                        }
107                    }
108                }
109            });
110        }
111        Err(e) => {
112            tracing::warn!("Failed to enable configuration hot-reload: {}", e);
113        }
114    }
115}
116
117async fn run_server_by_mode(
118    server: &CratesDocsServer,
119    transport_mode: &str,
120) -> Result<(), Box<dyn std::error::Error>> {
121    let mode_str = transport_mode.to_lowercase();
122    match mode_str.as_str() {
123        "stdio" => {
124            tracing::info!("Using Stdio transport mode");
125            transport::run_stdio_server(server)
126                .await
127                .map_err(|e| format!("Failed to start Stdio server: {e}"))?;
128        }
129        "http" => {
130            tracing::info!(
131                "Using HTTP transport mode, listening on {}:{}",
132                server.config().server.host,
133                server.config().server.port
134            );
135            transport::run_hyper_server(server, HyperServerConfig::http())
136                .await
137                .map_err(|e| format!("Failed to start HTTP server: {e}"))?;
138        }
139        "sse" => {
140            tracing::info!(
141                "Using SSE transport mode, listening on {}:{}",
142                server.config().server.host,
143                server.config().server.port
144            );
145            transport::run_hyper_server(server, HyperServerConfig::sse())
146                .await
147                .map_err(|e| format!("Failed to start SSE server: {e}"))?;
148        }
149        "hybrid" => {
150            tracing::info!(
151                "Using hybrid transport mode (HTTP + SSE), listening on {}:{}",
152                server.config().server.host,
153                server.config().server.port
154            );
155            transport::run_hyper_server(server, HyperServerConfig::hybrid())
156                .await
157                .map_err(|e| format!("Failed to start hybrid server: {e}"))?;
158        }
159        _ => {
160            return Err(format!("Unknown transport mode: {transport_mode}").into());
161        }
162    }
163    Ok(())
164}
165
166/// Start server command
167#[allow(clippy::too_many_arguments)]
168pub async fn run_serve_command(
169    config_path: &PathBuf,
170    debug: bool,
171    verbose: bool,
172    mode: Option<String>,
173    host: Option<String>,
174    port: Option<u16>,
175    enable_oauth: Option<bool>,
176    oauth_client_id: Option<String>,
177    oauth_client_secret: Option<String>,
178    oauth_redirect_uri: Option<String>,
179    enable_api_key: Option<bool>,
180    api_keys: Option<String>,
181    api_key_header: Option<String>,
182    api_key_query_param: Option<bool>,
183) -> Result<(), Box<dyn std::error::Error>> {
184    let config = load_config(
185        config_path,
186        host,
187        port,
188        mode,
189        enable_oauth,
190        oauth_client_id,
191        oauth_client_secret,
192        oauth_redirect_uri,
193        enable_api_key,
194        api_keys,
195        api_key_header,
196        api_key_query_param,
197    )?;
198
199    let transport_mode = &config.server.transport_mode;
200
201    init_logging(&config, debug, verbose)?;
202
203    tracing::info!(
204        "Starting Crates Docs MCP Server v{}",
205        env!("CARGO_PKG_VERSION")
206    );
207
208    let server: CratesDocsServer = CratesDocsServer::new_async(config.clone())
209        .await
210        .map_err(|e| format!("Failed to create server: {e}"))?;
211
212    let mode_str = transport_mode.to_lowercase();
213    let should_enable_reload = matches!(mode_str.as_str(), "http" | "sse" | "hybrid");
214
215    if should_enable_reload && config_path.exists() {
216        start_config_reloader(config_path, &server);
217    }
218
219    run_server_by_mode(&server, transport_mode).await
220}
221
222/// Load configuration
223#[allow(clippy::too_many_arguments)]
224fn load_config(
225    config_path: &PathBuf,
226    host: Option<String>,
227    port: Option<u16>,
228    mode: Option<String>,
229    enable_oauth: Option<bool>,
230    oauth_client_id: Option<String>,
231    oauth_client_secret: Option<String>,
232    oauth_redirect_uri: Option<String>,
233    enable_api_key: Option<bool>,
234    api_keys: Option<String>,
235    api_key_header: Option<String>,
236    api_key_query_param: Option<bool>,
237) -> Result<crate::config::AppConfig, Box<dyn std::error::Error>> {
238    let mut config = if config_path.exists() {
239        tracing::info!("Loading configuration from file: {}", config_path.display());
240        crate::config::AppConfig::from_file(config_path)
241            .map_err(|e| format!("Failed to load config file: {e}"))?
242    } else {
243        tracing::warn!(
244            "Config file does not exist, using default config: {}",
245            config_path.display()
246        );
247        crate::config::AppConfig::default()
248    };
249
250    load_from_env(&mut config)?;
251
252    // Only override config file when command line arguments are explicitly provided
253    if let Some(h) = host {
254        config.server.host = h;
255        tracing::info!(
256            "Command line argument overrides host: {}",
257            config.server.host
258        );
259    }
260    if let Some(p) = port {
261        config.server.port = p;
262        tracing::info!(
263            "Command line argument overrides port: {}",
264            config.server.port
265        );
266    }
267    if let Some(m) = mode {
268        config.server.transport_mode = m;
269        tracing::info!(
270            "Command line argument overrides transport_mode: {}",
271            config.server.transport_mode
272        );
273    }
274    if let Some(eo) = enable_oauth {
275        config.server.enable_oauth = eo;
276        tracing::info!(
277            "Command line argument overrides enable_oauth: {}",
278            config.server.enable_oauth
279        );
280    }
281
282    // Override command line OAuth parameters (if provided)
283    if let Some(client_id) = oauth_client_id {
284        config.oauth.client_id = Some(client_id);
285        config.oauth.enabled = true;
286    }
287    if let Some(client_secret) = oauth_client_secret {
288        config.oauth.client_secret = Some(client_secret);
289    }
290    if let Some(redirect_uri) = oauth_redirect_uri {
291        config.oauth.redirect_uri = Some(redirect_uri);
292    }
293
294    // Override command line API Key parameters (if provided)
295    if let Some(eak) = enable_api_key {
296        config.auth.api_key.enabled = eak;
297        tracing::info!(
298            "Command line argument overrides enable_api_key: {}",
299            config.auth.api_key.enabled
300        );
301    }
302    if let Some(keys) = api_keys {
303        let parsed_keys: Vec<String> = keys
304            .split(',')
305            .map(str::trim)
306            .filter(|s| !s.is_empty())
307            .map(ToOwned::to_owned)
308            .collect();
309
310        if !parsed_keys.is_empty() {
311            #[cfg(feature = "api-key")]
312            {
313                config.auth.api_key.keys = normalize_api_keys(&config.auth.api_key, parsed_keys)?;
314            }
315            #[cfg(not(feature = "api-key"))]
316            {
317                config.auth.api_key.keys = parsed_keys;
318            }
319            config.auth.api_key.enabled = true;
320            tracing::info!("Command line argument provided API key material");
321        }
322    }
323    if let Some(header) = api_key_header {
324        config.auth.api_key.header_name = header;
325        tracing::info!(
326            "Command line argument overrides api_key_header: {}",
327            config.auth.api_key.header_name
328        );
329    }
330    if let Some(allow_query) = api_key_query_param {
331        config.auth.api_key.allow_query_param = allow_query;
332        tracing::info!(
333            "Command line argument overrides api_key_query_param: {}",
334            config.auth.api_key.allow_query_param
335        );
336    }
337
338    // Validate configuration
339    config
340        .validate()
341        .map_err(|e| format!("Configuration validation failed: {e}"))?;
342
343    Ok(config)
344}