haematite-cli 0.21.1

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
use std::collections::BTreeSet;
use std::net::{IpAddr, SocketAddr};
use std::num::{NonZeroU64, NonZeroUsize};
use std::time::Duration;

use serde::Deserialize;
use ureq::http::{HeaderValue, Uri};

use super::error::Error;

/// The HTTP MCP's own settings.
///
/// Who may come in is not among them: each request's bearer sign-in is the
/// caller's own Lys pass for haematite, and the store judges it, and every
/// act under it, by that caller's grants.
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
    pub listen: SocketAddr,
    pub allowed_origins: BTreeSet<String>,
    /// The one request path answered, `/mcp` unless set, so two listeners
    /// behind one hostname can each own a path (`/mcp`, `/mcp/studio`).
    #[serde(default = "mcp_path")]
    pub path: String,
    /// Where an MCP client finds how to sign in, named in the challenge of
    /// every 401 (RFC 9728).
    #[serde(default)]
    pub resource_metadata_url: Option<String>,
    #[serde(default = "connections")]
    pub max_connections: NonZeroUsize,
    #[serde(default = "header_timeout")]
    pub header_timeout_ms: NonZeroU64,
    #[serde(default = "body_timeout")]
    pub body_timeout_ms: NonZeroU64,
}

fn mcp_path() -> String {
    "/mcp".to_owned()
}

const fn connections() -> NonZeroUsize {
    NonZeroUsize::MIN.saturating_add(63)
}

const fn header_timeout() -> NonZeroU64 {
    NonZeroU64::MIN.saturating_add(1_999)
}

const fn body_timeout() -> NonZeroU64 {
    NonZeroU64::MIN.saturating_add(4_999)
}

impl Config {
    pub(super) fn validate(&self) -> Result<(), Error> {
        if !self.listen.ip().is_loopback() {
            return Err(Error::config(
                "listen must be a loopback address behind a TLS proxy",
            ));
        }
        if !self.path.starts_with('/')
            || self.path.len() > 256
            || self.path.ends_with('/')
            || self.path.contains("//")
            || !self.path.bytes().all(|byte| {
                byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.')
            })
        {
            return Err(Error::config(
                "path must be an absolute request path of plain segments",
            ));
        }
        for origin in &self.allowed_origins {
            canonical_origin(origin)?;
        }
        if let Some(url) = &self.resource_metadata_url {
            trusted(url)?;
            if url.len() > 2_048 || HeaderValue::from_str(url).is_err() {
                return Err(Error::config(
                    "resource_metadata_url must fit one safe challenge header",
                ));
            }
        }
        for millis in [self.header_timeout_ms.get(), self.body_timeout_ms.get()] {
            if std::time::Instant::now()
                .checked_add(Duration::from_millis(millis))
                .is_none()
            {
                return Err(Error::config("HTTP deadline is not representable"));
            }
        }
        Ok(())
    }
}

fn canonical_origin(value: &str) -> Result<(), Error> {
    let uri = trusted(value)?;
    let scheme = uri
        .scheme_str()
        .ok_or_else(|| Error::config("origin scheme is missing"))?;
    let authority = uri
        .authority()
        .ok_or_else(|| Error::config("origin authority is missing"))?;
    let host = authority.host();
    let normalized = match host.trim_matches(['[', ']']).parse::<IpAddr>() {
        Ok(IpAddr::V4(address)) => address.to_string(),
        Ok(IpAddr::V6(address)) => format!("[{address}]"),
        Err(_) => host.to_ascii_lowercase(),
    };
    let port = authority.port_u16();
    let suffix = match port {
        Some(80) if scheme == "http" => String::new(),
        Some(443) if scheme == "https" => String::new(),
        Some(port) => format!(":{port}"),
        None => String::new(),
    };
    if uri.path() != "/"
        || uri.query().is_some()
        || value != format!("{scheme}://{normalized}{suffix}")
    {
        return Err(Error::config(
            "allowed_origins must contain canonical origins without paths",
        ));
    }
    Ok(())
}

fn trusted(value: &str) -> Result<Uri, Error> {
    if value.contains('#') || !value.is_ascii() || value.chars().any(|ch| matches!(ch, '"' | '\\'))
    {
        return Err(Error::config(
            "configured URLs must be absolute ASCII URLs without fragments",
        ));
    }
    let uri: Uri = value
        .parse()
        .map_err(|_| Error::config("configured URL is not valid"))?;
    let host = uri
        .host()
        .ok_or_else(|| Error::config("configured URL host is missing"))?;
    let authority = uri
        .authority()
        .ok_or_else(|| Error::config("configured URL authority is missing"))?;
    let loopback = host
        .trim_matches(['[', ']'])
        .parse::<IpAddr>()
        .is_ok_and(|address| address.is_loopback());
    if authority.as_str().contains('@')
        || !(uri.scheme_str() == Some("https") || (uri.scheme_str() == Some("http") && loopback))
    {
        return Err(Error::config(
            "configured URL requires HTTPS or a literal loopback HTTP address without credentials",
        ));
    }
    Ok(uri)
}