OXYGENHUB

Coverage / crates/spectra-mcp/src/client.rs 72.7%

120 of 165 lines run, 95 of 190 functions · run #36 on main · run missed

1
//! A client for the daemon over its Unix socket (spec §8.5), signing every
2
//! request with the caller's key: the shims and the CLI use it, and so do
3
//! the tests.
4
5
use anyhow::{Context, Result};
6
use http_body_util::{BodyExt, Full};
7
use hyper::body::Bytes;
8
use spectra_core::keys::KeyPair;
9
use spectra_core::proof::Proof;
10
use std::path::{Path, PathBuf};
11
12
/// A response, its body read whole.
13
#[derive(Debug)]
14
pub struct Response {
15
    pub status: u16,
16
    pub headers: Vec<(String, String)>,
17
    pub body: Vec<u8>,
18
}
19
20
impl Response {
21118×
    pub fn json(&self) -> Result<serde_json::Value> {
22118×
        serde_json::from_slice(&self.body).with_context(|| format!("not JSON: {}", String::from_utf8_lossy(&self.body)))
23118×
    }
2446×
    pub fn header(&self, name: &str) -> Option<&str> {
2547×
        self.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str())
2646×
    }
27
}
28
29
/// Where the daemon is: its Unix socket, or a server over TLS with a
30
/// pinned certificate (spec §8.6).
31
#[derive(Clone)]
32
pub enum Endpoint {
33
    Unix(PathBuf),
34
    Tls { addr: String, config: std::sync::Arc<rustls::ClientConfig> },
35
}
36
37
impl Endpoint {
38
    /// `https://HOST:PORT` accepting exactly the certificate pinned by `pin`.
3923×
    pub fn tls(url: &str, pin: [u8; 32]) -> Result<Endpoint> {
4023×
        Ok(Endpoint::Tls { addr: host_port(url)?, config: crate::tls::Pinned::new(Some(pin)).client_config()? })
4123×
    }
42
430×
    pub fn describe(&self) -> String {
440×
        match self {
450×
            Endpoint::Unix(p) => p.display().to_string(),
460×
            Endpoint::Tls { addr, .. } => format!("https://{addr}"),
47
        }
480×
    }
49
}
50
51
/// `host:port` of an `https://` URL.
5238×
pub fn host_port(url: &str) -> Result<String> {
5338×
    let rest = url.strip_prefix("https://").with_context(|| format!("{url} is not an https:// URL"))?;
5438×
    let hp = rest.split('/').next().unwrap_or("");
5538×
    if hp.is_empty() {
560×
        anyhow::bail!("{url} names no host");
5738×
    }
58
    // The port defaults to Spectra's.
5938×
    let has_port = if hp.starts_with('[') { hp.contains("]:") } else { hp.contains(':') };
6038×
    Ok(if has_port { hp.to_string() } else { format!("{hp}:7350") })
6138×
}
62
63
trait Io: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {}
64
impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send> Io for T {}
65
66
pub struct Client {
67
    endpoint: Endpoint,
68
    signer: Option<KeyPair>,
69
}
70
71
impl Client {
7247×
    pub fn new(socket: &Path, signer: Option<KeyPair>) -> Client {
7347×
        Client { endpoint: Endpoint::Unix(socket.to_path_buf()), signer }
7447×
    }
75
7647×
    pub fn at(endpoint: Endpoint, signer: Option<KeyPair>) -> Client {
7747×
        Client { endpoint, signer }
7847×
    }
79
800×
    pub fn endpoint(&self) -> &Endpoint {
810×
        &self.endpoint
820×
    }
83
84
    /// The key this client signs with, if any.
852×
    pub fn signer(&self) -> Option<&KeyPair> {
862×
        self.signer.as_ref()
872×
    }
88
89178×
    async fn io(&self) -> Result<Box<dyn Io>> {
90178×
        Ok(match &self.endpoint {
91118×
            Endpoint::Unix(socket) => Box::new(
92118×
                tokio::net::UnixStream::connect(socket)
93118×
                    .await
94118×
                    .with_context(|| format!("connecting to {} (is `spectra serve` running?)", socket.display()))?,
95
            ),
9660×
            Endpoint::Tls { addr, config } => Box::new(crate::tls::connect(addr, config.clone()).await?),
97
        })
98178×
    }
99
100
    /// Whether the daemon answers at the endpoint.
1010×
    pub async fn reachable(&self) -> bool {
1020×
        self.io().await.is_ok()
1030×
    }
104
105
    /// Whether a daemon answers on the socket.
106119×
    pub async fn alive(socket: &Path) -> bool {
107119×
        tokio::net::UnixStream::connect(socket).await.is_ok()
108119×
    }
109
110
    /// Send `method path` with `body` and extra headers, signed.
111176×
    pub async fn send(&self, method: &str, path: &str, headers: &[(&str, String)], body: Vec<u8>) -> Result<Response> {
112176×
        let stream = self.io().await?;
113174×
        let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream)).await?;
114174×
        tokio::spawn(async move {
115174×
            let _ = conn.await;
116174×
        });
117
        // One request a connection: the server closes it after answering, so
118
        // nothing lingers to hold up its shutdown.
119174×
        let mut req = hyper::Request::builder().method(method).uri(path).header("host", "spectra").header("connection", "close");
120
        // JSON unless the caller says otherwise (a form, say).
121224×
        if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type")) {
122174×
            req = req.header("content-type", "application/json");
123174×
        }
124224×
        for (k, v) in headers {
125224×
            req = req.header(*k, v);
126224×
        }
127174×
        if let Some(pair) = &self.signer {
128544×
            for (k, v) in sign_headers(pair, &format!("{method} {path}"), &body) {
129544×
                req = req.header(k, v);
130544×
            }
13138×
        }
132174×
        let resp = sender.send_request(req.body(Full::new(Bytes::from(body)))?).await?;
133174×
        let status = resp.status().as_u16();
134746×
        let headers = resp.headers().iter().map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())).collect();
135174×
        let body = resp.into_body().collect().await?.to_bytes().to_vec();
136174×
        Ok(Response { status, headers, body })
137176×
    }
138
139
    /// A JSON-RPC request to `/mcp` with the protocol headers of `version`.
14057×
    pub async fn mcp(&self, version: &str, request: &serde_json::Value) -> Result<Response> {
14157×
        let method = request.get("method").and_then(|m| m.as_str()).unwrap_or("").to_string();
14257×
        let mut headers = vec![
14357×
            ("accept", "application/json, text/event-stream".to_string()),
14457×
            ("mcp-protocol-version", version.to_string()),
14557×
            ("mcp-method", method.clone()),
146
        ];
14757×
        if let Some(name) = request.pointer("/params/name").or_else(|| request.pointer("/params/uri")).and_then(|n| n.as_str()) {
14846×
            headers.push(("mcp-name", name.to_string()));
14946×
        }
15057×
        self.send("POST", "/mcp", &headers, serde_json::to_vec(request)?).await
15157×
    }
152
}
153
154
impl Client {
155
    /// Pass a request through as it is, unsigned, and hand back the
156
    /// response with its body still streaming (Radar's proxy, spec §8.6).
1572×
    pub async fn forward(&self, req: hyper::Request<Full<Bytes>>) -> Result<hyper::Response<hyper::body::Incoming>> {
1582×
        let stream = self.io().await?;
1592×
        let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream)).await?;
1602×
        tokio::spawn(async move {
1612×
            let _ = conn.await;
1622×
        });
1632×
        Ok(sender.send_request(req).await?)
1642×
    }
165
166
    /// Send a signed GET and hand each line of the streamed body to `line`
167
    /// as it arrives (an SSE stream, say); stop when it returns false.
1680×
    pub async fn stream(&self, path: &str, mut line: impl FnMut(&str) -> bool) -> Result<u16> {
1690×
        let stream = self.io().await?;
1700×
        let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream)).await?;
1710×
        tokio::spawn(async move {
1720×
            let _ = conn.await;
1730×
        });
1740×
        let mut req = hyper::Request::builder().method("GET").uri(path).header("host", "spectra").header("connection", "close");
1750×
        if let Some(pair) = &self.signer {
1760×
            for (k, v) in sign_headers(pair, &format!("GET {path}"), b"") {
1770×
                req = req.header(k, v);
1780×
            }
1790×
        }
1800×
        let resp = sender.send_request(req.body(Full::new(Bytes::new()))?).await?;
1810×
        let status = resp.status().as_u16();
1820×
        let mut body = resp.into_body();
1830×
        let mut buf = String::new();
1840×
        while let Some(frame) = body.frame().await {
1850×
            if let Ok(data) = frame?.into_data() {
1860×
                buf.push_str(&String::from_utf8_lossy(&data));
1870×
                while let Some(i) = buf.find('\n') {
1880×
                    let l: String = buf.drain(..=i).collect();
1890×
                    if !line(l.trim_end()) {
1900×
                        return Ok(status);
1910×
                    }
192
                }
1930×
            }
194
        }
1950×
        Ok(status)
1960×
    }
197
}
198
199
/// The signature headers of spec §8.5 for `method_path` and `body`.
200138×
pub fn sign_headers(pair: &KeyPair, method_path: &str, body: &[u8]) -> Vec<(&'static str, String)> {
201138×
    let p = Proof::sign(pair, method_path, body);
202138×
    vec![
203138×
        ("spectra-key", p.sig.key.to_hex()),
204138×
        ("spectra-time", p.time_ms.to_string()),
205138×
        ("spectra-nonce", hex::encode(p.nonce)),
206138×
        ("spectra-signature", hex::encode(spectra_core::cbor::encode(&p.sig.to_value()))),
207
    ]
208138×
}
209
210
/// The JSON-RPC result inside a response, whether it came as JSON or as
211
/// the last `data:` of an SSE stream.
21244×
pub fn rpc_result(r: &Response) -> Result<serde_json::Value> {
21344×
    let is_sse = r.header("content-type").is_some_and(|c| c.starts_with("text/event-stream"));
21444×
    if !is_sse {
21539×
        return r.json();
2165×
    }
2175×
    let text = String::from_utf8_lossy(&r.body);
2185×
    let last = text
2195×
        .lines()
2205×
        .rev()
22110×
        .filter_map(|l| l.strip_prefix("data:"))
2225×
        .map(str::trim)
2235×
        .find(|l| !l.is_empty())
2245×
        .context("an SSE response with no data")?;
2255×
    Ok(serde_json::from_str(last)?)
22644×
}