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";
const UI_PATH: &str = "/ui";
const UI_PROBE_TIMEOUT: Duration = Duration::from_millis(150);
#[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")
}
fn html_response(status: StatusCode, body: String, content_type: &str) -> Response<HttpBody> {
Response::builder()
.status(status)
.header(http::header::CONTENT_TYPE, content_type)
.body(Full::new(Bytes::from(body)).boxed())
.expect("html response builds from a valid header + body")
}
struct UiRenderArgs {
root: PathBuf,
format: String,
edges: String,
algorithm: String,
min_confidence: Option<f32>,
max_nodes: Option<u32>,
max_edges: Option<u32>,
focus: Option<String>,
}
fn parse_ui_args(query: Option<&str>) -> Option<UiRenderArgs> {
let query = query?;
let (mut root, mut format, mut edges, mut algorithm, mut focus) = (None, None, None, None, None);
let (mut min_confidence, mut max_nodes, mut max_edges) = (None, None, None);
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"root" => root = Some(value.into_owned()),
"format" => format = Some(value.into_owned()),
"edges" => edges = Some(value.into_owned()),
"algorithm" | "algo" => algorithm = Some(value.into_owned()),
"min_confidence" => min_confidence = value.parse::<f32>().ok(),
"max_nodes" => max_nodes = value.parse::<u32>().ok(),
"max_edges" => max_edges = value.parse::<u32>().ok(),
"focus" => focus = Some(value.into_owned()),
_ => {}
}
}
let non_blank = |value: String| Some(value).filter(|v| !v.trim().is_empty());
let root = root.and_then(non_blank)?;
Some(UiRenderArgs {
root: PathBuf::from(root),
format: format.and_then(non_blank).unwrap_or_else(|| "html".to_string()),
edges: edges.and_then(non_blank).unwrap_or_else(|| "all".to_string()),
algorithm: algorithm
.and_then(non_blank)
.unwrap_or_else(|| "label_propagation".to_string()),
min_confidence,
max_nodes,
max_edges,
focus: focus.and_then(non_blank),
})
}
struct HttpRouter {
broker: Arc<Broker>,
session_manager: Arc<NeverSessionManager>,
cancel: CancellationToken,
loopback: bool,
}
fn authority_host(authority: &str) -> &str {
if authority.starts_with('[') {
return match authority.find(']') {
Some(end) => &authority[..=end],
None => authority,
};
}
match authority.rsplit_once(':') {
Some((host, _port)) => host,
None => authority,
}
}
fn host_is_loopback(request: &Request<Incoming>) -> bool {
let raw = match request
.headers()
.get(http::header::HOST)
.and_then(|value| value.to_str().ok())
{
Some(header) => Some(header),
None => request.uri().authority().map(|authority| authority.as_str()),
};
let Some(raw) = raw else {
return false;
};
let host = authority_host(raw);
if host.eq_ignore_ascii_case("localhost") {
return true;
}
let bare = host
.strip_prefix('[')
.and_then(|inner| inner.strip_suffix(']'))
.unwrap_or(host);
bare.parse::<std::net::IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false)
}
impl HttpRouter {
async fn handle(&self, request: Request<Incoming>) -> Response<HttpBody> {
if self.loopback && !host_is_loopback(&request) {
return text_response(
StatusCode::FORBIDDEN,
"forbidden: Host header is not a loopback address (DNS-rebinding protection)",
);
}
let _activity = self.broker.begin_http_request();
if request.uri().path() == UI_PATH {
return self.handle_ui(&request).await;
}
if request.uri().path() != MCP_PATH {
return text_response(
StatusCode::NOT_FOUND,
"not found: this server serves POST /mcp and GET /ui 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
}
async fn handle_ui(&self, request: &Request<Incoming>) -> Response<HttpBody> {
let Some(args) = parse_ui_args(request.uri().query()) else {
return text_response(StatusCode::BAD_REQUEST, "bad request: missing ?root=<abs-repo-path>");
};
let Ok(root) = std::fs::canonicalize(&args.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 /ui: 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 /ui: workspace connection accounting failed");
return text_response(StatusCode::NOT_FOUND, "not found: workspace could not be hosted");
}
};
let agent_id = identity::cli_agent_id(&root).into_string();
let server = BasemindServer::from_shared(shared, agent_id);
match server
.render_ui_http(
&args.format,
&args.edges,
&args.algorithm,
args.min_confidence,
args.max_nodes,
args.max_edges,
args.focus,
)
.await
{
Ok((body, content_type)) => html_response(StatusCode::OK, body, content_type),
Err(error) => text_response(StatusCode::BAD_REQUEST, &format!("bad request: {}", error.message)),
}
}
}
#[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(),
loopback: local.ip().is_loopback(),
});
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 Some(conn_activity) = router.broker.try_begin_http_connection().await else {
continue;
};
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}")
}
#[allow(clippy::too_many_arguments)]
fn build_ui_url(
addr: &SocketAddr,
root: &Path,
format: &str,
edges: &str,
algorithm: &str,
min_confidence: Option<f32>,
max_nodes: Option<u32>,
max_edges: Option<u32>,
focus: Option<&str>,
) -> String {
let mut ser = form_urlencoded::Serializer::new(String::new());
ser.append_pair("root", &root.to_string_lossy());
ser.append_pair("format", format);
ser.append_pair("edges", edges);
ser.append_pair("algorithm", algorithm);
if let Some(confidence) = min_confidence {
ser.append_pair("min_confidence", &confidence.to_string());
}
if let Some(max) = max_nodes {
ser.append_pair("max_nodes", &max.to_string());
}
if let Some(max) = max_edges {
ser.append_pair("max_edges", &max.to_string());
}
if let Some(prefix) = focus {
ser.append_pair("focus", prefix);
}
format!("http://{addr}/ui?{}", ser.finish())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn served_ui_url(
root: &Path,
format: &str,
edges: &str,
algorithm: &str,
min_confidence: Option<f32>,
max_nodes: Option<u32>,
max_edges: Option<u32>,
focus: Option<&str>,
) -> Option<String> {
let paths = super::singleton::resolve_paths().ok()?;
served_ui_url_from_comms_dir(
&paths.comms_dir,
root,
format,
edges,
algorithm,
min_confidence,
max_nodes,
max_edges,
focus,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn served_ui_url_from_comms_dir(
comms_dir: &Path,
root: &Path,
format: &str,
edges: &str,
algorithm: &str,
min_confidence: Option<f32>,
max_nodes: Option<u32>,
max_edges: Option<u32>,
focus: Option<&str>,
) -> Option<String> {
let addr = read_portfile(comms_dir)?;
tokio::time::timeout(UI_PROBE_TIMEOUT, tokio::net::TcpStream::connect(addr))
.await
.ok()?
.ok()?;
Some(build_ui_url(
&addr,
root,
format,
edges,
algorithm,
min_confidence,
max_nodes,
max_edges,
focus,
))
}
#[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");
}
#[test]
fn build_ui_url_encodes_root_and_all_knobs() {
let addr: SocketAddr = "127.0.0.1:51786".parse().unwrap();
let url = build_ui_url(
&addr,
Path::new("/tmp/my repo"),
"html",
"all",
"label_propagation",
Some(0.5),
Some(200),
Some(700),
Some("src/mcp"),
);
assert!(url.starts_with("http://127.0.0.1:51786/ui?"), "got {url}");
assert!(url.contains("root=%2Ftmp%2Fmy+repo"), "root encoded: {url}");
assert!(url.contains("format=html"), "{url}");
assert!(url.contains("edges=all"), "{url}");
assert!(url.contains("algorithm=label_propagation"), "{url}");
assert!(url.contains("min_confidence=0.5"), "{url}");
assert!(url.contains("max_nodes=200"), "{url}");
assert!(url.contains("max_edges=700"), "{url}");
assert!(url.contains("focus=src%2Fmcp"), "{url}");
let query = url.split_once('?').unwrap().1;
let args = parse_ui_args(Some(query)).expect("route parses its own URL");
assert_eq!(args.root, PathBuf::from("/tmp/my repo"));
assert_eq!(args.format, "html");
assert_eq!(args.min_confidence, Some(0.5));
assert_eq!(args.max_nodes, Some(200));
assert_eq!(args.max_edges, Some(700));
assert_eq!(args.focus.as_deref(), Some("src/mcp"));
}
#[test]
fn build_ui_url_omits_absent_optionals() {
let addr: SocketAddr = "127.0.0.1:51786".parse().unwrap();
let url = build_ui_url(
&addr,
Path::new("/repo"),
"svg",
"calls",
"louvain",
None,
None,
None,
None,
);
assert!(url.contains("format=svg"), "{url}");
assert!(!url.contains("min_confidence"), "{url}");
assert!(!url.contains("max_nodes"), "{url}");
assert!(!url.contains("max_edges"), "{url}");
assert!(!url.contains("focus"), "{url}");
}
#[test]
fn parse_ui_args_requires_root_and_defaults_knobs() {
assert!(parse_ui_args(Some("format=html")).is_none(), "root is required");
assert!(parse_ui_args(Some("root=%20%20")).is_none(), "blank root rejected");
assert!(parse_ui_args(None).is_none());
let args = parse_ui_args(Some("root=%2Frepo")).expect("root-only parses");
assert_eq!(args.root, PathBuf::from("/repo"));
assert_eq!(args.format, "html");
assert_eq!(args.edges, "all");
assert_eq!(args.algorithm, "label_propagation");
assert_eq!(args.min_confidence, None);
assert_eq!(args.max_nodes, None);
assert_eq!(args.max_edges, None);
assert_eq!(args.focus, None);
}
#[tokio::test]
async fn served_ui_url_resolves_live_daemon_and_degrades_when_dead() {
use std::net::TcpListener;
let comms_dir = tempfile::tempdir().expect("comms tempdir");
let root = comms_dir.path();
let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback listener");
let addr = listener.local_addr().expect("listener addr");
write_portfile(comms_dir.path(), &addr).expect("write portfile");
let live = served_ui_url_from_comms_dir(
comms_dir.path(),
root,
"html",
"all",
"label_propagation",
None,
None,
None,
None,
)
.await
.expect("a daemon answering the probe yields a served URL");
assert!(
live.starts_with(&format!("http://{addr}/ui?")),
"served URL targets the live daemon address: {live}"
);
assert!(
live.contains("format=html") && live.contains("edges=all"),
"served URL carries the requested knobs: {live}"
);
drop(listener);
let dead_addr = {
let ephemeral = TcpListener::bind("127.0.0.1:0").expect("bind loopback listener");
ephemeral.local_addr().expect("listener addr")
};
write_portfile(comms_dir.path(), &dead_addr).expect("rewrite portfile");
let dead = served_ui_url_from_comms_dir(
comms_dir.path(),
root,
"html",
"all",
"label_propagation",
None,
None,
None,
None,
)
.await;
assert!(
dead.is_none(),
"a stale portfile with no daemon answering degrades to None, got {dead:?}"
);
}
}