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
8pub 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#[derive(Clone)]
22pub struct OriginClient {
23 client: Client,
24 base_url: String,
25}
26
27const MAX_RETRIES: u32 = 3;
29const 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 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 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 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 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 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 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 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 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 let url = discover_origin_url(None);
181 assert_eq!(url, "http://127.0.0.1:7878");
182 }
183}