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