Skip to main content

origin_mcp_http/
proxy.rs

1//! The headless path when a GUI is already running (G17).
2//!
3//! Starting a second headless instance that opens the same SQLite file as the running
4//! GUI invites a class of locking problems. Instead, the headless start looks for a
5//! published HTTP endpoint (G16) and, if it is alive, **proxies** its stdio to that
6//! endpoint. Stdio then stays the transport a client speaks, but the process doing the
7//! work is the one that already owns the database.
8
9use crate::transport::post;
10use origin_domain::{AppError, Result};
11use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader};
12
13/// One JSON-RPC ping. Answered without an initialized session, so it is safe to use
14/// as a liveness probe.
15const PING: &str = r#"{"jsonrpc":"2.0","id":0,"method":"ping","params":{}}"#;
16
17/// Whether an endpoint is reachable and speaking MCP.
18pub async fn is_alive(url: &str, token: Option<&str>) -> bool {
19    match post(url, token, PING).await {
20        Ok(body) => serde_json::from_str::<serde_json::Value>(&body)
21            .map(|value| value.get("result").is_some())
22            .unwrap_or(false),
23        Err(_) => false,
24    }
25}
26
27/// Read JSON-RPC lines from `input`, forward each to `url`, and write the answers to
28/// `output`. Returns when the input ends.
29pub async fn proxy_streams<R, W>(
30    input: BufReader<R>,
31    mut output: W,
32    url: &str,
33    token: Option<&str>,
34) -> Result<()>
35where
36    R: tokio::io::AsyncRead + Unpin,
37    W: AsyncWrite + Unpin,
38{
39    tracing::info!(url, "proxying stdio to a running instance over http");
40
41    let mut lines = input.lines();
42    loop {
43        let line = match lines.next_line().await {
44            Ok(Some(line)) => line,
45            Ok(None) => break,
46            Err(error) => {
47                return Err(AppError::internal(format!("cannot read stdin: {error}")));
48            }
49        };
50
51        if line.trim().is_empty() {
52            continue;
53        }
54
55        let body = post(url, token, &line).await?;
56        if body.trim().is_empty() {
57            // A notification: no response is fed back.
58            continue;
59        }
60
61        output
62            .write_all(body.as_bytes())
63            .await
64            .map_err(|error| AppError::internal(format!("cannot write stdout: {error}")))?;
65        output
66            .write_all(b"\n")
67            .await
68            .map_err(|error| AppError::internal(format!("cannot write stdout: {error}")))?;
69        output
70            .flush()
71            .await
72            .map_err(|error| AppError::internal(format!("cannot flush stdout: {error}")))?;
73    }
74
75    Ok(())
76}