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/// Single source of truth for the space-lock header name.
9/// Mirrors the daemon's `X-Origin-Space` constant (HTTP normalises to lowercase).
10pub fn space_header_name() -> &'static str {
11    "x-origin-space"
12}
13
14/// Discover the Origin server URL.
15/// Priority: CLI flag > HTTP default.
16/// Note: UDS discovery disabled — reqwest doesn't support unix:// URLs natively.
17/// Origin always binds HTTP on 127.0.0.1:7878 alongside UDS, so HTTP is reliable.
18pub fn discover_origin_url(cli_url: Option<String>) -> String {
19    if let Some(url) = cli_url {
20        return url;
21    }
22
23    DEFAULT_HTTP_URL.to_string()
24}
25
26/// HTTP client for the Origin REST API.
27#[derive(Clone)]
28pub struct OriginClient {
29    client: Client,
30    base_url: String,
31    agent_name: Option<String>,
32}
33
34/// Max retries on connection errors (daemon restarting).
35const MAX_RETRIES: u32 = 3;
36/// Backoff per retry: attempt 1 = 1s, attempt 2 = 2s, attempt 3 = 3s.
37/// Total worst-case wait: ~6s, covering a typical daemon restart.
38const BACKOFF_BASE: Duration = Duration::from_secs(1);
39
40impl OriginClient {
41    pub fn new(base_url: String) -> Self {
42        Self {
43            client: Client::new(),
44            base_url,
45            agent_name: None,
46        }
47    }
48
49    /// Set the agent name to be sent as `x-agent-name` header on every request.
50    pub fn with_agent_name(mut self, name: String) -> Self {
51        self.agent_name = Some(name);
52        self
53    }
54
55    /// Retry a request on connection errors (daemon restarting).
56    /// Only retries on connect failures; non-connect errors and HTTP responses
57    /// are returned immediately.
58    async fn send_with_retry(
59        &self,
60        build: impl Fn() -> reqwest::RequestBuilder,
61    ) -> Result<reqwest::Response, OriginError> {
62        let mut last_err = None;
63        for attempt in 0..MAX_RETRIES {
64            if attempt > 0 {
65                tokio::time::sleep(BACKOFF_BASE * attempt).await;
66            }
67            match build().send().await {
68                Ok(resp) => return Ok(resp),
69                Err(e) if e.is_connect() => {
70                    tracing::debug!(attempt, "daemon unreachable, retrying");
71                    last_err = Some(e);
72                }
73                Err(e) => return Err(OriginError::Unreachable(e.to_string())),
74            }
75        }
76        Err(OriginError::Unreachable(last_err.map_or_else(
77            || "connection failed".into(),
78            |e| e.to_string(),
79        )))
80    }
81
82    /// Parse a successful response body as JSON.
83    fn parse_response<R: DeserializeOwned>(bytes: &[u8]) -> Result<R, OriginError> {
84        serde_json::from_slice::<R>(bytes).map_err(|e| {
85            let preview = std::str::from_utf8(bytes)
86                .unwrap_or("<non-utf8>")
87                .chars()
88                .take(512)
89                .collect::<String>();
90            OriginError::Deserialize(format!("{e} (body preview: {preview})"))
91        })
92    }
93
94    /// Read response body, checking status first.
95    async fn read_body(resp: reqwest::Response) -> Result<Vec<u8>, OriginError> {
96        if !resp.status().is_success() {
97            let status = resp.status().as_u16();
98            let body = resp.text().await.unwrap_or_default();
99            return Err(OriginError::Api { status, body });
100        }
101        resp.bytes()
102            .await
103            .map(|b| b.to_vec())
104            .map_err(|e| OriginError::Deserialize(format!("failed to read response body: {e:#}")))
105    }
106
107    /// Attach per-request headers common to all daemon calls:
108    /// `x-agent-name` (when set) and `x-origin-space` (when space is locked).
109    fn attach_common_headers(
110        mut req: reqwest::RequestBuilder,
111        agent: Option<&str>,
112    ) -> reqwest::RequestBuilder {
113        if let Some(a) = agent {
114            req = req.header("x-agent-name", a);
115        }
116        if let Some(space) = crate::lock_state::locked_space() {
117            req = req.header(space_header_name(), space);
118        }
119        req
120    }
121
122    /// GET request, deserialize JSON response.
123    pub async fn get<R: DeserializeOwned>(&self, path: &str) -> 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 req = self.client.get(&url);
129                Self::attach_common_headers(req, agent.as_deref())
130            })
131            .await?;
132        let bytes = Self::read_body(resp).await?;
133        Self::parse_response(&bytes)
134    }
135
136    /// POST request with JSON body, deserialize JSON response.
137    pub async fn post<B: Serialize, R: DeserializeOwned>(
138        &self,
139        path: &str,
140        body: &B,
141    ) -> 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 req = self.client.post(&url).json(body);
147                Self::attach_common_headers(req, agent.as_deref())
148            })
149            .await?;
150        let bytes = Self::read_body(resp).await?;
151        Self::parse_response(&bytes)
152    }
153
154    /// POST request with empty body, deserialize JSON response.
155    /// Used for mutate endpoints where the id is in the path and no body is needed.
156    pub async fn post_empty<R: DeserializeOwned>(&self, path: &str) -> Result<R, OriginError> {
157        let url = format!("{}{}", self.base_url, path);
158        let agent = self.agent_name.clone();
159        let resp = self
160            .send_with_retry(|| {
161                let req = self.client.post(&url);
162                Self::attach_common_headers(req, agent.as_deref())
163            })
164            .await?;
165        let bytes = Self::read_body(resp).await?;
166        Self::parse_response(&bytes)
167    }
168
169    /// DELETE request, deserialize JSON response.
170    pub async fn delete<R: DeserializeOwned>(&self, path: &str) -> Result<R, OriginError> {
171        let url = format!("{}{}", self.base_url, path);
172        let agent = self.agent_name.clone();
173        let resp = self
174            .send_with_retry(|| {
175                let req = self.client.delete(&url);
176                Self::attach_common_headers(req, agent.as_deref())
177            })
178            .await?;
179        let bytes = Self::read_body(resp).await?;
180        Self::parse_response(&bytes)
181    }
182
183    /// PUT request with JSON body, deserialize JSON response.
184    pub async fn put<B: Serialize, R: DeserializeOwned>(
185        &self,
186        path: &str,
187        body: &B,
188    ) -> Result<R, OriginError> {
189        let url = format!("{}{}", self.base_url, path);
190        let agent = self.agent_name.clone();
191        let resp = self
192            .send_with_retry(|| {
193                let req = self.client.put(&url).json(body);
194                Self::attach_common_headers(req, agent.as_deref())
195            })
196            .await?;
197        let bytes = Self::read_body(resp).await?;
198        Self::parse_response(&bytes)
199    }
200
201    /// Query the daemon's /api/health, compare versions, and return a
202    /// human-readable warning if origin-mcp is older than the daemon's minor.
203    /// Returns None if compatible OR if the daemon is unreachable / response
204    /// can't be parsed (handshake never blocks startup).
205    pub async fn version_handshake(&self) -> Option<String> {
206        use crate::version_check::{compare, VersionStatus};
207
208        let url = format!("{}/api/health", self.base_url);
209        // Bypass send_with_retry: a 6s retry loop at startup against a missing
210        // or hung daemon would be worse UX than a silent skip. 2s timeout bounds
211        // the worst case where the daemon socket accepts but the handler stalls.
212        let resp = self
213            .client
214            .get(&url)
215            .timeout(Duration::from_secs(2))
216            .send()
217            .await
218            .ok()?;
219        let body: serde_json::Value = resp.json().await.ok()?;
220        let daemon_version = body["version"].as_str()?;
221        let mcp_version = env!("CARGO_PKG_VERSION");
222
223        match compare(mcp_version, daemon_version) {
224            VersionStatus::Compatible => None,
225            VersionStatus::McpOutdated { mcp, daemon } => Some(format!(
226                "Your origin-mcp v{mcp} is older than the daemon v{daemon}. \
227                 Run `brew upgrade origin-mcp` (or `npm update -g origin-mcp`)."
228            )),
229        }
230    }
231}
232
233#[derive(Debug, thiserror::Error)]
234pub enum OriginError {
235    #[error("Origin is not reachable: {0}")]
236    Unreachable(String),
237
238    #[error("Origin API error (HTTP {status}): {body}")]
239    Api { status: u16, body: String },
240
241    #[error("Failed to parse Origin response: {0}")]
242    Deserialize(String),
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn test_discover_url_prefers_cli_flag() {
251        let url = discover_origin_url(Some("http://localhost:9999".into()));
252        assert_eq!(url, "http://localhost:9999");
253    }
254
255    #[test]
256    fn test_discover_url_falls_back_to_http() {
257        // With no CLI flag and no socket, should fall back to default HTTP
258        let url = discover_origin_url(None);
259        assert_eq!(url, "http://127.0.0.1:7878");
260    }
261
262    #[test]
263    fn space_header_attached_when_locked() {
264        // Share ENV_LOCK with lock_state::tests to prevent env var races.
265        let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
266        std::env::set_var("ORIGIN_SPACE", "career");
267        crate::lock_state::init_from_env();
268
269        let client = Client::new();
270        let builder = OriginClient::attach_common_headers(
271            client.get("http://127.0.0.1:7878/api/health"),
272            None,
273        );
274        let req = builder.build().unwrap();
275        let header = req.headers().get(space_header_name()).unwrap();
276        assert_eq!(header.to_str().unwrap(), "career");
277
278        // Clean up.
279        std::env::remove_var("ORIGIN_SPACE");
280        crate::lock_state::init_from_env();
281    }
282
283    #[test]
284    fn space_header_absent_when_unlocked() {
285        // Share ENV_LOCK with lock_state::tests to prevent env var races.
286        let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
287        std::env::remove_var("ORIGIN_SPACE");
288        crate::lock_state::init_from_env();
289
290        let client = Client::new();
291        let builder = OriginClient::attach_common_headers(
292            client.get("http://127.0.0.1:7878/api/health"),
293            None,
294        );
295        let req = builder.build().unwrap();
296        assert!(req.headers().get(space_header_name()).is_none());
297    }
298}