Skip to main content

ati/proxy/
server.rs

1/// ATI proxy server — holds API keys and executes tool calls on behalf of sandbox agents.
2///
3/// Authentication: ES256-signed JWT (or HS256 fallback). The JWT carries identity,
4/// scopes, and expiry. No more static tokens or unsigned scope lists.
5///
6/// Usage: `ati proxy --port 8080 [--ati-dir ~/.ati]`
7use axum::{
8    body::Body,
9    extract::{Extension, Query, State},
10    http::{Request as HttpRequest, StatusCode},
11    middleware::{self, Next},
12    response::{IntoResponse, Response},
13    routing::{get, post},
14    Json, Router,
15};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use std::collections::HashMap;
19use std::net::SocketAddr;
20use std::path::PathBuf;
21use std::sync::Arc;
22
23use crate::core::auth_generator::{AuthCache, GenContext};
24use crate::core::http;
25use crate::core::jwt::{self, JwtConfig, TokenClaims};
26use crate::core::keyring::Keyring;
27use crate::core::manifest::{ManifestRegistry, Provider, Tool};
28use crate::core::mcp_client;
29use crate::core::response;
30use crate::core::scope::ScopeConfig;
31use crate::core::skill::{self, SkillRegistry};
32use crate::core::skillati::{RemoteSkillMeta, SkillAtiClient, SkillAtiError};
33
34/// Shared state for the proxy server.
35pub struct ProxyState {
36    pub registry: ManifestRegistry,
37    pub skill_registry: SkillRegistry,
38    pub keyring: Keyring,
39    /// JWT validation config (None = auth disabled / dev mode).
40    pub jwt_config: Option<JwtConfig>,
41    /// Pre-computed JWKS JSON for the /.well-known/jwks.json endpoint.
42    pub jwks_json: Option<Value>,
43    /// Shared cache for dynamically generated auth credentials.
44    pub auth_cache: AuthCache,
45}
46
47// --- Request/Response types ---
48
49#[derive(Debug, Deserialize)]
50pub struct CallRequest {
51    pub tool_name: String,
52    /// Tool arguments — accepts a JSON object (key-value pairs) for HTTP/MCP/OpenAPI tools,
53    /// or a JSON array of strings / a single string for CLI tools.
54    /// The proxy auto-detects the handler type and routes accordingly.
55    #[serde(default = "default_args")]
56    pub args: Value,
57    /// Deprecated: use `args` with an array value instead.
58    /// Kept for backward compatibility — if present, takes precedence for CLI tools.
59    #[serde(default)]
60    pub raw_args: Option<Vec<String>>,
61}
62
63fn default_args() -> Value {
64    Value::Object(serde_json::Map::new())
65}
66
67impl CallRequest {
68    /// Extract args as a HashMap for HTTP/MCP/OpenAPI tools.
69    /// If `args` is a JSON object, returns its entries.
70    /// If `args` is something else (array, string), returns an empty map.
71    fn args_as_map(&self) -> HashMap<String, Value> {
72        match &self.args {
73            Value::Object(map) => map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
74            _ => HashMap::new(),
75        }
76    }
77
78    /// Extract positional args for CLI tools.
79    /// Priority: explicit `raw_args` field > `args` array > `args` string > `args._positional` > empty.
80    fn args_as_positional(&self) -> Vec<String> {
81        // Backward compat: explicit raw_args wins
82        if let Some(ref raw) = self.raw_args {
83            return raw.clone();
84        }
85        match &self.args {
86            // ["pr", "list", "--repo", "X"]
87            Value::Array(arr) => arr
88                .iter()
89                .map(|v| match v {
90                    Value::String(s) => s.clone(),
91                    other => other.to_string(),
92                })
93                .collect(),
94            // "pr list --repo X"
95            Value::String(s) => s.split_whitespace().map(String::from).collect(),
96            // {"_positional": ["pr", "list"]} or {"--key": "value"} converted to CLI flags
97            Value::Object(map) => {
98                if let Some(Value::Array(pos)) = map.get("_positional") {
99                    return pos
100                        .iter()
101                        .map(|v| match v {
102                            Value::String(s) => s.clone(),
103                            other => other.to_string(),
104                        })
105                        .collect();
106                }
107                // Convert map entries to --key value pairs
108                let mut result = Vec::new();
109                for (k, v) in map {
110                    result.push(format!("--{k}"));
111                    match v {
112                        Value::String(s) => result.push(s.clone()),
113                        Value::Bool(true) => {} // flag, no value needed
114                        other => result.push(other.to_string()),
115                    }
116                }
117                result
118            }
119            _ => Vec::new(),
120        }
121    }
122}
123
124#[derive(Debug, Serialize)]
125pub struct CallResponse {
126    pub result: Value,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub error: Option<String>,
129}
130
131#[derive(Debug, Deserialize)]
132pub struct HelpRequest {
133    pub query: String,
134    #[serde(default)]
135    pub tool: Option<String>,
136}
137
138#[derive(Debug, Serialize)]
139pub struct HelpResponse {
140    pub content: String,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub error: Option<String>,
143}
144
145#[derive(Debug, Serialize)]
146pub struct HealthResponse {
147    pub status: String,
148    pub version: String,
149    pub tools: usize,
150    pub providers: usize,
151    pub skills: usize,
152    pub auth: String,
153}
154
155// --- Skill endpoint types ---
156
157#[derive(Debug, Deserialize)]
158pub struct SkillsQuery {
159    #[serde(default)]
160    pub category: Option<String>,
161    #[serde(default)]
162    pub provider: Option<String>,
163    #[serde(default)]
164    pub tool: Option<String>,
165    #[serde(default)]
166    pub search: Option<String>,
167}
168
169#[derive(Debug, Deserialize)]
170pub struct SkillDetailQuery {
171    #[serde(default)]
172    pub meta: Option<bool>,
173    #[serde(default)]
174    pub refs: Option<bool>,
175}
176
177#[derive(Debug, Deserialize)]
178pub struct SkillResolveRequest {
179    pub scopes: Vec<String>,
180    /// When true, include SKILL.md content in each resolved skill.
181    #[serde(default)]
182    pub include_content: bool,
183}
184
185#[derive(Debug, Deserialize)]
186pub struct SkillBundleBatchRequest {
187    pub names: Vec<String>,
188}
189
190#[derive(Debug, Deserialize, Default)]
191pub struct SkillAtiCatalogQuery {
192    #[serde(default)]
193    pub search: Option<String>,
194}
195
196#[derive(Debug, Deserialize, Default)]
197pub struct SkillAtiResourcesQuery {
198    #[serde(default)]
199    pub prefix: Option<String>,
200}
201
202#[derive(Debug, Deserialize)]
203pub struct SkillAtiFileQuery {
204    pub path: String,
205}
206
207// --- Tool endpoint types ---
208
209#[derive(Debug, Deserialize)]
210pub struct ToolsQuery {
211    #[serde(default)]
212    pub provider: Option<String>,
213    #[serde(default)]
214    pub search: Option<String>,
215}
216
217// --- Handlers ---
218
219fn scopes_for_request(claims: Option<&TokenClaims>, state: &ProxyState) -> ScopeConfig {
220    match claims {
221        Some(claims) => ScopeConfig::from_jwt(claims),
222        None if state.jwt_config.is_none() => ScopeConfig::unrestricted(),
223        None => ScopeConfig {
224            scopes: Vec::new(),
225            sub: String::new(),
226            expires_at: 0,
227            rate_config: None,
228        },
229    }
230}
231
232fn visible_tools_for_scopes<'a>(
233    state: &'a ProxyState,
234    scopes: &ScopeConfig,
235) -> Vec<(&'a Provider, &'a Tool)> {
236    crate::core::scope::filter_tools_by_scope(state.registry.list_public_tools(), scopes)
237}
238
239fn visible_skill_names(
240    state: &ProxyState,
241    scopes: &ScopeConfig,
242) -> std::collections::HashSet<String> {
243    skill::visible_skills(&state.skill_registry, &state.registry, scopes)
244        .into_iter()
245        .map(|skill| skill.name.clone())
246        .collect()
247}
248
249/// Compute the set of remote (SkillATI-registry) skill names that the caller's
250/// scopes grant access to.
251///
252/// Mirrors the scope cascade in `skill::resolve_skills` — explicit `skill:X`
253/// scopes, `tool:Y` scopes resolved to the tool's covering skills (including
254/// provider/category bindings) — but against a remote catalog whose skills
255/// are **not** present in the local filesystem `SkillRegistry`.
256///
257/// Without this, proxies running `ATI_SKILL_REGISTRY=gcs://...` with an empty
258/// local skills directory return 404 for every remote skill, because the
259/// visibility gate only consults `state.skill_registry` (see issue #59).
260fn visible_remote_skill_names(
261    state: &ProxyState,
262    scopes: &ScopeConfig,
263    catalog: &[RemoteSkillMeta],
264) -> std::collections::HashSet<String> {
265    let mut visible: std::collections::HashSet<String> = std::collections::HashSet::new();
266    if catalog.is_empty() {
267        return visible;
268    }
269    if scopes.is_wildcard() {
270        for entry in catalog {
271            visible.insert(entry.name.clone());
272        }
273        return visible;
274    }
275
276    // Collect allowed tool/provider/category identifiers from the caller's scopes.
277    // 1. Direct `tool:X` scopes (including wildcards) → walk against the public
278    //    tool registry to collect concrete (provider, tool) pairs.
279    let allowed_tool_pairs: Vec<(String, String)> =
280        crate::core::scope::filter_tools_by_scope(state.registry.list_public_tools(), scopes)
281            .into_iter()
282            .map(|(p, t)| (p.name.clone(), t.name.clone()))
283            .collect();
284    let allowed_tool_names: std::collections::HashSet<&str> =
285        allowed_tool_pairs.iter().map(|(_, t)| t.as_str()).collect();
286    let allowed_provider_names: std::collections::HashSet<&str> =
287        allowed_tool_pairs.iter().map(|(p, _)| p.as_str()).collect();
288    let allowed_categories: std::collections::HashSet<String> = state
289        .registry
290        .list_providers()
291        .into_iter()
292        .filter(|p| allowed_provider_names.contains(p.name.as_str()))
293        .filter_map(|p| p.category.clone())
294        .collect();
295
296    // Explicit `skill:X` scopes → include X if present in the remote catalog.
297    for scope in &scopes.scopes {
298        if let Some(skill_name) = scope.strip_prefix("skill:") {
299            if catalog.iter().any(|e| e.name == skill_name) {
300                visible.insert(skill_name.to_string());
301            }
302        }
303    }
304
305    // Tool/provider/category cascade → include a remote skill if any of its
306    // `tools`, `providers`, or `categories` bindings match a scope-allowed
307    // tool/provider/category.
308    for entry in catalog {
309        if entry
310            .tools
311            .iter()
312            .any(|t| allowed_tool_names.contains(t.as_str()))
313            || entry
314                .providers
315                .iter()
316                .any(|p| allowed_provider_names.contains(p.as_str()))
317            || entry
318                .categories
319                .iter()
320                .any(|c| allowed_categories.contains(c))
321        {
322            visible.insert(entry.name.clone());
323        }
324    }
325
326    visible
327}
328
329/// Union of local + remote visible skill names, computed on demand. The
330/// remote catalog is fetched lazily (and is cached inside `SkillAtiClient`
331/// after the first call on the hot path).
332async fn visible_skill_names_with_remote(
333    state: &ProxyState,
334    scopes: &ScopeConfig,
335    client: &SkillAtiClient,
336) -> Result<std::collections::HashSet<String>, SkillAtiError> {
337    let mut names = visible_skill_names(state, scopes);
338    let catalog = client.catalog().await?;
339    let remote = visible_remote_skill_names(state, scopes, &catalog);
340    names.extend(remote);
341    Ok(names)
342}
343
344async fn handle_call(
345    State(state): State<Arc<ProxyState>>,
346    req: HttpRequest<Body>,
347) -> impl IntoResponse {
348    // Extract JWT claims from request extensions (set by auth middleware)
349    let claims = req.extensions().get::<TokenClaims>().cloned();
350
351    // Parse request body
352    let body_bytes = match axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await {
353        Ok(b) => b,
354        Err(e) => {
355            return (
356                StatusCode::BAD_REQUEST,
357                Json(CallResponse {
358                    result: Value::Null,
359                    error: Some(format!("Failed to read request body: {e}")),
360                }),
361            );
362        }
363    };
364
365    let call_req: CallRequest = match serde_json::from_slice(&body_bytes) {
366        Ok(r) => r,
367        Err(e) => {
368            return (
369                StatusCode::UNPROCESSABLE_ENTITY,
370                Json(CallResponse {
371                    result: Value::Null,
372                    error: Some(format!("Invalid request: {e}")),
373                }),
374            );
375        }
376    };
377
378    tracing::debug!(
379        tool = %call_req.tool_name,
380        args = ?call_req.args,
381        "POST /call"
382    );
383
384    // Look up tool in registry.
385    // If not found, try converting underscore format (finnhub_quote) to colon (finnhub:quote).
386    let (provider, tool) = match state.registry.get_tool(&call_req.tool_name) {
387        Some(pt) => pt,
388        None => {
389            // Try underscore → colon conversion at each underscore position.
390            // "finnhub_quote" → try "finnhub:quote"
391            // "test_api_get_data" → try "test:api_get_data", "test_api:get_data"
392            let mut resolved = None;
393            for (idx, _) in call_req.tool_name.match_indices('_') {
394                let candidate = format!(
395                    "{}:{}",
396                    &call_req.tool_name[..idx],
397                    &call_req.tool_name[idx + 1..]
398                );
399                if let Some(pt) = state.registry.get_tool(&candidate) {
400                    tracing::debug!(
401                        original = %call_req.tool_name,
402                        resolved = %candidate,
403                        "resolved underscore tool name to colon format"
404                    );
405                    resolved = Some(pt);
406                    break;
407                }
408            }
409
410            match resolved {
411                Some(pt) => pt,
412                None => {
413                    return (
414                        StatusCode::NOT_FOUND,
415                        Json(CallResponse {
416                            result: Value::Null,
417                            error: Some(format!("Unknown tool: '{}'", call_req.tool_name)),
418                        }),
419                    );
420                }
421            }
422        }
423    };
424
425    // Scope enforcement from JWT claims
426    if let Some(tool_scope) = &tool.scope {
427        let scopes = match &claims {
428            Some(c) => ScopeConfig::from_jwt(c),
429            None if state.jwt_config.is_none() => ScopeConfig::unrestricted(), // Dev mode
430            None => {
431                return (
432                    StatusCode::FORBIDDEN,
433                    Json(CallResponse {
434                        result: Value::Null,
435                        error: Some("Authentication required — no JWT provided".into()),
436                    }),
437                );
438            }
439        };
440
441        if !scopes.is_allowed(tool_scope) {
442            return (
443                StatusCode::FORBIDDEN,
444                Json(CallResponse {
445                    result: Value::Null,
446                    error: Some(format!(
447                        "Access denied: '{}' is not in your scopes",
448                        tool.name
449                    )),
450                }),
451            );
452        }
453    }
454
455    // Rate limit check
456    {
457        let scopes = match &claims {
458            Some(c) => ScopeConfig::from_jwt(c),
459            None => ScopeConfig::unrestricted(),
460        };
461        if let Some(ref rate_config) = scopes.rate_config {
462            if let Err(e) = crate::core::rate::check_and_record(&call_req.tool_name, rate_config) {
463                return (
464                    StatusCode::TOO_MANY_REQUESTS,
465                    Json(CallResponse {
466                        result: Value::Null,
467                        error: Some(format!("{e}")),
468                    }),
469                );
470            }
471        }
472    }
473
474    // Build auth generator context from JWT claims
475    let gen_ctx = GenContext {
476        jwt_sub: claims
477            .as_ref()
478            .map(|c| c.sub.clone())
479            .unwrap_or_else(|| "dev".into()),
480        jwt_scope: claims
481            .as_ref()
482            .map(|c| c.scope.clone())
483            .unwrap_or_else(|| "*".into()),
484        tool_name: call_req.tool_name.clone(),
485        timestamp: crate::core::jwt::now_secs(),
486    };
487
488    // Execute tool call — dispatch based on handler type, with timing for audit
489    let agent_sub = claims.as_ref().map(|c| c.sub.clone()).unwrap_or_default();
490    let job_id = claims
491        .as_ref()
492        .and_then(|c| c.job_id.clone())
493        .unwrap_or_default();
494    let sandbox_id = claims
495        .as_ref()
496        .and_then(|c| c.sandbox_id.clone())
497        .unwrap_or_default();
498    tracing::info!(
499        tool = %call_req.tool_name,
500        agent = %agent_sub,
501        job_id = %job_id,
502        sandbox_id = %sandbox_id,
503        "tool call"
504    );
505    let start = std::time::Instant::now();
506
507    let response = match provider.handler.as_str() {
508        "mcp" => {
509            let args_map = call_req.args_as_map();
510            match mcp_client::execute_with_gen(
511                provider,
512                &call_req.tool_name,
513                &args_map,
514                &state.keyring,
515                Some(&gen_ctx),
516                Some(&state.auth_cache),
517            )
518            .await
519            {
520                Ok(result) => (
521                    StatusCode::OK,
522                    Json(CallResponse {
523                        result,
524                        error: None,
525                    }),
526                ),
527                Err(e) => (
528                    StatusCode::BAD_GATEWAY,
529                    Json(CallResponse {
530                        result: Value::Null,
531                        error: Some(format!("MCP error: {e}")),
532                    }),
533                ),
534            }
535        }
536        "cli" => {
537            let positional = call_req.args_as_positional();
538            match crate::core::cli_executor::execute_with_gen(
539                provider,
540                &positional,
541                &state.keyring,
542                Some(&gen_ctx),
543                Some(&state.auth_cache),
544            )
545            .await
546            {
547                Ok(result) => (
548                    StatusCode::OK,
549                    Json(CallResponse {
550                        result,
551                        error: None,
552                    }),
553                ),
554                Err(e) => (
555                    StatusCode::BAD_GATEWAY,
556                    Json(CallResponse {
557                        result: Value::Null,
558                        error: Some(format!("CLI error: {e}")),
559                    }),
560                ),
561            }
562        }
563        _ => {
564            let args_map = call_req.args_as_map();
565            let raw_response = match http::execute_tool_with_gen(
566                provider,
567                tool,
568                &args_map,
569                &state.keyring,
570                Some(&gen_ctx),
571                Some(&state.auth_cache),
572            )
573            .await
574            {
575                Ok(resp) => resp,
576                Err(e) => {
577                    let duration = start.elapsed();
578                    write_proxy_audit(
579                        &call_req,
580                        &agent_sub,
581                        claims.as_ref(),
582                        duration,
583                        Some(&e.to_string()),
584                    );
585                    return (
586                        StatusCode::BAD_GATEWAY,
587                        Json(CallResponse {
588                            result: Value::Null,
589                            error: Some(format!("Upstream API error: {e}")),
590                        }),
591                    );
592                }
593            };
594
595            let processed = match response::process_response(&raw_response, tool.response.as_ref())
596            {
597                Ok(p) => p,
598                Err(e) => {
599                    let duration = start.elapsed();
600                    write_proxy_audit(
601                        &call_req,
602                        &agent_sub,
603                        claims.as_ref(),
604                        duration,
605                        Some(&e.to_string()),
606                    );
607                    return (
608                        StatusCode::INTERNAL_SERVER_ERROR,
609                        Json(CallResponse {
610                            result: raw_response,
611                            error: Some(format!("Response processing error: {e}")),
612                        }),
613                    );
614                }
615            };
616
617            (
618                StatusCode::OK,
619                Json(CallResponse {
620                    result: processed,
621                    error: None,
622                }),
623            )
624        }
625    };
626
627    let duration = start.elapsed();
628    let error_msg = response.1.error.as_deref();
629    write_proxy_audit(&call_req, &agent_sub, claims.as_ref(), duration, error_msg);
630
631    response
632}
633
634async fn handle_help(
635    State(state): State<Arc<ProxyState>>,
636    claims: Option<Extension<TokenClaims>>,
637    Json(req): Json<HelpRequest>,
638) -> impl IntoResponse {
639    tracing::debug!(query = %req.query, tool = ?req.tool, "POST /help");
640
641    let claims = claims.map(|Extension(claims)| claims);
642    let scopes = scopes_for_request(claims.as_ref(), &state);
643
644    let (llm_provider, llm_tool) = match state.registry.get_tool("_chat_completion") {
645        Some(pt) => pt,
646        None => {
647            return (
648                StatusCode::SERVICE_UNAVAILABLE,
649                Json(HelpResponse {
650                    content: String::new(),
651                    error: Some("No _llm.toml manifest found. Proxy help requires a configured LLM provider.".into()),
652                }),
653            );
654        }
655    };
656
657    let api_key = match llm_provider
658        .auth_key_name
659        .as_deref()
660        .and_then(|k| state.keyring.get(k))
661    {
662        Some(key) => key.to_string(),
663        None => {
664            return (
665                StatusCode::SERVICE_UNAVAILABLE,
666                Json(HelpResponse {
667                    content: String::new(),
668                    error: Some("LLM API key not found in keyring".into()),
669                }),
670            );
671        }
672    };
673
674    let resolved_skills = skill::resolve_skills(&state.skill_registry, &state.registry, &scopes);
675    let local_skills_section = if resolved_skills.is_empty() {
676        String::new()
677    } else {
678        format!(
679            "## Available Skills (methodology guides)\n{}",
680            skill::build_skill_context(&resolved_skills)
681        )
682    };
683    let remote_query = req
684        .tool
685        .as_ref()
686        .map(|tool| format!("{tool} {}", req.query))
687        .unwrap_or_else(|| req.query.clone());
688    let remote_skills_section =
689        build_remote_skillati_section(&state.keyring, &remote_query, 12).await;
690    let skills_section = merge_help_skill_sections(&[local_skills_section, remote_skills_section]);
691
692    // Build system prompt — scoped or unscoped
693    let visible_tools = visible_tools_for_scopes(&state, &scopes);
694    let system_prompt = if let Some(ref tool_name) = req.tool {
695        // Scoped mode: narrow tools to the specified tool or provider
696        match build_scoped_prompt(tool_name, &visible_tools, &skills_section) {
697            Some(prompt) => prompt,
698            None => {
699                return (
700                    StatusCode::FORBIDDEN,
701                    Json(HelpResponse {
702                        content: String::new(),
703                        error: Some(format!(
704                            "Scope '{tool_name}' is not visible in your current scopes."
705                        )),
706                    }),
707                );
708            }
709        }
710    } else {
711        let tools_context = build_tool_context(&visible_tools);
712        HELP_SYSTEM_PROMPT
713            .replace("{tools}", &tools_context)
714            .replace("{skills_section}", &skills_section)
715    };
716
717    let request_body = serde_json::json!({
718        "model": "zai-glm-4.7",
719        "messages": [
720            {"role": "system", "content": system_prompt},
721            {"role": "user", "content": req.query}
722        ],
723        "max_completion_tokens": 1536,
724        "temperature": 0.3
725    });
726
727    let client = reqwest::Client::new();
728    let url = format!(
729        "{}{}",
730        llm_provider.base_url.trim_end_matches('/'),
731        llm_tool.endpoint
732    );
733
734    let response = match client
735        .post(&url)
736        .bearer_auth(&api_key)
737        .json(&request_body)
738        .send()
739        .await
740    {
741        Ok(r) => r,
742        Err(e) => {
743            return (
744                StatusCode::BAD_GATEWAY,
745                Json(HelpResponse {
746                    content: String::new(),
747                    error: Some(format!("LLM request failed: {e}")),
748                }),
749            );
750        }
751    };
752
753    if !response.status().is_success() {
754        let status = response.status();
755        let body = response.text().await.unwrap_or_default();
756        return (
757            StatusCode::BAD_GATEWAY,
758            Json(HelpResponse {
759                content: String::new(),
760                error: Some(format!("LLM API error ({status}): {body}")),
761            }),
762        );
763    }
764
765    let body: Value = match response.json().await {
766        Ok(b) => b,
767        Err(e) => {
768            return (
769                StatusCode::INTERNAL_SERVER_ERROR,
770                Json(HelpResponse {
771                    content: String::new(),
772                    error: Some(format!("Failed to parse LLM response: {e}")),
773                }),
774            );
775        }
776    };
777
778    let content = body
779        .pointer("/choices/0/message/content")
780        .and_then(|c| c.as_str())
781        .unwrap_or("No response from LLM")
782        .to_string();
783
784    (
785        StatusCode::OK,
786        Json(HelpResponse {
787            content,
788            error: None,
789        }),
790    )
791}
792
793async fn handle_health(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
794    let auth = if state.jwt_config.is_some() {
795        "jwt"
796    } else {
797        "disabled"
798    };
799
800    Json(HealthResponse {
801        status: "ok".into(),
802        version: env!("CARGO_PKG_VERSION").into(),
803        tools: state.registry.list_public_tools().len(),
804        providers: state.registry.list_providers().len(),
805        skills: state.skill_registry.skill_count(),
806        auth: auth.into(),
807    })
808}
809
810/// GET /.well-known/jwks.json — serves the public key for JWT validation.
811async fn handle_jwks(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
812    match &state.jwks_json {
813        Some(jwks) => (StatusCode::OK, Json(jwks.clone())),
814        None => (
815            StatusCode::NOT_FOUND,
816            Json(serde_json::json!({"error": "JWKS not configured"})),
817        ),
818    }
819}
820
821// ---------------------------------------------------------------------------
822// POST /mcp — MCP JSON-RPC proxy endpoint
823// ---------------------------------------------------------------------------
824
825async fn handle_mcp(
826    State(state): State<Arc<ProxyState>>,
827    claims: Option<Extension<TokenClaims>>,
828    Json(msg): Json<Value>,
829) -> impl IntoResponse {
830    let claims = claims.map(|Extension(claims)| claims);
831    let scopes = scopes_for_request(claims.as_ref(), &state);
832    let method = msg.get("method").and_then(|m| m.as_str()).unwrap_or("");
833    let id = msg.get("id").cloned();
834    tracing::info!(
835        %method,
836        agent = claims.as_ref().map(|c| c.sub.as_str()).unwrap_or(""),
837        job_id = claims.as_ref().and_then(|c| c.job_id.as_deref()).unwrap_or(""),
838        sandbox_id = claims.as_ref().and_then(|c| c.sandbox_id.as_deref()).unwrap_or(""),
839        "mcp call"
840    );
841
842    match method {
843        "initialize" => {
844            let result = serde_json::json!({
845                "protocolVersion": "2025-03-26",
846                "capabilities": {
847                    "tools": { "listChanged": false }
848                },
849                "serverInfo": {
850                    "name": "ati-proxy",
851                    "version": env!("CARGO_PKG_VERSION")
852                }
853            });
854            jsonrpc_success(id, result)
855        }
856
857        "notifications/initialized" => (StatusCode::ACCEPTED, Json(Value::Null)),
858
859        "tools/list" => {
860            let visible_tools = visible_tools_for_scopes(&state, &scopes);
861            let mcp_tools: Vec<Value> = visible_tools
862                .iter()
863                .map(|(_provider, tool)| {
864                    serde_json::json!({
865                        "name": tool.name,
866                        "description": tool.description,
867                        "inputSchema": tool.input_schema.clone().unwrap_or(serde_json::json!({
868                            "type": "object",
869                            "properties": {}
870                        }))
871                    })
872                })
873                .collect();
874
875            let result = serde_json::json!({
876                "tools": mcp_tools,
877            });
878            jsonrpc_success(id, result)
879        }
880
881        "tools/call" => {
882            let params = msg.get("params").cloned().unwrap_or(Value::Null);
883            let tool_name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
884            let arguments: HashMap<String, Value> = params
885                .get("arguments")
886                .and_then(|a| serde_json::from_value(a.clone()).ok())
887                .unwrap_or_default();
888
889            if tool_name.is_empty() {
890                return jsonrpc_error(id, -32602, "Missing tool name in params.name");
891            }
892
893            let (provider, _tool) = match state.registry.get_tool(tool_name) {
894                Some(pt) => pt,
895                None => {
896                    return jsonrpc_error(id, -32602, &format!("Unknown tool: '{tool_name}'"));
897                }
898            };
899
900            if let Some(tool_scope) = &_tool.scope {
901                if !scopes.is_allowed(tool_scope) {
902                    return jsonrpc_error(
903                        id,
904                        -32001,
905                        &format!("Access denied: '{}' is not in your scopes", _tool.name),
906                    );
907                }
908            }
909
910            tracing::debug!(%tool_name, provider = %provider.name, "MCP tools/call");
911
912            let mcp_gen_ctx = GenContext {
913                jwt_sub: claims
914                    .as_ref()
915                    .map(|claims| claims.sub.clone())
916                    .unwrap_or_else(|| "dev".into()),
917                jwt_scope: claims
918                    .as_ref()
919                    .map(|claims| claims.scope.clone())
920                    .unwrap_or_else(|| "*".into()),
921                tool_name: tool_name.to_string(),
922                timestamp: crate::core::jwt::now_secs(),
923            };
924
925            let result = if provider.is_mcp() {
926                mcp_client::execute_with_gen(
927                    provider,
928                    tool_name,
929                    &arguments,
930                    &state.keyring,
931                    Some(&mcp_gen_ctx),
932                    Some(&state.auth_cache),
933                )
934                .await
935            } else if provider.is_cli() {
936                // Convert arguments map to CLI-style args for MCP passthrough
937                let raw: Vec<String> = arguments
938                    .iter()
939                    .flat_map(|(k, v)| {
940                        let val = match v {
941                            Value::String(s) => s.clone(),
942                            other => other.to_string(),
943                        };
944                        vec![format!("--{k}"), val]
945                    })
946                    .collect();
947                crate::core::cli_executor::execute_with_gen(
948                    provider,
949                    &raw,
950                    &state.keyring,
951                    Some(&mcp_gen_ctx),
952                    Some(&state.auth_cache),
953                )
954                .await
955                .map_err(|e| mcp_client::McpError::Transport(e.to_string()))
956            } else {
957                match http::execute_tool_with_gen(
958                    provider,
959                    _tool,
960                    &arguments,
961                    &state.keyring,
962                    Some(&mcp_gen_ctx),
963                    Some(&state.auth_cache),
964                )
965                .await
966                {
967                    Ok(val) => Ok(val),
968                    Err(e) => Err(mcp_client::McpError::Transport(e.to_string())),
969                }
970            };
971
972            match result {
973                Ok(value) => {
974                    let text = match &value {
975                        Value::String(s) => s.clone(),
976                        other => serde_json::to_string_pretty(other).unwrap_or_default(),
977                    };
978                    let mcp_result = serde_json::json!({
979                        "content": [{"type": "text", "text": text}],
980                        "isError": false,
981                    });
982                    jsonrpc_success(id, mcp_result)
983                }
984                Err(e) => {
985                    let mcp_result = serde_json::json!({
986                        "content": [{"type": "text", "text": format!("Error: {e}")}],
987                        "isError": true,
988                    });
989                    jsonrpc_success(id, mcp_result)
990                }
991            }
992        }
993
994        _ => jsonrpc_error(id, -32601, &format!("Method not found: '{method}'")),
995    }
996}
997
998fn jsonrpc_success(id: Option<Value>, result: Value) -> (StatusCode, Json<Value>) {
999    (
1000        StatusCode::OK,
1001        Json(serde_json::json!({
1002            "jsonrpc": "2.0",
1003            "id": id,
1004            "result": result,
1005        })),
1006    )
1007}
1008
1009fn jsonrpc_error(id: Option<Value>, code: i64, message: &str) -> (StatusCode, Json<Value>) {
1010    (
1011        StatusCode::OK,
1012        Json(serde_json::json!({
1013            "jsonrpc": "2.0",
1014            "id": id,
1015            "error": {
1016                "code": code,
1017                "message": message,
1018            }
1019        })),
1020    )
1021}
1022
1023// ---------------------------------------------------------------------------
1024// Tool endpoints
1025// ---------------------------------------------------------------------------
1026
1027/// GET /tools — list available tools with optional filters.
1028async fn handle_tools_list(
1029    State(state): State<Arc<ProxyState>>,
1030    claims: Option<Extension<TokenClaims>>,
1031    axum::extract::Query(query): axum::extract::Query<ToolsQuery>,
1032) -> impl IntoResponse {
1033    tracing::debug!(
1034        provider = ?query.provider,
1035        search = ?query.search,
1036        "GET /tools"
1037    );
1038
1039    let claims = claims.map(|Extension(claims)| claims);
1040    let scopes = scopes_for_request(claims.as_ref(), &state);
1041    let all_tools = visible_tools_for_scopes(&state, &scopes);
1042
1043    let tools: Vec<Value> = all_tools
1044        .iter()
1045        .filter(|(provider, tool)| {
1046            if let Some(ref p) = query.provider {
1047                if provider.name != *p {
1048                    return false;
1049                }
1050            }
1051            if let Some(ref q) = query.search {
1052                let q = q.to_lowercase();
1053                let name_match = tool.name.to_lowercase().contains(&q);
1054                let desc_match = tool.description.to_lowercase().contains(&q);
1055                let tag_match = tool.tags.iter().any(|t| t.to_lowercase().contains(&q));
1056                if !name_match && !desc_match && !tag_match {
1057                    return false;
1058                }
1059            }
1060            true
1061        })
1062        .map(|(provider, tool)| {
1063            serde_json::json!({
1064                "name": tool.name,
1065                "description": tool.description,
1066                "provider": provider.name,
1067                "method": format!("{:?}", tool.method),
1068                "tags": tool.tags,
1069                "skills": provider.skills,
1070                "input_schema": tool.input_schema,
1071            })
1072        })
1073        .collect();
1074
1075    (StatusCode::OK, Json(Value::Array(tools)))
1076}
1077
1078/// GET /tools/:name — get detailed info about a specific tool.
1079async fn handle_tool_info(
1080    State(state): State<Arc<ProxyState>>,
1081    claims: Option<Extension<TokenClaims>>,
1082    axum::extract::Path(name): axum::extract::Path<String>,
1083) -> impl IntoResponse {
1084    tracing::debug!(tool = %name, "GET /tools/:name");
1085
1086    let claims = claims.map(|Extension(claims)| claims);
1087    let scopes = scopes_for_request(claims.as_ref(), &state);
1088
1089    match state
1090        .registry
1091        .get_tool(&name)
1092        .filter(|(_, tool)| match &tool.scope {
1093            Some(scope) => scopes.is_allowed(scope),
1094            None => true,
1095        }) {
1096        Some((provider, tool)) => {
1097            // Merge skills from manifest + SkillRegistry (tool binding + provider binding)
1098            let mut skills: Vec<String> = provider.skills.clone();
1099            for s in state.skill_registry.skills_for_tool(&tool.name) {
1100                if !skills.contains(&s.name) {
1101                    skills.push(s.name.clone());
1102                }
1103            }
1104            for s in state.skill_registry.skills_for_provider(&provider.name) {
1105                if !skills.contains(&s.name) {
1106                    skills.push(s.name.clone());
1107                }
1108            }
1109
1110            (
1111                StatusCode::OK,
1112                Json(serde_json::json!({
1113                    "name": tool.name,
1114                    "description": tool.description,
1115                    "provider": provider.name,
1116                    "method": format!("{:?}", tool.method),
1117                    "endpoint": tool.endpoint,
1118                    "tags": tool.tags,
1119                    "hint": tool.hint,
1120                    "skills": skills,
1121                    "input_schema": tool.input_schema,
1122                    "scope": tool.scope,
1123                })),
1124            )
1125        }
1126        None => (
1127            StatusCode::NOT_FOUND,
1128            Json(serde_json::json!({"error": format!("Tool '{name}' not found")})),
1129        ),
1130    }
1131}
1132
1133// ---------------------------------------------------------------------------
1134// Skill endpoints
1135// ---------------------------------------------------------------------------
1136
1137async fn handle_skills_list(
1138    State(state): State<Arc<ProxyState>>,
1139    claims: Option<Extension<TokenClaims>>,
1140    axum::extract::Query(query): axum::extract::Query<SkillsQuery>,
1141) -> impl IntoResponse {
1142    tracing::debug!(
1143        category = ?query.category,
1144        provider = ?query.provider,
1145        tool = ?query.tool,
1146        search = ?query.search,
1147        "GET /skills"
1148    );
1149
1150    let claims = claims.map(|Extension(claims)| claims);
1151    let scopes = scopes_for_request(claims.as_ref(), &state);
1152    let visible_names = visible_skill_names(&state, &scopes);
1153
1154    let skills: Vec<&skill::SkillMeta> = if let Some(search_query) = &query.search {
1155        state
1156            .skill_registry
1157            .search(search_query)
1158            .into_iter()
1159            .filter(|skill| visible_names.contains(&skill.name))
1160            .collect()
1161    } else if let Some(cat) = &query.category {
1162        state
1163            .skill_registry
1164            .skills_for_category(cat)
1165            .into_iter()
1166            .filter(|skill| visible_names.contains(&skill.name))
1167            .collect()
1168    } else if let Some(prov) = &query.provider {
1169        state
1170            .skill_registry
1171            .skills_for_provider(prov)
1172            .into_iter()
1173            .filter(|skill| visible_names.contains(&skill.name))
1174            .collect()
1175    } else if let Some(t) = &query.tool {
1176        state
1177            .skill_registry
1178            .skills_for_tool(t)
1179            .into_iter()
1180            .filter(|skill| visible_names.contains(&skill.name))
1181            .collect()
1182    } else {
1183        state
1184            .skill_registry
1185            .list_skills()
1186            .iter()
1187            .filter(|skill| visible_names.contains(&skill.name))
1188            .collect()
1189    };
1190
1191    let json: Vec<Value> = skills
1192        .iter()
1193        .map(|s| {
1194            serde_json::json!({
1195                "name": s.name,
1196                "version": s.version,
1197                "description": s.description,
1198                "tools": s.tools,
1199                "providers": s.providers,
1200                "categories": s.categories,
1201                "hint": s.hint,
1202            })
1203        })
1204        .collect();
1205
1206    (StatusCode::OK, Json(Value::Array(json)))
1207}
1208
1209async fn handle_skill_detail(
1210    State(state): State<Arc<ProxyState>>,
1211    claims: Option<Extension<TokenClaims>>,
1212    axum::extract::Path(name): axum::extract::Path<String>,
1213    axum::extract::Query(query): axum::extract::Query<SkillDetailQuery>,
1214) -> impl IntoResponse {
1215    tracing::debug!(%name, meta = ?query.meta, refs = ?query.refs, "GET /skills/:name");
1216
1217    let claims = claims.map(|Extension(claims)| claims);
1218    let scopes = scopes_for_request(claims.as_ref(), &state);
1219    let visible_names = visible_skill_names(&state, &scopes);
1220
1221    let skill_meta = match state
1222        .skill_registry
1223        .get_skill(&name)
1224        .filter(|skill| visible_names.contains(&skill.name))
1225    {
1226        Some(s) => s,
1227        None => {
1228            return (
1229                StatusCode::NOT_FOUND,
1230                Json(serde_json::json!({"error": format!("Skill '{name}' not found")})),
1231            );
1232        }
1233    };
1234
1235    if query.meta.unwrap_or(false) {
1236        return (
1237            StatusCode::OK,
1238            Json(serde_json::json!({
1239                "name": skill_meta.name,
1240                "version": skill_meta.version,
1241                "description": skill_meta.description,
1242                "author": skill_meta.author,
1243                "tools": skill_meta.tools,
1244                "providers": skill_meta.providers,
1245                "categories": skill_meta.categories,
1246                "keywords": skill_meta.keywords,
1247                "hint": skill_meta.hint,
1248                "depends_on": skill_meta.depends_on,
1249                "suggests": skill_meta.suggests,
1250                "license": skill_meta.license,
1251                "compatibility": skill_meta.compatibility,
1252                "allowed_tools": skill_meta.allowed_tools,
1253                "format": skill_meta.format,
1254            })),
1255        );
1256    }
1257
1258    let content = match state.skill_registry.read_content(&name) {
1259        Ok(c) => c,
1260        Err(e) => {
1261            return (
1262                StatusCode::INTERNAL_SERVER_ERROR,
1263                Json(serde_json::json!({"error": format!("Failed to read skill: {e}")})),
1264            );
1265        }
1266    };
1267
1268    let mut response = serde_json::json!({
1269        "name": skill_meta.name,
1270        "version": skill_meta.version,
1271        "description": skill_meta.description,
1272        "content": content,
1273    });
1274
1275    if query.refs.unwrap_or(false) {
1276        if let Ok(refs) = state.skill_registry.list_references(&name) {
1277            response["references"] = serde_json::json!(refs);
1278        }
1279    }
1280
1281    (StatusCode::OK, Json(response))
1282}
1283
1284/// GET /skills/:name/bundle — return all files in a skill directory.
1285/// Response: `{"name": "...", "files": {"SKILL.md": "...", "scripts/generate.sh": "...", ...}}`
1286/// Binary files are base64-encoded; text files are returned as-is.
1287async fn handle_skill_bundle(
1288    State(state): State<Arc<ProxyState>>,
1289    claims: Option<Extension<TokenClaims>>,
1290    axum::extract::Path(name): axum::extract::Path<String>,
1291) -> impl IntoResponse {
1292    tracing::debug!(skill = %name, "GET /skills/:name/bundle");
1293
1294    let claims = claims.map(|Extension(claims)| claims);
1295    let scopes = scopes_for_request(claims.as_ref(), &state);
1296    let visible_names = visible_skill_names(&state, &scopes);
1297    if !visible_names.contains(&name) {
1298        return (
1299            StatusCode::NOT_FOUND,
1300            Json(serde_json::json!({"error": format!("Skill '{name}' not found")})),
1301        );
1302    }
1303
1304    let files = match state.skill_registry.bundle_files(&name) {
1305        Ok(f) => f,
1306        Err(_) => {
1307            return (
1308                StatusCode::NOT_FOUND,
1309                Json(serde_json::json!({"error": format!("Skill '{name}' not found")})),
1310            );
1311        }
1312    };
1313
1314    // Convert bytes to strings (UTF-8 text) or base64 for binary files
1315    let mut file_map = serde_json::Map::new();
1316    for (path, data) in &files {
1317        match std::str::from_utf8(data) {
1318            Ok(text) => {
1319                file_map.insert(path.clone(), Value::String(text.to_string()));
1320            }
1321            Err(_) => {
1322                // Binary file — base64 encode
1323                use base64::Engine;
1324                let encoded = base64::engine::general_purpose::STANDARD.encode(data);
1325                file_map.insert(path.clone(), serde_json::json!({"base64": encoded}));
1326            }
1327        }
1328    }
1329
1330    (
1331        StatusCode::OK,
1332        Json(serde_json::json!({
1333            "name": name,
1334            "files": file_map,
1335        })),
1336    )
1337}
1338
1339/// POST /skills/bundle — return all files for multiple skills in one response.
1340/// Request: `{"names": ["fal-generate", "compliance-screening"]}`
1341/// Response: `{"skills": {...}, "missing": [...]}`
1342async fn handle_skills_bundle_batch(
1343    State(state): State<Arc<ProxyState>>,
1344    claims: Option<Extension<TokenClaims>>,
1345    Json(req): Json<SkillBundleBatchRequest>,
1346) -> impl IntoResponse {
1347    const MAX_BATCH: usize = 50;
1348    if req.names.len() > MAX_BATCH {
1349        return (
1350            StatusCode::BAD_REQUEST,
1351            Json(
1352                serde_json::json!({"error": format!("batch size {} exceeds limit of {MAX_BATCH}", req.names.len())}),
1353            ),
1354        );
1355    }
1356
1357    tracing::debug!(names = ?req.names, "POST /skills/bundle");
1358
1359    let claims = claims.map(|Extension(claims)| claims);
1360    let scopes = scopes_for_request(claims.as_ref(), &state);
1361    let visible_names = visible_skill_names(&state, &scopes);
1362
1363    let mut result = serde_json::Map::new();
1364    let mut missing: Vec<String> = Vec::new();
1365
1366    for name in &req.names {
1367        if !visible_names.contains(name) {
1368            missing.push(name.clone());
1369            continue;
1370        }
1371        let files = match state.skill_registry.bundle_files(name) {
1372            Ok(f) => f,
1373            Err(_) => {
1374                missing.push(name.clone());
1375                continue;
1376            }
1377        };
1378
1379        let mut file_map = serde_json::Map::new();
1380        for (path, data) in &files {
1381            match std::str::from_utf8(data) {
1382                Ok(text) => {
1383                    file_map.insert(path.clone(), Value::String(text.to_string()));
1384                }
1385                Err(_) => {
1386                    use base64::Engine;
1387                    let encoded = base64::engine::general_purpose::STANDARD.encode(data);
1388                    file_map.insert(path.clone(), serde_json::json!({"base64": encoded}));
1389                }
1390            }
1391        }
1392
1393        result.insert(name.clone(), serde_json::json!({ "files": file_map }));
1394    }
1395
1396    (
1397        StatusCode::OK,
1398        Json(serde_json::json!({ "skills": result, "missing": missing })),
1399    )
1400}
1401
1402async fn handle_skills_resolve(
1403    State(state): State<Arc<ProxyState>>,
1404    claims: Option<Extension<TokenClaims>>,
1405    Json(req): Json<SkillResolveRequest>,
1406) -> impl IntoResponse {
1407    tracing::debug!(scopes = ?req.scopes, include_content = req.include_content, "POST /skills/resolve");
1408
1409    let include_content = req.include_content;
1410    let request_scopes = ScopeConfig {
1411        scopes: req.scopes,
1412        sub: String::new(),
1413        expires_at: 0,
1414        rate_config: None,
1415    };
1416    let claims = claims.map(|Extension(claims)| claims);
1417    let caller_scopes = scopes_for_request(claims.as_ref(), &state);
1418    let visible_names = visible_skill_names(&state, &caller_scopes);
1419
1420    let resolved: Vec<&skill::SkillMeta> =
1421        skill::resolve_skills(&state.skill_registry, &state.registry, &request_scopes)
1422            .into_iter()
1423            .filter(|skill| visible_names.contains(&skill.name))
1424            .collect();
1425
1426    let json: Vec<Value> = resolved
1427        .iter()
1428        .map(|s| {
1429            let mut entry = serde_json::json!({
1430                "name": s.name,
1431                "version": s.version,
1432                "description": s.description,
1433                "tools": s.tools,
1434                "providers": s.providers,
1435                "categories": s.categories,
1436            });
1437            if include_content {
1438                if let Ok(content) = state.skill_registry.read_content(&s.name) {
1439                    entry["content"] = Value::String(content);
1440                }
1441            }
1442            entry
1443        })
1444        .collect();
1445
1446    (StatusCode::OK, Json(Value::Array(json)))
1447}
1448
1449fn skillati_client(keyring: &Keyring) -> Result<SkillAtiClient, SkillAtiError> {
1450    match SkillAtiClient::from_env(keyring)? {
1451        Some(client) => Ok(client),
1452        None => Err(SkillAtiError::NotConfigured),
1453    }
1454}
1455
1456async fn handle_skillati_catalog(
1457    State(state): State<Arc<ProxyState>>,
1458    claims: Option<Extension<TokenClaims>>,
1459    Query(query): Query<SkillAtiCatalogQuery>,
1460) -> impl IntoResponse {
1461    tracing::debug!(search = ?query.search, "GET /skillati/catalog");
1462
1463    let client = match skillati_client(&state.keyring) {
1464        Ok(client) => client,
1465        Err(err) => return skillati_error_response(err),
1466    };
1467
1468    let claims = claims.map(|Extension(c)| c);
1469    let scopes = scopes_for_request(claims.as_ref(), &state);
1470
1471    match client.catalog().await {
1472        Ok(catalog) => {
1473            // Union of local + remote visibility. Merging here (instead of
1474            // calling visible_skill_names_with_remote, which would re-fetch)
1475            // avoids a redundant catalog request on the hot path.
1476            let mut visible_names = visible_skill_names(&state, &scopes);
1477            visible_names.extend(visible_remote_skill_names(&state, &scopes, &catalog));
1478
1479            let mut skills: Vec<_> = catalog
1480                .into_iter()
1481                .filter(|s| visible_names.contains(&s.name))
1482                .collect();
1483            if let Some(search) = query.search.as_deref() {
1484                skills = SkillAtiClient::filter_catalog(&skills, search, 25);
1485            }
1486            (
1487                StatusCode::OK,
1488                Json(serde_json::json!({
1489                    "skills": skills,
1490                })),
1491            )
1492        }
1493        Err(err) => skillati_error_response(err),
1494    }
1495}
1496
1497async fn handle_skillati_read(
1498    State(state): State<Arc<ProxyState>>,
1499    claims: Option<Extension<TokenClaims>>,
1500    axum::extract::Path(name): axum::extract::Path<String>,
1501) -> impl IntoResponse {
1502    tracing::debug!(%name, "GET /skillati/:name");
1503
1504    let client = match skillati_client(&state.keyring) {
1505        Ok(client) => client,
1506        Err(err) => return skillati_error_response(err),
1507    };
1508
1509    let claims = claims.map(|Extension(c)| c);
1510    let scopes = scopes_for_request(claims.as_ref(), &state);
1511    let visible_names = match visible_skill_names_with_remote(&state, &scopes, &client).await {
1512        Ok(v) => v,
1513        Err(err) => return skillati_error_response(err),
1514    };
1515    if !visible_names.contains(&name) {
1516        return skillati_error_response(SkillAtiError::SkillNotFound(name));
1517    }
1518
1519    match client.read_skill(&name).await {
1520        Ok(activation) => (StatusCode::OK, Json(serde_json::json!(activation))),
1521        Err(err) => skillati_error_response(err),
1522    }
1523}
1524
1525async fn handle_skillati_resources(
1526    State(state): State<Arc<ProxyState>>,
1527    claims: Option<Extension<TokenClaims>>,
1528    axum::extract::Path(name): axum::extract::Path<String>,
1529    Query(query): Query<SkillAtiResourcesQuery>,
1530) -> impl IntoResponse {
1531    tracing::debug!(%name, prefix = ?query.prefix, "GET /skillati/:name/resources");
1532
1533    let client = match skillati_client(&state.keyring) {
1534        Ok(client) => client,
1535        Err(err) => return skillati_error_response(err),
1536    };
1537
1538    let claims = claims.map(|Extension(c)| c);
1539    let scopes = scopes_for_request(claims.as_ref(), &state);
1540    let visible_names = match visible_skill_names_with_remote(&state, &scopes, &client).await {
1541        Ok(v) => v,
1542        Err(err) => return skillati_error_response(err),
1543    };
1544    if !visible_names.contains(&name) {
1545        return skillati_error_response(SkillAtiError::SkillNotFound(name));
1546    }
1547
1548    match client.list_resources(&name, query.prefix.as_deref()).await {
1549        Ok(resources) => (
1550            StatusCode::OK,
1551            Json(serde_json::json!({
1552                "name": name,
1553                "prefix": query.prefix,
1554                "resources": resources,
1555            })),
1556        ),
1557        Err(err) => skillati_error_response(err),
1558    }
1559}
1560
1561async fn handle_skillati_file(
1562    State(state): State<Arc<ProxyState>>,
1563    claims: Option<Extension<TokenClaims>>,
1564    axum::extract::Path(name): axum::extract::Path<String>,
1565    Query(query): Query<SkillAtiFileQuery>,
1566) -> impl IntoResponse {
1567    tracing::debug!(%name, path = %query.path, "GET /skillati/:name/file");
1568
1569    let client = match skillati_client(&state.keyring) {
1570        Ok(client) => client,
1571        Err(err) => return skillati_error_response(err),
1572    };
1573
1574    let claims = claims.map(|Extension(c)| c);
1575    let scopes = scopes_for_request(claims.as_ref(), &state);
1576    let visible_names = match visible_skill_names_with_remote(&state, &scopes, &client).await {
1577        Ok(v) => v,
1578        Err(err) => return skillati_error_response(err),
1579    };
1580    if !visible_names.contains(&name) {
1581        return skillati_error_response(SkillAtiError::SkillNotFound(name));
1582    }
1583
1584    match client.read_path(&name, &query.path).await {
1585        Ok(file) => (StatusCode::OK, Json(serde_json::json!(file))),
1586        Err(err) => skillati_error_response(err),
1587    }
1588}
1589
1590async fn handle_skillati_refs(
1591    State(state): State<Arc<ProxyState>>,
1592    claims: Option<Extension<TokenClaims>>,
1593    axum::extract::Path(name): axum::extract::Path<String>,
1594) -> impl IntoResponse {
1595    tracing::debug!(%name, "GET /skillati/:name/refs");
1596
1597    let client = match skillati_client(&state.keyring) {
1598        Ok(client) => client,
1599        Err(err) => return skillati_error_response(err),
1600    };
1601
1602    let claims = claims.map(|Extension(c)| c);
1603    let scopes = scopes_for_request(claims.as_ref(), &state);
1604    let visible_names = match visible_skill_names_with_remote(&state, &scopes, &client).await {
1605        Ok(v) => v,
1606        Err(err) => return skillati_error_response(err),
1607    };
1608    if !visible_names.contains(&name) {
1609        return skillati_error_response(SkillAtiError::SkillNotFound(name));
1610    }
1611
1612    match client.list_references(&name).await {
1613        Ok(references) => (
1614            StatusCode::OK,
1615            Json(serde_json::json!({
1616                "name": name,
1617                "references": references,
1618            })),
1619        ),
1620        Err(err) => skillati_error_response(err),
1621    }
1622}
1623
1624async fn handle_skillati_ref(
1625    State(state): State<Arc<ProxyState>>,
1626    claims: Option<Extension<TokenClaims>>,
1627    axum::extract::Path((name, reference)): axum::extract::Path<(String, String)>,
1628) -> impl IntoResponse {
1629    tracing::debug!(%name, %reference, "GET /skillati/:name/ref/:reference");
1630
1631    let client = match skillati_client(&state.keyring) {
1632        Ok(client) => client,
1633        Err(err) => return skillati_error_response(err),
1634    };
1635
1636    let claims = claims.map(|Extension(c)| c);
1637    let scopes = scopes_for_request(claims.as_ref(), &state);
1638    let visible_names = match visible_skill_names_with_remote(&state, &scopes, &client).await {
1639        Ok(v) => v,
1640        Err(err) => return skillati_error_response(err),
1641    };
1642    if !visible_names.contains(&name) {
1643        return skillati_error_response(SkillAtiError::SkillNotFound(name));
1644    }
1645
1646    match client.read_reference(&name, &reference).await {
1647        Ok(content) => (
1648            StatusCode::OK,
1649            Json(serde_json::json!({
1650                "name": name,
1651                "reference": reference,
1652                "content": content,
1653            })),
1654        ),
1655        Err(err) => skillati_error_response(err),
1656    }
1657}
1658
1659fn skillati_error_response(err: SkillAtiError) -> (StatusCode, Json<Value>) {
1660    let status = match &err {
1661        SkillAtiError::NotConfigured
1662        | SkillAtiError::UnsupportedRegistry(_)
1663        | SkillAtiError::MissingCredentials(_)
1664        | SkillAtiError::ProxyUrlRequired => StatusCode::SERVICE_UNAVAILABLE,
1665        SkillAtiError::SkillNotFound(_) | SkillAtiError::PathNotFound { .. } => {
1666            StatusCode::NOT_FOUND
1667        }
1668        SkillAtiError::InvalidPath(_) => StatusCode::BAD_REQUEST,
1669        SkillAtiError::Gcs(_)
1670        | SkillAtiError::ProxyRequest(_)
1671        | SkillAtiError::ProxyResponse(_) => StatusCode::BAD_GATEWAY,
1672    };
1673
1674    (
1675        status,
1676        Json(serde_json::json!({
1677            "error": err.to_string(),
1678        })),
1679    )
1680}
1681
1682// --- Auth middleware ---
1683
1684/// JWT authentication middleware.
1685///
1686/// - /health and /.well-known/jwks.json → skip auth
1687/// - JWT configured → validate Bearer token, attach claims to request extensions
1688/// - No JWT configured → allow all (dev mode)
1689async fn auth_middleware(
1690    State(state): State<Arc<ProxyState>>,
1691    mut req: HttpRequest<Body>,
1692    next: Next,
1693) -> Result<Response, StatusCode> {
1694    let path = req.uri().path();
1695
1696    // Skip auth for public endpoints
1697    if path == "/health" || path == "/.well-known/jwks.json" {
1698        return Ok(next.run(req).await);
1699    }
1700
1701    // If no JWT configured, allow all (dev mode)
1702    let jwt_config = match &state.jwt_config {
1703        Some(c) => c,
1704        None => return Ok(next.run(req).await),
1705    };
1706
1707    // Extract Authorization: Bearer <token>
1708    let auth_header = req
1709        .headers()
1710        .get("authorization")
1711        .and_then(|v| v.to_str().ok());
1712
1713    let token = match auth_header {
1714        Some(header) if header.starts_with("Bearer ") => &header[7..],
1715        _ => return Err(StatusCode::UNAUTHORIZED),
1716    };
1717
1718    // Validate JWT
1719    match jwt::validate(token, jwt_config) {
1720        Ok(claims) => {
1721            tracing::debug!(sub = %claims.sub, scopes = %claims.scope, "JWT validated");
1722            req.extensions_mut().insert(claims);
1723            Ok(next.run(req).await)
1724        }
1725        Err(e) => {
1726            tracing::debug!(error = %e, "JWT validation failed");
1727            Err(StatusCode::UNAUTHORIZED)
1728        }
1729    }
1730}
1731
1732// --- Router builder ---
1733
1734/// Build the axum Router from a pre-constructed ProxyState.
1735pub fn build_router(state: Arc<ProxyState>) -> Router {
1736    Router::new()
1737        .route("/call", post(handle_call))
1738        .route("/help", post(handle_help))
1739        .route("/mcp", post(handle_mcp))
1740        .route("/tools", get(handle_tools_list))
1741        .route("/tools/{name}", get(handle_tool_info))
1742        .route("/skills", get(handle_skills_list))
1743        .route("/skills/resolve", post(handle_skills_resolve))
1744        .route("/skills/bundle", post(handle_skills_bundle_batch))
1745        .route("/skills/{name}", get(handle_skill_detail))
1746        .route("/skills/{name}/bundle", get(handle_skill_bundle))
1747        .route("/skillati/catalog", get(handle_skillati_catalog))
1748        .route("/skillati/{name}", get(handle_skillati_read))
1749        .route("/skillati/{name}/resources", get(handle_skillati_resources))
1750        .route("/skillati/{name}/file", get(handle_skillati_file))
1751        .route("/skillati/{name}/refs", get(handle_skillati_refs))
1752        .route("/skillati/{name}/ref/{reference}", get(handle_skillati_ref))
1753        .route("/health", get(handle_health))
1754        .route("/.well-known/jwks.json", get(handle_jwks))
1755        .layer(middleware::from_fn_with_state(
1756            state.clone(),
1757            auth_middleware,
1758        ))
1759        .with_state(state)
1760}
1761
1762// --- Server startup ---
1763
1764/// Start the proxy server.
1765pub async fn run(
1766    port: u16,
1767    bind_addr: Option<String>,
1768    ati_dir: PathBuf,
1769    _verbose: bool,
1770    env_keys: bool,
1771) -> Result<(), Box<dyn std::error::Error>> {
1772    // Load manifests
1773    let manifests_dir = ati_dir.join("manifests");
1774    let mut registry = ManifestRegistry::load(&manifests_dir)?;
1775    let provider_count = registry.list_providers().len();
1776
1777    // Load keyring
1778    let keyring_source;
1779    let keyring = if env_keys {
1780        // --env-keys: scan ATI_KEY_* environment variables
1781        let kr = Keyring::from_env();
1782        let key_names = kr.key_names();
1783        tracing::info!(
1784            count = key_names.len(),
1785            "loaded API keys from ATI_KEY_* env vars"
1786        );
1787        for name in &key_names {
1788            tracing::debug!(key = %name, "env key loaded");
1789        }
1790        keyring_source = "env-vars (ATI_KEY_*)";
1791        kr
1792    } else {
1793        // Cascade: keyring.enc (sealed) → keyring.enc (persistent) → credentials → empty
1794        let keyring_path = ati_dir.join("keyring.enc");
1795        if keyring_path.exists() {
1796            if let Ok(kr) = Keyring::load(&keyring_path) {
1797                keyring_source = "keyring.enc (sealed key)";
1798                kr
1799            } else if let Ok(kr) = Keyring::load_local(&keyring_path, &ati_dir) {
1800                keyring_source = "keyring.enc (persistent key)";
1801                kr
1802            } else {
1803                tracing::warn!("keyring.enc exists but could not be decrypted");
1804                keyring_source = "empty (decryption failed)";
1805                Keyring::empty()
1806            }
1807        } else {
1808            let creds_path = ati_dir.join("credentials");
1809            if creds_path.exists() {
1810                match Keyring::load_credentials(&creds_path) {
1811                    Ok(kr) => {
1812                        keyring_source = "credentials (plaintext)";
1813                        kr
1814                    }
1815                    Err(e) => {
1816                        tracing::warn!(error = %e, "failed to load credentials");
1817                        keyring_source = "empty (credentials error)";
1818                        Keyring::empty()
1819                    }
1820                }
1821            } else {
1822                tracing::warn!("no keyring.enc or credentials found — running without API keys");
1823                tracing::warn!("tools requiring authentication will fail");
1824                keyring_source = "empty (no auth)";
1825                Keyring::empty()
1826            }
1827        }
1828    };
1829
1830    // Discover MCP tools at startup so they appear in GET /tools.
1831    // Runs concurrently across providers with 30s per-provider timeout.
1832    mcp_client::discover_all_mcp_tools(&mut registry, &keyring).await;
1833
1834    let tool_count = registry.list_public_tools().len();
1835
1836    // Log MCP and OpenAPI providers
1837    let mcp_providers: Vec<(String, String)> = registry
1838        .list_mcp_providers()
1839        .iter()
1840        .map(|p| (p.name.clone(), p.mcp_transport_type().to_string()))
1841        .collect();
1842    let mcp_count = mcp_providers.len();
1843    let openapi_providers: Vec<String> = registry
1844        .list_openapi_providers()
1845        .iter()
1846        .map(|p| p.name.clone())
1847        .collect();
1848    let openapi_count = openapi_providers.len();
1849
1850    // Load installed/local skill registry only.
1851    let skills_dir = ati_dir.join("skills");
1852    let skill_registry = SkillRegistry::load(&skills_dir).unwrap_or_else(|e| {
1853        tracing::warn!(error = %e, "failed to load skills");
1854        SkillRegistry::load(std::path::Path::new("/nonexistent-fallback")).unwrap()
1855    });
1856
1857    if let Ok(registry_url) = std::env::var("ATI_SKILL_REGISTRY") {
1858        if registry_url.strip_prefix("gcs://").is_some() {
1859            tracing::info!(
1860                registry = %registry_url,
1861                "SkillATI remote registry configured for lazy reads"
1862            );
1863        } else {
1864            tracing::warn!(url = %registry_url, "SkillATI only supports gcs:// registries");
1865        }
1866    }
1867
1868    let skill_count = skill_registry.skill_count();
1869
1870    // Load JWT config from environment
1871    let jwt_config = match jwt::config_from_env() {
1872        Ok(config) => config,
1873        Err(e) => {
1874            tracing::warn!(error = %e, "JWT config error");
1875            None
1876        }
1877    };
1878
1879    let auth_status = if jwt_config.is_some() {
1880        "JWT enabled"
1881    } else {
1882        "DISABLED (no JWT keys configured)"
1883    };
1884
1885    // Build JWKS for the endpoint
1886    let jwks_json = jwt_config.as_ref().and_then(|config| {
1887        config
1888            .public_key_pem
1889            .as_ref()
1890            .and_then(|pem| jwt::public_key_to_jwks(pem, config.algorithm, "ati-proxy-1").ok())
1891    });
1892
1893    let state = Arc::new(ProxyState {
1894        registry,
1895        skill_registry,
1896        keyring,
1897        jwt_config,
1898        jwks_json,
1899        auth_cache: AuthCache::new(),
1900    });
1901
1902    let app = build_router(state);
1903
1904    let addr: SocketAddr = if let Some(ref bind) = bind_addr {
1905        format!("{bind}:{port}").parse()?
1906    } else {
1907        SocketAddr::from(([127, 0, 0, 1], port))
1908    };
1909
1910    tracing::info!(
1911        version = env!("CARGO_PKG_VERSION"),
1912        %addr,
1913        auth = auth_status,
1914        ati_dir = %ati_dir.display(),
1915        tools = tool_count,
1916        providers = provider_count,
1917        mcp = mcp_count,
1918        openapi = openapi_count,
1919        skills = skill_count,
1920        keyring = keyring_source,
1921        "ATI proxy server starting"
1922    );
1923    for (name, transport) in &mcp_providers {
1924        tracing::info!(provider = %name, transport = %transport, "MCP provider");
1925    }
1926    for name in &openapi_providers {
1927        tracing::info!(provider = %name, "OpenAPI provider");
1928    }
1929
1930    let listener = tokio::net::TcpListener::bind(addr).await?;
1931    axum::serve(listener, app).await?;
1932
1933    Ok(())
1934}
1935
1936/// Write an audit entry from the proxy server. Failures are silently ignored.
1937fn write_proxy_audit(
1938    call_req: &CallRequest,
1939    agent_sub: &str,
1940    claims: Option<&TokenClaims>,
1941    duration: std::time::Duration,
1942    error: Option<&str>,
1943) {
1944    let entry = crate::core::audit::AuditEntry {
1945        ts: chrono::Utc::now().to_rfc3339(),
1946        tool: call_req.tool_name.clone(),
1947        args: crate::core::audit::sanitize_args(&call_req.args),
1948        status: if error.is_some() {
1949            crate::core::audit::AuditStatus::Error
1950        } else {
1951            crate::core::audit::AuditStatus::Ok
1952        },
1953        duration_ms: duration.as_millis() as u64,
1954        agent_sub: agent_sub.to_string(),
1955        job_id: claims.and_then(|c| c.job_id.clone()),
1956        sandbox_id: claims.and_then(|c| c.sandbox_id.clone()),
1957        error: error.map(|s| s.to_string()),
1958        exit_code: None,
1959    };
1960    let _ = crate::core::audit::append(&entry);
1961}
1962
1963// --- Helpers ---
1964
1965const HELP_SYSTEM_PROMPT: &str = r#"You are a helpful assistant for an AI agent that uses external tools via the `ati` CLI.
1966
1967## Available Tools
1968{tools}
1969
1970{skills_section}
1971
1972Answer the agent's question naturally, like a knowledgeable colleague would. Keep it short but useful:
1973
1974- Explain which tools to use and why, with `ati run` commands showing realistic parameter values
1975- If multiple steps are needed, walk through them briefly in order
1976- Mention important gotchas or parameter choices that matter
1977- If skills are relevant, suggest `ati skill show <name>` for the full methodology
1978
1979Keep your answer concise — a few short paragraphs with embedded code blocks. Only recommend tools from the list above."#;
1980
1981async fn build_remote_skillati_section(keyring: &Keyring, query: &str, limit: usize) -> String {
1982    let client = match SkillAtiClient::from_env(keyring) {
1983        Ok(Some(client)) => client,
1984        Ok(None) => return String::new(),
1985        Err(err) => {
1986            tracing::warn!(error = %err, "failed to initialize SkillATI catalog for proxy help");
1987            return String::new();
1988        }
1989    };
1990
1991    let catalog = match client.catalog().await {
1992        Ok(catalog) => catalog,
1993        Err(err) => {
1994            tracing::warn!(error = %err, "failed to load SkillATI catalog for proxy help");
1995            return String::new();
1996        }
1997    };
1998
1999    let matched = SkillAtiClient::filter_catalog(&catalog, query, limit);
2000    if matched.is_empty() {
2001        return String::new();
2002    }
2003
2004    render_remote_skillati_section(&matched, catalog.len())
2005}
2006
2007fn render_remote_skillati_section(skills: &[RemoteSkillMeta], total_catalog: usize) -> String {
2008    let mut section = String::from("## Remote Skills Available Via SkillATI\n\n");
2009    section.push_str(
2010        "These skills are available remotely from the SkillATI registry. They are not installed locally. Activate one on demand with `ati skillati read <name>`, inspect bundled paths with `ati skillati resources <name>`, and fetch specific files with `ati skillati cat <name> <path>`.\n\n",
2011    );
2012
2013    for skill in skills {
2014        section.push_str(&format!("- **{}**: {}\n", skill.name, skill.description));
2015    }
2016
2017    if total_catalog > skills.len() {
2018        section.push_str(&format!(
2019            "\nOnly the most relevant {} remote skills are shown here.\n",
2020            skills.len()
2021        ));
2022    }
2023
2024    section
2025}
2026
2027fn merge_help_skill_sections(sections: &[String]) -> String {
2028    sections
2029        .iter()
2030        .filter_map(|section| {
2031            let trimmed = section.trim();
2032            if trimmed.is_empty() {
2033                None
2034            } else {
2035                Some(trimmed.to_string())
2036            }
2037        })
2038        .collect::<Vec<_>>()
2039        .join("\n\n")
2040}
2041
2042fn build_tool_context(
2043    tools: &[(
2044        &crate::core::manifest::Provider,
2045        &crate::core::manifest::Tool,
2046    )],
2047) -> String {
2048    let mut summaries = Vec::new();
2049    for (provider, tool) in tools {
2050        let mut summary = if let Some(cat) = &provider.category {
2051            format!(
2052                "- **{}** (provider: {}, category: {}): {}",
2053                tool.name, provider.name, cat, tool.description
2054            )
2055        } else {
2056            format!(
2057                "- **{}** (provider: {}): {}",
2058                tool.name, provider.name, tool.description
2059            )
2060        };
2061        if !tool.tags.is_empty() {
2062            summary.push_str(&format!("\n  Tags: {}", tool.tags.join(", ")));
2063        }
2064        // CLI tools: show passthrough usage
2065        if provider.is_cli() && tool.input_schema.is_none() {
2066            let cmd = provider.cli_command.as_deref().unwrap_or("?");
2067            summary.push_str(&format!(
2068                "\n  Usage: `ati run {} -- <args>`  (passthrough to `{}`)",
2069                tool.name, cmd
2070            ));
2071        } else if let Some(schema) = &tool.input_schema {
2072            if let Some(props) = schema.get("properties") {
2073                if let Some(obj) = props.as_object() {
2074                    let params: Vec<String> = obj
2075                        .iter()
2076                        .filter(|(_, v)| {
2077                            v.get("x-ati-param-location").is_none()
2078                                || v.get("description").is_some()
2079                        })
2080                        .map(|(k, v)| {
2081                            let type_str =
2082                                v.get("type").and_then(|t| t.as_str()).unwrap_or("string");
2083                            let desc = v.get("description").and_then(|d| d.as_str()).unwrap_or("");
2084                            format!("    --{k} ({type_str}): {desc}")
2085                        })
2086                        .collect();
2087                    if !params.is_empty() {
2088                        summary.push_str("\n  Parameters:\n");
2089                        summary.push_str(&params.join("\n"));
2090                    }
2091                }
2092            }
2093        }
2094        summaries.push(summary);
2095    }
2096    summaries.join("\n\n")
2097}
2098
2099/// Build a scoped system prompt for a specific tool or provider.
2100///
2101/// Returns None if the scope_name doesn't match any tool or provider.
2102fn build_scoped_prompt(
2103    scope_name: &str,
2104    visible_tools: &[(&Provider, &Tool)],
2105    skills_section: &str,
2106) -> Option<String> {
2107    // Check if scope_name is a tool
2108    if let Some((provider, tool)) = visible_tools
2109        .iter()
2110        .find(|(_, tool)| tool.name == scope_name)
2111    {
2112        let mut details = format!(
2113            "**Name**: `{}`\n**Provider**: {} (handler: {})\n**Description**: {}\n",
2114            tool.name, provider.name, provider.handler, tool.description
2115        );
2116        if let Some(cat) = &provider.category {
2117            details.push_str(&format!("**Category**: {}\n", cat));
2118        }
2119        if provider.is_cli() {
2120            let cmd = provider.cli_command.as_deref().unwrap_or("?");
2121            details.push_str(&format!(
2122                "\n**Usage**: `ati run {} -- <args>`  (passthrough to `{}`)\n",
2123                tool.name, cmd
2124            ));
2125        } else if let Some(schema) = &tool.input_schema {
2126            if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
2127                let required: Vec<String> = schema
2128                    .get("required")
2129                    .and_then(|r| r.as_array())
2130                    .map(|arr| {
2131                        arr.iter()
2132                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
2133                            .collect()
2134                    })
2135                    .unwrap_or_default();
2136                details.push_str("\n**Parameters**:\n");
2137                for (key, val) in props {
2138                    let type_str = val.get("type").and_then(|t| t.as_str()).unwrap_or("string");
2139                    let desc = val
2140                        .get("description")
2141                        .and_then(|d| d.as_str())
2142                        .unwrap_or("");
2143                    let req = if required.contains(key) {
2144                        " **(required)**"
2145                    } else {
2146                        ""
2147                    };
2148                    details.push_str(&format!("- `--{key}` ({type_str}{req}): {desc}\n"));
2149                }
2150            }
2151        }
2152
2153        let prompt = format!(
2154            "You are an expert assistant for the `{}` tool, accessed via the `ati` CLI.\n\n\
2155            ## Tool Details\n{}\n\n{}\n\n\
2156            Answer the agent's question about this specific tool. Provide exact commands, explain flags and options, and give practical examples. Be concise and actionable.",
2157            tool.name, details, skills_section
2158        );
2159        return Some(prompt);
2160    }
2161
2162    // Check if scope_name is a provider
2163    let tools: Vec<(&Provider, &Tool)> = visible_tools
2164        .iter()
2165        .copied()
2166        .filter(|(provider, _)| provider.name == scope_name)
2167        .collect();
2168    if !tools.is_empty() {
2169        let tools_context = build_tool_context(&tools);
2170        let prompt = format!(
2171            "You are an expert assistant for the `{}` provider's tools, accessed via the `ati` CLI.\n\n\
2172            ## Tools in provider `{}`\n{}\n\n{}\n\n\
2173            Answer the agent's question about these tools. Provide exact `ati run` commands, explain parameters, and give practical examples. Be concise and actionable.",
2174            scope_name, scope_name, tools_context, skills_section
2175        );
2176        return Some(prompt);
2177    }
2178
2179    None
2180}