use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use thiserror::Error;
pub const TOOL_SEP: char = ':';
pub const TOOL_SEP_STR: &str = ":";
#[derive(Error, Debug)]
pub enum ManifestError {
#[error("Failed to read manifest file {0}: {1}")]
Io(String, std::io::Error),
#[error("Failed to parse manifest {0}: {1}")]
Parse(String, toml::de::Error),
#[error("No manifests directory found at {0}")]
NoDirectory(String),
#[error("Manifest {0} is invalid: {1}")]
Invalid(String, String),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum AuthType {
Bearer,
Header,
Query,
Basic,
#[default]
None,
Oauth2,
Url,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Provider {
pub name: String,
pub description: String,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub auth_type: AuthType,
#[serde(default)]
pub auth_key_name: Option<String>,
#[serde(default)]
pub auth_header_name: Option<String>,
#[serde(default)]
pub auth_query_name: Option<String>,
#[serde(default)]
pub auth_value_prefix: Option<String>,
#[serde(default)]
pub extra_headers: HashMap<String, String>,
#[serde(default)]
pub oauth2_token_url: Option<String>,
#[serde(default)]
pub auth_secret_name: Option<String>,
#[serde(default)]
pub oauth2_basic_auth: bool,
#[serde(default)]
pub internal: bool,
#[serde(default = "default_handler")]
pub handler: String,
#[serde(default)]
pub mcp_transport: Option<String>,
#[serde(default)]
pub mcp_command: Option<String>,
#[serde(default)]
pub mcp_args: Vec<String>,
#[serde(default)]
pub mcp_url: Option<String>,
#[serde(default)]
pub mcp_env: HashMap<String, String>,
#[serde(default)]
pub cli_command: Option<String>,
#[serde(default)]
pub cli_default_args: Vec<String>,
#[serde(default)]
pub cli_env: HashMap<String, String>,
#[serde(default)]
pub cli_timeout_secs: Option<u64>,
#[serde(default)]
pub cli_output_args: Vec<String>,
#[serde(default)]
pub cli_output_positional: HashMap<String, usize>,
#[serde(default)]
pub upload_destinations: HashMap<String, crate::core::file_manager::UploadDestination>,
#[serde(default)]
pub upload_default_destination: Option<String>,
#[serde(default)]
pub openapi_spec: Option<String>,
#[serde(default)]
pub openapi_include_tags: Vec<String>,
#[serde(default)]
pub openapi_exclude_tags: Vec<String>,
#[serde(default)]
pub openapi_include_operations: Vec<String>,
#[serde(default)]
pub openapi_exclude_operations: Vec<String>,
#[serde(default)]
pub openapi_max_operations: Option<usize>,
#[serde(default)]
pub openapi_overrides: HashMap<String, OpenApiToolOverride>,
#[serde(default)]
pub auth_generator: Option<AuthGenerator>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub skills: Vec<String>,
}
fn default_handler() -> String {
"http".to_string()
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct OpenApiToolOverride {
pub hint: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub examples: Vec<String>,
pub description: Option<String>,
pub scope: Option<String>,
pub response_extract: Option<String>,
pub response_format: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AuthGenerator {
#[serde(rename = "type")]
pub gen_type: AuthGenType,
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
pub interpreter: Option<String>,
pub script: Option<String>,
#[serde(default)]
pub cache_ttl_secs: u64,
#[serde(default)]
pub output_format: AuthOutputFormat,
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub inject: HashMap<String, InjectTarget>,
#[serde(default = "default_gen_timeout")]
pub timeout_secs: u64,
}
fn default_gen_timeout() -> u64 {
30
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthGenType {
Command,
Script,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AuthOutputFormat {
#[default]
Text,
Json,
}
#[derive(Debug, Clone, Deserialize)]
pub struct InjectTarget {
#[serde(rename = "type")]
pub inject_type: String,
pub name: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
#[derive(Default)]
pub enum HttpMethod {
#[serde(alias = "get", alias = "Get")]
#[default]
Get,
#[serde(alias = "post", alias = "Post")]
Post,
#[serde(alias = "put", alias = "Put")]
Put,
#[serde(alias = "delete", alias = "Delete")]
Delete,
}
impl std::fmt::Display for HttpMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HttpMethod::Get => write!(f, "GET"),
HttpMethod::Post => write!(f, "POST"),
HttpMethod::Put => write!(f, "PUT"),
HttpMethod::Delete => write!(f, "DELETE"),
}
}
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ResponseFormat {
MarkdownTable,
Json,
#[default]
Text,
Raw,
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ResponseConfig {
#[serde(default)]
pub extract: Option<String>,
#[serde(default)]
pub format: ResponseFormat,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Tool {
pub name: String,
pub description: String,
#[serde(default)]
pub endpoint: String,
#[serde(default)]
pub method: HttpMethod,
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub input_schema: Option<serde_json::Value>,
#[serde(default)]
pub response: Option<ResponseConfig>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub hint: Option<String>,
#[serde(default)]
pub examples: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
pub provider: Provider,
#[serde(default, rename = "tools")]
pub tools: Vec<Tool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedProvider {
pub name: String,
pub provider_type: String,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub auth_type: String,
#[serde(default)]
pub auth_key_name: Option<String>,
#[serde(default)]
pub auth_header_name: Option<String>,
#[serde(default)]
pub auth_query_name: Option<String>,
#[serde(default)]
pub spec_content: Option<String>,
#[serde(default)]
pub mcp_transport: Option<String>,
#[serde(default)]
pub mcp_url: Option<String>,
#[serde(default)]
pub mcp_command: Option<String>,
#[serde(default)]
pub mcp_args: Vec<String>,
#[serde(default)]
pub mcp_env: HashMap<String, String>,
#[serde(default)]
pub cli_command: Option<String>,
#[serde(default)]
pub cli_default_args: Vec<String>,
#[serde(default)]
pub cli_env: HashMap<String, String>,
#[serde(default)]
pub cli_timeout_secs: Option<u64>,
#[serde(default)]
pub auth: Option<String>,
#[serde(default)]
pub skills: Vec<String>,
pub created_at: String,
pub ttl_seconds: u64,
}
impl CachedProvider {
pub fn is_expired(&self) -> bool {
let created = match DateTime::parse_from_rfc3339(&self.created_at) {
Ok(dt) => dt.with_timezone(&Utc),
Err(_) => return true, };
let now = Utc::now();
let elapsed = now.signed_duration_since(created);
elapsed.num_seconds() as u64 > self.ttl_seconds
}
pub fn expires_at(&self) -> Option<String> {
let created = DateTime::parse_from_rfc3339(&self.created_at).ok()?;
let expires = created + chrono::Duration::seconds(self.ttl_seconds as i64);
Some(expires.to_rfc3339())
}
pub fn remaining_seconds(&self) -> u64 {
let created = match DateTime::parse_from_rfc3339(&self.created_at) {
Ok(dt) => dt.with_timezone(&Utc),
Err(_) => return 0,
};
let now = Utc::now();
let elapsed = now.signed_duration_since(created).num_seconds() as u64;
self.ttl_seconds.saturating_sub(elapsed)
}
pub fn to_provider(&self) -> Provider {
let auth_type = match self.auth_type.as_str() {
"bearer" => AuthType::Bearer,
"header" => AuthType::Header,
"query" => AuthType::Query,
"basic" => AuthType::Basic,
"oauth2" => AuthType::Oauth2,
_ => AuthType::None,
};
let handler = match self.provider_type.as_str() {
"mcp" => "mcp".to_string(),
"openapi" => "openapi".to_string(),
_ => "http".to_string(),
};
Provider {
name: self.name.clone(),
description: format!("{} (cached)", self.name),
base_url: self.base_url.clone(),
auth_type,
auth_key_name: self.auth_key_name.clone(),
auth_header_name: self.auth_header_name.clone(),
auth_query_name: self.auth_query_name.clone(),
auth_value_prefix: None,
extra_headers: HashMap::new(),
oauth2_token_url: None,
auth_secret_name: None,
oauth2_basic_auth: false,
internal: false,
handler,
mcp_transport: self.mcp_transport.clone(),
mcp_command: self.mcp_command.clone(),
mcp_args: self.mcp_args.clone(),
mcp_url: self.mcp_url.clone(),
mcp_env: self.mcp_env.clone(),
openapi_spec: None,
openapi_include_tags: Vec::new(),
openapi_exclude_tags: Vec::new(),
openapi_include_operations: Vec::new(),
openapi_exclude_operations: Vec::new(),
openapi_max_operations: None,
openapi_overrides: HashMap::new(),
cli_command: self.cli_command.clone(),
cli_default_args: self.cli_default_args.clone(),
cli_env: self.cli_env.clone(),
cli_timeout_secs: self.cli_timeout_secs,
cli_output_args: Vec::new(),
cli_output_positional: HashMap::new(),
upload_destinations: HashMap::new(),
upload_default_destination: None,
auth_generator: None,
category: None,
skills: self.skills.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolDef {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default, rename = "inputSchema")]
pub input_schema: Option<serde_json::Value>,
}
pub struct ManifestRegistry {
manifests: Vec<Manifest>,
tool_index: HashMap<String, (usize, usize)>,
}
impl ManifestRegistry {
pub fn load(dir: &Path) -> Result<Self, ManifestError> {
if !dir.is_dir() {
return Err(ManifestError::NoDirectory(dir.display().to_string()));
}
let mut manifests = Vec::new();
let mut tool_index = HashMap::new();
let pattern = dir.join("*.toml");
let entries = glob::glob(pattern.to_str().unwrap_or(""))
.map_err(|e| ManifestError::NoDirectory(e.to_string()))?;
let specs_dir = dir.parent().map(|p| p.join("specs"));
for entry in entries {
let path = entry.map_err(|e| {
ManifestError::Io(format!("{e}"), std::io::Error::other("glob error"))
})?;
let contents = std::fs::read_to_string(&path)
.map_err(|e| ManifestError::Io(path.display().to_string(), e))?;
let mut manifest: Manifest = toml::from_str(&contents)
.map_err(|e| ManifestError::Parse(path.display().to_string(), e))?;
if manifest.provider.is_openapi() {
if let Some(spec_ref) = &manifest.provider.openapi_spec {
match crate::core::openapi::load_and_register(
&manifest.provider,
spec_ref,
specs_dir.as_deref(),
) {
Ok(tools) => {
manifest.tools = tools;
}
Err(e) => {
tracing::warn!(
provider = %manifest.provider.name,
error = %e,
"failed to load OpenAPI spec for provider"
);
}
}
}
}
if manifest.provider.handler == "file_manager" {
if let Some(ref default) = manifest.provider.upload_default_destination {
if !manifest.provider.upload_destinations.contains_key(default) {
return Err(ManifestError::Invalid(
path.display().to_string(),
format!(
"upload_default_destination '{default}' is not present in [provider.upload_destinations]"
),
));
}
}
}
if manifest.provider.is_cli() && manifest.tools.is_empty() {
let tool_name = manifest.provider.name.clone();
manifest.tools.push(Tool {
name: tool_name.clone(),
description: manifest.provider.description.clone(),
endpoint: String::new(),
method: HttpMethod::Get,
scope: Some(format!("tool:{tool_name}")),
input_schema: None,
response: None,
tags: Vec::new(),
hint: None,
examples: Vec::new(),
});
}
let provider_name = &manifest.provider.name;
for tool in &mut manifest.tools {
if tool.scope.is_none() && !manifest.provider.internal {
tool.scope = Some(format!("tool:{}", tool.name));
tracing::trace!(
tool = %tool.name,
provider = %provider_name,
scope = ?tool.scope,
"auto-assigned scope to tool"
);
}
}
let mi = manifests.len();
for (ti, tool) in manifest.tools.iter().enumerate() {
tool_index.insert(tool.name.clone(), (mi, ti));
}
manifests.push(manifest);
}
if let Some(parent) = dir.parent() {
let cache_dir = parent.join("cache").join("providers");
if cache_dir.is_dir() {
let cache_pattern = cache_dir.join("*.json");
if let Ok(cache_entries) = glob::glob(cache_pattern.to_str().unwrap_or("")) {
for entry in cache_entries {
let path = match entry {
Ok(p) => p,
Err(_) => continue,
};
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let cached: CachedProvider = match serde_json::from_str(&content) {
Ok(c) => c,
Err(_) => continue,
};
if cached.is_expired() {
let _ = std::fs::remove_file(&path);
continue;
}
if manifests.iter().any(|m| m.provider.name == cached.name) {
continue;
}
let provider = cached.to_provider();
let mut cached_tools = Vec::new();
if cached.provider_type == "openapi" {
if let Some(spec_content) = &cached.spec_content {
if let Ok(spec) = crate::core::openapi::parse_spec(spec_content) {
let filters = crate::core::openapi::OpenApiFilters {
include_tags: vec![],
exclude_tags: vec![],
include_operations: vec![],
exclude_operations: vec![],
max_operations: None,
};
let defs = crate::core::openapi::extract_tools(&spec, &filters);
cached_tools = defs
.into_iter()
.map(|def| {
crate::core::openapi::to_ati_tool(
def,
&cached.name,
&HashMap::new(),
)
})
.collect();
}
}
}
let mi = manifests.len();
for (ti, tool) in cached_tools.iter().enumerate() {
tool_index.insert(tool.name.clone(), (mi, ti));
}
manifests.push(Manifest {
provider,
tools: cached_tools,
});
}
}
}
}
let mut registry = ManifestRegistry {
manifests,
tool_index,
};
register_file_manager_provider(&mut registry);
Ok(registry)
}
pub fn empty() -> Self {
let mut registry = ManifestRegistry {
manifests: Vec::new(),
tool_index: HashMap::new(),
};
register_file_manager_provider(&mut registry);
registry
}
pub fn get_tool(&self, name: &str) -> Option<(&Provider, &Tool)> {
self.tool_index.get(name).map(|(mi, ti)| {
let m = &self.manifests[*mi];
(&m.provider, &m.tools[*ti])
})
}
pub fn list_tools(&self) -> Vec<(&Provider, &Tool)> {
self.manifests
.iter()
.flat_map(|m| m.tools.iter().map(move |t| (&m.provider, t)))
.collect()
}
pub fn list_providers(&self) -> Vec<&Provider> {
self.manifests.iter().map(|m| &m.provider).collect()
}
pub fn list_public_tools(&self) -> Vec<(&Provider, &Tool)> {
self.manifests
.iter()
.filter(|m| !m.provider.internal)
.flat_map(|m| m.tools.iter().map(move |t| (&m.provider, t)))
.collect()
}
pub fn tool_count(&self) -> usize {
self.tool_index.len()
}
pub fn provider_count(&self) -> usize {
self.manifests.len()
}
pub fn list_mcp_providers(&self) -> Vec<&Provider> {
self.manifests
.iter()
.filter(|m| m.provider.handler == "mcp")
.map(|m| &m.provider)
.collect()
}
pub fn find_mcp_provider_for_tool(&self, tool_name: &str) -> Option<&Provider> {
let prefix = tool_name.split(TOOL_SEP).next()?;
self.manifests
.iter()
.find(|m| m.provider.handler == "mcp" && m.provider.name == prefix)
.map(|m| &m.provider)
}
pub fn list_openapi_providers(&self) -> Vec<&Provider> {
self.manifests
.iter()
.filter(|m| m.provider.handler == "openapi")
.map(|m| &m.provider)
.collect()
}
pub fn has_provider(&self, name: &str) -> bool {
self.manifests.iter().any(|m| m.provider.name == name)
}
pub fn tools_by_provider(&self, provider_name: &str) -> Vec<(&Provider, &Tool)> {
self.manifests
.iter()
.filter(|m| m.provider.name == provider_name)
.flat_map(|m| m.tools.iter().map(move |t| (&m.provider, t)))
.collect()
}
pub fn list_cli_providers(&self) -> Vec<&Provider> {
self.manifests
.iter()
.filter(|m| m.provider.handler == "cli")
.map(|m| &m.provider)
.collect()
}
pub fn register_mcp_tools(&mut self, provider_name: &str, mcp_tools: Vec<McpToolDef>) {
let mi = match self
.manifests
.iter()
.position(|m| m.provider.name == provider_name)
{
Some(idx) => idx,
None => return,
};
for mcp_tool in mcp_tools {
let prefixed_name = format!("{}{}{}", provider_name, TOOL_SEP_STR, mcp_tool.name);
let tool = Tool {
name: prefixed_name.clone(),
description: mcp_tool.description.unwrap_or_default(),
endpoint: String::new(),
method: HttpMethod::Post,
scope: Some(format!("tool:{prefixed_name}")),
input_schema: mcp_tool.input_schema,
response: None,
tags: Vec::new(),
hint: None,
examples: Vec::new(),
};
let ti = self.manifests[mi].tools.len();
self.manifests[mi].tools.push(tool);
self.tool_index.insert(prefixed_name, (mi, ti));
}
}
}
impl Provider {
pub fn is_mcp(&self) -> bool {
self.handler == "mcp"
}
pub fn is_openapi(&self) -> bool {
self.handler == "openapi"
}
pub fn is_cli(&self) -> bool {
self.handler == "cli"
}
pub fn mcp_transport_type(&self) -> &str {
self.mcp_transport.as_deref().unwrap_or("stdio")
}
pub fn is_file_manager(&self) -> bool {
self.handler == "file_manager"
}
}
pub(crate) fn register_file_manager_provider(registry: &mut ManifestRegistry) {
let download_tool = build_file_manager_download_tool();
let upload_tool = build_file_manager_upload_tool();
if let Some(mi) = registry
.manifests
.iter()
.position(|m| m.provider.handler == "file_manager")
{
if registry.manifests[mi].tools.is_empty() {
let tools = vec![download_tool, upload_tool];
for (ti, tool) in tools.iter().enumerate() {
registry.tool_index.insert(tool.name.clone(), (mi, ti));
}
registry.manifests[mi].tools = tools;
}
return;
}
let provider = Provider {
name: "file_manager".to_string(),
description: "Generic binary download/upload for agents".to_string(),
base_url: String::new(),
auth_type: AuthType::None,
auth_key_name: None,
auth_header_name: None,
auth_query_name: None,
auth_value_prefix: None,
extra_headers: HashMap::new(),
oauth2_token_url: None,
auth_secret_name: None,
oauth2_basic_auth: false,
internal: false,
handler: "file_manager".to_string(),
mcp_transport: None,
mcp_command: None,
mcp_args: Vec::new(),
mcp_url: None,
mcp_env: HashMap::new(),
cli_command: None,
cli_default_args: Vec::new(),
cli_env: HashMap::new(),
cli_timeout_secs: None,
cli_output_args: Vec::new(),
cli_output_positional: HashMap::new(),
upload_destinations: HashMap::new(),
upload_default_destination: None,
openapi_spec: None,
openapi_include_tags: Vec::new(),
openapi_exclude_tags: Vec::new(),
openapi_include_operations: Vec::new(),
openapi_exclude_operations: Vec::new(),
openapi_max_operations: None,
openapi_overrides: HashMap::new(),
auth_generator: None,
category: Some("file_manager".to_string()),
skills: Vec::new(),
};
let tools = vec![download_tool, upload_tool];
let mi = registry.manifests.len();
for (ti, tool) in tools.iter().enumerate() {
registry.tool_index.insert(tool.name.clone(), (mi, ti));
}
registry.manifests.push(Manifest { provider, tools });
}
fn build_file_manager_download_tool() -> Tool {
let schema = serde_json::json!({
"type": "object",
"required": ["url"],
"properties": {
"url": {"type": "string", "description": "URL to fetch bytes from"},
"out": {"type": "string", "description": "Local path to write bytes; if omitted, returns base64 inline"},
"inline": {"type": "boolean", "description": "Return bytes as base64 in the response instead of writing to disk"},
"max_bytes": {"type": "integer", "description": "Abort if body exceeds this many bytes (default 500 MB)"},
"timeout": {"type": "integer", "description": "Request timeout in seconds (default 120)"},
"headers": {"type": "object", "description": "Extra request headers, e.g. {\"Authorization\": \"Bearer abc\"}"},
"follow_redirects": {"type": "boolean", "description": "Follow 3xx redirects (default true)"}
}
});
Tool {
name: "file_manager:download".to_string(),
description: "Download bytes from a URL. Writes to --out <path> or returns base64 inline."
.to_string(),
endpoint: String::new(),
method: HttpMethod::Post,
scope: Some("tool:file_manager:download".to_string()),
input_schema: Some(schema),
response: None,
tags: vec![
"file".to_string(),
"download".to_string(),
"binary".to_string(),
],
hint: Some(
"Use for 'I have a URL, give me the bytes' — images, video, audio, PDFs, CSVs, ZIPs."
.to_string(),
),
examples: vec![
"ati run file_manager:download --url https://example.com/file.mp4 --out /tmp/clip.mp4"
.to_string(),
"ati run file_manager:download --url https://example.com/data.csv --inline true"
.to_string(),
],
}
}
fn build_file_manager_upload_tool() -> Tool {
let schema = serde_json::json!({
"type": "object",
"required": ["path"],
"properties": {
"path": {"type": "string", "description": "Local file path to upload"},
"content_type": {"type": "string", "description": "Override MIME type (default: inferred from extension)"},
"object_name": {"type": "string", "description": "Object key (when destination is GCS-style); default: auto-generated"},
"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."}
}
});
Tool {
name: "file_manager:upload".to_string(),
description: "Upload a local file to a manifest-declared destination, return a public URL.".to_string(),
endpoint: String::new(),
method: HttpMethod::Post,
scope: Some("tool:file_manager:upload".to_string()),
input_schema: Some(schema),
response: None,
tags: vec!["file".to_string(), "upload".to_string(), "binary".to_string()],
hint: Some("Upload a local file to a manifest-declared destination (GCS, fal_storage, etc.) and get a public URL.".to_string()),
examples: vec![
"ati run file_manager:upload --path /tmp/narration.mp3".to_string(),
"ati run file_manager:upload --path /tmp/report.pdf --destination gcs".to_string(),
],
}
}