use std::io::{self, BufRead, Write};
use std::num::{NonZeroU32, NonZeroU64};
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use serde::Deserialize;
use serde_json::value::RawValue;
use super::lys_pass::{LysAgent, PassSource, Proof};
use super::{adapter, rpc};
use crate::client::network::{Address, Remote, Trust};
use crate::client::{self, Client, Reply};
use crate::serve::{cli::Config, protocol};
const USAGE: &str = "usage: haematite mcp (--socket <path> | --config <file.json> | --endpoint <host:port> --server-name <TLS name>) [--timeout-ms N] [--pass-file <path> | --lys-agent <issuer origin> <agent id> ((--credential-file | --key-file | --launch-proof-file) <absolute path> | --run-pass-env <variable>)]";
pub(crate) const DEFAULT_TIMEOUT_MS: NonZeroU64 = NonZeroU64::MIN.saturating_add(29_999);
#[path = "stdio_error.rs"]
mod error;
pub use error::RelayError;
pub fn relay(
mut input: impl BufRead,
mut output: impl Write,
sender: &mut impl adapter::Sender,
cap: NonZeroU32,
) -> Result<(), RelayError> {
let byte_limit = usize::try_from(cap.get()).map_err(|_| RelayError::CapNotRepresentable)?;
let codec = rpc::RpcCodec::default();
while let Some(line) = read_line_bounded(&mut input, byte_limit).map_err(RelayError::Input)? {
if let Some(mut response) = answer(&codec, sender, &line, byte_limit)? {
response.push(b'\n');
if let Err(cause) = output.write_all(&response).and_then(|()| output.flush()) {
return Err(RelayError::ReceiptOutput { cause, response });
}
}
}
Ok(())
}
pub(super) fn answer(
codec: &rpc::RpcCodec,
sender: &mut impl adapter::Sender,
line: &[u8],
byte_limit: usize,
) -> Result<Option<Vec<u8>>, RelayError> {
let prepared = codec
.decode(line, byte_limit)
.and_then(|request| codec.prepare(request));
match prepared {
Err(refusal) => refusal.encode().map_err(RelayError::Encoding),
Ok(rpc::Prepared::NoResponse) => Ok(None),
Ok(rpc::Prepared::Response(response)) => Ok(Some(response)),
Ok(rpc::Prepared::ToolCall { id, call }) => {
let mut result = adapter::dispatch(sender, &call).map_err(RelayError::Adapter)?;
compact_result(&mut result)?;
match rpc::encode_result(&id, &result) {
Ok(response) => Ok(Some(response)),
Err(refusal) => Err(RelayError::ResultEncoding { refusal, result }),
}
}
}
}
fn compact_result(result: &mut adapter::ToolResult) -> Result<(), RelayError> {
if result.is_error {
return Ok(());
}
let mut body = result.structured_content.get().as_bytes().to_vec();
compact_line(&mut body);
result.structured_content = serde_json::from_slice(&body).map_err(RelayError::Encoding)?;
let [adapter::TextContent::Text { text }] = &mut result.content;
text.clear();
text.push_str(result.structured_content.get());
Ok(())
}
pub(super) fn read_line_bounded(
reader: &mut impl BufRead,
cap: usize,
) -> io::Result<Option<Vec<u8>>> {
let mut line = Vec::new();
let retained = cap.saturating_add(1);
let mut consumed_any = false;
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
return Ok(consumed_any.then_some(line));
}
consumed_any = true;
let newline = available.iter().position(|byte| *byte == b'\n');
let before = newline.unwrap_or(available.len());
let take = before.min(retained.saturating_sub(line.len()));
line.extend_from_slice(&available[..take]);
reader.consume(before + usize::from(newline.is_some()));
if newline.is_some() {
return Ok(Some(line));
}
}
}
fn compact_line(frame: &mut Vec<u8>) {
let mut in_string = false;
let mut escaped = false;
frame.retain(|byte| {
if in_string {
if escaped {
escaped = false;
} else if *byte == b'\\' {
escaped = true;
} else if *byte == b'"' {
in_string = false;
}
true
} else if *byte == b'"' {
in_string = true;
true
} else {
!matches!(*byte, b' ' | b'\t' | b'\r' | b'\n')
}
});
}
pub fn run(args: impl Iterator<Item = String>) -> ExitCode {
match run_inner(args) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("haematite mcp: {error}");
ExitCode::FAILURE
}
}
}
fn run_inner(args: impl Iterator<Item = String>) -> Result<(), RelayError> {
let (mut sender, cap) = Options::parse(args)?.sender()?;
relay(io::stdin().lock(), io::stdout().lock(), &mut sender, cap)
}
enum Target {
Socket(PathBuf),
Config(PathBuf),
Endpoint {
endpoint: String,
server_name: String,
},
}
struct Options {
target: Target,
timeout: Duration,
pass: Option<PassSource>,
}
fn flag_value(
args: &mut impl Iterator<Item = String>,
why: &'static str,
) -> Result<String, RelayError> {
args.next()
.filter(|value| !value.is_empty() && !value.starts_with("--"))
.ok_or(RelayError::Arguments(why))
}
fn one_target(
target: Option<Target>,
endpoint: Option<String>,
server_name: Option<String>,
) -> Result<Target, RelayError> {
match (target, endpoint, server_name) {
(Some(target), None, None) => Ok(target),
(None, Some(endpoint), Some(server_name)) => Ok(Target::Endpoint {
endpoint,
server_name,
}),
(None, Some(_), None) => Err(RelayError::Arguments("endpoint requires --server-name")),
(_, None, Some(_)) => Err(RelayError::Arguments(
"server name is given only with --endpoint",
)),
(None, None, None) | (Some(_), Some(_), _) => Err(RelayError::Arguments(
"give exactly one of socket, config and endpoint",
)),
}
}
impl Options {
fn parse(mut args: impl Iterator<Item = String>) -> Result<Self, RelayError> {
let mut target = None;
let mut endpoint = None;
let mut server_name = None;
let mut timeout_ms = None;
let mut pass_file = None;
let mut lys_agent = None;
let mut credential_file = None;
while let Some(arg) = args.next() {
match arg.as_str() {
"--socket" | "--config" if target.is_none() => {
let path = flag_value(&mut args, "target requires a path")?;
let path = PathBuf::from(path);
target = Some(if arg == "--socket" {
Target::Socket(path)
} else {
Target::Config(path)
});
}
"--endpoint" if endpoint.is_none() => {
endpoint = Some(flag_value(&mut args, "endpoint requires <host>:<port>")?);
}
"--server-name" if server_name.is_none() => {
server_name = Some(flag_value(&mut args, "server name requires a name")?);
}
"--timeout-ms" if timeout_ms.is_none() => {
let value = args
.next()
.ok_or(RelayError::Arguments("timeout requires milliseconds"))?;
timeout_ms = Some(value.parse::<NonZeroU64>().map_err(|_| {
RelayError::Arguments("timeout requires positive integer milliseconds")
})?);
}
"--pass-file" if pass_file.is_none() => {
let path = flag_value(&mut args, "pass file requires a path")?;
pass_file = Some(PathBuf::from(path));
}
"--lys-agent" if lys_agent.is_none() => {
let words = (args.next(), args.next());
let (Some(issuer), Some(agent)) = words else {
return Err(RelayError::Arguments(
"lys agent requires an issuer origin and an agent id",
));
};
if issuer.starts_with("--") || agent.starts_with("--") {
return Err(RelayError::Arguments(
"lys agent requires an issuer origin and an agent id",
));
}
lys_agent = Some((issuer, agent));
}
"--credential-file" if credential_file.is_none() => {
let path = flag_value(&mut args, "credential file requires a path")?;
credential_file = Some(Proof::Grant(PathBuf::from(path)));
}
"--key-file" if credential_file.is_none() => {
let path = flag_value(&mut args, "key file requires a path")?;
credential_file = Some(Proof::Key(PathBuf::from(path)));
}
"--launch-proof-file" if credential_file.is_none() => {
let path = flag_value(&mut args, "launch proof file requires a path")?;
credential_file = Some(Proof::Launch(PathBuf::from(path)));
}
"--run-pass-env" if credential_file.is_none() => {
let name = flag_value(&mut args, "run pass requires a variable name")?;
credential_file = Some(Proof::RunPass(name));
}
_ => return Err(RelayError::Arguments("unknown or repeated argument")),
}
}
let target = one_target(target, endpoint, server_name)?;
let timeout = Duration::from_millis(timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS).get());
let pass = match (pass_file, lys_agent, credential_file) {
(None, None, None) => None,
(Some(path), None, None) => Some(PassSource::File(path)),
(None, Some((issuer, agent)), Some(credential)) => Some(PassSource::Lys(
LysAgent::new(&issuer, &agent, credential, timeout)
.map_err(RelayError::Arguments)?,
)),
(Some(_), Some(_), _) | (Some(_), None, Some(_)) => {
return Err(RelayError::Arguments(
"give at most one of pass file and lys agent",
));
}
(None, Some(_), None) | (None, None, Some(_)) => {
return Err(RelayError::Arguments(
"lys agent and exactly one proof source are given together",
));
}
};
Ok(Self {
target,
timeout,
pass,
})
}
fn sender(self) -> Result<(NativeSender, NonZeroU32), RelayError> {
let (address, first, cap) = match self.target {
Target::Config(path) => {
let bytes = std::fs::read(path).map_err(RelayError::ConfigRead)?;
let config: Config =
serde_json::from_slice(&bytes).map_err(RelayError::ConfigDecode)?;
let endpoint = config.endpoint(false).map_err(RelayError::Runtime)?;
(Address::Socket(endpoint.socket), None, config.max_frame)
}
Target::Socket(socket) => {
let address = Address::Socket(socket);
let first = Client::reach(&address, self.timeout).map_err(RelayError::SocketCap)?;
let cap = first.max_frame();
(address, Some(first), cap)
}
Target::Endpoint {
endpoint,
server_name,
} => {
let trust = Trust::system().map_err(RelayError::Network)?;
let remote =
Remote::new(&endpoint, &server_name, &trust).map_err(RelayError::Network)?;
let address = Address::Network(remote);
let first = Client::reach(&address, self.timeout)
.map_err(|ended| RelayError::Network(ended.why().to_owned()))?;
let cap = first.max_frame();
(address, Some(first), cap)
}
};
Ok((
NativeSender {
address,
timeout: self.timeout,
first,
first_presented: false,
pass: self.pass,
},
cap,
))
}
}
pub(super) struct NativeSender {
address: Address,
timeout: Duration,
first: Option<Client>,
first_presented: bool,
pass: Option<PassSource>,
}
impl NativeSender {
#[cfg(test)]
pub(super) const fn new(socket: PathBuf, timeout: Duration) -> Self {
Self {
address: Address::Socket(socket),
timeout,
first: None,
first_presented: false,
pass: None,
}
}
pub(super) const fn presented(
socket: PathBuf,
timeout: Duration,
first: Client,
pass: String,
) -> Self {
Self {
address: Address::Socket(socket),
timeout,
first: Some(first),
first_presented: true,
pass: Some(PassSource::Held(pass)),
}
}
}
#[derive(Deserialize)]
struct WireOperation {
method: String,
params: Option<Box<RawValue>>,
}
impl adapter::Sender for NativeSender {
fn send(
&mut self,
request: protocol::Operation,
) -> Result<Box<RawValue>, adapter::ServiceRefusal> {
let encode_failure = |error: serde_json::Error| adapter::ServiceRefusal {
code: "mcp_client_request_encoding_failed".into(),
message: error.to_string(),
receipt: None,
};
let raw = serde_json::value::to_raw_value(&request).map_err(encode_failure)?;
let operation: WireOperation = serde_json::from_str(raw.get()).map_err(encode_failure)?;
let (mut client, presented) = match self.first.take() {
Some(first) => (first, std::mem::take(&mut self.first_presented)),
None => (
Client::reach(&self.address, self.timeout).map_err(client_refusal)?,
false,
),
};
if !presented && let Some(source) = &mut self.pass {
source.present(|pass| present_pass(&mut client, pass))?;
}
match client
.call(&operation.method, operation.params.as_deref())
.map_err(client_refusal)?
{
Reply::Answered(raw) => Ok(raw),
Reply::Refused(refusal) => Err(adapter::ServiceRefusal {
code: refusal.code,
message: refusal.message,
receipt: refusal
.verified
.as_ref()
.map(|receipt| receipt.raw().to_owned()),
}),
}
}
}
pub(super) fn present_pass(client: &mut Client, pass: &str) -> Result<(), adapter::ServiceRefusal> {
let pass =
serde_json::value::to_raw_value(&serde_json::json!({ "pass": pass })).map_err(|error| {
adapter::ServiceRefusal {
code: "mcp_pass_unreadable".into(),
message: format!("the pass could not be written: {error}"),
receipt: None,
}
})?;
match client
.call("session.pass", Some(&pass))
.map_err(client_refusal)?
{
Reply::Answered(_) => Ok(()),
Reply::Refused(refusal) => Err(adapter::ServiceRefusal {
code: refusal.code,
message: refusal.message,
receipt: None,
}),
}
}
fn client_refusal(ended: client::Ended) -> adapter::ServiceRefusal {
let (code, message, receipt) = match ended {
client::Ended::Usage(message) => ("mcp_client_usage", message, None),
client::Ended::NotAsked(message) => ("mcp_client_not_asked", message, None),
client::Ended::Unknown(message) => ("mcp_client_outcome_unknown", message, None),
client::Ended::Received { why, receipt } => (
"mcp_client_complete_receipt",
why,
Some(receipt.raw().to_owned()),
),
};
adapter::ServiceRefusal {
code: code.into(),
message,
receipt,
}
}
#[cfg(test)]
#[path = "stdio_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "launch_proof_tests.rs"]
mod launch_proof_tests;
#[cfg(test)]
mod endpoint_argument_tests {
use super::{Options, RelayError, Target};
fn parsed(words: &[&str]) -> Result<Options, RelayError> {
Options::parse(words.iter().map(|word| (*word).to_owned()))
}
#[test]
fn an_endpoint_with_its_server_name_is_the_network_target() {
let options = parsed(&[
"--endpoint",
"store.example:7443",
"--server-name",
"store.example",
]);
assert!(
matches!(
options.map(|options| options.target),
Ok(Target::Endpoint { endpoint, server_name })
if endpoint == "store.example:7443" && server_name == "store.example"
),
"the endpoint and its name were not the target"
);
}
#[test]
fn an_endpoint_alone_a_name_alone_and_an_endpoint_beside_a_socket_are_refused_by_name() {
for (words, why) in [
(
&["--endpoint", "store.example:7443"][..],
"endpoint requires --server-name",
),
(
&["--server-name", "store.example"][..],
"server name is given only with --endpoint",
),
(
&[
"--socket",
"/tmp/s.sock",
"--endpoint",
"store.example:7443",
"--server-name",
"store.example",
][..],
"give exactly one of socket, config and endpoint",
),
(
&[
"--endpoint",
"store.example:7443",
"--server-name",
"store.example",
"--config",
"/tmp/c.json",
][..],
"give exactly one of socket, config and endpoint",
),
] {
assert!(
matches!(parsed(words), Err(RelayError::Arguments(named)) if named == why),
"{words:?} was not refused with {why}"
);
}
}
}
#[cfg(test)]
mod runtime_endpoint_tests {
use super::{Address, Options, Target};
use crate::serve::cli::endpoint_tests::with_fixture;
use std::time::Duration;
#[test]
fn stdio_configuration_uses_the_services_default_endpoint()
-> Result<(), Box<dyn std::error::Error>> {
with_fixture(|fixture| {
let expected = fixture.config()?.endpoint(false)?.socket;
let options = Options {
target: Target::Config(fixture.config_path.clone()),
timeout: Duration::from_secs(1),
pass: None,
};
let (sender, cap) = options.sender()?;
assert!(
matches!(&sender.address, Address::Socket(socket) if *socket == expected),
"{:?}",
sender.address
);
assert_eq!(cap.get(), 65_536);
assert!(sender.first.is_none());
Ok(())
})
}
}