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 VersionStatus::DaemonOutdated { mcp, daemon } => Some(format!(
230 "The Origin daemon is running v{daemon} but origin-mcp v{mcp} is installed. \
231 The daemon was not restarted after an upgrade. Run `origin restart` to load it."
232 )),
233 }
234 }
235}
236
237#[derive(Debug, thiserror::Error)]
238pub enum OriginError {
239 #[error("Origin is not reachable: {0}")]
240 Unreachable(String),
241
242 #[error("Origin API error (HTTP {status}): {body}")]
243 Api { status: u16, body: String },
244
245 #[error("Failed to parse Origin response: {0}")]
246 Deserialize(String),
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252
253 #[test]
254 fn test_discover_url_prefers_cli_flag() {
255 let url = discover_origin_url(Some("http://localhost:9999".into()));
256 assert_eq!(url, "http://localhost:9999");
257 }
258
259 #[test]
260 fn test_discover_url_falls_back_to_http() {
261 let url = discover_origin_url(None);
263 assert_eq!(url, "http://127.0.0.1:7878");
264 }
265
266 #[test]
267 fn space_header_attached_when_locked() {
268 let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
270 std::env::set_var("ORIGIN_SPACE", "career");
271 crate::lock_state::init_from_env();
272
273 let client = Client::new();
274 let builder = OriginClient::attach_common_headers(
275 client.get("http://127.0.0.1:7878/api/health"),
276 None,
277 );
278 let req = builder.build().unwrap();
279 let header = req.headers().get(space_header_name()).unwrap();
280 assert_eq!(header.to_str().unwrap(), "career");
281
282 std::env::remove_var("ORIGIN_SPACE");
284 crate::lock_state::init_from_env();
285 }
286
287 #[test]
288 fn space_header_absent_when_unlocked() {
289 let _guard = crate::lock_state::ENV_LOCK.lock().unwrap();
291 std::env::remove_var("ORIGIN_SPACE");
292 crate::lock_state::init_from_env();
293
294 let client = Client::new();
295 let builder = OriginClient::attach_common_headers(
296 client.get("http://127.0.0.1:7878/api/health"),
297 None,
298 );
299 let req = builder.build().unwrap();
300 assert!(req.headers().get(space_header_name()).is_none());
301 }
302}