use crate::error::Result;
use crate::server::handler::CratesDocsHandler;
use crate::server::CratesDocsServer;
use rust_mcp_sdk::{
error::McpSdkError,
event_store,
mcp_server::{hyper_server, server_runtime, HyperServerOptions, McpServerOptions},
McpServer, StdioTransport, ToMcpServerHandler, TransportOptions,
};
use std::sync::Arc;
pub async fn run_stdio_server(server: &CratesDocsServer) -> Result<()> {
tracing::info!("Starting Stdio MCP server...");
let server_info = server.server_info();
let handler = CratesDocsHandler::new(Arc::new(server.clone()));
let transport = StdioTransport::new(TransportOptions::default())
.map_err(|e| crate::error::Error::mcp("transport", e.to_string()))?;
let mcp_server: Arc<rust_mcp_sdk::mcp_server::ServerRuntime> =
server_runtime::create_server(McpServerOptions {
server_details: server_info,
transport,
handler: handler.to_mcp_server_handler(),
task_store: None,
client_task_store: None,
message_observer: None,
});
tracing::info!("Stdio MCP server started, waiting for connections...");
mcp_server
.start()
.await
.map_err(|e: McpSdkError| crate::error::Error::mcp("server_start", e.to_string()))?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct HyperServerConfig {
protocol_name: String,
sse_support: bool,
}
impl HyperServerConfig {
#[must_use]
pub fn http() -> Self {
Self {
protocol_name: "HTTP".to_string(),
sse_support: false,
}
}
#[must_use]
pub fn sse() -> Self {
Self {
protocol_name: "SSE".to_string(),
sse_support: true,
}
}
#[must_use]
pub fn hybrid() -> Self {
Self {
protocol_name: "Hybrid".to_string(),
sse_support: true,
}
}
#[must_use]
pub fn protocol_name(&self) -> &str {
&self.protocol_name
}
#[must_use]
pub fn sse_support(&self) -> bool {
self.sse_support
}
}
#[cfg(all(feature = "api-key", feature = "auth"))]
fn api_key_auth_enforced(server_config: &crate::config::AppConfig) -> bool {
server_config.auth.api_key.enabled
}
#[cfg(not(all(feature = "api-key", feature = "auth")))]
fn api_key_auth_enforced(_server_config: &crate::config::AppConfig) -> bool {
false
}
#[cfg(all(feature = "api-key", feature = "auth"))]
fn build_api_key_auth(
server_config: &crate::config::AppConfig,
) -> Option<Arc<dyn rust_mcp_sdk::auth::AuthProvider>> {
server_config.auth.api_key.enabled.then(|| {
Arc::new(crate::server::auth::ApiKeyAuthProvider::new(
server_config.auth.api_key.clone(),
)) as Arc<dyn rust_mcp_sdk::auth::AuthProvider>
})
}
fn warn_if_auth_configured_but_unenforced(server_config: &crate::config::AppConfig) {
#[cfg(feature = "api-key")]
if server_config.auth.api_key.enabled {
if api_key_auth_enforced(server_config) {
tracing::info!(
"API key authentication is ENFORCED on the HTTP/SSE transport: clients must send \
`Authorization: Bearer <key>` or receive 401 (the /health endpoint stays open). \
The in-process layer does not read the `X-API-Key` header directly — to keep \
using `X-API-Key` and to encrypt traffic with TLS, front the server with the \
bundled reverse proxy (docs/reverse-proxy/)."
);
} else {
tracing::warn!(
"API key authentication is enabled in configuration but is NOT enforced: this \
binary was built without the `auth` feature, so HTTP/SSE requests are \
unauthenticated. Rebuild with the `auth` feature (it is in the default set) or \
front the server with an authenticating reverse proxy. Do not expose this \
server on an untrusted network."
);
}
}
if server_config.auth.oauth.enabled || server_config.oauth.enabled {
tracing::warn!(
"OAuth authentication is enabled in configuration but is NOT enforced on the \
HTTP/SSE transport: OAuth requests are not validated. Do not rely on it for access \
control; use API-key authentication or an authenticating reverse proxy instead."
);
}
}
#[cfg(all(feature = "api-key", feature = "auth"))]
fn warn_if_api_key_header_settings_ignored(server_config: &crate::config::AppConfig) {
if !api_key_auth_enforced(server_config) {
return;
}
let non_default_header = !server_config
.auth
.api_key
.header_name
.eq_ignore_ascii_case("x-api-key");
let query_allowed = server_config.auth.api_key.allow_query_param;
if non_default_header || query_allowed {
tracing::warn!(
header_name = %server_config.auth.api_key.header_name,
allow_query_param = query_allowed,
"In-process API-key enforcement reads ONLY `Authorization: Bearer <key>`; the \
configured `header_name` and `allow_query_param` are ignored by the server and take \
effect only at a fronting reverse proxy. Translate your custom header / query param \
into `Authorization: Bearer <key>` at the proxy — see docs/reverse-proxy/."
);
}
}
fn warn_if_metrics_configured_but_unavailable(server_config: &crate::config::AppConfig) {
if server_config.performance.enable_metrics {
tracing::warn!(
metrics_port = server_config.performance.metrics_port,
"performance.enable_metrics is set, but this server does not yet collect or expose \
Prometheus metrics: no metrics endpoint is served and no request metrics are recorded. \
This setting currently has no effect."
);
}
}
fn unenforced_server_limits(server_config: &crate::config::AppConfig) -> Vec<&'static str> {
let defaults = crate::config::ServerConfig::default();
let mut unenforced = Vec::new();
if server_config.server.request_timeout_secs != defaults.request_timeout_secs {
unenforced.push("request_timeout_secs");
}
if server_config.server.response_timeout_secs != defaults.response_timeout_secs {
unenforced.push("response_timeout_secs");
}
if server_config.server.max_connections != defaults.max_connections {
unenforced.push("max_connections");
}
unenforced
}
fn warn_if_unenforced_server_limits_configured(server_config: &crate::config::AppConfig) {
let unenforced = unenforced_server_limits(server_config);
if !unenforced.is_empty() {
tracing::warn!(
fields = unenforced.join(", "),
"These server limit settings are configured with non-default values but are NOT \
enforced: the HTTP transport applies neither request/response timeouts nor a maximum \
connection cap. These settings currently have no effect."
);
}
}
fn enable_sse_setting_ignored(configured_enable_sse: bool, sse_active: bool) -> bool {
configured_enable_sse != sse_active
}
fn warn_if_enable_sse_ignored(server_config: &crate::config::AppConfig, sse_active: bool) {
if enable_sse_setting_ignored(server_config.server.enable_sse, sse_active) {
tracing::warn!(
configured_enable_sse = server_config.server.enable_sse,
sse_active,
"server.enable_sse does not match the active transport and is being ignored: SSE \
support is determined solely by transport_mode (sse/hybrid serve SSE, http does not). \
Set transport_mode to control SSE; the enable_sse flag has no effect."
);
}
}
fn host_is_loopback(host: &str) -> bool {
match host.parse::<std::net::IpAddr>() {
Ok(ip) => ip.is_loopback(),
Err(_) => host.eq_ignore_ascii_case("localhost"),
}
}
fn warn_if_network_exposed(server_config: &crate::config::AppConfig) {
if host_is_loopback(&server_config.server.host) {
return;
}
if api_key_auth_enforced(server_config) {
tracing::warn!(
host = %server_config.server.host,
"Server is binding to a non-loopback address and is reachable from other hosts on \
the network. API-key authentication IS enforced (requests need `Authorization: \
Bearer <key>`), but traffic is sent UNENCRYPTED over plain HTTP: anyone who can \
observe the network sees requests and keys in clear text. Terminate TLS with the \
bundled reverse proxy (docs/reverse-proxy/) or restrict the network."
);
} else {
tracing::warn!(
host = %server_config.server.host,
"Server is binding to a non-loopback address and is reachable from other hosts on \
the network. The HTTP/SSE transport performs no authentication; put a reverse proxy \
with authentication in front of it, restrict the network, or run in stdio mode."
);
}
}
fn warn_if_dns_rebinding_protection_disabled(server_config: &crate::config::AppConfig) {
if !server_config.server.dns_rebinding_protection {
tracing::warn!(
"dns_rebinding_protection is disabled: the allowed_hosts/allowed_origins allowlists \
are NOT enforced, so a malicious local web page could reach this server via DNS \
rebinding. Set server.dns_rebinding_protection = true (with exact host:port and \
origin values) to enable Host/Origin validation."
);
}
}
pub async fn run_hyper_server(server: &CratesDocsServer, config: HyperServerConfig) -> Result<()> {
let server_config = server.config();
let server_info = server.server_info();
let handler = CratesDocsHandler::new(Arc::new(server.clone()));
tracing::info!(
"Starting {} MCP server on {}:{}...",
config.protocol_name(),
server_config.server.host,
server_config.server.port
);
warn_if_auth_configured_but_unenforced(server_config);
#[cfg(all(feature = "api-key", feature = "auth"))]
warn_if_api_key_header_settings_ignored(server_config);
warn_if_metrics_configured_but_unavailable(server_config);
warn_if_unenforced_server_limits_configured(server_config);
warn_if_enable_sse_ignored(server_config, config.sse_support());
warn_if_network_exposed(server_config);
warn_if_dns_rebinding_protection_disabled(server_config);
let options = HyperServerOptions {
host: server_config.server.host.clone(),
port: server_config.server.port,
transport_options: Arc::new(TransportOptions::default()),
sse_support: config.sse_support(),
event_store: Some(Arc::new(event_store::InMemoryEventStore::default())),
task_store: None,
client_task_store: None,
allowed_hosts: Some(server_config.server.allowed_hosts.clone()),
allowed_origins: Some(server_config.server.allowed_origins.clone()),
dns_rebinding_protection: server_config.server.dns_rebinding_protection,
health_endpoint: Some("/health".to_string()),
#[cfg(all(feature = "api-key", feature = "auth"))]
auth: build_api_key_auth(server_config),
..Default::default()
};
if server_config.server.dns_rebinding_protection
&& server_config.server.allowed_hosts.is_empty()
&& server_config.server.allowed_origins.is_empty()
{
tracing::warn!(
"dns_rebinding_protection is enabled but both allowed_hosts and allowed_origins are empty; no Host/Origin validation will occur"
);
}
let mcp_server =
hyper_server::create_server(server_info, handler.to_mcp_server_handler(), options);
let started_msg = if config.sse_support() && config.protocol_name() != "SSE" {
format!(
"{} MCP server started, listening on {}:{} (HTTP + SSE)",
config.protocol_name(),
server_config.server.host,
server_config.server.port
)
} else {
format!(
"{} MCP server started, listening on {}:{}",
config.protocol_name(),
server_config.server.host,
server_config.server.port
)
};
tracing::info!("{}", started_msg);
mcp_server
.start()
.await
.map_err(|e: McpSdkError| crate::error::Error::mcp("server_start", e.to_string()))?;
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub enum TransportMode {
Stdio,
Http,
Sse,
Hybrid,
}
impl std::str::FromStr for TransportMode {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"stdio" => Ok(TransportMode::Stdio),
"http" => Ok(TransportMode::Http),
"sse" => Ok(TransportMode::Sse),
"hybrid" => Ok(TransportMode::Hybrid),
_ => Err(format!("Unknown transport mode: {s}")),
}
}
}
impl std::fmt::Display for TransportMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TransportMode::Stdio => write!(f, "stdio"),
TransportMode::Http => write!(f, "http"),
TransportMode::Sse => write!(f, "sse"),
TransportMode::Hybrid => write!(f, "hybrid"),
}
}
}
impl TransportMode {
#[must_use]
pub fn to_hyper_config(&self) -> Option<HyperServerConfig> {
match self {
TransportMode::Stdio => None,
TransportMode::Http => Some(HyperServerConfig::http()),
TransportMode::Sse => Some(HyperServerConfig::sse()),
TransportMode::Hybrid => Some(HyperServerConfig::hybrid()),
}
}
}
pub async fn run_server_with_mode(server: &CratesDocsServer, mode: TransportMode) -> Result<()> {
match mode {
TransportMode::Stdio => run_stdio_server(server).await,
TransportMode::Http | TransportMode::Sse | TransportMode::Hybrid => {
let config = mode
.to_hyper_config()
.expect("Hyper config should exist for HTTP/SSE/Hybrid");
run_hyper_server(server, config).await
}
}
}
#[cfg(test)]
mod tests {
use super::unenforced_server_limits;
use crate::config::AppConfig;
#[test]
fn test_unenforced_limits_empty_for_defaults() {
let config = AppConfig::default();
assert!(unenforced_server_limits(&config).is_empty());
}
#[test]
fn test_unenforced_limits_flags_changed_fields() {
let mut config = AppConfig::default();
config.server.request_timeout_secs += 1;
config.server.max_connections += 1;
let flagged = unenforced_server_limits(&config);
assert!(flagged.contains(&"request_timeout_secs"));
assert!(flagged.contains(&"max_connections"));
assert!(!flagged.contains(&"response_timeout_secs"));
}
#[test]
fn test_host_is_loopback() {
assert!(super::host_is_loopback("127.0.0.1"));
assert!(super::host_is_loopback("::1"));
assert!(super::host_is_loopback("localhost"));
assert!(super::host_is_loopback("LocalHost"));
assert!(!super::host_is_loopback("0.0.0.0"));
assert!(!super::host_is_loopback("::"));
assert!(!super::host_is_loopback("192.168.1.5"));
assert!(!super::host_is_loopback("example.com"));
}
#[test]
fn test_enable_sse_setting_ignored() {
assert!(!super::enable_sse_setting_ignored(true, true));
assert!(!super::enable_sse_setting_ignored(false, false));
assert!(super::enable_sse_setting_ignored(false, true));
assert!(super::enable_sse_setting_ignored(true, false));
}
#[cfg(all(feature = "api-key", feature = "auth"))]
#[test]
fn test_api_key_auth_enforced_tracks_enabled_flag() {
let mut config = AppConfig::default();
assert!(!super::api_key_auth_enforced(&config));
config.auth.api_key.enabled = true;
assert!(super::api_key_auth_enforced(&config));
}
#[cfg(all(feature = "api-key", feature = "auth"))]
#[test]
fn test_build_api_key_auth_follows_enabled_flag() {
let mut config = AppConfig::default();
assert!(super::build_api_key_auth(&config).is_none());
config.auth.api_key.enabled = true;
assert!(super::build_api_key_auth(&config).is_some());
}
}