OXYGENHUB

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

86 of 120 lines run, 58 of 143 functions · run #16 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 {
2143×
    pub fn json(&self) -> Result<serde_json::Value> {
2243×
        serde_json::from_slice(&self.body).with_context(|| format!("not JSON: {}", String::from_utf8_lossy(&self.body)))
2343×
    }
2433×
    pub fn header(&self, name: &str) -> Option<&str> {
2542×
        self.headers.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.as_str())
2633×
    }
27
}
28
29
pub struct Client {
30
    socket: PathBuf,
31
    signer: Option<KeyPair>,
32
}
33
34
impl Client {
3524×
    pub fn new(socket: &Path, signer: Option<KeyPair>) -> Client {
3624×
        Client { socket: socket.to_path_buf(), signer }
3724×
    }
38
39
    /// Whether a daemon answers on the socket.
4043×
    pub async fn alive(socket: &Path) -> bool {
4143×
        tokio::net::UnixStream::connect(socket).await.is_ok()
4243×
    }
43
44
    /// Send `method path` with `body` and extra headers, signed.
4587×
    pub async fn send(&self, method: &str, path: &str, headers: &[(&str, String)], body: Vec<u8>) -> Result<Response> {
4687×
        let stream = tokio::net::UnixStream::connect(&self.socket)
4787×
            .await
4887×
            .with_context(|| format!("connecting to {} (is `spectra serve` running?)", self.socket.display()))?;
4987×
        let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream)).await?;
5087×
        tokio::spawn(async move {
5187×
            let _ = conn.await;
5287×
        });
53
        // One request a connection: the server closes it after answering, so
54
        // nothing lingers to hold up its shutdown.
5587×
        let mut req = hyper::Request::builder().method(method).uri(path).header("host", "spectra").header("connection", "close");
56
        // JSON unless the caller says otherwise (a form, say).
57152×
        if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type")) {
5883×
            req = req.header("content-type", "application/json");
5983×
        }
60153×
        for (k, v) in headers {
61153×
            req = req.header(*k, v);
62153×
        }
6387×
        if let Some(pair) = &self.signer {
64268×
            for (k, v) in sign_headers(pair, &format!("{method} {path}"), &body) {
65268×
                req = req.header(k, v);
66268×
            }
6720×
        }
6887×
        let resp = sender.send_request(req.body(Full::new(Bytes::from(body)))?).await?;
6987×
        let status = resp.status().as_u16();
70437×
        let headers = resp.headers().iter().map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())).collect();
7187×
        let body = resp.into_body().collect().await?.to_bytes().to_vec();
7287×
        Ok(Response { status, headers, body })
7387×
    }
74
75
    /// A JSON-RPC request to `/mcp` with the protocol headers of `version`.
7635×
    pub async fn mcp(&self, version: &str, request: &serde_json::Value) -> Result<Response> {
7735×
        let method = request.get("method").and_then(|m| m.as_str()).unwrap_or("").to_string();
7835×
        let mut headers = vec![
7935×
            ("accept", "application/json, text/event-stream".to_string()),
8035×
            ("mcp-protocol-version", version.to_string()),
8135×
            ("mcp-method", method.clone()),
82
        ];
8335×
        if let Some(name) = request.pointer("/params/name").or_else(|| request.pointer("/params/uri")).and_then(|n| n.as_str()) {
8426×
            headers.push(("mcp-name", name.to_string()));
8526×
        }
8635×
        self.send("POST", "/mcp", &headers, serde_json::to_vec(request)?).await
8735×
    }
88
}
89
90
impl Client {
91
    /// Send a signed GET and hand each line of the streamed body to `line`
92
    /// as it arrives (an SSE stream, say); stop when it returns false.
930×
    pub async fn stream(&self, path: &str, mut line: impl FnMut(&str) -> bool) -> Result<u16> {
940×
        let stream = tokio::net::UnixStream::connect(&self.socket)
950×
            .await
960×
            .with_context(|| format!("connecting to {} (is `spectra serve` running?)", self.socket.display()))?;
970×
        let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream)).await?;
980×
        tokio::spawn(async move {
990×
            let _ = conn.await;
1000×
        });
1010×
        let mut req = hyper::Request::builder().method("GET").uri(path).header("host", "spectra").header("connection", "close");
1020×
        if let Some(pair) = &self.signer {
1030×
            for (k, v) in sign_headers(pair, &format!("GET {path}"), b"") {
1040×
                req = req.header(k, v);
1050×
            }
1060×
        }
1070×
        let resp = sender.send_request(req.body(Full::new(Bytes::new()))?).await?;
1080×
        let status = resp.status().as_u16();
1090×
        let mut body = resp.into_body();
1100×
        let mut buf = String::new();
1110×
        while let Some(frame) = body.frame().await {
1120×
            if let Ok(data) = frame?.into_data() {
1130×
                buf.push_str(&String::from_utf8_lossy(&data));
1140×
                while let Some(i) = buf.find('\n') {
1150×
                    let l: String = buf.drain(..=i).collect();
1160×
                    if !line(l.trim_end()) {
1170×
                        return Ok(status);
1180×
                    }
119
                }
1200×
            }
121
        }
1220×
        Ok(status)
1230×
    }
124
}
125
126
/// The signature headers of spec §8.5 for `method_path` and `body`.
12767×
pub fn sign_headers(pair: &KeyPair, method_path: &str, body: &[u8]) -> Vec<(&'static str, String)> {
12867×
    let p = Proof::sign(pair, method_path, body);
12967×
    vec![
13067×
        ("spectra-key", p.sig.key.to_hex()),
13167×
        ("spectra-time", p.time_ms.to_string()),
13267×
        ("spectra-nonce", hex::encode(p.nonce)),
13367×
        ("spectra-signature", hex::encode(oxy_core::cbor::encode(&p.sig.to_value()))),
134
    ]
13567×
}
136
137
/// The JSON-RPC result inside a response, whether it came as JSON or as
138
/// the last `data:` of an SSE stream.
13923×
pub fn rpc_result(r: &Response) -> Result<serde_json::Value> {
14023×
    let is_sse = r.header("content-type").is_some_and(|c| c.starts_with("text/event-stream"));
14123×
    if !is_sse {
14218×
        return r.json();
1435×
    }
1445×
    let text = String::from_utf8_lossy(&r.body);
1455×
    let last = text
1465×
        .lines()
1475×
        .rev()
14810×
        .filter_map(|l| l.strip_prefix("data:"))
1495×
        .map(str::trim)
1505×
        .find(|l| !l.is_empty())
1515×
        .context("an SSE response with no data")?;
1525×
    Ok(serde_json::from_str(last)?)
15323×
}