Skip to main content

origin_mcp/
client.rs

1use std::time::Duration;
2
3use reqwest::Client;
4use serde::{de::DeserializeOwned, Serialize};
5
6const DEFAULT_HTTP_URL: &str = "http://127.0.0.1:7878";
7
8/// Discover the Origin server URL.
9/// Priority: CLI flag > HTTP default.
10/// Note: UDS discovery disabled — reqwest doesn't support unix:// URLs natively.
11/// Origin always binds HTTP on 127.0.0.1:7878 alongside UDS, so HTTP is reliable.
12pub fn discover_origin_url(cli_url: Option<String>) -> String {
13    if let Some(url) = cli_url {
14        return url;
15    }
16
17    DEFAULT_HTTP_URL.to_string()
18}
19
20/// HTTP client for the Origin REST API.
21#[derive(Clone)]
22pub struct OriginClient {
23    client: Client,
24    base_url: String,
25}
26
27/// Max retries on connection errors (daemon restarting).
28const MAX_RETRIES: u32 = 3;
29/// Backoff per retry: attempt 1 = 1s, attempt 2 = 2s, attempt 3 = 3s.
30/// Total worst-case wait: ~6s, covering a typical daemon restart.
31const BACKOFF_BASE: Duration = Duration::from_secs(1);
32
33impl OriginClient {
34    pub fn new(base_url: String) -> Self {
35        Self {
36            client: Client::new(),
37            base_url,
38        }
39    }
40
41    /// Retry a request on connection errors (daemon restarting).
42    /// Only retries on connect failures; non-connect errors and HTTP responses
43    /// are returned immediately.
44    async fn send_with_retry(
45        &self,
46        build: impl Fn() -> reqwest::RequestBuilder,
47    ) -> Result<reqwest::Response, OriginError> {
48        let mut last_err = None;
49        for attempt in 0..MAX_RETRIES {
50            if attempt > 0 {
51                tokio::time::sleep(BACKOFF_BASE * attempt).await;
52            }
53            match build().send().await {
54                Ok(resp) => return Ok(resp),
55                Err(e) if e.is_connect() => {
56                    tracing::debug!(attempt, "daemon unreachable, retrying");
57                    last_err = Some(e);
58                }
59                Err(e) => return Err(OriginError::Unreachable(e.to_string())),
60            }
61        }
62        Err(OriginError::Unreachable(last_err.map_or_else(
63            || "connection failed".into(),
64            |e| e.to_string(),
65        )))
66    }
67
68    /// Parse a successful response body as JSON.
69    fn parse_response<R: DeserializeOwned>(bytes: &[u8]) -> Result<R, OriginError> {
70        serde_json::from_slice::<R>(bytes).map_err(|e| {
71            let preview = std::str::from_utf8(bytes)
72                .unwrap_or("<non-utf8>")
73                .chars()
74                .take(512)
75                .collect::<String>();
76            OriginError::Deserialize(format!("{e} (body preview: {preview})"))
77        })
78    }
79
80    /// Read response body, checking status first.
81    async fn read_body(resp: reqwest::Response) -> Result<Vec<u8>, OriginError> {
82        if !resp.status().is_success() {
83            let status = resp.status().as_u16();
84            let body = resp.text().await.unwrap_or_default();
85            return Err(OriginError::Api { status, body });
86        }
87        resp.bytes()
88            .await
89            .map(|b| b.to_vec())
90            .map_err(|e| OriginError::Deserialize(format!("failed to read response body: {e:#}")))
91    }
92
93    /// GET request, deserialize JSON response.
94    pub async fn get<R: DeserializeOwned>(&self, path: &str) -> Result<R, OriginError> {
95        let url = format!("{}{}", self.base_url, path);
96        let resp = self.send_with_retry(|| self.client.get(&url)).await?;
97        let bytes = Self::read_body(resp).await?;
98        Self::parse_response(&bytes)
99    }
100
101    /// POST request with JSON body, deserialize JSON response.
102    pub async fn post<B: Serialize, R: DeserializeOwned>(
103        &self,
104        path: &str,
105        body: &B,
106    ) -> Result<R, OriginError> {
107        let url = format!("{}{}", self.base_url, path);
108        let resp = self
109            .send_with_retry(|| self.client.post(&url).json(body))
110            .await?;
111        let bytes = Self::read_body(resp).await?;
112        Self::parse_response(&bytes)
113    }
114
115    /// DELETE request, deserialize JSON response.
116    pub async fn delete<R: DeserializeOwned>(&self, path: &str) -> Result<R, OriginError> {
117        let url = format!("{}{}", self.base_url, path);
118        let resp = self.send_with_retry(|| self.client.delete(&url)).await?;
119        let bytes = Self::read_body(resp).await?;
120        Self::parse_response(&bytes)
121    }
122
123    /// Query the daemon's /api/health, compare versions, and return a
124    /// human-readable warning if origin-mcp is older than the daemon's minor.
125    /// Returns None if compatible OR if the daemon is unreachable / response
126    /// can't be parsed (handshake never blocks startup).
127    pub async fn version_handshake(&self) -> Option<String> {
128        use crate::version_check::{compare, VersionStatus};
129
130        let url = format!("{}/api/health", self.base_url);
131        // Bypass send_with_retry: a 6s retry loop at startup against a missing
132        // or hung daemon would be worse UX than a silent skip. 2s timeout bounds
133        // the worst case where the daemon socket accepts but the handler stalls.
134        let resp = self
135            .client
136            .get(&url)
137            .timeout(Duration::from_secs(2))
138            .send()
139            .await
140            .ok()?;
141        let body: serde_json::Value = resp.json().await.ok()?;
142        let daemon_version = body["version"].as_str()?;
143        let mcp_version = env!("CARGO_PKG_VERSION");
144
145        match compare(mcp_version, daemon_version) {
146            VersionStatus::Compatible => None,
147            VersionStatus::McpOutdated { mcp, daemon } => Some(format!(
148                "Your origin-mcp v{mcp} is older than the daemon v{daemon}. \
149                 Run `brew upgrade origin-mcp` (or `npm update -g origin-mcp`)."
150            )),
151        }
152    }
153}
154
155#[derive(Debug, thiserror::Error)]
156pub enum OriginError {
157    #[error("Origin is not reachable: {0}")]
158    Unreachable(String),
159
160    #[error("Origin API error (HTTP {status}): {body}")]
161    Api { status: u16, body: String },
162
163    #[error("Failed to parse Origin response: {0}")]
164    Deserialize(String),
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn test_discover_url_prefers_cli_flag() {
173        let url = discover_origin_url(Some("http://localhost:9999".into()));
174        assert_eq!(url, "http://localhost:9999");
175    }
176
177    #[test]
178    fn test_discover_url_falls_back_to_http() {
179        // With no CLI flag and no socket, should fall back to default HTTP
180        let url = discover_origin_url(None);
181        assert_eq!(url, "http://127.0.0.1:7878");
182    }
183}