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 ureq::http::HeaderMap;

use super::error::Error;
use crate::mcp::rpc;

#[cfg(test)]
#[path = "protocol_header_tests.rs"]
mod protocol_header_tests;

pub(super) fn one<'a>(
    headers: &'a HeaderMap,
    name: &'static str,
    status: u16,
    code: &'static str,
    message: &'static str,
) -> Result<Option<&'a str>, Error> {
    let mut values = headers.get_all(name).iter();
    let Some(value) = values.next() else {
        return Ok(None);
    };
    if values.next().is_some() {
        return Err(Error::new(status, code, message));
    }
    value
        .to_str()
        .map(Some)
        .map_err(|_| Error::new(status, code, message))
}

pub(super) fn bearer(headers: &HeaderMap) -> Result<&str, Error> {
    let refusal = || {
        Error::new(
            401,
            "mcp_sign_in_required",
            "Exactly one bearer sign-in is required",
        )
    };
    let value = one(
        headers,
        "authorization",
        401,
        "mcp_sign_in_required",
        "Exactly one bearer sign-in is required",
    )?
    .ok_or_else(refusal)?;
    let (scheme, token) = value.split_once(' ').ok_or_else(refusal)?;
    if !scheme.eq_ignore_ascii_case("Bearer")
        || token.is_empty()
        || !token
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || b"-._~+/=".contains(&byte))
    {
        return Err(refusal());
    }
    Ok(token)
}

pub(super) fn origin(origins: &BTreeSet<String>, headers: &HeaderMap) -> Result<(), Error> {
    let value = one(
        headers,
        "origin",
        403,
        "mcp_http_origin_refused",
        "The request origin is not explicitly permitted",
    )?;
    if value.is_some_and(|value| !origins.contains(value)) {
        return Err(Error::new(
            403,
            "mcp_http_origin_refused",
            "The request origin is not explicitly permitted",
        ));
    }
    Ok(())
}

pub(super) fn protocol(headers: &HeaderMap) -> Result<(), Error> {
    let content_type = one(
        headers,
        "content-type",
        415,
        "mcp_http_content_type_invalid",
        "The request body must be application/json",
    )?;
    if !content_type.is_some_and(|value| {
        value
            .split(';')
            .next()
            .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("application/json"))
    }) {
        return Err(Error::new(
            415,
            "mcp_http_content_type_invalid",
            "The request body must be application/json",
        ));
    }
    let accept = one(
        headers,
        "accept",
        406,
        "mcp_http_accept_invalid",
        "Accept must include application/json and text/event-stream",
    )?;
    if !accept.is_some_and(|value| {
        accepts(value, "application/json") && accepts(value, "text/event-stream")
    }) {
        return Err(Error::new(
            406,
            "mcp_http_accept_invalid",
            "Accept must include application/json and text/event-stream",
        ));
    }
    let version = one(
        headers,
        "mcp-protocol-version",
        400,
        "mcp_http_protocol_version_unsupported",
        "The supplied MCP protocol version is not served",
    )?;
    if version.is_some_and(|version| version != rpc::SUPPORTED_PROTOCOL_VERSION) {
        return Err(Error::new(
            400,
            "mcp_http_protocol_version_unsupported",
            "The supplied MCP protocol version is not served",
        ));
    }
    Ok(())
}

fn accepts(value: &str, kind: &str) -> bool {
    let exact = value.split(',').any(|part| {
        part.split(';')
            .next()
            .is_some_and(|media| media.trim().eq_ignore_ascii_case(kind))
    });
    value.split(',').any(|part| {
        let mut fields = part.trim().split(';');
        let Some(media) = fields.next() else {
            return false;
        };
        if (exact && !media.trim().eq_ignore_ascii_case(kind)) || (!exact && media.trim() != "*/*")
        {
            return false;
        }
        let mut quality = None;
        for field in fields {
            let Some((key, value)) = field.trim().split_once('=') else {
                return false;
            };
            if key.trim().eq_ignore_ascii_case("q") {
                if quality.is_some() {
                    return false;
                }
                let Some(parsed) = parse_quality(value.trim()) else {
                    return false;
                };
                quality = Some(parsed);
            }
        }
        quality.is_none_or(|quality| quality > 0)
    })
}

fn parse_quality(value: &str) -> Option<u16> {
    let (whole, fractional) = value.split_once('.').unwrap_or((value, ""));
    if !matches!(whole, "0" | "1")
        || fractional.len() > 3
        || !fractional.bytes().all(|byte| byte.is_ascii_digit())
    {
        return None;
    }
    if whole == "1" {
        return fractional.bytes().all(|byte| byte == b'0').then_some(1000);
    }
    let number = if fractional.is_empty() {
        0
    } else {
        fractional.parse::<u16>().ok()?
    };
    let scale = match fractional.len() {
        0 | 3 => 1,
        1 => 100,
        2 => 10,
        _ => return None,
    };
    Some(number * scale)
}