Skip to main content

ati/proxy/
client.rs

1/// Proxy client — forwards tool calls to an external ATI proxy server.
2///
3/// When ATI_PROXY_URL is set, `ati run <tool>` sends tool_name + args
4/// to the proxy. Authentication is via JWT in the Authorization header
5/// (ATI_SESSION_TOKEN env var).
6use reqwest::Client;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10use std::time::Duration;
11use thiserror::Error;
12
13#[derive(Error, Debug)]
14pub enum ProxyError {
15    #[error("Proxy request failed: {0}")]
16    Request(#[from] reqwest::Error),
17    #[error("Proxy error ({status}): {body}")]
18    ProxyResponse { status: u16, body: String },
19    #[error("Invalid proxy URL: {0}")]
20    InvalidUrl(String),
21    #[error("Proxy returned invalid response: {0}")]
22    InvalidResponse(String),
23}
24
25/// Request payload sent to the proxy server's /call endpoint.
26#[derive(Debug, Serialize)]
27pub struct ProxyCallRequest {
28    pub tool_name: String,
29    /// Tool arguments — JSON object for HTTP/MCP tools, or JSON array for CLI tools.
30    pub args: Value,
31    /// Raw positional args for CLI tools. When present, the proxy's
32    /// `args_as_positional()` uses these instead of parsing `args`.
33    /// This preserves bare positional words like `browse status` that
34    /// don't survive the `--key value` parse into the args map.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub raw_args: Option<Vec<String>>,
37}
38
39/// Response payload from the proxy server.
40#[derive(Debug, Deserialize)]
41pub struct ProxyCallResponse {
42    pub result: Value,
43    #[serde(default)]
44    pub error: Option<String>,
45}
46
47/// Request payload for the proxy's /help endpoint.
48#[derive(Debug, Serialize)]
49pub struct ProxyHelpRequest {
50    pub query: String,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub tool: Option<String>,
53}
54
55/// Response from the proxy's /help endpoint.
56#[derive(Debug, Deserialize)]
57pub struct ProxyHelpResponse {
58    pub content: String,
59    #[serde(default)]
60    pub error: Option<String>,
61}
62
63const PROXY_TIMEOUT_SECS: u64 = 120;
64
65/// Build an HTTP request builder with JWT Bearer auth.
66///
67/// `token_env` selects which env var holds the bearer:
68///   - `None` → default `ATI_SESSION_TOKEN` (every catalog/metadata route,
69///     plus any `/call` for a provider that didn't opt into per-provider
70///     token selection).
71///   - `Some("PARCHA_TOOLS_SESSION_TOKEN")` → reads that env var (with the
72///     same `<NAME>_FILE` and default-path fallback). Used when the manifest
73///     declares `auth_session_token_env` for the target provider — see
74///     issue #121.
75///
76/// If a per-provider token env is named but unset/empty, this falls back to
77/// `ATI_SESSION_TOKEN` rather than sending the request unauthenticated.
78/// The proxy is the source of truth on whether the fallback token is
79/// acceptable (it's been audience-validated either way); silently dropping
80/// the Authorization header would make a misconfigured supervisor look
81/// like a network error to the operator.
82fn build_proxy_request(
83    client: &Client,
84    method: reqwest::Method,
85    url: &str,
86    token_env: Option<&str>,
87) -> reqwest::RequestBuilder {
88    let mut req = client.request(method, url);
89    let env_name = token_env.unwrap_or("ATI_SESSION_TOKEN");
90    match crate::core::token::resolve_token(env_name) {
91        Ok(Some(token)) => {
92            req = req.header("Authorization", format!("Bearer {token}"));
93        }
94        Ok(None) if env_name != "ATI_SESSION_TOKEN" => {
95            // Provider asked for a specific env var but it's unset and the
96            // file fallback didn't yield one either. Don't drop auth on the
97            // floor — try the default token. The proxy's audience allowlist
98            // (ATI_JWT_ACCEPTED_AUDIENCES) decides whether that's acceptable;
99            // if not, we get a clean 401 instead of a silent network
100            // mystery.
101            tracing::debug!(
102                env = %env_name,
103                "per-provider token env unset; falling back to ATI_SESSION_TOKEN"
104            );
105            if let Ok(Some(token)) = crate::core::token::resolve_token("ATI_SESSION_TOKEN") {
106                req = req.header("Authorization", format!("Bearer {token}"));
107            }
108        }
109        Ok(None) => {}
110        Err(e) => {
111            // File-read error (e.g., permission denied on $ENV_FILE).
112            // For a per-provider env that errored, also try the default
113            // ATI_SESSION_TOKEN — same rationale as the Ok(None) branch
114            // above: surface a clean 401 from the proxy if the default
115            // token isn't acceptable rather than silently sending an
116            // unauthenticated request. Greptile P2 on #121: a file-perm
117            // bug on the per-provider token file should produce identical
118            // graceful-degradation behaviour as a missing env var.
119            tracing::debug!(
120                env = %env_name,
121                error = %e,
122                "session token file unreadable; trying ATI_SESSION_TOKEN fallback"
123            );
124            if env_name != "ATI_SESSION_TOKEN" {
125                if let Ok(Some(token)) = crate::core::token::resolve_token("ATI_SESSION_TOKEN") {
126                    req = req.header("Authorization", format!("Bearer {token}"));
127                }
128            }
129        }
130    }
131    req
132}
133
134/// Execute a tool call via the proxy server.
135///
136/// POST {proxy_url}/call with JSON body: { tool_name, args }
137/// Scopes are carried inside the JWT — not in the request body.
138///
139/// `args` carries key-value pairs for HTTP/MCP tools.
140/// `raw_args`, if provided, is sent as an array in the `args` field for CLI tools.
141///
142/// `token_env` selects which sandbox env var holds the bearer to send. `None`
143/// uses the default `ATI_SESSION_TOKEN` (back-compat with every caller before
144/// issue #121); `Some("PARCHA_TOOLS_SESSION_TOKEN")` reads that env var
145/// instead, falling back to the default if it's unset. The caller normally
146/// derives this from the target provider's `auth_session_token_env` field
147/// in the manifest.
148pub async fn call_tool(
149    proxy_url: &str,
150    tool_name: &str,
151    args: &HashMap<String, Value>,
152    raw_args: Option<&[String]>,
153    token_env: Option<&str>,
154) -> Result<Value, ProxyError> {
155    let client = Client::builder()
156        .timeout(Duration::from_secs(PROXY_TIMEOUT_SECS))
157        .build()?;
158
159    let url = format!("{}/call", proxy_url.trim_end_matches('/'));
160
161    // Send both the parsed args map (for HTTP/MCP/OpenAPI tools) AND the raw
162    // positional args (for CLI tools). The proxy's CallRequest handler uses
163    // args_as_map() for HTTP tools and args_as_positional() for CLI tools.
164    // args_as_positional() checks `raw_args` first, so CLI tools always get
165    // their original positional args even when the map is empty.
166    let args_value = serde_json::to_value(args).unwrap_or(Value::Object(serde_json::Map::new()));
167    let raw_args_vec = raw_args.filter(|r| !r.is_empty()).map(|r| r.to_vec());
168
169    let payload = ProxyCallRequest {
170        tool_name: tool_name.to_string(),
171        args: args_value,
172        raw_args: raw_args_vec,
173    };
174
175    let response = build_proxy_request(&client, reqwest::Method::POST, &url, token_env)
176        .json(&payload)
177        .send()
178        .await?;
179    let status = response.status();
180
181    if !status.is_success() {
182        let body = response.text().await.unwrap_or_else(|_| "empty".into());
183        return Err(ProxyError::ProxyResponse {
184            status: status.as_u16(),
185            body,
186        });
187    }
188
189    let body: ProxyCallResponse = response
190        .json()
191        .await
192        .map_err(|e| ProxyError::InvalidResponse(e.to_string()))?;
193
194    if let Some(err) = body.error {
195        return Err(ProxyError::ProxyResponse {
196            status: 200,
197            body: err,
198        });
199    }
200
201    Ok(body.result)
202}
203
204/// List available tools from the proxy.
205pub async fn list_tools(proxy_url: &str, query_params: &str) -> Result<Value, ProxyError> {
206    let client = Client::builder()
207        .timeout(Duration::from_secs(PROXY_TIMEOUT_SECS))
208        .build()?;
209    let mut url = format!("{}/tools", proxy_url.trim_end_matches('/'));
210    if !query_params.is_empty() {
211        url.push('?');
212        url.push_str(query_params);
213    }
214    let response = build_proxy_request(&client, reqwest::Method::GET, &url, None)
215        .send()
216        .await?;
217    let status = response.status();
218    if !status.is_success() {
219        let body = response.text().await.unwrap_or_default();
220        return Err(ProxyError::ProxyResponse {
221            status: status.as_u16(),
222            body,
223        });
224    }
225    Ok(response.json().await?)
226}
227
228/// Get detailed info about a specific tool from the proxy.
229pub async fn get_tool_info(proxy_url: &str, name: &str) -> Result<Value, ProxyError> {
230    let client = Client::builder()
231        .timeout(Duration::from_secs(PROXY_TIMEOUT_SECS))
232        .build()?;
233    let url = format!("{}/tools/{}", proxy_url.trim_end_matches('/'), name);
234    let response = build_proxy_request(&client, reqwest::Method::GET, &url, None)
235        .send()
236        .await?;
237    let status = response.status();
238    if !status.is_success() {
239        let body = response.text().await.unwrap_or_default();
240        return Err(ProxyError::ProxyResponse {
241            status: status.as_u16(),
242            body,
243        });
244    }
245    Ok(response.json().await?)
246}
247
248/// Forward a raw MCP JSON-RPC message via the proxy's /mcp endpoint.
249///
250/// `token_env` works the same way as for [`call_tool`] — see issue #121.
251pub async fn call_mcp(
252    proxy_url: &str,
253    method: &str,
254    params: Option<Value>,
255    token_env: Option<&str>,
256) -> Result<Value, ProxyError> {
257    use std::sync::atomic::{AtomicU64, Ordering};
258    static MCP_ID: AtomicU64 = AtomicU64::new(1);
259
260    let id = MCP_ID.fetch_add(1, Ordering::SeqCst);
261    let msg = serde_json::json!({
262        "jsonrpc": "2.0",
263        "id": id,
264        "method": method,
265        "params": params,
266    });
267
268    let client = Client::builder()
269        .timeout(Duration::from_secs(PROXY_TIMEOUT_SECS))
270        .build()?;
271
272    let url = format!("{}/mcp", proxy_url.trim_end_matches('/'));
273
274    let response = build_proxy_request(&client, reqwest::Method::POST, &url, token_env)
275        .json(&msg)
276        .send()
277        .await?;
278    let status = response.status();
279
280    if status == reqwest::StatusCode::ACCEPTED {
281        return Ok(Value::Null);
282    }
283
284    if !status.is_success() {
285        let body = response.text().await.unwrap_or_else(|_| "empty".into());
286        return Err(ProxyError::ProxyResponse {
287            status: status.as_u16(),
288            body,
289        });
290    }
291
292    let body: Value = response
293        .json()
294        .await
295        .map_err(|e| ProxyError::InvalidResponse(e.to_string()))?;
296
297    if let Some(err) = body.get("error") {
298        let message = err
299            .get("message")
300            .and_then(|m| m.as_str())
301            .unwrap_or("MCP proxy error");
302        return Err(ProxyError::ProxyResponse {
303            status: 200,
304            body: message.to_string(),
305        });
306    }
307
308    Ok(body.get("result").cloned().unwrap_or(Value::Null))
309}
310
311/// Fetch skill list from the proxy server.
312pub async fn list_skills(
313    proxy_url: &str,
314    query_params: &str,
315) -> Result<serde_json::Value, ProxyError> {
316    let client = Client::builder()
317        .timeout(Duration::from_secs(PROXY_TIMEOUT_SECS))
318        .build()?;
319
320    let url = if query_params.is_empty() {
321        format!("{}/skills", proxy_url.trim_end_matches('/'))
322    } else {
323        format!("{}/skills?{query_params}", proxy_url.trim_end_matches('/'))
324    };
325
326    let response = build_proxy_request(&client, reqwest::Method::GET, &url, None)
327        .send()
328        .await?;
329    let status = response.status();
330
331    if !status.is_success() {
332        let body = response.text().await.unwrap_or_else(|_| "empty".into());
333        return Err(ProxyError::ProxyResponse {
334            status: status.as_u16(),
335            body,
336        });
337    }
338
339    response
340        .json()
341        .await
342        .map_err(|e| ProxyError::InvalidResponse(e.to_string()))
343}
344
345/// Fetch a skill's detail from the proxy server.
346pub async fn get_skill(
347    proxy_url: &str,
348    name: &str,
349    query_params: &str,
350) -> Result<serde_json::Value, ProxyError> {
351    let client = Client::builder()
352        .timeout(Duration::from_secs(PROXY_TIMEOUT_SECS))
353        .build()?;
354
355    let url = if query_params.is_empty() {
356        format!("{}/skills/{name}", proxy_url.trim_end_matches('/'))
357    } else {
358        format!(
359            "{}/skills/{name}?{query_params}",
360            proxy_url.trim_end_matches('/')
361        )
362    };
363
364    let response = build_proxy_request(&client, reqwest::Method::GET, &url, None)
365        .send()
366        .await?;
367    let status = response.status();
368
369    if !status.is_success() {
370        let body = response.text().await.unwrap_or_else(|_| "empty".into());
371        return Err(ProxyError::ProxyResponse {
372            status: status.as_u16(),
373            body,
374        });
375    }
376
377    response
378        .json()
379        .await
380        .map_err(|e| ProxyError::InvalidResponse(e.to_string()))
381}
382
383async fn get_proxy_json(proxy_url: &str, path: &str) -> Result<serde_json::Value, ProxyError> {
384    let client = Client::builder()
385        .timeout(Duration::from_secs(PROXY_TIMEOUT_SECS))
386        .build()?;
387
388    let url = format!(
389        "{}/{}",
390        proxy_url.trim_end_matches('/'),
391        path.trim_start_matches('/')
392    );
393
394    let response = build_proxy_request(&client, reqwest::Method::GET, &url, None)
395        .send()
396        .await?;
397    let status = response.status();
398
399    if !status.is_success() {
400        let body = response.text().await.unwrap_or_else(|_| "empty".into());
401        return Err(ProxyError::ProxyResponse {
402            status: status.as_u16(),
403            body,
404        });
405    }
406
407    response
408        .json()
409        .await
410        .map_err(|e| ProxyError::InvalidResponse(e.to_string()))
411}
412
413async fn get_proxy_json_with_query(
414    proxy_url: &str,
415    path: &str,
416    query: &[(&str, String)],
417) -> Result<serde_json::Value, ProxyError> {
418    let client = Client::builder()
419        .timeout(Duration::from_secs(PROXY_TIMEOUT_SECS))
420        .build()?;
421
422    let mut url = format!(
423        "{}/{}",
424        proxy_url.trim_end_matches('/'),
425        path.trim_start_matches('/')
426    );
427
428    if !query.is_empty() {
429        let params = query
430            .iter()
431            .map(|(key, value)| format!("{key}={}", urlencoding(value)))
432            .collect::<Vec<_>>()
433            .join("&");
434        url.push('?');
435        url.push_str(&params);
436    }
437
438    let response = build_proxy_request(&client, reqwest::Method::GET, &url, None)
439        .send()
440        .await?;
441    let status = response.status();
442
443    if !status.is_success() {
444        let body = response.text().await.unwrap_or_else(|_| "empty".into());
445        return Err(ProxyError::ProxyResponse {
446            status: status.as_u16(),
447            body,
448        });
449    }
450
451    response
452        .json()
453        .await
454        .map_err(|e| ProxyError::InvalidResponse(e.to_string()))
455}
456
457/// List remote SkillATI skills from the proxy server.
458pub async fn get_skillati_catalog(
459    proxy_url: &str,
460    search: Option<&str>,
461) -> Result<serde_json::Value, ProxyError> {
462    let query = search
463        .map(|value| vec![("search", value.to_string())])
464        .unwrap_or_default();
465    get_proxy_json_with_query(proxy_url, "skillati/catalog", &query).await
466}
467
468/// Read a remote SkillATI skill from the proxy server.
469pub async fn get_skillati_read(
470    proxy_url: &str,
471    name: &str,
472) -> Result<serde_json::Value, ProxyError> {
473    get_proxy_json(proxy_url, &format!("skillati/{}", urlencoding(name))).await
474}
475
476/// List bundled resources for a remote SkillATI skill via the proxy server.
477pub async fn get_skillati_resources(
478    proxy_url: &str,
479    name: &str,
480    prefix: Option<&str>,
481) -> Result<serde_json::Value, ProxyError> {
482    let query = prefix
483        .map(|value| vec![("prefix", value.to_string())])
484        .unwrap_or_default();
485    get_proxy_json_with_query(
486        proxy_url,
487        &format!("skillati/{}/resources", urlencoding(name)),
488        &query,
489    )
490    .await
491}
492
493/// Read one arbitrary skill-relative path from a remote SkillATI skill via the proxy server.
494pub async fn get_skillati_file(
495    proxy_url: &str,
496    name: &str,
497    path: &str,
498) -> Result<serde_json::Value, ProxyError> {
499    get_proxy_json_with_query(
500        proxy_url,
501        &format!("skillati/{}/file", urlencoding(name)),
502        &[("path", path.to_string())],
503    )
504    .await
505}
506
507/// List on-demand references for a remote SkillATI skill via the proxy server.
508pub async fn get_skillati_refs(
509    proxy_url: &str,
510    name: &str,
511) -> Result<serde_json::Value, ProxyError> {
512    get_proxy_json(proxy_url, &format!("skillati/{}/refs", urlencoding(name))).await
513}
514
515/// Read one reference file from a remote SkillATI skill via the proxy server.
516pub async fn get_skillati_ref(
517    proxy_url: &str,
518    name: &str,
519    reference: &str,
520) -> Result<serde_json::Value, ProxyError> {
521    get_proxy_json(
522        proxy_url,
523        &format!(
524            "skillati/{}/ref/{}",
525            urlencoding(name),
526            urlencoding(reference)
527        ),
528    )
529    .await
530}
531
532fn urlencoding(s: &str) -> String {
533    s.replace('%', "%25")
534        .replace(' ', "%20")
535        .replace('#', "%23")
536        .replace('&', "%26")
537        .replace('?', "%3F")
538        .replace('/', "%2F")
539        .replace('=', "%3D")
540}
541
542/// Resolve skills for given scopes via the proxy.
543pub async fn resolve_skills(
544    proxy_url: &str,
545    scopes: &serde_json::Value,
546) -> Result<serde_json::Value, ProxyError> {
547    let client = Client::builder()
548        .timeout(Duration::from_secs(PROXY_TIMEOUT_SECS))
549        .build()?;
550
551    let url = format!("{}/skills/resolve", proxy_url.trim_end_matches('/'));
552
553    let response = build_proxy_request(&client, reqwest::Method::POST, &url, None)
554        .json(scopes)
555        .send()
556        .await?;
557    let status = response.status();
558
559    if !status.is_success() {
560        let body = response.text().await.unwrap_or_else(|_| "empty".into());
561        return Err(ProxyError::ProxyResponse {
562            status: status.as_u16(),
563            body,
564        });
565    }
566
567    response
568        .json()
569        .await
570        .map_err(|e| ProxyError::InvalidResponse(e.to_string()))
571}
572
573/// Execute an LLM help query via the proxy server.
574pub async fn call_help(
575    proxy_url: &str,
576    query: &str,
577    tool: Option<&str>,
578) -> Result<String, ProxyError> {
579    let client = Client::builder()
580        .timeout(Duration::from_secs(PROXY_TIMEOUT_SECS))
581        .build()?;
582
583    let url = format!("{}/help", proxy_url.trim_end_matches('/'));
584
585    let payload = ProxyHelpRequest {
586        query: query.to_string(),
587        tool: tool.map(|t| t.to_string()),
588    };
589
590    let response = build_proxy_request(&client, reqwest::Method::POST, &url, None)
591        .json(&payload)
592        .send()
593        .await?;
594    let status = response.status();
595
596    if !status.is_success() {
597        let body = response.text().await.unwrap_or_else(|_| "empty".into());
598        return Err(ProxyError::ProxyResponse {
599            status: status.as_u16(),
600            body,
601        });
602    }
603
604    let body: ProxyHelpResponse = response
605        .json()
606        .await
607        .map_err(|e| ProxyError::InvalidResponse(e.to_string()))?;
608
609    if let Some(err) = body.error {
610        return Err(ProxyError::ProxyResponse {
611            status: 200,
612            body: err,
613        });
614    }
615
616    Ok(body.content)
617}