haematite-cli 0.21.8

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::io::Read;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;

use super::error::Error;
use crate::serve::cli::Config;

const CONFIG_BYTES: u64 = 1_048_576;
const USAGE: &str =
    "usage: haematite mcp-http --config <absolute service.json> [--exit-on-stdin-close]";

pub fn run(args: impl Iterator<Item = String>) -> ExitCode {
    match run_inner(args) {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("haematite mcp-http: {error}");
            ExitCode::FAILURE
        }
    }
}

fn run_inner(mut args: impl Iterator<Item = String>) -> Result<(), Error> {
    let mut path = None;
    let mut exit_on_stdin_close = false;
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--config" if path.is_none() => {
                path = Some(PathBuf::from(
                    args.next().ok_or_else(|| Error::config(USAGE))?,
                ));
            }
            "--exit-on-stdin-close" if !exit_on_stdin_close => exit_on_stdin_close = true,
            _ => return Err(Error::config(USAGE)),
        }
    }
    let path = path
        .filter(|path| path.is_absolute())
        .ok_or_else(|| Error::config(USAGE))?;
    let file = std::fs::File::open(path).map_err(|error| {
        Error::config("service configuration could not be opened").with_io(&error)
    })?;
    let mut bytes = Vec::new();
    file.take(CONFIG_BYTES + 1)
        .read_to_end(&mut bytes)
        .map_err(|error| {
            Error::config("service configuration could not be read").with_io(&error)
        })?;
    if u64::try_from(bytes.len())
        .map_err(|_| Error::config("configuration length is not representable"))?
        > CONFIG_BYTES
    {
        return Err(Error::config(
            "service configuration exceeds its byte limit",
        ));
    }
    let mut config: Config = serde_json::from_slice(&bytes)
        .map_err(|_| Error::config("service configuration is not valid"))?;
    let http = config
        .mcp_http
        .take()
        .ok_or_else(|| Error::config("mcp_http must be explicitly configured"))?;
    let address = http.listen;
    let cap = usize::try_from(config.max_frame.get())
        .map_err(|_| Error::config("frame cap is not representable"))?;
    let socket = resolve_socket(&config)?;
    let server = super::listener::Server::new(
        http,
        socket,
        Duration::from_millis(config.timeout_ms.get()),
        cap,
        config.max_clients.get(),
    )?;
    let listener = std::net::TcpListener::bind(address)
        .map_err(|error| Error::config("HTTP listener could not be bound").with_io(&error))?;
    let address = listener
        .local_addr()
        .map_err(|error| Error::config("bound address could not be read").with_io(&error))?;
    let watch = exit_on_stdin_close
        .then(crate::serve::stdin_close::Watch::start)
        .transpose()
        .map_err(|error| {
            Error::new(
                500,
                "mcp_http_stdin_watch_failed",
                "stdin reader could not start",
            )
            .with_io(&error)
        })?;
    eprintln!("haematite mcp-http: ready on {address}");
    if let Some(watch) = watch {
        server.until_stdin(listener, watch)?;
    } else {
        server.serve(listener);
    }
    Ok(())
}

fn resolve_socket(config: &Config) -> Result<PathBuf, Error> {
    config
        .endpoint(false)
        .map(|endpoint| endpoint.socket)
        .map_err(|error| {
            Error::new(
                500,
                error.code,
                "service runtime endpoint could not be resolved",
            )
        })
}

#[cfg(test)]
mod runtime_endpoint_tests {
    use crate::serve::cli::endpoint_tests::with_fixture;

    #[test]
    fn http_configuration_uses_the_services_default_endpoint()
    -> Result<(), Box<dyn std::error::Error>> {
        with_fixture(|fixture| {
            let config = fixture.config()?;
            assert_eq!(
                super::resolve_socket(&config)?,
                config.endpoint(false)?.socket
            );
            Ok(())
        })
    }
}