use super::allowlist::{AllowedOrigin, effective_allowed_hosts};
use super::body::enforce_body_inactivity;
use super::config::{ConnectionLimit, HeaderReadTimeout, HttpConfig, WriteStallTimeout};
use super::connection_io::ConnectionIo;
use super::lease::attach_listen_lease;
use super::session_manager::{CappedSessionManager, enforce_session_cap, supervise_idle_reaper};
use super::shutdown::ShutdownSignal;
pub async fn run_http(
mcp_server: crate::mcp::McplsServer,
cfg: HttpConfig,
shutdown_signal: ShutdownSignal,
) -> Result<(), crate::Error> {
let listener = tokio::net::TcpListener::bind(cfg.bind)
.await
.map_err(|source| crate::Error::HttpBind {
addr: cfg.bind,
source,
})?;
serve_http(listener, mcp_server, cfg, shutdown_signal).await
}
fn session_factory(
mcp_server: crate::mcp::McplsServer,
shutdown: &tokio_util::sync::CancellationToken,
) -> impl Fn() -> Result<crate::mcp::McplsServer, std::io::Error> + Send + Sync + 'static {
let mcp_for_factory = mcp_server.with_stop(shutdown.clone());
move || Ok(mcp_for_factory.for_new_session())
}
fn rmcp_service_config(
local_addr: std::net::SocketAddr,
cfg: &HttpConfig,
cancel: tokio_util::sync::CancellationToken,
) -> rmcp::transport::streamable_http_server::StreamableHttpServerConfig {
use rmcp::transport::streamable_http_server::StreamableHttpServerConfig;
let allowed_origins = AllowedOrigin::loopback(local_addr.port())
.into_iter()
.chain(cfg.allowed_origins.iter().cloned())
.map(|origin| origin.to_string());
let allowed_hosts = effective_allowed_hosts(local_addr, &cfg.allowed_hosts)
.into_iter()
.map(|host| host.to_string());
let mut http_cfg = StreamableHttpServerConfig::default()
.with_allowed_hosts(allowed_hosts)
.with_allowed_origins(allowed_origins)
.enforce_origin_validation();
http_cfg.cancellation_token = cancel;
http_cfg.max_request_body_bytes = cfg.max_request_body.get();
http_cfg
}
#[expect(
clippy::significant_drop_tightening,
reason = "moved into `app` and served until shutdown; the lint misreads the embedded lock types as early-droppable"
)]
pub async fn serve_http(
listener: tokio::net::TcpListener,
mcp_server: crate::mcp::McplsServer,
cfg: HttpConfig,
mut shutdown_signal: ShutdownSignal,
) -> Result<(), crate::Error> {
use std::sync::Arc;
use rmcp::transport::streamable_http_server::StreamableHttpService;
use tokio_util::sync::CancellationToken;
let session_manager = Arc::new(
CappedSessionManager::new(cfg.max_concurrent_sessions, cfg.session_idle_timeout)
.with_stream_liveness(cfg.stream_liveness)
.with_response_stream_deadline(cfg.response_stream_deadline),
);
let reaper_manager = Arc::clone(&session_manager);
let cancel = CancellationToken::new();
let session_factory = session_factory(mcp_server, &cancel);
let local_addr = listener.local_addr()?;
let http_cfg = rmcp_service_config(local_addr, &cfg, cancel.clone());
let service = StreamableHttpService::new(session_factory, session_manager, http_cfg);
let app = axum::Router::new()
.nest_service(cfg.path.as_str(), service.clone())
.route_service("/", service)
.layer(axum::middleware::from_fn_with_state(
cfg.effective_listen_lease(),
attach_listen_lease,
))
.layer(axum::middleware::from_fn(enforce_session_cap))
.layer(axum::middleware::from_fn_with_state(
cfg.header_read_timeout,
enforce_body_inactivity,
));
let reaper_cancel = cancel.child_token();
let _reaper_guard = reaper_cancel.clone().drop_guard();
tokio::spawn(supervise_idle_reaper(reaper_manager, reaper_cancel));
tracing::info!(addr = %local_addr, path = %cfg.path, "MCP HTTP transport listening");
if !local_addr.ip().is_loopback() {
tracing::warn!(
addr = %local_addr,
"binding to a non-loopback address: mcpls performs no authentication of its own on \
any transport — place this endpoint behind a reverse proxy that enforces \
authentication. mcpls itself enforces only a header-read/idle timeout, a write-stall \
timeout and a connection cap. The Host header must be localhost, 127.0.0.1, ::1, \
the bound IP or one of the configured allowed hosts (a proxy can rewrite it), and \
browser clients may use only loopback origins on the bound port or the \
configured allowed origins"
);
}
let cancel_for_force_timeout = cancel.clone();
let cancel_for_repeat_signal = cancel.clone();
let cancel_for_serve = cancel.clone();
let serve = async move {
let signal = async move {
shutdown_signal.recv().await;
cancel.cancel();
};
tokio::join!(
serve_http1(
listener,
app,
cancel_for_serve,
cfg.header_read_timeout,
cfg.write_stall_timeout,
cfg.max_concurrent_connections,
),
signal
);
};
tokio::select! {
() = serve => Ok(()),
() = async move {
cancel_for_force_timeout.cancelled().await;
tokio::time::sleep(HTTP_GRACEFUL_SHUTDOWN_TIMEOUT).await;
} => {
tracing::warn!(
timeout = ?HTTP_GRACEFUL_SHUTDOWN_TIMEOUT,
"HTTP graceful shutdown did not complete in time, proceeding with shutdown anyway"
);
Ok(())
}
() = async move {
cancel_for_repeat_signal.cancelled().await;
let mut repeat_signal = ShutdownSignal::new();
repeat_signal.recv().await;
} => {
tracing::warn!(
"repeat shutdown signal received during HTTP connection drain, cutting drain short"
);
Ok(())
}
}
}
pub(super) async fn serve_http1(
listener: tokio::net::TcpListener,
app: axum::Router,
cancel: tokio_util::sync::CancellationToken,
header_read_timeout: HeaderReadTimeout,
write_stall: WriteStallTimeout,
max_connections: ConnectionLimit,
) {
use std::sync::Arc;
use hyper_util::rt::{TokioIo, TokioTimer};
use hyper_util::service::TowerToHyperService;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
let mut builder = hyper::server::conn::http1::Builder::new();
builder
.timer(TokioTimer::new())
.header_read_timeout(header_read_timeout.get());
let permits = Arc::new(Semaphore::new(max_connections.get()));
let mut connections = JoinSet::new();
loop {
while connections.try_join_next().is_some() {}
let permit = tokio::select! {
() = cancel.cancelled() => break,
permit = Arc::clone(&permits).acquire_owned() => match permit {
Ok(permit) => permit,
Err(_) => break,
},
};
let stream = tokio::select! {
() = cancel.cancelled() => break,
accepted = listener.accept() => match accepted {
Ok((stream, _)) => stream,
Err(e) if is_connection_error(&e) => continue,
Err(e) => {
tracing::warn!(error = %e, "HTTP accept failed, backing off");
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
continue;
}
},
};
#[cfg(any(target_os = "linux", target_os = "android"))]
set_half_open_timeout(&stream);
let io = TokioIo::new(ConnectionIo::new(
stream,
write_stall,
header_read_timeout,
cancel.clone(),
));
let conn = builder.serve_connection(io, TowerToHyperService::new(app.clone()));
let cancel = cancel.clone();
connections.spawn(async move {
let _permit = permit;
let mut conn = std::pin::pin!(conn);
tokio::select! {
result = conn.as_mut() => {
if let Err(e) = result {
tracing::trace!(error = %e, "HTTP connection ended with error");
}
}
() = cancel.cancelled() => {
conn.as_mut().graceful_shutdown();
if let Err(e) = conn.as_mut().await {
tracing::trace!(error = %e, "HTTP connection ended with error");
}
}
}
});
}
while connections.join_next().await.is_some() {}
}
fn is_connection_error(e: &std::io::Error) -> bool {
matches!(
e.kind(),
std::io::ErrorKind::ConnectionRefused
| std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::ConnectionReset
)
}
#[cfg(any(target_os = "linux", target_os = "android"))]
const HALF_OPEN_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(1);
#[cfg(any(target_os = "linux", target_os = "android"))]
fn set_half_open_timeout(stream: &tokio::net::TcpStream) {
if let Err(e) = socket2::SockRef::from(stream).set_tcp_user_timeout(Some(HALF_OPEN_TIMEOUT)) {
tracing::debug!(error = %e, "failed to set TCP_USER_TIMEOUT on accepted connection");
}
}
pub(super) const HTTP_GRACEFUL_SHUTDOWN_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(30);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_session_servers_report_the_shutdown_token_as_their_stop() {
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::bridge::{NotificationCache, Translator, WorkspaceRoots};
use crate::config::McpConfig;
use crate::mcp::{McplsServer, SubscriptionRegistry};
let server = McplsServer::new(
Arc::new(Translator::new()),
Arc::new(Mutex::new(NotificationCache::new())),
WorkspaceRoots::default(),
SubscriptionRegistry::new(),
crate::ProjectConfigStatus::NotIgnored,
McpConfig::default(),
);
let shutdown = tokio_util::sync::CancellationToken::new();
let factory = session_factory(server, &shutdown);
let session = factory().unwrap();
assert!(!session.stop_token().is_cancelled());
shutdown.cancel();
assert!(session.stop_token().is_cancelled());
assert!(factory().unwrap().stop_token().is_cancelled());
}
#[test]
fn test_rmcp_config_allows_the_bound_ip_without_a_port() {
let cfg = HttpConfig::new("192.168.1.5:80".parse().unwrap());
let rmcp_cfg = rmcp_service_config(
"192.168.1.5:80".parse().unwrap(),
&cfg,
tokio_util::sync::CancellationToken::new(),
);
assert!(rmcp_cfg.allowed_hosts.iter().any(|h| h == "192.168.1.5"));
assert!(rmcp_cfg.allowed_hosts.iter().all(|h| h != "192.168.1.5:80"));
}
#[cfg(any(target_os = "linux", target_os = "android"))]
#[tokio::test]
async fn test_set_half_open_timeout_applies_tcp_user_timeout() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let _client = tokio::net::TcpStream::connect(addr).await.unwrap();
let (accepted, _) = listener.accept().await.unwrap();
super::set_half_open_timeout(&accepted);
let timeout = socket2::SockRef::from(&accepted)
.tcp_user_timeout()
.unwrap();
assert_eq!(timeout, Some(super::HALF_OPEN_TIMEOUT));
}
}