Skip to main content

ati/core/
manifest.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::Path;
5use thiserror::Error;
6
7/// Separator between provider name and tool name in compound tool identifiers.
8/// Example: `"finnhub:quote"`, `"github:search_repositories"`.
9pub const TOOL_SEP: char = ':';
10pub const TOOL_SEP_STR: &str = ":";
11
12#[derive(Error, Debug)]
13pub enum ManifestError {
14    #[error("Failed to read manifest file {0}: {1}")]
15    Io(String, std::io::Error),
16    #[error("Failed to parse manifest {0}: {1}")]
17    Parse(String, toml::de::Error),
18    #[error("No manifests directory found at {0}")]
19    NoDirectory(String),
20    #[error("Manifest {0} is invalid: {1}")]
21    Invalid(String, String),
22}
23
24#[derive(Debug, Clone, Deserialize)]
25#[serde(rename_all = "snake_case")]
26#[derive(Default)]
27pub enum AuthType {
28    Bearer,
29    Header,
30    Query,
31    Basic,
32    #[default]
33    None,
34    Oauth2,
35    /// API key is embedded in the URL path via `${key_name}` placeholder.
36    /// No auth header is sent — the key is resolved from the keyring and
37    /// interpolated into the URL at connection time by `resolve_env_value`.
38    /// Example: `mcp_url = "https://mcp.serpapi.com/${serpapi_api_key}/mcp"`
39    Url,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43pub struct Provider {
44    pub name: String,
45    pub description: String,
46    /// Base URL for HTTP providers. Optional for MCP providers.
47    #[serde(default)]
48    pub base_url: String,
49    #[serde(default)]
50    pub auth_type: AuthType,
51    #[serde(default)]
52    pub auth_key_name: Option<String>,
53    /// Custom header name for auth_type = "header" (default: "X-Api-Key").
54    /// Examples: "X-Finnhub-Token", "X-API-KEY", "Authorization"
55    #[serde(default)]
56    pub auth_header_name: Option<String>,
57    /// Custom query parameter name for auth_type = "query" (default: "api_key").
58    #[serde(default)]
59    pub auth_query_name: Option<String>,
60    /// Optional prefix for auth header value (e.g. "Token ", "Basic ").
61    /// Used with auth_type = "header". Value becomes: "{prefix}{key}".
62    #[serde(default)]
63    pub auth_value_prefix: Option<String>,
64    /// Additional headers to include on every request for this provider.
65    /// Examples: X-Goog-FieldMask, X-EBAY-C-MARKETPLACE-ID
66    #[serde(default)]
67    pub extra_headers: HashMap<String, String>,
68    /// Token URL for OAuth2 (relative to base_url or absolute)
69    #[serde(default)]
70    pub oauth2_token_url: Option<String>,
71    /// Second key name for OAuth2 client_secret
72    #[serde(default)]
73    pub auth_secret_name: Option<String>,
74    /// If true, send OAuth2 credentials via Basic Auth header instead of form body.
75    /// Some providers (e.g. Sovos) require this per RFC 6749 §2.3.1.
76    #[serde(default)]
77    pub oauth2_basic_auth: bool,
78    #[serde(default)]
79    pub internal: bool,
80    #[serde(default = "default_handler")]
81    pub handler: String,
82
83    // --- MCP provider fields (handler = "mcp") ---
84    /// MCP transport type: "stdio" or "http"
85    #[serde(default)]
86    pub mcp_transport: Option<String>,
87    /// Command to launch stdio MCP server (e.g., "npx", "uvx")
88    #[serde(default)]
89    pub mcp_command: Option<String>,
90    /// Arguments for stdio command (e.g., ["-y", "@modelcontextprotocol/server-github"])
91    #[serde(default)]
92    pub mcp_args: Vec<String>,
93    /// URL for HTTP/Streamable HTTP MCP server
94    #[serde(default)]
95    pub mcp_url: Option<String>,
96    /// Environment variables to pass to stdio subprocess
97    #[serde(default)]
98    pub mcp_env: HashMap<String, String>,
99
100    // --- CLI provider fields (handler = "cli") ---
101    /// Command to run for CLI providers (e.g., "gsutil", "gh", "kubectl")
102    #[serde(default)]
103    pub cli_command: Option<String>,
104    /// Default args prepended to every invocation
105    #[serde(default)]
106    pub cli_default_args: Vec<String>,
107    /// Environment variables for CLI. ${key} = string from keyring, @{key} = credential file
108    #[serde(default)]
109    pub cli_env: HashMap<String, String>,
110    /// Default timeout in seconds (default: 120)
111    #[serde(default)]
112    pub cli_timeout_secs: Option<u64>,
113    /// Named flags whose value is an output file path the proxy must capture.
114    /// Example: `["--output", "-o", "--out"]`. When the agent passes one of these
115    /// flags + a value, the proxy substitutes a temp path, runs the CLI, then
116    /// reads the file back and base64s it into the response. The sandbox-side
117    /// CLI writes those bytes to the original path the agent specified.
118    #[serde(default)]
119    pub cli_output_args: Vec<String>,
120    /// Subcommand prefix → 0-based positional argument index that designates
121    /// an output file path. Example: `{"browse screenshot": 0}` matches
122    /// `bb browse screenshot /tmp/x.png` — arg 0 of the remaining positional
123    /// args (after the matched prefix) is the output path.
124    #[serde(default)]
125    pub cli_output_positional: HashMap<String, usize>,
126
127    // --- file_manager provider fields (handler = "file_manager") ---
128    /// Operator-declared allowlist of upload destinations. Each key is a
129    /// short name agents can pass via `--destination <key>`; the value is a
130    /// typed sink (GCS bucket, fal storage). Anything not in this map is
131    /// refused. **An empty map disables uploads entirely.**
132    #[serde(default)]
133    pub upload_destinations: HashMap<String, crate::core::file_manager::UploadDestination>,
134    /// Destination key used when the agent omits `--destination`. Must be
135    /// present in `upload_destinations` (validated at load time).
136    #[serde(default)]
137    pub upload_default_destination: Option<String>,
138
139    // --- OpenAPI provider fields (handler = "openapi") ---
140    /// Path (relative to ~/.ati/specs/) or URL to OpenAPI spec (JSON or YAML)
141    #[serde(default)]
142    pub openapi_spec: Option<String>,
143    /// Only include operations with these tags
144    #[serde(default)]
145    pub openapi_include_tags: Vec<String>,
146    /// Exclude operations with these tags
147    #[serde(default)]
148    pub openapi_exclude_tags: Vec<String>,
149    /// Only include operations with these operationIds
150    #[serde(default)]
151    pub openapi_include_operations: Vec<String>,
152    /// Exclude operations with these operationIds
153    #[serde(default)]
154    pub openapi_exclude_operations: Vec<String>,
155    /// Maximum number of operations to register (for huge APIs)
156    #[serde(default)]
157    pub openapi_max_operations: Option<usize>,
158    /// Per-operationId overrides (hint, tags, description, response_extract, etc.)
159    #[serde(default)]
160    pub openapi_overrides: HashMap<String, OpenApiToolOverride>,
161
162    // --- Auth generator (dynamic credential generation) ---
163    /// Optional auth generator for producing short-lived credentials at call time.
164    /// Runs where secrets live (proxy server in proxy mode, local machine in local mode).
165    #[serde(default)]
166    pub auth_generator: Option<AuthGenerator>,
167
168    // --- Optional metadata fields ---
169    /// Provider category for discovery (e.g., "finance", "search", "social")
170    #[serde(default)]
171    pub category: Option<String>,
172
173    /// Associated skill names that teach agents how to use this provider's tools.
174    /// Resolved from the SkillRegistry (installed skills or GCS registry).
175    #[serde(default)]
176    pub skills: Vec<String>,
177}
178
179fn default_handler() -> String {
180    "http".to_string()
181}
182
183/// Per-operationId overrides for OpenAPI-discovered tools.
184#[derive(Debug, Clone, Deserialize, Default)]
185pub struct OpenApiToolOverride {
186    pub hint: Option<String>,
187    #[serde(default)]
188    pub tags: Vec<String>,
189    #[serde(default)]
190    pub examples: Vec<String>,
191    pub description: Option<String>,
192    pub scope: Option<String>,
193    pub response_extract: Option<String>,
194    pub response_format: Option<String>,
195}
196
197/// Dynamic auth generator configuration — produces short-lived credentials at call time.
198///
199/// Two types:
200/// - `command`: runs an external command, captures stdout as the credential
201/// - `script`: writes an inline script to a temp file and runs it via an interpreter
202///
203/// Variable expansion in `args` and `env` values:
204/// - `${key_name}` → keyring lookup
205/// - `${JWT_SUB}` → agent's JWT `sub` claim
206/// - `${JWT_SCOPE}` → agent's JWT `scope` claim
207/// - `${TOOL_NAME}` → tool being invoked
208/// - `${TIMESTAMP}` → current unix timestamp
209#[derive(Debug, Clone, Deserialize)]
210pub struct AuthGenerator {
211    #[serde(rename = "type")]
212    pub gen_type: AuthGenType,
213    /// Command to run (for `type = "command"`)
214    pub command: Option<String>,
215    /// Arguments for the command
216    #[serde(default)]
217    pub args: Vec<String>,
218    /// Interpreter for inline script (for `type = "script"`, e.g. "python3")
219    pub interpreter: Option<String>,
220    /// Inline script body (for `type = "script"`)
221    pub script: Option<String>,
222    /// TTL for cached credentials (0 = no cache)
223    #[serde(default)]
224    pub cache_ttl_secs: u64,
225    /// Output format: "text" (trimmed stdout) or "json" (parsed, fields extracted via `inject`)
226    #[serde(default)]
227    pub output_format: AuthOutputFormat,
228    /// Environment variables for the subprocess (values support `${key}` expansion)
229    #[serde(default)]
230    pub env: HashMap<String, String>,
231    /// For JSON output: map dot-notation JSON paths to injection targets
232    #[serde(default)]
233    pub inject: HashMap<String, InjectTarget>,
234    /// Subprocess timeout in seconds (default: 30)
235    #[serde(default = "default_gen_timeout")]
236    pub timeout_secs: u64,
237}
238
239fn default_gen_timeout() -> u64 {
240    30
241}
242
243#[derive(Debug, Clone, Deserialize)]
244#[serde(rename_all = "snake_case")]
245pub enum AuthGenType {
246    Command,
247    Script,
248}
249
250#[derive(Debug, Clone, Deserialize, Default)]
251#[serde(rename_all = "snake_case")]
252pub enum AuthOutputFormat {
253    #[default]
254    Text,
255    Json,
256}
257
258/// Target for injecting a JSON-extracted credential value.
259#[derive(Debug, Clone, Deserialize)]
260pub struct InjectTarget {
261    /// Where to inject: "header", "env", or "query"
262    #[serde(rename = "type")]
263    pub inject_type: String,
264    /// Name of the header/env var/query param
265    pub name: String,
266}
267
268#[derive(Debug, Clone, Deserialize)]
269#[serde(rename_all = "UPPERCASE")]
270#[derive(Default)]
271pub enum HttpMethod {
272    #[serde(alias = "get", alias = "Get")]
273    #[default]
274    Get,
275    #[serde(alias = "post", alias = "Post")]
276    Post,
277    #[serde(alias = "put", alias = "Put")]
278    Put,
279    #[serde(alias = "delete", alias = "Delete")]
280    Delete,
281}
282
283impl std::fmt::Display for HttpMethod {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        match self {
286            HttpMethod::Get => write!(f, "GET"),
287            HttpMethod::Post => write!(f, "POST"),
288            HttpMethod::Put => write!(f, "PUT"),
289            HttpMethod::Delete => write!(f, "DELETE"),
290        }
291    }
292}
293
294#[derive(Debug, Clone, Deserialize, Default)]
295#[serde(rename_all = "snake_case")]
296pub enum ResponseFormat {
297    MarkdownTable,
298    Json,
299    #[default]
300    Text,
301    Raw,
302}
303
304#[derive(Debug, Clone, Deserialize, Default)]
305pub struct ResponseConfig {
306    /// JSONPath expression to extract useful content from the API response
307    #[serde(default)]
308    pub extract: Option<String>,
309    /// Output format for the extracted data
310    #[serde(default)]
311    pub format: ResponseFormat,
312}
313
314#[derive(Debug, Clone, Deserialize)]
315pub struct Tool {
316    pub name: String,
317    pub description: String,
318    #[serde(default)]
319    pub endpoint: String,
320    #[serde(default)]
321    pub method: HttpMethod,
322    /// Scope required to use this tool (e.g. "tool:web_search")
323    #[serde(default)]
324    pub scope: Option<String>,
325    /// JSON Schema for tool input
326    #[serde(default)]
327    pub input_schema: Option<serde_json::Value>,
328    /// Response extraction config
329    #[serde(default)]
330    pub response: Option<ResponseConfig>,
331
332    // --- Optional metadata fields ---
333    /// Tags for discovery (e.g., ["search", "real-time"])
334    #[serde(default)]
335    pub tags: Vec<String>,
336    /// Short hint for the LLM on when to use this tool
337    #[serde(default)]
338    pub hint: Option<String>,
339    /// Example invocations
340    #[serde(default)]
341    pub examples: Vec<String>,
342}
343
344/// A parsed manifest file: one provider + multiple tools.
345/// For MCP providers, tools may be empty — they're discovered dynamically via tools/list.
346#[derive(Debug, Clone, Deserialize)]
347pub struct Manifest {
348    pub provider: Provider,
349    #[serde(default, rename = "tools")]
350    pub tools: Vec<Tool>,
351}
352
353/// A cached (ephemeral) provider, persisted as JSON in `$ATI_DIR/cache/providers/<name>.json`.
354/// Used by `ati provider load` to make providers available across process invocations
355/// without writing permanent TOML manifests.
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct CachedProvider {
358    pub name: String,
359    /// "openapi" or "mcp"
360    pub provider_type: String,
361    #[serde(default)]
362    pub base_url: String,
363    #[serde(default)]
364    pub auth_type: String,
365    #[serde(default)]
366    pub auth_key_name: Option<String>,
367    #[serde(default)]
368    pub auth_header_name: Option<String>,
369    #[serde(default)]
370    pub auth_query_name: Option<String>,
371    // OpenAPI fields
372    #[serde(default)]
373    pub spec_content: Option<String>,
374    // MCP fields
375    #[serde(default)]
376    pub mcp_transport: Option<String>,
377    #[serde(default)]
378    pub mcp_url: Option<String>,
379    #[serde(default)]
380    pub mcp_command: Option<String>,
381    #[serde(default)]
382    pub mcp_args: Vec<String>,
383    #[serde(default)]
384    pub mcp_env: HashMap<String, String>,
385    // CLI fields
386    #[serde(default)]
387    pub cli_command: Option<String>,
388    #[serde(default)]
389    pub cli_default_args: Vec<String>,
390    #[serde(default)]
391    pub cli_env: HashMap<String, String>,
392    #[serde(default)]
393    pub cli_timeout_secs: Option<u64>,
394    // MCP/HTTP auth
395    #[serde(default)]
396    pub auth: Option<String>,
397    // Skills
398    #[serde(default)]
399    pub skills: Vec<String>,
400    // Cache metadata
401    pub created_at: String,
402    pub ttl_seconds: u64,
403}
404
405impl CachedProvider {
406    /// Returns true if this cached provider has expired.
407    pub fn is_expired(&self) -> bool {
408        let created = match DateTime::parse_from_rfc3339(&self.created_at) {
409            Ok(dt) => dt.with_timezone(&Utc),
410            Err(_) => return true, // Can't parse → treat as expired
411        };
412        let now = Utc::now();
413        let elapsed = now.signed_duration_since(created);
414        elapsed.num_seconds() as u64 > self.ttl_seconds
415    }
416
417    /// Returns the expiry time as an ISO timestamp.
418    pub fn expires_at(&self) -> Option<String> {
419        let created = DateTime::parse_from_rfc3339(&self.created_at).ok()?;
420        let expires = created + chrono::Duration::seconds(self.ttl_seconds as i64);
421        Some(expires.to_rfc3339())
422    }
423
424    /// Returns remaining TTL in seconds (0 if expired).
425    pub fn remaining_seconds(&self) -> u64 {
426        let created = match DateTime::parse_from_rfc3339(&self.created_at) {
427            Ok(dt) => dt.with_timezone(&Utc),
428            Err(_) => return 0,
429        };
430        let now = Utc::now();
431        let elapsed = now.signed_duration_since(created).num_seconds() as u64;
432        self.ttl_seconds.saturating_sub(elapsed)
433    }
434
435    /// Build a Provider struct from this cached entry.
436    pub fn to_provider(&self) -> Provider {
437        let auth_type = match self.auth_type.as_str() {
438            "bearer" => AuthType::Bearer,
439            "header" => AuthType::Header,
440            "query" => AuthType::Query,
441            "basic" => AuthType::Basic,
442            "oauth2" => AuthType::Oauth2,
443            _ => AuthType::None,
444        };
445
446        let handler = match self.provider_type.as_str() {
447            "mcp" => "mcp".to_string(),
448            "openapi" => "openapi".to_string(),
449            _ => "http".to_string(),
450        };
451
452        Provider {
453            name: self.name.clone(),
454            description: format!("{} (cached)", self.name),
455            base_url: self.base_url.clone(),
456            auth_type,
457            auth_key_name: self.auth_key_name.clone(),
458            auth_header_name: self.auth_header_name.clone(),
459            auth_query_name: self.auth_query_name.clone(),
460            auth_value_prefix: None,
461            extra_headers: HashMap::new(),
462            oauth2_token_url: None,
463            auth_secret_name: None,
464            oauth2_basic_auth: false,
465            internal: false,
466            handler,
467            mcp_transport: self.mcp_transport.clone(),
468            mcp_command: self.mcp_command.clone(),
469            mcp_args: self.mcp_args.clone(),
470            mcp_url: self.mcp_url.clone(),
471            mcp_env: self.mcp_env.clone(),
472            openapi_spec: None,
473            openapi_include_tags: Vec::new(),
474            openapi_exclude_tags: Vec::new(),
475            openapi_include_operations: Vec::new(),
476            openapi_exclude_operations: Vec::new(),
477            openapi_max_operations: None,
478            openapi_overrides: HashMap::new(),
479            cli_command: self.cli_command.clone(),
480            cli_default_args: self.cli_default_args.clone(),
481            cli_env: self.cli_env.clone(),
482            cli_timeout_secs: self.cli_timeout_secs,
483            cli_output_args: Vec::new(),
484            cli_output_positional: HashMap::new(),
485            upload_destinations: HashMap::new(),
486            upload_default_destination: None,
487            auth_generator: None,
488            category: None,
489            skills: self.skills.clone(),
490        }
491    }
492}
493
494/// A tool discovered from an MCP server via tools/list.
495/// Converted into a Tool for the registry.
496#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct McpToolDef {
498    pub name: String,
499    #[serde(default)]
500    pub description: Option<String>,
501    #[serde(default, rename = "inputSchema")]
502    pub input_schema: Option<serde_json::Value>,
503}
504
505/// Registry holding all loaded manifests, with indexes for fast lookup.
506pub struct ManifestRegistry {
507    manifests: Vec<Manifest>,
508    /// tool_name -> (manifest_index, tool_index)
509    tool_index: HashMap<String, (usize, usize)>,
510}
511
512impl ManifestRegistry {
513    /// Load all .toml manifests from a directory.
514    /// OpenAPI providers (handler = "openapi") have their specs loaded and tools auto-registered.
515    pub fn load(dir: &Path) -> Result<Self, ManifestError> {
516        if !dir.is_dir() {
517            return Err(ManifestError::NoDirectory(dir.display().to_string()));
518        }
519
520        let mut manifests = Vec::new();
521        let mut tool_index = HashMap::new();
522
523        let pattern = dir.join("*.toml");
524        let entries = glob::glob(pattern.to_str().unwrap_or(""))
525            .map_err(|e| ManifestError::NoDirectory(e.to_string()))?;
526
527        // Resolve specs dir: sibling of manifests dir (e.g., ~/.ati/specs/)
528        let specs_dir = dir.parent().map(|p| p.join("specs"));
529
530        for entry in entries {
531            let path = entry.map_err(|e| {
532                ManifestError::Io(format!("{e}"), std::io::Error::other("glob error"))
533            })?;
534            let contents = std::fs::read_to_string(&path)
535                .map_err(|e| ManifestError::Io(path.display().to_string(), e))?;
536            let mut manifest: Manifest = toml::from_str(&contents)
537                .map_err(|e| ManifestError::Parse(path.display().to_string(), e))?;
538
539            // For OpenAPI providers, load spec and register tools
540            if manifest.provider.is_openapi() {
541                if let Some(spec_ref) = &manifest.provider.openapi_spec {
542                    match crate::core::openapi::load_and_register(
543                        &manifest.provider,
544                        spec_ref,
545                        specs_dir.as_deref(),
546                    ) {
547                        Ok(tools) => {
548                            manifest.tools = tools;
549                        }
550                        Err(e) => {
551                            tracing::warn!(
552                                provider = %manifest.provider.name,
553                                error = %e,
554                                "failed to load OpenAPI spec for provider"
555                            );
556                            // Graceful degradation — continue without tools
557                        }
558                    }
559                }
560            }
561
562            // For file_manager providers, validate that any declared default
563            // destination is actually present in the allowlist. Refuse to load
564            // an inconsistent manifest rather than silently coercing it.
565            if manifest.provider.handler == "file_manager" {
566                if let Some(ref default) = manifest.provider.upload_default_destination {
567                    if !manifest.provider.upload_destinations.contains_key(default) {
568                        return Err(ManifestError::Invalid(
569                            path.display().to_string(),
570                            format!(
571                                "upload_default_destination '{default}' is not present in [provider.upload_destinations]"
572                            ),
573                        ));
574                    }
575                }
576            }
577
578            // For CLI providers with no [[tools]], auto-register one implicit tool
579            if manifest.provider.is_cli() && manifest.tools.is_empty() {
580                let tool_name = manifest.provider.name.clone();
581                manifest.tools.push(Tool {
582                    name: tool_name.clone(),
583                    description: manifest.provider.description.clone(),
584                    endpoint: String::new(),
585                    method: HttpMethod::Get,
586                    scope: Some(format!("tool:{tool_name}")),
587                    input_schema: None,
588                    response: None,
589                    tags: Vec::new(),
590                    hint: None,
591                    examples: Vec::new(),
592                });
593            }
594
595            // Auto-assign scope to tools that don't have one explicitly set.
596            // This ensures all tools participate in JWT scope filtering.
597            let provider_name = &manifest.provider.name;
598            for tool in &mut manifest.tools {
599                if tool.scope.is_none() && !manifest.provider.internal {
600                    tool.scope = Some(format!("tool:{}", tool.name));
601                    tracing::trace!(
602                        tool = %tool.name,
603                        provider = %provider_name,
604                        scope = ?tool.scope,
605                        "auto-assigned scope to tool"
606                    );
607                }
608            }
609
610            let mi = manifests.len();
611            for (ti, tool) in manifest.tools.iter().enumerate() {
612                tool_index.insert(tool.name.clone(), (mi, ti));
613            }
614            manifests.push(manifest);
615        }
616
617        // Load cached providers from cache/providers/*.json
618        // Cache dir is sibling of manifests dir: e.g., ~/.ati/cache/providers/
619        if let Some(parent) = dir.parent() {
620            let cache_dir = parent.join("cache").join("providers");
621            if cache_dir.is_dir() {
622                let cache_pattern = cache_dir.join("*.json");
623                if let Ok(cache_entries) = glob::glob(cache_pattern.to_str().unwrap_or("")) {
624                    for entry in cache_entries {
625                        let path = match entry {
626                            Ok(p) => p,
627                            Err(_) => continue,
628                        };
629                        let content = match std::fs::read_to_string(&path) {
630                            Ok(c) => c,
631                            Err(_) => continue,
632                        };
633                        let cached: CachedProvider = match serde_json::from_str(&content) {
634                            Ok(c) => c,
635                            Err(_) => continue,
636                        };
637
638                        // Skip and delete expired entries
639                        if cached.is_expired() {
640                            let _ = std::fs::remove_file(&path);
641                            continue;
642                        }
643
644                        // Skip if a permanent manifest with same provider name already exists
645                        if manifests.iter().any(|m| m.provider.name == cached.name) {
646                            continue;
647                        }
648
649                        let provider = cached.to_provider();
650
651                        let mut cached_tools = Vec::new();
652                        if cached.provider_type == "openapi" {
653                            if let Some(spec_content) = &cached.spec_content {
654                                if let Ok(spec) = crate::core::openapi::parse_spec(spec_content) {
655                                    let filters = crate::core::openapi::OpenApiFilters {
656                                        include_tags: vec![],
657                                        exclude_tags: vec![],
658                                        include_operations: vec![],
659                                        exclude_operations: vec![],
660                                        max_operations: None,
661                                    };
662                                    let defs = crate::core::openapi::extract_tools(&spec, &filters);
663                                    cached_tools = defs
664                                        .into_iter()
665                                        .map(|def| {
666                                            crate::core::openapi::to_ati_tool(
667                                                def,
668                                                &cached.name,
669                                                &HashMap::new(),
670                                            )
671                                        })
672                                        .collect();
673                                }
674                            }
675                        }
676                        // MCP providers have empty tools — lazy discovery at run time
677
678                        let mi = manifests.len();
679                        for (ti, tool) in cached_tools.iter().enumerate() {
680                            tool_index.insert(tool.name.clone(), (mi, ti));
681                        }
682                        manifests.push(Manifest {
683                            provider,
684                            tools: cached_tools,
685                        });
686                    }
687                }
688            }
689        }
690
691        let mut registry = ManifestRegistry {
692            manifests,
693            tool_index,
694        };
695        register_file_manager_provider(&mut registry);
696        Ok(registry)
697    }
698
699    /// Create an empty registry (no manifests loaded).
700    pub fn empty() -> Self {
701        let mut registry = ManifestRegistry {
702            manifests: Vec::new(),
703            tool_index: HashMap::new(),
704        };
705        register_file_manager_provider(&mut registry);
706        registry
707    }
708
709    /// Look up a tool by name. Returns the provider and tool definition.
710    pub fn get_tool(&self, name: &str) -> Option<(&Provider, &Tool)> {
711        self.tool_index.get(name).map(|(mi, ti)| {
712            let m = &self.manifests[*mi];
713            (&m.provider, &m.tools[*ti])
714        })
715    }
716
717    /// List all tools across all providers.
718    pub fn list_tools(&self) -> Vec<(&Provider, &Tool)> {
719        self.manifests
720            .iter()
721            .flat_map(|m| m.tools.iter().map(move |t| (&m.provider, t)))
722            .collect()
723    }
724
725    /// List all providers.
726    pub fn list_providers(&self) -> Vec<&Provider> {
727        self.manifests.iter().map(|m| &m.provider).collect()
728    }
729
730    /// List all non-internal tools (excludes providers marked internal=true).
731    pub fn list_public_tools(&self) -> Vec<(&Provider, &Tool)> {
732        self.manifests
733            .iter()
734            .filter(|m| !m.provider.internal)
735            .flat_map(|m| m.tools.iter().map(move |t| (&m.provider, t)))
736            .collect()
737    }
738
739    /// Get the number of loaded tools.
740    pub fn tool_count(&self) -> usize {
741        self.tool_index.len()
742    }
743
744    /// Get the number of loaded providers.
745    pub fn provider_count(&self) -> usize {
746        self.manifests.len()
747    }
748
749    /// List all MCP providers (handler = "mcp").
750    pub fn list_mcp_providers(&self) -> Vec<&Provider> {
751        self.manifests
752            .iter()
753            .filter(|m| m.provider.handler == "mcp")
754            .map(|m| &m.provider)
755            .collect()
756    }
757
758    /// If `tool_name` has a `<provider>:<name>` prefix matching an MCP provider, return it.
759    pub fn find_mcp_provider_for_tool(&self, tool_name: &str) -> Option<&Provider> {
760        let prefix = tool_name.split(TOOL_SEP).next()?;
761        self.manifests
762            .iter()
763            .find(|m| m.provider.handler == "mcp" && m.provider.name == prefix)
764            .map(|m| &m.provider)
765    }
766
767    /// List all OpenAPI providers (handler = "openapi").
768    pub fn list_openapi_providers(&self) -> Vec<&Provider> {
769        self.manifests
770            .iter()
771            .filter(|m| m.provider.handler == "openapi")
772            .map(|m| &m.provider)
773            .collect()
774    }
775
776    /// Check if a provider with the given name exists.
777    pub fn has_provider(&self, name: &str) -> bool {
778        self.manifests.iter().any(|m| m.provider.name == name)
779    }
780
781    /// Get tools belonging to a specific provider.
782    pub fn tools_by_provider(&self, provider_name: &str) -> Vec<(&Provider, &Tool)> {
783        self.manifests
784            .iter()
785            .filter(|m| m.provider.name == provider_name)
786            .flat_map(|m| m.tools.iter().map(move |t| (&m.provider, t)))
787            .collect()
788    }
789
790    /// List all CLI providers (handler = "cli").
791    pub fn list_cli_providers(&self) -> Vec<&Provider> {
792        self.manifests
793            .iter()
794            .filter(|m| m.provider.handler == "cli")
795            .map(|m| &m.provider)
796            .collect()
797    }
798
799    /// Register dynamically discovered MCP tools for a provider.
800    /// Tools are prefixed with provider name: `"github:read_file"`.
801    pub fn register_mcp_tools(&mut self, provider_name: &str, mcp_tools: Vec<McpToolDef>) {
802        // Find the manifest for this provider
803        let mi = match self
804            .manifests
805            .iter()
806            .position(|m| m.provider.name == provider_name)
807        {
808            Some(idx) => idx,
809            None => return,
810        };
811
812        for mcp_tool in mcp_tools {
813            let prefixed_name = format!("{}{}{}", provider_name, TOOL_SEP_STR, mcp_tool.name);
814
815            let tool = Tool {
816                name: prefixed_name.clone(),
817                description: mcp_tool.description.unwrap_or_default(),
818                endpoint: String::new(),
819                method: HttpMethod::Post,
820                scope: Some(format!("tool:{prefixed_name}")),
821                input_schema: mcp_tool.input_schema,
822                response: None,
823                tags: Vec::new(),
824                hint: None,
825                examples: Vec::new(),
826            };
827
828            let ti = self.manifests[mi].tools.len();
829            self.manifests[mi].tools.push(tool);
830            self.tool_index.insert(prefixed_name, (mi, ti));
831        }
832    }
833}
834
835impl Provider {
836    /// Returns true if this provider uses MCP protocol.
837    pub fn is_mcp(&self) -> bool {
838        self.handler == "mcp"
839    }
840
841    /// Returns true if this provider uses OpenAPI spec-based tool discovery.
842    pub fn is_openapi(&self) -> bool {
843        self.handler == "openapi"
844    }
845
846    /// Returns true if this provider uses CLI handler.
847    pub fn is_cli(&self) -> bool {
848        self.handler == "cli"
849    }
850
851    /// Returns the MCP transport type, defaulting to "stdio".
852    pub fn mcp_transport_type(&self) -> &str {
853        self.mcp_transport.as_deref().unwrap_or("stdio")
854    }
855
856    /// Returns true if this provider uses the built-in file_manager handler.
857    pub fn is_file_manager(&self) -> bool {
858        self.handler == "file_manager"
859    }
860}
861
862/// Register the virtual `file_manager` provider (download + upload tools).
863///
864/// Three cases:
865/// 1. Operator manifest already declares the `file_manager` provider WITH tools
866///    → leave it alone.
867/// 2. Operator manifest declares it but with no `[[tools]]` (the common case —
868///    they're just declaring the upload allowlist) → attach the built-in tools
869///    so the operator only needs the destinations block.
870/// 3. No manifest at all → register a default provider with the built-in tools
871///    and an empty destinations map (uploads will return UploadNotConfigured).
872pub(crate) fn register_file_manager_provider(registry: &mut ManifestRegistry) {
873    let download_tool = build_file_manager_download_tool();
874    let upload_tool = build_file_manager_upload_tool();
875
876    if let Some(mi) = registry
877        .manifests
878        .iter()
879        .position(|m| m.provider.handler == "file_manager")
880    {
881        // Operator declared it. Backfill tools if they didn't list any.
882        if registry.manifests[mi].tools.is_empty() {
883            let tools = vec![download_tool, upload_tool];
884            for (ti, tool) in tools.iter().enumerate() {
885                registry.tool_index.insert(tool.name.clone(), (mi, ti));
886            }
887            registry.manifests[mi].tools = tools;
888        }
889        return;
890    }
891
892    let provider = Provider {
893        name: "file_manager".to_string(),
894        description: "Generic binary download/upload for agents".to_string(),
895        base_url: String::new(),
896        auth_type: AuthType::None,
897        auth_key_name: None,
898        auth_header_name: None,
899        auth_query_name: None,
900        auth_value_prefix: None,
901        extra_headers: HashMap::new(),
902        oauth2_token_url: None,
903        auth_secret_name: None,
904        oauth2_basic_auth: false,
905        internal: false,
906        handler: "file_manager".to_string(),
907        mcp_transport: None,
908        mcp_command: None,
909        mcp_args: Vec::new(),
910        mcp_url: None,
911        mcp_env: HashMap::new(),
912        cli_command: None,
913        cli_default_args: Vec::new(),
914        cli_env: HashMap::new(),
915        cli_timeout_secs: None,
916        cli_output_args: Vec::new(),
917        cli_output_positional: HashMap::new(),
918        upload_destinations: HashMap::new(),
919        upload_default_destination: None,
920        openapi_spec: None,
921        openapi_include_tags: Vec::new(),
922        openapi_exclude_tags: Vec::new(),
923        openapi_include_operations: Vec::new(),
924        openapi_exclude_operations: Vec::new(),
925        openapi_max_operations: None,
926        openapi_overrides: HashMap::new(),
927        auth_generator: None,
928        category: Some("file_manager".to_string()),
929        skills: Vec::new(),
930    };
931
932    let tools = vec![download_tool, upload_tool];
933    let mi = registry.manifests.len();
934    for (ti, tool) in tools.iter().enumerate() {
935        registry.tool_index.insert(tool.name.clone(), (mi, ti));
936    }
937    registry.manifests.push(Manifest { provider, tools });
938}
939
940fn build_file_manager_download_tool() -> Tool {
941    let schema = serde_json::json!({
942        "type": "object",
943        "required": ["url"],
944        "properties": {
945            "url": {"type": "string", "description": "URL to fetch bytes from"},
946            "out": {"type": "string", "description": "Local path to write bytes; if omitted, returns base64 inline"},
947            "inline": {"type": "boolean", "description": "Return bytes as base64 in the response instead of writing to disk"},
948            "max_bytes": {"type": "integer", "description": "Abort if body exceeds this many bytes (default 500 MB)"},
949            "timeout": {"type": "integer", "description": "Request timeout in seconds (default 120)"},
950            "headers": {"type": "object", "description": "Extra request headers, e.g. {\"Authorization\": \"Bearer abc\"}"},
951            "follow_redirects": {"type": "boolean", "description": "Follow 3xx redirects (default true)"}
952        }
953    });
954
955    Tool {
956        name: "file_manager:download".to_string(),
957        description: "Download bytes from a URL. Writes to --out <path> or returns base64 inline."
958            .to_string(),
959        endpoint: String::new(),
960        method: HttpMethod::Post,
961        scope: Some("tool:file_manager:download".to_string()),
962        input_schema: Some(schema),
963        response: None,
964        tags: vec![
965            "file".to_string(),
966            "download".to_string(),
967            "binary".to_string(),
968        ],
969        hint: Some(
970            "Use for 'I have a URL, give me the bytes' — images, video, audio, PDFs, CSVs, ZIPs."
971                .to_string(),
972        ),
973        examples: vec![
974            "ati run file_manager:download --url https://example.com/file.mp4 --out /tmp/clip.mp4"
975                .to_string(),
976            "ati run file_manager:download --url https://example.com/data.csv --inline true"
977                .to_string(),
978        ],
979    }
980}
981
982fn build_file_manager_upload_tool() -> Tool {
983    let schema = serde_json::json!({
984        "type": "object",
985        "required": ["path"],
986        "properties": {
987            "path": {"type": "string", "description": "Local file path to upload"},
988            "content_type": {"type": "string", "description": "Override MIME type (default: inferred from extension)"},
989            "object_name": {"type": "string", "description": "Object key (when destination is GCS-style); default: auto-generated"},
990            "destination": {"type": "string", "description": "Allowlist key declared in the operator's file_manager.toml manifest (e.g. \"fal\", \"gcs\"). Omit to use the operator default."}
991        }
992    });
993
994    Tool {
995        name: "file_manager:upload".to_string(),
996        description: "Upload a local file to a manifest-declared destination, return a public URL.".to_string(),
997        endpoint: String::new(),
998        method: HttpMethod::Post,
999        scope: Some("tool:file_manager:upload".to_string()),
1000        input_schema: Some(schema),
1001        response: None,
1002        tags: vec!["file".to_string(), "upload".to_string(), "binary".to_string()],
1003        hint: Some("Upload a local file to a manifest-declared destination (GCS, fal_storage, etc.) and get a public URL.".to_string()),
1004        examples: vec![
1005            "ati run file_manager:upload --path /tmp/narration.mp3".to_string(),
1006            "ati run file_manager:upload --path /tmp/report.pdf --destination gcs".to_string(),
1007        ],
1008    }
1009}