Skip to main content

haematite_cli/mcp/http/
cli.rs

1use std::io::Read;
2use std::path::PathBuf;
3use std::process::ExitCode;
4use std::time::Duration;
5
6use super::error::Error;
7use crate::serve::cli::Config;
8
9const CONFIG_BYTES: u64 = 1_048_576;
10const USAGE: &str =
11    "usage: haematite mcp-http --config <absolute service.json> [--exit-on-stdin-close]";
12
13pub fn run(args: impl Iterator<Item = String>) -> ExitCode {
14    match run_inner(args) {
15        Ok(()) => ExitCode::SUCCESS,
16        Err(error) => {
17            eprintln!("haematite mcp-http: {error}");
18            ExitCode::FAILURE
19        }
20    }
21}
22
23fn run_inner(mut args: impl Iterator<Item = String>) -> Result<(), Error> {
24    let mut path = None;
25    let mut exit_on_stdin_close = false;
26    while let Some(arg) = args.next() {
27        match arg.as_str() {
28            "--config" if path.is_none() => {
29                path = Some(PathBuf::from(
30                    args.next().ok_or_else(|| Error::config(USAGE))?,
31                ));
32            }
33            "--exit-on-stdin-close" if !exit_on_stdin_close => exit_on_stdin_close = true,
34            _ => return Err(Error::config(USAGE)),
35        }
36    }
37    let path = path
38        .filter(|path| path.is_absolute())
39        .ok_or_else(|| Error::config(USAGE))?;
40    let file = std::fs::File::open(path).map_err(|error| {
41        Error::config("service configuration could not be opened").with_io(&error)
42    })?;
43    let mut bytes = Vec::new();
44    file.take(CONFIG_BYTES + 1)
45        .read_to_end(&mut bytes)
46        .map_err(|error| {
47            Error::config("service configuration could not be read").with_io(&error)
48        })?;
49    if u64::try_from(bytes.len())
50        .map_err(|_| Error::config("configuration length is not representable"))?
51        > CONFIG_BYTES
52    {
53        return Err(Error::config(
54            "service configuration exceeds its byte limit",
55        ));
56    }
57    let mut config: Config = serde_json::from_slice(&bytes)
58        .map_err(|_| Error::config("service configuration is not valid"))?;
59    let http = config
60        .mcp_http
61        .take()
62        .ok_or_else(|| Error::config("mcp_http must be explicitly configured"))?;
63    let address = http.listen;
64    let cap = usize::try_from(config.max_frame.get())
65        .map_err(|_| Error::config("frame cap is not representable"))?;
66    let socket = resolve_socket(&config)?;
67    let server = super::listener::Server::new(
68        http,
69        socket,
70        Duration::from_millis(config.timeout_ms.get()),
71        cap,
72        config.max_clients.get(),
73    )?;
74    let listener = std::net::TcpListener::bind(address)
75        .map_err(|error| Error::config("HTTP listener could not be bound").with_io(&error))?;
76    let address = listener
77        .local_addr()
78        .map_err(|error| Error::config("bound address could not be read").with_io(&error))?;
79    let watch = exit_on_stdin_close
80        .then(crate::serve::stdin_close::Watch::start)
81        .transpose()
82        .map_err(|error| {
83            Error::new(
84                500,
85                "mcp_http_stdin_watch_failed",
86                "stdin reader could not start",
87            )
88            .with_io(&error)
89        })?;
90    eprintln!("haematite mcp-http: ready on {address}");
91    if let Some(watch) = watch {
92        server.until_stdin(listener, watch)?;
93    } else {
94        server.serve(listener);
95    }
96    Ok(())
97}
98
99fn resolve_socket(config: &Config) -> Result<PathBuf, Error> {
100    config
101        .endpoint(false)
102        .map(|endpoint| endpoint.socket)
103        .map_err(|error| {
104            Error::new(
105                500,
106                error.code,
107                "service runtime endpoint could not be resolved",
108            )
109        })
110}
111
112#[cfg(test)]
113mod runtime_endpoint_tests {
114    use crate::serve::cli::endpoint_tests::with_fixture;
115
116    #[test]
117    fn http_configuration_uses_the_services_default_endpoint()
118    -> Result<(), Box<dyn std::error::Error>> {
119        with_fixture(|fixture| {
120            let config = fixture.config()?;
121            assert_eq!(
122                super::resolve_socket(&config)?,
123                config.endpoint(false)?.socket
124            );
125            Ok(())
126        })
127    }
128}