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