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 space_header_name() -> &'static str {
11 "x-origin-space"
12}
13
14pub 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#[derive(Clone)]
28pub struct OriginClient {
29 client: Client,
30 base_url: String,
31 agent_name: Option<String>,
32}
33
34const MAX_RETRIES: u32 = 3;
36const 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 pub fn with_agent_name(mut self, name: String) -> Self {
51 self.agent_name = Some(name);
52 self
53 }
54
55 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}