haematite-cli 0.21.0

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
//! `haematite mcp-pass`: one haematite pass from Lys, as a bearer header.
//!
//! It asks Lys's agent pass route for one pass and writes it as the HTTP
//! MCP's bearer sign-in header, one JSON object on stdout, for an MCP client
//! that runs a command for its headers (Claude Code's `headersHelper`) and
//! connects to the HTTP MCP itself.
//!
//! The pass goes only to stdout, for the client that ran the command; a
//! refusal on stderr names the reason and never the pass or the proof.

use std::io::Write;
use std::num::NonZeroU64;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;

use super::lys_pass::{LysAgent, Proof};

const USAGE: &str = "usage: haematite mcp-pass --lys-agent <issuer origin> <agent id> ((--credential-file | --key-file) <absolute path> | --run-pass-env <variable>) [--timeout-ms N]";
const DEFAULT_TIMEOUT_MS: u64 = 10_000;

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

fn run_inner(args: impl Iterator<Item = String>) -> Result<(), String> {
    let mut agent = parse(args)?;
    let pass = agent
        .ask_now()
        .map_err(|refusal| format!("{}: {}", refusal.code, refusal.message))?;
    let line = header(pass).map_err(|error| format!("mcp_pass_unwritten: {error}"))?;
    let mut stdout = std::io::stdout().lock();
    stdout
        .write_all(line.as_bytes())
        .and_then(|()| stdout.flush())
        .map_err(|error| format!("mcp_pass_unwritten: {}", error.kind()))
}

/// The one header object an MCP client sends on every request.
pub(super) fn header(pass: &str) -> Result<String, serde_json::Error> {
    let mut line = serde_json::to_string(&serde_json::json!({
        "Authorization": format!("Bearer {pass}"),
    }))?;
    line.push('\n');
    Ok(line)
}

pub(super) fn parse(mut args: impl Iterator<Item = String>) -> Result<LysAgent, String> {
    let mut lys_agent = None;
    let mut proof = None;
    let mut timeout_ms = None;
    let path = |value: Option<String>| match value {
        Some(value) if !value.is_empty() && !value.starts_with("--") => Ok(value),
        _ => Err(USAGE.to_owned()),
    };
    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--lys-agent" if lys_agent.is_none() => {
                lys_agent = Some((path(args.next())?, path(args.next())?));
            }
            "--credential-file" if proof.is_none() => {
                proof = Some(Proof::Grant(PathBuf::from(path(args.next())?)));
            }
            "--key-file" if proof.is_none() => {
                proof = Some(Proof::Key(PathBuf::from(path(args.next())?)));
            }
            "--run-pass-env" if proof.is_none() => {
                proof = Some(Proof::RunPass(path(args.next())?));
            }
            "--timeout-ms" if timeout_ms.is_none() => {
                let value = path(args.next())?;
                timeout_ms =
                    Some(value.parse::<NonZeroU64>().map_err(|_| {
                        "timeout requires positive integer milliseconds".to_owned()
                    })?);
            }
            _ => return Err(USAGE.to_owned()),
        }
    }
    let (Some((issuer, agent)), Some(proof)) = (lys_agent, proof) else {
        return Err(USAGE.to_owned());
    };
    let timeout = Duration::from_millis(timeout_ms.map_or(DEFAULT_TIMEOUT_MS, NonZeroU64::get));
    LysAgent::new(&issuer, &agent, proof, timeout).map_err(str::to_owned)
}

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