Skip to main content

crates_docs/server/
transport.rs

1//! Transport module
2//!
3//! Provides Stdio, HTTP and SSE transport protocol support.
4//!
5//! # Supported Transport Modes
6//!
7//! - **Stdio**: Standard input/output, suitable for MCP client integration
8//! - **HTTP**: Streamable HTTP, supports stateless requests
9//! - **SSE**: Server-Sent Events, supports server push
10//! - **Hybrid**: Hybrid mode, supports both HTTP and SSE
11//!
12//! # Example
13//!
14//! ```rust,no_run
15//! use crates_docs::server::transport::{run_stdio_server, TransportMode};
16//! use crates_docs::{AppConfig, CratesDocsServer};
17//!
18//! #[tokio::main]
19//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
20//!     let config = AppConfig::default();
21//!     let server = CratesDocsServer::new(config)?;
22//!
23//!     // Run Stdio server
24//!     run_stdio_server(&server).await?;
25//!
26//!     Ok(())
27//! }
28//! ```
29
30use crate::error::Result;
31use crate::server::handler::CratesDocsHandler;
32use crate::server::CratesDocsServer;
33use rust_mcp_sdk::{
34    error::McpSdkError,
35    event_store,
36    mcp_server::{hyper_server, server_runtime, HyperServerOptions, McpServerOptions},
37    McpServer, StdioTransport, ToMcpServerHandler, TransportOptions,
38};
39use std::sync::Arc;
40
41/// Run Stdio server
42///
43/// Communicates with MCP clients via standard input/output.
44///
45/// # Arguments
46///
47/// * `server` - `CratesDocsServer` instance
48///
49/// # Errors
50///
51/// Returns error if server startup fails
52///
53/// # Example
54///
55/// ```rust,no_run
56/// use crates_docs::server::transport::run_stdio_server;
57/// use crates_docs::{AppConfig, CratesDocsServer};
58///
59/// #[tokio::main]
60/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
61///     let config = AppConfig::default();
62///     let server = CratesDocsServer::new(config)?;
63///     run_stdio_server(&server).await?;
64///     Ok(())
65/// }
66/// ```
67pub async fn run_stdio_server(server: &CratesDocsServer) -> Result<()> {
68    tracing::info!("Starting Stdio MCP server...");
69
70    let server_info = server.server_info();
71    let handler = CratesDocsHandler::new(Arc::new(server.clone()));
72
73    // Create Stdio transport
74    let transport = StdioTransport::new(TransportOptions::default())
75        .map_err(|e| crate::error::Error::mcp("transport", e.to_string()))?;
76
77    // Create MCP server
78    let mcp_server: Arc<rust_mcp_sdk::mcp_server::ServerRuntime> =
79        server_runtime::create_server(McpServerOptions {
80            server_details: server_info,
81            transport,
82            handler: handler.to_mcp_server_handler(),
83            task_store: None,
84            client_task_store: None,
85            message_observer: None,
86        });
87
88    tracing::info!("Stdio MCP server started, waiting for connections...");
89    mcp_server
90        .start()
91        .await
92        .map_err(|e: McpSdkError| crate::error::Error::mcp("server_start", e.to_string()))?;
93
94    Ok(())
95}
96
97/// Hyper server configuration
98///
99/// Configuration for HTTP/SSE/Hybrid MCP servers using the Builder pattern.
100///
101/// # Example
102///
103/// ```rust
104/// use crates_docs::server::transport::HyperServerConfig;
105///
106/// let http_config = HyperServerConfig::http();
107/// let sse_config = HyperServerConfig::sse();
108/// let hybrid_config = HyperServerConfig::hybrid();
109/// ```
110#[derive(Debug, Clone)]
111pub struct HyperServerConfig {
112    /// Protocol name for logging (e.g., "HTTP", "SSE", "Hybrid")
113    protocol_name: String,
114    /// Whether SSE support is enabled
115    sse_support: bool,
116}
117
118impl HyperServerConfig {
119    /// Create HTTP server configuration
120    ///
121    /// HTTP mode supports Streamable HTTP protocol for stateless requests.
122    #[must_use]
123    pub fn http() -> Self {
124        Self {
125            protocol_name: "HTTP".to_string(),
126            sse_support: false,
127        }
128    }
129
130    /// Create SSE server configuration
131    ///
132    /// SSE mode supports Server-Sent Events for server push capabilities.
133    #[must_use]
134    pub fn sse() -> Self {
135        Self {
136            protocol_name: "SSE".to_string(),
137            sse_support: true,
138        }
139    }
140
141    /// Create Hybrid server configuration
142    ///
143    /// Hybrid mode supports both HTTP and SSE protocols.
144    #[must_use]
145    pub fn hybrid() -> Self {
146        Self {
147            protocol_name: "Hybrid".to_string(),
148            sse_support: true,
149        }
150    }
151
152    /// Get protocol name
153    #[must_use]
154    pub fn protocol_name(&self) -> &str {
155        &self.protocol_name
156    }
157
158    /// Check if SSE support is enabled
159    #[must_use]
160    pub fn sse_support(&self) -> bool {
161        self.sse_support
162    }
163}
164
165/// Whether in-process API-key enforcement is active for this build and config.
166///
167/// True only when the binary is compiled with **both** the `api-key` and `auth`
168/// features (so [`crate::server::auth::ApiKeyAuthProvider`] exists and the SDK
169/// can attach its `AuthMiddleware`) **and** `api_key.enabled` is set. When true,
170/// the HTTP/SSE transport rejects any request lacking a valid
171/// `Authorization: Bearer <key>` with 401; when false, no API-key check runs
172/// in-process.
173#[cfg(all(feature = "api-key", feature = "auth"))]
174fn api_key_auth_enforced(server_config: &crate::config::AppConfig) -> bool {
175    server_config.auth.api_key.enabled
176}
177
178/// Fallback for builds without both `api-key` and `auth`: enforcement is
179/// impossible, so it is never active.
180#[cfg(not(all(feature = "api-key", feature = "auth")))]
181fn api_key_auth_enforced(_server_config: &crate::config::AppConfig) -> bool {
182    false
183}
184
185/// Build the SDK auth provider for in-process API-key enforcement, if enabled.
186///
187/// Returns `Some(provider)` only when `api_key.enabled` is set; the SDK's
188/// `HyperServer::new` then auto-attaches its `AuthMiddleware`. Returns `None`
189/// otherwise, leaving the transport unauthenticated (the runtime on/off switch).
190#[cfg(all(feature = "api-key", feature = "auth"))]
191fn build_api_key_auth(
192    server_config: &crate::config::AppConfig,
193) -> Option<Arc<dyn rust_mcp_sdk::auth::AuthProvider>> {
194    server_config.auth.api_key.enabled.then(|| {
195        Arc::new(crate::server::auth::ApiKeyAuthProvider::new(
196            server_config.auth.api_key.clone(),
197        )) as Arc<dyn rust_mcp_sdk::auth::AuthProvider>
198    })
199}
200
201/// Report how authentication settings map onto actual HTTP/SSE enforcement.
202///
203/// In-process API-key enforcement is active only when the binary is built with
204/// both the `api-key` and `auth` features and `api_key.enabled` is set; the
205/// SDK's `AuthMiddleware` then rejects requests without a valid
206/// `Authorization: Bearer <key>`. OAuth, by contrast, is still **not** wired
207/// into the request pipeline, so an enabled OAuth config protects nothing.
208/// Reporting both accurately avoids giving operators a false sense of security
209/// (and avoids hiding that protection is, in fact, on).
210fn warn_if_auth_configured_but_unenforced(server_config: &crate::config::AppConfig) {
211    #[cfg(feature = "api-key")]
212    if server_config.auth.api_key.enabled {
213        if api_key_auth_enforced(server_config) {
214            tracing::info!(
215                "API key authentication is ENFORCED on the HTTP/SSE transport: clients must send \
216                 `Authorization: Bearer <key>` or receive 401 (the /health endpoint stays open). \
217                 The in-process layer does not read the `X-API-Key` header directly — to keep \
218                 using `X-API-Key` and to encrypt traffic with TLS, front the server with the \
219                 bundled reverse proxy (docs/reverse-proxy/)."
220            );
221        } else {
222            tracing::warn!(
223                "API key authentication is enabled in configuration but is NOT enforced: this \
224                 binary was built without the `auth` feature, so HTTP/SSE requests are \
225                 unauthenticated. Rebuild with the `auth` feature (it is in the default set) or \
226                 front the server with an authenticating reverse proxy. Do not expose this \
227                 server on an untrusted network."
228            );
229        }
230    }
231
232    // OAuth is accepted in configuration but never attached to the pipeline.
233    if server_config.auth.oauth.enabled || server_config.oauth.enabled {
234        tracing::warn!(
235            "OAuth authentication is enabled in configuration but is NOT enforced on the \
236             HTTP/SSE transport: OAuth requests are not validated. Do not rely on it for access \
237             control; use API-key authentication or an authenticating reverse proxy instead."
238        );
239    }
240}
241
242/// Warn when API-key header / query settings are set but ignored in-process.
243///
244/// The SDK middleware reads **only** `Authorization: Bearer <token>` — it cannot
245/// honor a custom `header_name` or `allow_query_param`. Those settings take
246/// effect solely at a fronting reverse proxy. The default `header_name`
247/// (`X-API-Key`) is the documented proxy path and is covered by the info log
248/// above, so this only fires for a genuinely non-default header or an enabled
249/// query parameter, preventing the belief that the server reads them directly.
250#[cfg(all(feature = "api-key", feature = "auth"))]
251fn warn_if_api_key_header_settings_ignored(server_config: &crate::config::AppConfig) {
252    if !api_key_auth_enforced(server_config) {
253        return;
254    }
255    let non_default_header = !server_config
256        .auth
257        .api_key
258        .header_name
259        .eq_ignore_ascii_case("x-api-key");
260    let query_allowed = server_config.auth.api_key.allow_query_param;
261    if non_default_header || query_allowed {
262        tracing::warn!(
263            header_name = %server_config.auth.api_key.header_name,
264            allow_query_param = query_allowed,
265            "In-process API-key enforcement reads ONLY `Authorization: Bearer <key>`; the \
266             configured `header_name` and `allow_query_param` are ignored by the server and take \
267             effect only at a fronting reverse proxy. Translate your custom header / query param \
268             into `Authorization: Bearer <key>` at the proxy — see docs/reverse-proxy/."
269        );
270    }
271}
272
273/// Warn when Prometheus metrics are requested in configuration but the server
274/// neither collects nor exposes them.
275///
276/// The metrics subsystem (`ServerMetrics`, `performance.metrics_port`) is not
277/// currently wired into the request pipeline and no metrics endpoint is served,
278/// so `enable_metrics = true` has no observable effect. Surfacing this avoids
279/// misleading operators into believing a scrape target exists.
280fn warn_if_metrics_configured_but_unavailable(server_config: &crate::config::AppConfig) {
281    if server_config.performance.enable_metrics {
282        tracing::warn!(
283            metrics_port = server_config.performance.metrics_port,
284            "performance.enable_metrics is set, but this server does not yet collect or expose \
285             Prometheus metrics: no metrics endpoint is served and no request metrics are recorded. \
286             This setting currently has no effect."
287        );
288    }
289}
290
291/// Warn when server resource limits are configured but not enforced.
292///
293/// `request_timeout_secs`, `response_timeout_secs`, and `max_connections` are
294/// accepted in configuration, but the underlying SDK `HyperServerOptions` does
295/// not expose request/response timeouts or a connection cap, so these values
296/// are never applied. Warning when an operator sets a non-default value avoids
297/// a false sense that the server enforces limits it does not.
298fn unenforced_server_limits(server_config: &crate::config::AppConfig) -> Vec<&'static str> {
299    let defaults = crate::config::ServerConfig::default();
300    let mut unenforced = Vec::new();
301    if server_config.server.request_timeout_secs != defaults.request_timeout_secs {
302        unenforced.push("request_timeout_secs");
303    }
304    if server_config.server.response_timeout_secs != defaults.response_timeout_secs {
305        unenforced.push("response_timeout_secs");
306    }
307    if server_config.server.max_connections != defaults.max_connections {
308        unenforced.push("max_connections");
309    }
310    unenforced
311}
312
313fn warn_if_unenforced_server_limits_configured(server_config: &crate::config::AppConfig) {
314    let unenforced = unenforced_server_limits(server_config);
315    if !unenforced.is_empty() {
316        tracing::warn!(
317            fields = unenforced.join(", "),
318            "These server limit settings are configured with non-default values but are NOT \
319             enforced: the HTTP transport applies neither request/response timeouts nor a maximum \
320             connection cap. These settings currently have no effect."
321        );
322    }
323}
324
325/// Whether the `server.enable_sse` setting contradicts the active transport.
326///
327/// SSE support is decided solely by the transport mode (`sse`/`hybrid` enable
328/// it; `http` does not); the `enable_sse` config flag is never consulted. A
329/// mismatch means the operator's `enable_sse` value is being ignored.
330fn enable_sse_setting_ignored(configured_enable_sse: bool, sse_active: bool) -> bool {
331    configured_enable_sse != sse_active
332}
333
334/// Warn when `server.enable_sse` does not match the transport-derived state.
335fn warn_if_enable_sse_ignored(server_config: &crate::config::AppConfig, sse_active: bool) {
336    if enable_sse_setting_ignored(server_config.server.enable_sse, sse_active) {
337        tracing::warn!(
338            configured_enable_sse = server_config.server.enable_sse,
339            sse_active,
340            "server.enable_sse does not match the active transport and is being ignored: SSE \
341             support is determined solely by transport_mode (sse/hybrid serve SSE, http does not). \
342             Set transport_mode to control SSE; the enable_sse flag has no effect."
343        );
344    }
345}
346
347/// Whether `host` is a loopback address (or `localhost`).
348///
349/// Used to decide whether binding exposes the server beyond the local machine.
350/// Anything that is not an IP loopback address and not `localhost` is treated
351/// as network-exposed (conservative: unknown hostnames warn).
352fn host_is_loopback(host: &str) -> bool {
353    match host.parse::<std::net::IpAddr>() {
354        Ok(ip) => ip.is_loopback(),
355        Err(_) => host.eq_ignore_ascii_case("localhost"),
356    }
357}
358
359/// Warn when the server binds to a non-loopback address and is therefore
360/// reachable from other hosts on the network.
361///
362/// If in-process API-key auth is enforced, the risk is plaintext exposure
363/// (requests and keys travel unencrypted over HTTP); otherwise the risk is the
364/// absence of any authentication. Both warrant a reverse proxy.
365fn warn_if_network_exposed(server_config: &crate::config::AppConfig) {
366    if host_is_loopback(&server_config.server.host) {
367        return;
368    }
369    if api_key_auth_enforced(server_config) {
370        tracing::warn!(
371            host = %server_config.server.host,
372            "Server is binding to a non-loopback address and is reachable from other hosts on \
373             the network. API-key authentication IS enforced (requests need `Authorization: \
374             Bearer <key>`), but traffic is sent UNENCRYPTED over plain HTTP: anyone who can \
375             observe the network sees requests and keys in clear text. Terminate TLS with the \
376             bundled reverse proxy (docs/reverse-proxy/) or restrict the network."
377        );
378    } else {
379        tracing::warn!(
380            host = %server_config.server.host,
381            "Server is binding to a non-loopback address and is reachable from other hosts on \
382             the network. The HTTP/SSE transport performs no authentication; put a reverse proxy \
383             with authentication in front of it, restrict the network, or run in stdio mode."
384        );
385    }
386}
387
388/// Warn when DNS rebinding protection is disabled, so the configured
389/// `allowed_hosts`/`allowed_origins` allowlists are not enforced.
390///
391/// With protection off (the default) the SDK installs no `Host`/`Origin`
392/// validation, so a malicious web page loaded in a local browser can reach
393/// this server via DNS rebinding. Surfacing this avoids a false sense of
394/// security from the presence of the allowlist settings.
395fn warn_if_dns_rebinding_protection_disabled(server_config: &crate::config::AppConfig) {
396    if !server_config.server.dns_rebinding_protection {
397        tracing::warn!(
398            "dns_rebinding_protection is disabled: the allowed_hosts/allowed_origins allowlists \
399             are NOT enforced, so a malicious local web page could reach this server via DNS \
400             rebinding. Set server.dns_rebinding_protection = true (with exact host:port and \
401             origin values) to enable Host/Origin validation."
402        );
403    }
404}
405
406/// Run a Hyper-based MCP server with the given configuration.
407///
408/// This function handles HTTP, SSE, and Hybrid servers based on the configuration.
409///
410/// # Arguments
411///
412/// * `server` - `CratesDocsServer` instance
413/// * `config` - `HyperServerConfig` instance
414///
415/// # Errors
416///
417/// Returns error if server startup fails
418///
419/// # Example
420///
421/// ```rust,no_run
422/// use crates_docs::server::transport::{run_hyper_server, HyperServerConfig};
423/// use crates_docs::{AppConfig, CratesDocsServer};
424///
425/// #[tokio::main]
426/// async fn main() -> Result<(), Box<dyn std::error::Error>> {
427///     let config = AppConfig::default();
428///     let server = CratesDocsServer::new(config)?;
429///     let http_config = HyperServerConfig::http();
430///     run_hyper_server(&server, http_config).await?;
431///     Ok(())
432/// }
433/// ```
434pub async fn run_hyper_server(server: &CratesDocsServer, config: HyperServerConfig) -> Result<()> {
435    let server_config = server.config();
436    let server_info = server.server_info();
437    let handler = CratesDocsHandler::new(Arc::new(server.clone()));
438
439    tracing::info!(
440        "Starting {} MCP server on {}:{}...",
441        config.protocol_name(),
442        server_config.server.host,
443        server_config.server.port
444    );
445
446    warn_if_auth_configured_but_unenforced(server_config);
447    #[cfg(all(feature = "api-key", feature = "auth"))]
448    warn_if_api_key_header_settings_ignored(server_config);
449    warn_if_metrics_configured_but_unavailable(server_config);
450    warn_if_unenforced_server_limits_configured(server_config);
451    warn_if_enable_sse_ignored(server_config, config.sse_support());
452    warn_if_network_exposed(server_config);
453    warn_if_dns_rebinding_protection_disabled(server_config);
454
455    // Create Hyper server options with security settings from config
456    let options = HyperServerOptions {
457        host: server_config.server.host.clone(),
458        port: server_config.server.port,
459        transport_options: Arc::new(TransportOptions::default()),
460        sse_support: config.sse_support(),
461        event_store: Some(Arc::new(event_store::InMemoryEventStore::default())),
462        task_store: None,
463        client_task_store: None,
464        allowed_hosts: Some(server_config.server.allowed_hosts.clone()),
465        allowed_origins: Some(server_config.server.allowed_origins.clone()),
466        // Without this flag the SDK never installs the DnsRebindProtector, so
467        // the allowlists above would be silently ignored. Honor the operator's
468        // explicit opt-in instead.
469        dns_rebinding_protection: server_config.server.dns_rebinding_protection,
470        health_endpoint: Some("/health".to_string()),
471        // Runtime on/off switch for in-process auth: `Some` only when
472        // `api_key.enabled` is set, which makes the SDK attach its
473        // `AuthMiddleware`. Toggling the config flag + restart flips
474        // enforcement without a rebuild. Cfg-gated as a field init (rather than
475        // a `mut` mutation) so `options` stays immutable under `-D warnings`.
476        #[cfg(all(feature = "api-key", feature = "auth"))]
477        auth: build_api_key_auth(server_config),
478        ..Default::default()
479    };
480
481    if server_config.server.dns_rebinding_protection
482        && server_config.server.allowed_hosts.is_empty()
483        && server_config.server.allowed_origins.is_empty()
484    {
485        tracing::warn!(
486            "dns_rebinding_protection is enabled but both allowed_hosts and              allowed_origins are empty; no Host/Origin validation will occur"
487        );
488    }
489
490    // Create HTTP/SSE/Hybrid server
491    let mcp_server =
492        hyper_server::create_server(server_info, handler.to_mcp_server_handler(), options);
493
494    // Build the started message based on the protocol
495    let started_msg = if config.sse_support() && config.protocol_name() != "SSE" {
496        // Hybrid mode
497        format!(
498            "{} MCP server started, listening on {}:{} (HTTP + SSE)",
499            config.protocol_name(),
500            server_config.server.host,
501            server_config.server.port
502        )
503    } else {
504        format!(
505            "{} MCP server started, listening on {}:{}",
506            config.protocol_name(),
507            server_config.server.host,
508            server_config.server.port
509        )
510    };
511    tracing::info!("{}", started_msg);
512
513    mcp_server
514        .start()
515        .await
516        .map_err(|e: McpSdkError| crate::error::Error::mcp("server_start", e.to_string()))?;
517
518    Ok(())
519}
520
521/// Transport mode
522///
523/// Defines the transport protocol types supported by MCP server.
524///
525/// # Variants
526///
527/// - `Stdio`: Standard input/output, suitable for MCP client integration
528/// - `Http`: Streamable HTTP, supports stateless requests
529/// - `Sse`: Server-Sent Events, supports server push
530/// - `Hybrid`: Hybrid mode, supports both HTTP and SSE
531///
532/// # Example
533///
534/// ```rust
535/// use crates_docs::server::transport::TransportMode;
536/// use std::str::FromStr;
537///
538/// let mode = TransportMode::from_str("http").unwrap();
539/// assert_eq!(mode, TransportMode::Http);
540/// assert_eq!(mode.to_string(), "http");
541/// ```
542#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
543pub enum TransportMode {
544    /// Stdio transport (for CLI integration)
545    Stdio,
546    /// HTTP transport (Streamable HTTP)
547    Http,
548    /// SSE transport (Server-Sent Events)
549    Sse,
550    /// Hybrid mode (supports both HTTP and SSE)
551    Hybrid,
552}
553
554impl std::str::FromStr for TransportMode {
555    type Err = String;
556
557    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
558        match s.to_lowercase().as_str() {
559            "stdio" => Ok(TransportMode::Stdio),
560            "http" => Ok(TransportMode::Http),
561            "sse" => Ok(TransportMode::Sse),
562            "hybrid" => Ok(TransportMode::Hybrid),
563            _ => Err(format!("Unknown transport mode: {s}")),
564        }
565    }
566}
567
568impl std::fmt::Display for TransportMode {
569    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
570        match self {
571            TransportMode::Stdio => write!(f, "stdio"),
572            TransportMode::Http => write!(f, "http"),
573            TransportMode::Sse => write!(f, "sse"),
574            TransportMode::Hybrid => write!(f, "hybrid"),
575        }
576    }
577}
578
579impl TransportMode {
580    /// Convert to `HyperServerConfig`
581    #[must_use]
582    pub fn to_hyper_config(&self) -> Option<HyperServerConfig> {
583        match self {
584            TransportMode::Stdio => None,
585            TransportMode::Http => Some(HyperServerConfig::http()),
586            TransportMode::Sse => Some(HyperServerConfig::sse()),
587            TransportMode::Hybrid => Some(HyperServerConfig::hybrid()),
588        }
589    }
590}
591
592/// Run server with the specified transport mode
593pub async fn run_server_with_mode(server: &CratesDocsServer, mode: TransportMode) -> Result<()> {
594    match mode {
595        TransportMode::Stdio => run_stdio_server(server).await,
596        TransportMode::Http | TransportMode::Sse | TransportMode::Hybrid => {
597            let config = mode
598                .to_hyper_config()
599                .expect("Hyper config should exist for HTTP/SSE/Hybrid");
600            run_hyper_server(server, config).await
601        }
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::unenforced_server_limits;
608    use crate::config::AppConfig;
609
610    #[test]
611    fn test_unenforced_limits_empty_for_defaults() {
612        let config = AppConfig::default();
613        assert!(unenforced_server_limits(&config).is_empty());
614    }
615
616    #[test]
617    fn test_unenforced_limits_flags_changed_fields() {
618        let mut config = AppConfig::default();
619        config.server.request_timeout_secs += 1;
620        config.server.max_connections += 1;
621        let flagged = unenforced_server_limits(&config);
622        assert!(flagged.contains(&"request_timeout_secs"));
623        assert!(flagged.contains(&"max_connections"));
624        assert!(!flagged.contains(&"response_timeout_secs"));
625    }
626
627    #[test]
628    fn test_host_is_loopback() {
629        assert!(super::host_is_loopback("127.0.0.1"));
630        assert!(super::host_is_loopback("::1"));
631        assert!(super::host_is_loopback("localhost"));
632        assert!(super::host_is_loopback("LocalHost"));
633        // Non-loopback / network-exposed binds.
634        assert!(!super::host_is_loopback("0.0.0.0"));
635        assert!(!super::host_is_loopback("::"));
636        assert!(!super::host_is_loopback("192.168.1.5"));
637        assert!(!super::host_is_loopback("example.com"));
638    }
639
640    #[test]
641    fn test_enable_sse_setting_ignored() {
642        // No contradiction: enable_sse matches the active SSE state.
643        assert!(!super::enable_sse_setting_ignored(true, true));
644        assert!(!super::enable_sse_setting_ignored(false, false));
645        // Contradiction: setting is ignored.
646        assert!(super::enable_sse_setting_ignored(false, true));
647        assert!(super::enable_sse_setting_ignored(true, false));
648    }
649
650    #[cfg(all(feature = "api-key", feature = "auth"))]
651    #[test]
652    fn test_api_key_auth_enforced_tracks_enabled_flag() {
653        let mut config = AppConfig::default();
654        // Disabled by default → no in-process enforcement.
655        assert!(!super::api_key_auth_enforced(&config));
656        // Flipping the runtime flag turns enforcement on (no rebuild needed).
657        config.auth.api_key.enabled = true;
658        assert!(super::api_key_auth_enforced(&config));
659    }
660
661    #[cfg(all(feature = "api-key", feature = "auth"))]
662    #[test]
663    fn test_build_api_key_auth_follows_enabled_flag() {
664        let mut config = AppConfig::default();
665        assert!(super::build_api_key_auth(&config).is_none());
666        config.auth.api_key.enabled = true;
667        assert!(super::build_api_key_auth(&config).is_some());
668    }
669}