1use std::io::{self, BufRead, Write};
2use std::num::{NonZeroU32, NonZeroU64};
3use std::path::PathBuf;
4use std::process::ExitCode;
5use std::time::Duration;
6
7use serde::Deserialize;
8use serde_json::value::RawValue;
9
10use super::lys_pass::{LysAgent, PassSource, Proof};
11use super::{adapter, rpc};
12use crate::client::{self, Client, Reply};
13use crate::serve::{cli::Config, protocol};
14
15const USAGE: &str = "usage: haematite mcp (--socket <path> | --config <file.json>) [--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>)]";
16pub(super) const DEFAULT_TIMEOUT_MS: NonZeroU64 = NonZeroU64::MIN.saturating_add(29_999);
17
18#[path = "stdio_error.rs"]
19mod error;
20pub use error::RelayError;
21
22pub fn relay(
29 mut input: impl BufRead,
30 mut output: impl Write,
31 sender: &mut impl adapter::Sender,
32 cap: NonZeroU32,
33) -> Result<(), RelayError> {
34 let byte_limit = usize::try_from(cap.get()).map_err(|_| RelayError::CapNotRepresentable)?;
35 let codec = rpc::RpcCodec::default();
36 while let Some(line) = read_line_bounded(&mut input, byte_limit).map_err(RelayError::Input)? {
37 if let Some(mut response) = answer(&codec, sender, &line, byte_limit)? {
38 response.push(b'\n');
39 if let Err(cause) = output.write_all(&response).and_then(|()| output.flush()) {
40 return Err(RelayError::ReceiptOutput { cause, response });
41 }
42 }
43 }
44 Ok(())
45}
46
47pub(super) fn answer(
48 codec: &rpc::RpcCodec,
49 sender: &mut impl adapter::Sender,
50 line: &[u8],
51 byte_limit: usize,
52) -> Result<Option<Vec<u8>>, RelayError> {
53 let prepared = codec
54 .decode(line, byte_limit)
55 .and_then(|request| codec.prepare(request));
56 match prepared {
57 Err(refusal) => refusal.encode().map_err(RelayError::Encoding),
58 Ok(rpc::Prepared::NoResponse) => Ok(None),
59 Ok(rpc::Prepared::Response(response)) => Ok(Some(response)),
60 Ok(rpc::Prepared::ToolCall { id, call }) => {
61 let mut result = adapter::dispatch(sender, &call).map_err(RelayError::Adapter)?;
62 compact_result(&mut result)?;
63 match rpc::encode_result(&id, &result) {
64 Ok(response) => Ok(Some(response)),
65 Err(refusal) => Err(RelayError::ResultEncoding { refusal, result }),
66 }
67 }
68 }
69}
70
71fn compact_result(result: &mut adapter::ToolResult) -> Result<(), RelayError> {
72 if result.is_error {
73 return Ok(());
74 }
75 let mut body = result.structured_content.get().as_bytes().to_vec();
76 compact_line(&mut body);
77 result.structured_content = serde_json::from_slice(&body).map_err(RelayError::Encoding)?;
78 let [adapter::TextContent::Text { text }] = &mut result.content;
79 text.clear();
80 text.push_str(result.structured_content.get());
81 Ok(())
82}
83
84pub(super) fn read_line_bounded(
85 reader: &mut impl BufRead,
86 cap: usize,
87) -> io::Result<Option<Vec<u8>>> {
88 let mut line = Vec::new();
89 let retained = cap.saturating_add(1);
90 let mut consumed_any = false;
91 loop {
92 let available = reader.fill_buf()?;
93 if available.is_empty() {
94 return Ok(consumed_any.then_some(line));
95 }
96 consumed_any = true;
97 let newline = available.iter().position(|byte| *byte == b'\n');
98 let before = newline.unwrap_or(available.len());
99 let take = before.min(retained.saturating_sub(line.len()));
100 line.extend_from_slice(&available[..take]);
101 reader.consume(before + usize::from(newline.is_some()));
102 if newline.is_some() {
103 return Ok(Some(line));
104 }
105 }
106}
107
108fn compact_line(frame: &mut Vec<u8>) {
109 let mut in_string = false;
110 let mut escaped = false;
111 frame.retain(|byte| {
112 if in_string {
113 if escaped {
114 escaped = false;
115 } else if *byte == b'\\' {
116 escaped = true;
117 } else if *byte == b'"' {
118 in_string = false;
119 }
120 true
121 } else if *byte == b'"' {
122 in_string = true;
123 true
124 } else {
125 !matches!(*byte, b' ' | b'\t' | b'\r' | b'\n')
126 }
127 });
128}
129
130pub fn run(args: impl Iterator<Item = String>) -> ExitCode {
132 match run_inner(args) {
133 Ok(()) => ExitCode::SUCCESS,
134 Err(error) => {
135 eprintln!("haematite mcp: {error}");
136 ExitCode::FAILURE
137 }
138 }
139}
140
141fn run_inner(args: impl Iterator<Item = String>) -> Result<(), RelayError> {
142 let (mut sender, cap) = Options::parse(args)?.sender()?;
143 relay(io::stdin().lock(), io::stdout().lock(), &mut sender, cap)
144}
145
146enum Target {
147 Socket(PathBuf),
148 Config(PathBuf),
149}
150
151struct Options {
152 target: Target,
153 timeout: Duration,
154 pass: Option<PassSource>,
155}
156
157fn flag_value(
159 args: &mut impl Iterator<Item = String>,
160 why: &'static str,
161) -> Result<String, RelayError> {
162 args.next()
163 .filter(|value| !value.is_empty() && !value.starts_with("--"))
164 .ok_or(RelayError::Arguments(why))
165}
166
167impl Options {
168 fn parse(mut args: impl Iterator<Item = String>) -> Result<Self, RelayError> {
169 let mut target = None;
170 let mut timeout_ms = None;
171 let mut pass_file = None;
172 let mut lys_agent = None;
173 let mut credential_file = None;
174 while let Some(arg) = args.next() {
175 match arg.as_str() {
176 "--socket" | "--config" if target.is_none() => {
177 let path = flag_value(&mut args, "target requires a path")?;
178 let path = PathBuf::from(path);
179 target = Some(if arg == "--socket" {
180 Target::Socket(path)
181 } else {
182 Target::Config(path)
183 });
184 }
185 "--timeout-ms" if timeout_ms.is_none() => {
186 let value = args
187 .next()
188 .ok_or(RelayError::Arguments("timeout requires milliseconds"))?;
189 timeout_ms = Some(value.parse::<NonZeroU64>().map_err(|_| {
190 RelayError::Arguments("timeout requires positive integer milliseconds")
191 })?);
192 }
193 "--pass-file" if pass_file.is_none() => {
194 let path = flag_value(&mut args, "pass file requires a path")?;
195 pass_file = Some(PathBuf::from(path));
196 }
197 "--lys-agent" if lys_agent.is_none() => {
198 let words = (args.next(), args.next());
199 let (Some(issuer), Some(agent)) = words else {
200 return Err(RelayError::Arguments(
201 "lys agent requires an issuer origin and an agent id",
202 ));
203 };
204 if issuer.starts_with("--") || agent.starts_with("--") {
205 return Err(RelayError::Arguments(
206 "lys agent requires an issuer origin and an agent id",
207 ));
208 }
209 lys_agent = Some((issuer, agent));
210 }
211 "--credential-file" if credential_file.is_none() => {
212 let path = flag_value(&mut args, "credential file requires a path")?;
213 credential_file = Some(Proof::Grant(PathBuf::from(path)));
214 }
215 "--key-file" if credential_file.is_none() => {
216 let path = flag_value(&mut args, "key file requires a path")?;
217 credential_file = Some(Proof::Key(PathBuf::from(path)));
218 }
219 "--launch-proof-file" if credential_file.is_none() => {
220 let path = flag_value(&mut args, "launch proof file requires a path")?;
221 credential_file = Some(Proof::Launch(PathBuf::from(path)));
222 }
223 "--run-pass-env" if credential_file.is_none() => {
224 let name = flag_value(&mut args, "run pass requires a variable name")?;
225 credential_file = Some(Proof::RunPass(name));
226 }
227 _ => return Err(RelayError::Arguments("unknown or repeated argument")),
228 }
229 }
230 let target = target.ok_or(RelayError::Arguments(
231 "give exactly one of socket and config",
232 ))?;
233 let timeout = Duration::from_millis(timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS).get());
234 let pass = match (pass_file, lys_agent, credential_file) {
235 (None, None, None) => None,
236 (Some(path), None, None) => Some(PassSource::File(path)),
237 (None, Some((issuer, agent)), Some(credential)) => Some(PassSource::Lys(
238 LysAgent::new(&issuer, &agent, credential, timeout)
239 .map_err(RelayError::Arguments)?,
240 )),
241 (Some(_), Some(_), _) | (Some(_), None, Some(_)) => {
242 return Err(RelayError::Arguments(
243 "give at most one of pass file and lys agent",
244 ));
245 }
246 (None, Some(_), None) | (None, None, Some(_)) => {
247 return Err(RelayError::Arguments(
248 "lys agent and exactly one proof source are given together",
249 ));
250 }
251 };
252 Ok(Self {
253 target,
254 timeout,
255 pass,
256 })
257 }
258
259 fn sender(self) -> Result<(NativeSender, NonZeroU32), RelayError> {
260 let (socket, first, cap) = match self.target {
261 Target::Config(path) => {
262 let bytes = std::fs::read(path).map_err(RelayError::ConfigRead)?;
263 let config: Config =
264 serde_json::from_slice(&bytes).map_err(RelayError::ConfigDecode)?;
265 let endpoint = config.endpoint(false).map_err(RelayError::Runtime)?;
266 (endpoint.socket, None, config.max_frame)
267 }
268 Target::Socket(socket) => {
269 let first =
270 Client::connect(&socket, self.timeout).map_err(RelayError::SocketCap)?;
271 let cap = first.max_frame();
272 (socket, Some(first), cap)
273 }
274 };
275 Ok((
276 NativeSender {
277 socket,
278 timeout: self.timeout,
279 first,
280 first_presented: false,
281 pass: self.pass,
282 },
283 cap,
284 ))
285 }
286}
287
288pub(super) struct NativeSender {
289 socket: PathBuf,
290 timeout: Duration,
291 first: Option<Client>,
292 first_presented: bool,
295 pass: Option<PassSource>,
299}
300
301impl NativeSender {
302 #[cfg(test)]
303 pub(super) const fn new(socket: PathBuf, timeout: Duration) -> Self {
304 Self {
305 socket,
306 timeout,
307 first: None,
308 first_presented: false,
309 pass: None,
310 }
311 }
312
313 pub(super) const fn presented(
317 socket: PathBuf,
318 timeout: Duration,
319 first: Client,
320 pass: String,
321 ) -> Self {
322 Self {
323 socket,
324 timeout,
325 first: Some(first),
326 first_presented: true,
327 pass: Some(PassSource::Held(pass)),
328 }
329 }
330}
331
332#[derive(Deserialize)]
333struct WireOperation {
334 method: String,
335 params: Option<Box<RawValue>>,
336}
337
338impl adapter::Sender for NativeSender {
339 fn send(
340 &mut self,
341 request: protocol::Operation,
342 ) -> Result<Box<RawValue>, adapter::ServiceRefusal> {
343 let encode_failure = |error: serde_json::Error| adapter::ServiceRefusal {
344 code: "mcp_client_request_encoding_failed".into(),
345 message: error.to_string(),
346 receipt: None,
347 };
348 let raw = serde_json::value::to_raw_value(&request).map_err(encode_failure)?;
349 let operation: WireOperation = serde_json::from_str(raw.get()).map_err(encode_failure)?;
350 let (mut client, presented) = match self.first.take() {
351 Some(first) => (first, std::mem::take(&mut self.first_presented)),
352 None => (
353 Client::connect(&self.socket, self.timeout).map_err(client_refusal)?,
354 false,
355 ),
356 };
357 if !presented && let Some(source) = &mut self.pass {
358 source.present(|pass| present_pass(&mut client, pass))?;
359 }
360 match client
361 .call(&operation.method, operation.params.as_deref())
362 .map_err(client_refusal)?
363 {
364 Reply::Answered(raw) => Ok(raw),
365 Reply::Refused(refusal) => Err(adapter::ServiceRefusal {
366 code: refusal.code,
367 message: refusal.message,
368 receipt: refusal
369 .verified
370 .as_ref()
371 .map(|receipt| receipt.raw().to_owned()),
372 }),
373 }
374 }
375}
376
377pub(super) fn present_pass(client: &mut Client, pass: &str) -> Result<(), adapter::ServiceRefusal> {
381 let pass =
382 serde_json::value::to_raw_value(&serde_json::json!({ "pass": pass })).map_err(|error| {
383 adapter::ServiceRefusal {
384 code: "mcp_pass_unreadable".into(),
385 message: format!("the pass could not be written: {error}"),
386 receipt: None,
387 }
388 })?;
389 match client
390 .call("session.pass", Some(&pass))
391 .map_err(client_refusal)?
392 {
393 Reply::Answered(_) => Ok(()),
394 Reply::Refused(refusal) => Err(adapter::ServiceRefusal {
395 code: refusal.code,
396 message: refusal.message,
397 receipt: None,
398 }),
399 }
400}
401
402fn client_refusal(ended: client::Ended) -> adapter::ServiceRefusal {
403 let (code, message, receipt) = match ended {
404 client::Ended::Usage(message) => ("mcp_client_usage", message, None),
405 client::Ended::NotAsked(message) => ("mcp_client_not_asked", message, None),
406 client::Ended::Unknown(message) => ("mcp_client_outcome_unknown", message, None),
407 client::Ended::Received { why, receipt } => (
408 "mcp_client_complete_receipt",
409 why,
410 Some(receipt.raw().to_owned()),
411 ),
412 };
413 adapter::ServiceRefusal {
414 code: code.into(),
415 message,
416 receipt,
417 }
418}
419
420#[cfg(test)]
421#[path = "stdio_tests.rs"]
422mod tests;
423
424#[cfg(test)]
425#[path = "launch_proof_tests.rs"]
426mod launch_proof_tests;
427
428#[cfg(test)]
429mod runtime_endpoint_tests {
430 use super::{Options, Target};
431 use crate::serve::cli::endpoint_tests::with_fixture;
432 use std::time::Duration;
433
434 #[test]
435 fn stdio_configuration_uses_the_services_default_endpoint()
436 -> Result<(), Box<dyn std::error::Error>> {
437 with_fixture(|fixture| {
438 let expected = fixture.config()?.endpoint(false)?.socket;
439 let options = Options {
440 target: Target::Config(fixture.config_path.clone()),
441 timeout: Duration::from_secs(1),
442 pass: None,
443 };
444 let (sender, cap) = options.sender()?;
445 assert_eq!(sender.socket, expected);
446 assert_eq!(cap.get(), 65_536);
447 assert!(sender.first.is_none());
448 Ok(())
449 })
450 }
451}