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 agent_name: Option<String>,
26}
27
28const MAX_RETRIES: u32 = 3;
30const 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 pub fn with_agent_name(mut self, name: String) -> Self {
45 self.agent_name = Some(name);
46 self
47 }
48
49 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 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 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 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 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 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 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 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 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 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 let url = discover_origin_url(None);
253 assert_eq!(url, "http://127.0.0.1:7878");
254 }
255}