use std::convert::Infallible;
use std::future::Future;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use bytes::Bytes;
use http::{Request, Response, StatusCode};
use http_body_util::combinators::BoxBody;
use http_body_util::{BodyExt, Full};
use hyper::body::Incoming;
use hyper::service::Service;
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto;
use rmcp::transport::streamable_http_server::session::never::NeverSessionManager;
use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
use tokio::net::TcpListener;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use super::daemon::Broker;
use super::identity;
use super::ids::AgentId;
use crate::mcp::BasemindServer;
pub const HTTP_ADDR_ENV: &str = "BASEMIND_HTTP_ADDR";
const DEFAULT_HTTP_ADDR: &str = "127.0.0.1:51786";
const PORTFILE_NAME: &str = "http.addr";
const MCP_PATH: &str = "/mcp";
#[cfg(unix)]
const PORTFILE_MODE: u32 = 0o600;
const HTTP_READY_POLL: Duration = Duration::from_millis(50);
type HttpBody = BoxBody<Bytes, Infallible>;
pub fn portfile_path(comms_dir: &Path) -> PathBuf {
comms_dir.join(PORTFILE_NAME)
}
fn resolve_addr() -> Result<SocketAddr> {
let raw = std::env::var(HTTP_ADDR_ENV)
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| DEFAULT_HTTP_ADDR.to_string());
raw.parse::<SocketAddr>()
.with_context(|| format!("parse {HTTP_ADDR_ENV}={raw:?} as a host:port socket address"))
}
fn write_portfile(comms_dir: &Path, addr: &SocketAddr) -> std::io::Result<()> {
let path = portfile_path(comms_dir);
std::fs::write(&path, addr.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(PORTFILE_MODE))?;
}
Ok(())
}
fn read_portfile(comms_dir: &Path) -> Option<SocketAddr> {
std::fs::read_to_string(portfile_path(comms_dir))
.ok()?
.trim()
.parse()
.ok()
}
fn parse_target(query: Option<&str>) -> Option<(PathBuf, Option<String>)> {
let query = query?;
let mut root: Option<String> = None;
let mut agent: Option<String> = None;
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"root" => root = Some(value.into_owned()),
"agent" => agent = Some(value.into_owned()),
_ => {}
}
}
let root = root.filter(|value| !value.trim().is_empty())?;
let agent = agent.filter(|value| !value.trim().is_empty());
Some((PathBuf::from(root), agent))
}
fn text_response(status: StatusCode, message: &str) -> Response<HttpBody> {
Response::builder()
.status(status)
.header(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Full::new(Bytes::from(message.to_string())).boxed())
.expect("text response builds from constant parts")
}
struct HttpRouter {
broker: Arc<Broker>,
session_manager: Arc<NeverSessionManager>,
cancel: CancellationToken,
}
impl HttpRouter {
async fn handle(&self, request: Request<Incoming>) -> Response<HttpBody> {
let _activity = self.broker.begin_http_request();
if request.uri().path() != MCP_PATH {
return text_response(StatusCode::NOT_FOUND, "not found: this server serves POST /mcp only");
}
let Some((raw_root, agent)) = parse_target(request.uri().query()) else {
return text_response(StatusCode::NOT_FOUND, "not found: missing ?root=<abs-repo-path>");
};
let Ok(root) = std::fs::canonicalize(&raw_root) else {
return text_response(
StatusCode::NOT_FOUND,
"not found: root does not resolve to an existing path",
);
};
let shared = match self.broker.host_read_stack(&root).await {
Ok(shared) => shared,
Err(error) => {
tracing::warn!(%error, root = %root.display(), "http: hosting read stack failed");
return text_response(StatusCode::NOT_FOUND, "not found: workspace could not be hosted");
}
};
let _conn = match self.broker.begin_workspace_conn(&root) {
Ok(guard) => guard,
Err(error) => {
tracing::warn!(%error, root = %root.display(), "http: workspace connection accounting failed");
return text_response(StatusCode::NOT_FOUND, "not found: workspace could not be hosted");
}
};
let agent_id = match agent {
Some(raw) => match AgentId::parse(raw) {
Ok(id) => id.into_string(),
Err(error) => {
return text_response(
StatusCode::BAD_REQUEST,
&format!("bad request: invalid ?agent= ({error})"),
);
}
},
None => identity::cli_agent_id(&root).into_string(),
};
tracing::debug!(agent = %agent_id, root = %root.display(), "http: serving stateless mcp request");
let factory = move || Ok(BasemindServer::from_shared(shared.clone(), agent_id.clone()));
let config = StreamableHttpServerConfig::default()
.with_legacy_session_mode(false)
.with_json_response(true)
.with_cancellation_token(self.cancel.child_token());
let service = StreamableHttpService::new(factory, self.session_manager.clone(), config);
service.handle(request).await
}
}
#[derive(Clone)]
struct HyperSvc(Arc<HttpRouter>);
impl Service<Request<Incoming>> for HyperSvc {
type Response = Response<HttpBody>;
type Error = Infallible;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn call(&self, request: Request<Incoming>) -> Self::Future {
let router = self.0.clone();
Box::pin(async move { Ok(router.handle(request).await) })
}
}
pub async fn serve_http(broker: Arc<Broker>, comms_dir: PathBuf, mut shutdown: watch::Receiver<bool>) -> Result<()> {
let addr = resolve_addr()?;
if !addr.ip().is_loopback() {
tracing::warn!(
%addr,
"BASEMIND_HTTP_ADDR binds a NON-loopback address: the MCP transport becomes reachable \
off-host. rmcp Host-header (DNS-rebinding) validation still applies, but for genuine \
remote access you must configure allowed_hosts/origins for the real hostnames."
);
}
let listener = match TcpListener::bind(addr).await {
Ok(listener) => listener,
Err(error) => {
let path = portfile_path(&comms_dir);
if let Err(remove_error) = std::fs::remove_file(&path)
&& remove_error.kind() != std::io::ErrorKind::NotFound
{
tracing::debug!(error = %remove_error, path = %path.display(),
"http: clearing stale portfile after bind failure");
}
return Err(anyhow::Error::new(error)).with_context(|| {
format!("bind streamable-HTTP MCP listener on {addr} (is another process holding it?)")
});
}
};
let local = listener.local_addr().context("read the bound HTTP address")?;
write_portfile(&comms_dir, &local).with_context(|| format!("write HTTP portfile under {}", comms_dir.display()))?;
tracing::info!(addr = %local, "comms: streamable-HTTP MCP transport listening");
let cancel = CancellationToken::new();
let router = Arc::new(HttpRouter {
broker,
session_manager: Arc::new(NeverSessionManager::default()),
cancel: cancel.clone(),
});
loop {
tokio::select! {
changed = shutdown.changed() => {
if changed.is_err() || *shutdown.borrow() {
break;
}
}
accepted = listener.accept() => {
let (stream, _peer) = match accepted {
Ok(pair) => pair,
Err(error) => {
tracing::warn!(%error, "http: accept failed");
continue;
}
};
let io = TokioIo::new(stream);
let service = HyperSvc(router.clone());
let conn_cancel = cancel.clone();
let conn_activity = router.broker.begin_http_request();
tokio::spawn(async move {
let _conn_activity = conn_activity;
let builder = auto::Builder::new(TokioExecutor::new());
tokio::select! {
result = builder.serve_connection(io, service) => {
if let Err(error) = result {
tracing::debug!(error = %error, "http: connection ended with error");
}
}
_ = conn_cancel.cancelled() => {}
}
});
}
}
}
cancel.cancel();
let path = portfile_path(&comms_dir);
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => tracing::debug!(%error, path = %path.display(), "http: removing portfile failed"),
}
tracing::info!("comms: streamable-HTTP MCP transport stopped");
Ok(())
}
pub async fn await_http_ready(comms_dir: &Path, timeout: Duration) -> Result<String> {
let deadline = std::time::Instant::now() + timeout;
loop {
if let Some(addr) = read_portfile(comms_dir)
&& tokio::net::TcpStream::connect(addr).await.is_ok()
{
return Ok(addr.to_string());
}
if std::time::Instant::now() >= deadline {
anyhow::bail!("streamable-HTTP MCP transport did not become ready within {timeout:?}");
}
tokio::time::sleep(HTTP_READY_POLL).await;
}
}
pub fn base_url(addr: &str) -> String {
format!("http://{addr}{MCP_PATH}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_target_reads_root_and_agent() {
let (root, agent) = parse_target(Some("root=%2Ftmp%2Fmy%20repo&agent=alice")).expect("parses");
assert_eq!(root, PathBuf::from("/tmp/my repo"));
assert_eq!(agent.as_deref(), Some("alice"));
}
#[test]
fn parse_target_treats_blank_agent_as_absent() {
let (root, agent) = parse_target(Some("root=%2Ftmp%2Fr&agent=%20%20")).expect("parses");
assert_eq!(root, PathBuf::from("/tmp/r"));
assert_eq!(agent, None);
}
#[test]
fn parse_target_requires_root() {
assert!(parse_target(Some("agent=alice")).is_none());
assert!(parse_target(Some("root=")).is_none());
assert!(parse_target(None).is_none());
}
#[test]
fn resolve_addr_defaults_and_honors_env() {
unsafe { std::env::remove_var(HTTP_ADDR_ENV) };
assert_eq!(resolve_addr().expect("default parses").to_string(), DEFAULT_HTTP_ADDR);
unsafe { std::env::set_var(HTTP_ADDR_ENV, "127.0.0.1:0") };
assert_eq!(
resolve_addr().expect("env parses"),
"127.0.0.1:0".parse::<SocketAddr>().unwrap()
);
unsafe { std::env::remove_var(HTTP_ADDR_ENV) };
}
#[test]
fn base_url_appends_mcp_path() {
assert_eq!(base_url("127.0.0.1:51786"), "http://127.0.0.1:51786/mcp");
}
}