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    agent_name: Option<String>,
26}
27
28/// Max retries on connection errors (daemon restarting).
29const MAX_RETRIES: u32 = 3;
30/// Backoff per retry: attempt 1 = 1s, attempt 2 = 2s, attempt 3 = 3s.
31/// Total worst-case wait: ~6s, covering a typical daemon restart.
32const BACKOFF_BASE: Duration = Duration::from_secs(1);
33
34impl OriginClient {
35    pub fn new(base_url: String) -> Self {
36        Self {
37            client: Client::new(),
38            base_url,
39            agent_name: None,
40        }
41    }
42
43    /// Set the agent name to be sent as `x-agent-name` header on every request.
44    pub fn with_agent_name(mut self, name: String) -> Self {
45        self.agent_name = Some(name);
46        self
47    }
48
49    /// Retry a request on connection errors (daemon restarting).
50    /// Only retries on connect failures; non-connect errors and HTTP responses
51    /// are returned immediately.
52    async fn send_with_retry(
53        &self,
54        build: impl Fn() -> reqwest::RequestBuilder,
55    ) -> Result<reqwest::Response, OriginError> {
56        let mut last_err = None;
57        for attempt in 0..MAX_RETRIES {
58            if attempt > 0 {
59                tokio::time::sleep(BACKOFF_BASE * attempt).await;
60            }
61            match build().send().await {
62                Ok(resp) => return Ok(resp),
63                Err(e) if e.is_connect() => {
64                    tracing::debug!(attempt, "daemon unreachable, retrying");
65                    last_err = Some(e);
66                }
67                Err(e) => return Err(OriginError::Unreachable(e.to_string())),
68            }
69        }
70        Err(OriginError::Unreachable(last_err.map_or_else(
71            || "connection failed".into(),
72            |e| e.to_string(),
73        )))
74    }
75
76    /// Parse a successful response body as JSON.
77    fn parse_response<R: DeserializeOwned>(bytes: &[u8]) -> Result<R, OriginError> {
78        serde_json::from_slice::<R>(bytes).map_err(|e| {
79            let preview = std::str::from_utf8(bytes)
80                .unwrap_or("<non-utf8>")
81                .chars()
82                .take(512)
83                .collect::<String>();
84            OriginError::Deserialize(format!("{e} (body preview: {preview})"))
85        })
86    }
87
88    /// Read response body, checking status first.
89    async fn read_body(resp: reqwest::Response) -> Result<Vec<u8>, OriginError> {
90        if !resp.status().is_success() {
91            let status = resp.status().as_u16();
92            let body = resp.text().await.unwrap_or_default();
93            return Err(OriginError::Api { status, body });
94        }
95        resp.bytes()
96            .await
97            .map(|b| b.to_vec())
98            .map_err(|e| OriginError::Deserialize(format!("failed to read response body: {e:#}")))
99    }
100
101    /// GET request, deserialize JSON response.
102    pub async fn get<R: DeserializeOwned>(&self, path: &str) -> Result<R, OriginError> {
103        let url = format!("{}{}", self.base_url, path);
104        let agent = self.agent_name.clone();
105        let resp = self
106            .send_with_retry(|| {
107                let mut req = self.client.get(&url);
108                if let Some(a) = agent.as_deref() {
109                    req = req.header("x-agent-name", a);
110                }
111                req
112            })
113            .await?;
114        let bytes = Self::read_body(resp).await?;
115        Self::parse_response(&bytes)
116    }
117
118    /// POST request with JSON body, deserialize JSON response.
119    pub async fn post<B: Serialize, R: DeserializeOwned>(
120        &self,
121        path: &str,
122        body: &B,
123    ) -> Result<R, OriginError> {
124        let url = format!("{}{}", self.base_url, path);
125        let agent = self.agent_name.clone();
126        let resp = self
127            .send_with_retry(|| {
128                let mut req = self.client.post(&url).json(body);
129                if let Some(a) = agent.as_deref() {
130                    req = req.header("x-agent-name", a);
131                }
132                req
133            })
134            .await?;
135        let bytes = Self::read_body(resp).await?;
136        Self::parse_response(&bytes)
137    }
138
139    /// POST request with empty body, deserialize JSON response.
140    /// Used for mutate endpoints where the id is in the path and no body is needed.
141    pub async fn post_empty<R: DeserializeOwned>(&self, path: &str) -> Result<R, OriginError> {
142        let url = format!("{}{}", self.base_url, path);
143        let agent = self.agent_name.clone();
144        let resp = self
145            .send_with_retry(|| {
146                let mut req = self.client.post(&url);
147                if let Some(a) = agent.as_deref() {
148                    req = req.header("x-agent-name", a);
149                }
150                req
151            })
152            .await?;
153        let bytes = Self::read_body(resp).await?;
154        Self::parse_response(&bytes)
155    }
156
157    /// DELETE request, deserialize JSON response.
158    pub async fn delete<R: DeserializeOwned>(&self, path: &str) -> Result<R, OriginError> {
159        let url = format!("{}{}", self.base_url, path);
160        let agent = self.agent_name.clone();
161        let resp = self
162            .send_with_retry(|| {
163                let mut req = self.client.delete(&url);
164                if let Some(a) = agent.as_deref() {
165                    req = req.header("x-agent-name", a);
166                }
167                req
168            })
169            .await?;
170        let bytes = Self::read_body(resp).await?;
171        Self::parse_response(&bytes)
172    }
173
174    /// PUT request with JSON body, deserialize JSON response.
175    pub async fn put<B: Serialize, R: DeserializeOwned>(
176        &self,
177        path: &str,
178        body: &B,
179    ) -> Result<R, OriginError> {
180        let url = format!("{}{}", self.base_url, path);
181        let agent = self.agent_name.clone();
182        let resp = self
183            .send_with_retry(|| {
184                let mut req = self.client.put(&url).json(body);
185                if let Some(a) = agent.as_deref() {
186                    req = req.header("x-agent-name", a);
187                }
188                req
189            })
190            .await?;
191        let bytes = Self::read_body(resp).await?;
192        Self::parse_response(&bytes)
193    }
194
195    /// Query the daemon's /api/health, compare versions, and return a
196    /// human-readable warning if origin-mcp is older than the daemon's minor.
197    /// Returns None if compatible OR if the daemon is unreachable / response
198    /// can't be parsed (handshake never blocks startup).
199    pub async fn version_handshake(&self) -> Option<String> {
200        use crate::version_check::{compare, VersionStatus};
201
202        let url = format!("{}/api/health", self.base_url);
203        // Bypass send_with_retry: a 6s retry loop at startup against a missing
204        // or hung daemon would be worse UX than a silent skip. 2s timeout bounds
205        // the worst case where the daemon socket accepts but the handler stalls.
206        let resp = self
207            .client
208            .get(&url)
209            .timeout(Duration::from_secs(2))
210            .send()
211            .await
212            .ok()?;
213        let body: serde_json::Value = resp.json().await.ok()?;
214        let daemon_version = body["version"].as_str()?;
215        let mcp_version = env!("CARGO_PKG_VERSION");
216
217        match compare(mcp_version, daemon_version) {
218            VersionStatus::Compatible => None,
219            VersionStatus::McpOutdated { mcp, daemon } => Some(format!(
220                "Your origin-mcp v{mcp} is older than the daemon v{daemon}. \
221                 Run `brew upgrade origin-mcp` (or `npm update -g origin-mcp`)."
222            )),
223        }
224    }
225}
226
227#[derive(Debug, thiserror::Error)]
228pub enum OriginError {
229    #[error("Origin is not reachable: {0}")]
230    Unreachable(String),
231
232    #[error("Origin API error (HTTP {status}): {body}")]
233    Api { status: u16, body: String },
234
235    #[error("Failed to parse Origin response: {0}")]
236    Deserialize(String),
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn test_discover_url_prefers_cli_flag() {
245        let url = discover_origin_url(Some("http://localhost:9999".into()));
246        assert_eq!(url, "http://localhost:9999");
247    }
248
249    #[test]
250    fn test_discover_url_falls_back_to_http() {
251        // With no CLI flag and no socket, should fall back to default HTTP
252        let url = discover_origin_url(None);
253        assert_eq!(url, "http://127.0.0.1:7878");
254    }
255}