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}
21
22#[derive(Debug, Clone, Deserialize)]
23#[serde(rename_all = "snake_case")]
24#[derive(Default)]
25pub enum AuthType {
26    Bearer,
27    Header,
28    Query,
29    Basic,
30    #[default]
31    None,
32    Oauth2,
33    /// API key is embedded in the URL path via `${key_name}` placeholder.
34    /// No auth header is sent — the key is resolved from the keyring and
35    /// interpolated into the URL at connection time by `resolve_env_value`.
36    /// Example: `mcp_url = "https://mcp.serpapi.com/${serpapi_api_key}/mcp"`
37    Url,
38}
39
40#[derive(Debug, Clone, Deserialize)]
41pub struct Provider {
42    pub name: String,
43    pub description: String,
44    /// Base URL for HTTP providers. Optional for MCP providers.
45    #[serde(default)]
46    pub base_url: String,
47    #[serde(default)]
48    pub auth_type: AuthType,
49    #[serde(default)]
50    pub auth_key_name: Option<String>,
51    /// Custom header name for auth_type = "header" (default: "X-Api-Key").
52    /// Examples: "X-Finnhub-Token", "X-API-KEY", "Authorization"
53    #[serde(default)]
54    pub auth_header_name: Option<String>,
55    /// Custom query parameter name for auth_type = "query" (default: "api_key").
56    #[serde(default)]
57    pub auth_query_name: Option<String>,
58    /// Optional prefix for auth header value (e.g. "Token ", "Basic ").
59    /// Used with auth_type = "header". Value becomes: "{prefix}{key}".
60    #[serde(default)]
61    pub auth_value_prefix: Option<String>,
62    /// Additional headers to include on every request for this provider.
63    /// Examples: X-Goog-FieldMask, X-EBAY-C-MARKETPLACE-ID
64    #[serde(default)]
65    pub extra_headers: HashMap<String, String>,
66    /// Token URL for OAuth2 (relative to base_url or absolute)
67    #[serde(default)]
68    pub oauth2_token_url: Option<String>,
69    /// Second key name for OAuth2 client_secret
70    #[serde(default)]
71    pub auth_secret_name: Option<String>,
72    /// If true, send OAuth2 credentials via Basic Auth header instead of form body.
73    /// Some providers (e.g. Sovos) require this per RFC 6749 §2.3.1.
74    #[serde(default)]
75    pub oauth2_basic_auth: bool,
76    #[serde(default)]
77    pub internal: bool,
78    #[serde(default = "default_handler")]
79    pub handler: String,
80
81    // --- MCP provider fields (handler = "mcp") ---
82    /// MCP transport type: "stdio" or "http"
83    #[serde(default)]
84    pub mcp_transport: Option<String>,
85    /// Command to launch stdio MCP server (e.g., "npx", "uvx")
86    #[serde(default)]
87    pub mcp_command: Option<String>,
88    /// Arguments for stdio command (e.g., ["-y", "@modelcontextprotocol/server-github"])
89    #[serde(default)]
90    pub mcp_args: Vec<String>,
91    /// URL for HTTP/Streamable HTTP MCP server
92    #[serde(default)]
93    pub mcp_url: Option<String>,
94    /// Environment variables to pass to stdio subprocess
95    #[serde(default)]
96    pub mcp_env: HashMap<String, String>,
97
98    // --- CLI provider fields (handler = "cli") ---
99    /// Command to run for CLI providers (e.g., "gsutil", "gh", "kubectl")
100    #[serde(default)]
101    pub cli_command: Option<String>,
102    /// Default args prepended to every invocation
103    #[serde(default)]
104    pub cli_default_args: Vec<String>,
105    /// Environment variables for CLI. ${key} = string from keyring, @{key} = credential file
106    #[serde(default)]
107    pub cli_env: HashMap<String, String>,
108    /// Default timeout in seconds (default: 120)
109    #[serde(default)]
110    pub cli_timeout_secs: Option<u64>,
111
112    // --- OpenAPI provider fields (handler = "openapi") ---
113    /// Path (relative to ~/.ati/specs/) or URL to OpenAPI spec (JSON or YAML)
114    #[serde(default)]
115    pub openapi_spec: Option<String>,
116    /// Only include operations with these tags
117    #[serde(default)]
118    pub openapi_include_tags: Vec<String>,
119    /// Exclude operations with these tags
120    #[serde(default)]
121    pub openapi_exclude_tags: Vec<String>,
122    /// Only include operations with these operationIds
123    #[serde(default)]
124    pub openapi_include_operations: Vec<String>,
125    /// Exclude operations with these operationIds
126    #[serde(default)]
127    pub openapi_exclude_operations: Vec<String>,
128    /// Maximum number of operations to register (for huge APIs)
129    #[serde(default)]
130    pub openapi_max_operations: Option<usize>,
131    /// Per-operationId overrides (hint, tags, description, response_extract, etc.)
132    #[serde(default)]
133    pub openapi_overrides: HashMap<String, OpenApiToolOverride>,
134
135    // --- Auth generator (dynamic credential generation) ---
136    /// Optional auth generator for producing short-lived credentials at call time.
137    /// Runs where secrets live (proxy server in proxy mode, local machine in local mode).
138    #[serde(default)]
139    pub auth_generator: Option<AuthGenerator>,
140
141    // --- Optional metadata fields ---
142    /// Provider category for discovery (e.g., "finance", "search", "social")
143    #[serde(default)]
144    pub category: Option<String>,
145
146    /// Associated skill names that teach agents how to use this provider's tools.
147    /// Resolved from the SkillRegistry (installed skills or GCS registry).
148    #[serde(default)]
149    pub skills: Vec<String>,
150}
151
152fn default_handler() -> String {
153    "http".to_string()
154}
155
156/// Per-operationId overrides for OpenAPI-discovered tools.
157#[derive(Debug, Clone, Deserialize, Default)]
158pub struct OpenApiToolOverride {
159    pub hint: Option<String>,
160    #[serde(default)]
161    pub tags: Vec<String>,
162    #[serde(default)]
163    pub examples: Vec<String>,
164    pub description: Option<String>,
165    pub scope: Option<String>,
166    pub response_extract: Option<String>,
167    pub response_format: Option<String>,
168}
169
170/// Dynamic auth generator configuration — produces short-lived credentials at call time.
171///
172/// Two types:
173/// - `command`: runs an external command, captures stdout as the credential
174/// - `script`: writes an inline script to a temp file and runs it via an interpreter
175///
176/// Variable expansion in `args` and `env` values:
177/// - `${key_name}` → keyring lookup
178/// - `${JWT_SUB}` → agent's JWT `sub` claim
179/// - `${JWT_SCOPE}` → agent's JWT `scope` claim
180/// - `${TOOL_NAME}` → tool being invoked
181/// - `${TIMESTAMP}` → current unix timestamp
182#[derive(Debug, Clone, Deserialize)]
183pub struct AuthGenerator {
184    #[serde(rename = "type")]
185    pub gen_type: AuthGenType,
186    /// Command to run (for `type = "command"`)
187    pub command: Option<String>,
188    /// Arguments for the command
189    #[serde(default)]
190    pub args: Vec<String>,
191    /// Interpreter for inline script (for `type = "script"`, e.g. "python3")
192    pub interpreter: Option<String>,
193    /// Inline script body (for `type = "script"`)
194    pub script: Option<String>,
195    /// TTL for cached credentials (0 = no cache)
196    #[serde(default)]
197    pub cache_ttl_secs: u64,
198    /// Output format: "text" (trimmed stdout) or "json" (parsed, fields extracted via `inject`)
199    #[serde(default)]
200    pub output_format: AuthOutputFormat,
201    /// Environment variables for the subprocess (values support `${key}` expansion)
202    #[serde(default)]
203    pub env: HashMap<String, String>,
204    /// For JSON output: map dot-notation JSON paths to injection targets
205    #[serde(default)]
206    pub inject: HashMap<String, InjectTarget>,
207    /// Subprocess timeout in seconds (default: 30)
208    #[serde(default = "default_gen_timeout")]
209    pub timeout_secs: u64,
210}
211
212fn default_gen_timeout() -> u64 {
213    30
214}
215
216#[derive(Debug, Clone, Deserialize)]
217#[serde(rename_all = "snake_case")]
218pub enum AuthGenType {
219    Command,
220    Script,
221}
222
223#[derive(Debug, Clone, Deserialize, Default)]
224#[serde(rename_all = "snake_case")]
225pub enum AuthOutputFormat {
226    #[default]
227    Text,
228    Json,
229}
230
231/// Target for injecting a JSON-extracted credential value.
232#[derive(Debug, Clone, Deserialize)]
233pub struct InjectTarget {
234    /// Where to inject: "header", "env", or "query"
235    #[serde(rename = "type")]
236    pub inject_type: String,
237    /// Name of the header/env var/query param
238    pub name: String,
239}
240
241#[derive(Debug, Clone, Deserialize)]
242#[serde(rename_all = "UPPERCASE")]
243#[derive(Default)]
244pub enum HttpMethod {
245    #[serde(alias = "get", alias = "Get")]
246    #[default]
247    Get,
248    #[serde(alias = "post", alias = "Post")]
249    Post,
250    #[serde(alias = "put", alias = "Put")]
251    Put,
252    #[serde(alias = "delete", alias = "Delete")]
253    Delete,
254}
255
256impl std::fmt::Display for HttpMethod {
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        match self {
259            HttpMethod::Get => write!(f, "GET"),
260            HttpMethod::Post => write!(f, "POST"),
261            HttpMethod::Put => write!(f, "PUT"),
262            HttpMethod::Delete => write!(f, "DELETE"),
263        }
264    }
265}
266
267#[derive(Debug, Clone, Deserialize, Default)]
268#[serde(rename_all = "snake_case")]
269pub enum ResponseFormat {
270    MarkdownTable,
271    Json,
272    #[default]
273    Text,
274    Raw,
275}
276
277#[derive(Debug, Clone, Deserialize, Default)]
278pub struct ResponseConfig {
279    /// JSONPath expression to extract useful content from the API response
280    #[serde(default)]
281    pub extract: Option<String>,
282    /// Output format for the extracted data
283    #[serde(default)]
284    pub format: ResponseFormat,
285}
286
287#[derive(Debug, Clone, Deserialize)]
288pub struct Tool {
289    pub name: String,
290    pub description: String,
291    #[serde(default)]
292    pub endpoint: String,
293    #[serde(default)]
294    pub method: HttpMethod,
295    /// Scope required to use this tool (e.g. "tool:web_search")
296    #[serde(default)]
297    pub scope: Option<String>,
298    /// JSON Schema for tool input
299    #[serde(default)]
300    pub input_schema: Option<serde_json::Value>,
301    /// Response extraction config
302    #[serde(default)]
303    pub response: Option<ResponseConfig>,
304
305    // --- Optional metadata fields ---
306    /// Tags for discovery (e.g., ["search", "real-time"])
307    #[serde(default)]
308    pub tags: Vec<String>,
309    /// Short hint for the LLM on when to use this tool
310    #[serde(default)]
311    pub hint: Option<String>,
312    /// Example invocations
313    #[serde(default)]
314    pub examples: Vec<String>,
315}
316
317/// A parsed manifest file: one provider + multiple tools.
318/// For MCP providers, tools may be empty — they're discovered dynamically via tools/list.
319#[derive(Debug, Clone, Deserialize)]
320pub struct Manifest {
321    pub provider: Provider,
322    #[serde(default, rename = "tools")]
323    pub tools: Vec<Tool>,
324}
325
326/// A cached (ephemeral) provider, persisted as JSON in `$ATI_DIR/cache/providers/<name>.json`.
327/// Used by `ati provider load` to make providers available across process invocations
328/// without writing permanent TOML manifests.
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct CachedProvider {
331    pub name: String,
332    /// "openapi" or "mcp"
333    pub provider_type: String,
334    #[serde(default)]
335    pub base_url: String,
336    #[serde(default)]
337    pub auth_type: String,
338    #[serde(default)]
339    pub auth_key_name: Option<String>,
340    #[serde(default)]
341    pub auth_header_name: Option<String>,
342    #[serde(default)]
343    pub auth_query_name: Option<String>,
344    // OpenAPI fields
345    #[serde(default)]
346    pub spec_content: Option<String>,
347    // MCP fields
348    #[serde(default)]
349    pub mcp_transport: Option<String>,
350    #[serde(default)]
351    pub mcp_url: Option<String>,
352    #[serde(default)]
353    pub mcp_command: Option<String>,
354    #[serde(default)]
355    pub mcp_args: Vec<String>,
356    #[serde(default)]
357    pub mcp_env: HashMap<String, String>,
358    // CLI fields
359    #[serde(default)]
360    pub cli_command: Option<String>,
361    #[serde(default)]
362    pub cli_default_args: Vec<String>,
363    #[serde(default)]
364    pub cli_env: HashMap<String, String>,
365    #[serde(default)]
366    pub cli_timeout_secs: Option<u64>,
367    // MCP/HTTP auth
368    #[serde(default)]
369    pub auth: Option<String>,
370    // Skills
371    #[serde(default)]
372    pub skills: Vec<String>,
373    // Cache metadata
374    pub created_at: String,
375    pub ttl_seconds: u64,
376}
377
378impl CachedProvider {
379    /// Returns true if this cached provider has expired.
380    pub fn is_expired(&self) -> bool {
381        let created = match DateTime::parse_from_rfc3339(&self.created_at) {
382            Ok(dt) => dt.with_timezone(&Utc),
383            Err(_) => return true, // Can't parse → treat as expired
384        };
385        let now = Utc::now();
386        let elapsed = now.signed_duration_since(created);
387        elapsed.num_seconds() as u64 > self.ttl_seconds
388    }
389
390    /// Returns the expiry time as an ISO timestamp.
391    pub fn expires_at(&self) -> Option<String> {
392        let created = DateTime::parse_from_rfc3339(&self.created_at).ok()?;
393        let expires = created + chrono::Duration::seconds(self.ttl_seconds as i64);
394        Some(expires.to_rfc3339())
395    }
396
397    /// Returns remaining TTL in seconds (0 if expired).
398    pub fn remaining_seconds(&self) -> u64 {
399        let created = match DateTime::parse_from_rfc3339(&self.created_at) {
400            Ok(dt) => dt.with_timezone(&Utc),
401            Err(_) => return 0,
402        };
403        let now = Utc::now();
404        let elapsed = now.signed_duration_since(created).num_seconds() as u64;
405        self.ttl_seconds.saturating_sub(elapsed)
406    }
407
408    /// Build a Provider struct from this cached entry.
409    pub fn to_provider(&self) -> Provider {
410        let auth_type = match self.auth_type.as_str() {
411            "bearer" => AuthType::Bearer,
412            "header" => AuthType::Header,
413            "query" => AuthType::Query,
414            "basic" => AuthType::Basic,
415            "oauth2" => AuthType::Oauth2,
416            _ => AuthType::None,
417        };
418
419        let handler = match self.provider_type.as_str() {
420            "mcp" => "mcp".to_string(),
421            "openapi" => "openapi".to_string(),
422            _ => "http".to_string(),
423        };
424
425        Provider {
426            name: self.name.clone(),
427            description: format!("{} (cached)", self.name),
428            base_url: self.base_url.clone(),
429            auth_type,
430            auth_key_name: self.auth_key_name.clone(),
431            auth_header_name: self.auth_header_name.clone(),
432            auth_query_name: self.auth_query_name.clone(),
433            auth_value_prefix: None,
434            extra_headers: HashMap::new(),
435            oauth2_token_url: None,
436            auth_secret_name: None,
437            oauth2_basic_auth: false,
438            internal: false,
439            handler,
440            mcp_transport: self.mcp_transport.clone(),
441            mcp_command: self.mcp_command.clone(),
442            mcp_args: self.mcp_args.clone(),
443            mcp_url: self.mcp_url.clone(),
444            mcp_env: self.mcp_env.clone(),
445            openapi_spec: None,
446            openapi_include_tags: Vec::new(),
447            openapi_exclude_tags: Vec::new(),
448            openapi_include_operations: Vec::new(),
449            openapi_exclude_operations: Vec::new(),
450            openapi_max_operations: None,
451            openapi_overrides: HashMap::new(),
452            cli_command: self.cli_command.clone(),
453            cli_default_args: self.cli_default_args.clone(),
454            cli_env: self.cli_env.clone(),
455            cli_timeout_secs: self.cli_timeout_secs,
456            auth_generator: None,
457            category: None,
458            skills: self.skills.clone(),
459        }
460    }
461}
462
463/// A tool discovered from an MCP server via tools/list.
464/// Converted into a Tool for the registry.
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct McpToolDef {
467    pub name: String,
468    #[serde(default)]
469    pub description: Option<String>,
470    #[serde(default, rename = "inputSchema")]
471    pub input_schema: Option<serde_json::Value>,
472}
473
474/// Registry holding all loaded manifests, with indexes for fast lookup.
475pub struct ManifestRegistry {
476    manifests: Vec<Manifest>,
477    /// tool_name -> (manifest_index, tool_index)
478    tool_index: HashMap<String, (usize, usize)>,
479}
480
481impl ManifestRegistry {
482    /// Load all .toml manifests from a directory.
483    /// OpenAPI providers (handler = "openapi") have their specs loaded and tools auto-registered.
484    pub fn load(dir: &Path) -> Result<Self, ManifestError> {
485        if !dir.is_dir() {
486            return Err(ManifestError::NoDirectory(dir.display().to_string()));
487        }
488
489        let mut manifests = Vec::new();
490        let mut tool_index = HashMap::new();
491
492        let pattern = dir.join("*.toml");
493        let entries = glob::glob(pattern.to_str().unwrap_or(""))
494            .map_err(|e| ManifestError::NoDirectory(e.to_string()))?;
495
496        // Resolve specs dir: sibling of manifests dir (e.g., ~/.ati/specs/)
497        let specs_dir = dir.parent().map(|p| p.join("specs"));
498
499        for entry in entries {
500            let path = entry.map_err(|e| {
501                ManifestError::Io(format!("{e}"), std::io::Error::other("glob error"))
502            })?;
503            let contents = std::fs::read_to_string(&path)
504                .map_err(|e| ManifestError::Io(path.display().to_string(), e))?;
505            let mut manifest: Manifest = toml::from_str(&contents)
506                .map_err(|e| ManifestError::Parse(path.display().to_string(), e))?;
507
508            // For OpenAPI providers, load spec and register tools
509            if manifest.provider.is_openapi() {
510                if let Some(spec_ref) = &manifest.provider.openapi_spec {
511                    match crate::core::openapi::load_and_register(
512                        &manifest.provider,
513                        spec_ref,
514                        specs_dir.as_deref(),
515                    ) {
516                        Ok(tools) => {
517                            manifest.tools = tools;
518                        }
519                        Err(e) => {
520                            tracing::warn!(
521                                provider = %manifest.provider.name,
522                                error = %e,
523                                "failed to load OpenAPI spec for provider"
524                            );
525                            // Graceful degradation — continue without tools
526                        }
527                    }
528                }
529            }
530
531            // For CLI providers with no [[tools]], auto-register one implicit tool
532            if manifest.provider.is_cli() && manifest.tools.is_empty() {
533                let tool_name = manifest.provider.name.clone();
534                manifest.tools.push(Tool {
535                    name: tool_name.clone(),
536                    description: manifest.provider.description.clone(),
537                    endpoint: String::new(),
538                    method: HttpMethod::Get,
539                    scope: Some(format!("tool:{tool_name}")),
540                    input_schema: None,
541                    response: None,
542                    tags: Vec::new(),
543                    hint: None,
544                    examples: Vec::new(),
545                });
546            }
547
548            // Auto-assign scope to tools that don't have one explicitly set.
549            // This ensures all tools participate in JWT scope filtering.
550            let provider_name = &manifest.provider.name;
551            for tool in &mut manifest.tools {
552                if tool.scope.is_none() && !manifest.provider.internal {
553                    tool.scope = Some(format!("tool:{}", tool.name));
554                    tracing::trace!(
555                        tool = %tool.name,
556                        provider = %provider_name,
557                        scope = ?tool.scope,
558                        "auto-assigned scope to tool"
559                    );
560                }
561            }
562
563            let mi = manifests.len();
564            for (ti, tool) in manifest.tools.iter().enumerate() {
565                tool_index.insert(tool.name.clone(), (mi, ti));
566            }
567            manifests.push(manifest);
568        }
569
570        // Load cached providers from cache/providers/*.json
571        // Cache dir is sibling of manifests dir: e.g., ~/.ati/cache/providers/
572        if let Some(parent) = dir.parent() {
573            let cache_dir = parent.join("cache").join("providers");
574            if cache_dir.is_dir() {
575                let cache_pattern = cache_dir.join("*.json");
576                if let Ok(cache_entries) = glob::glob(cache_pattern.to_str().unwrap_or("")) {
577                    for entry in cache_entries {
578                        let path = match entry {
579                            Ok(p) => p,
580                            Err(_) => continue,
581                        };
582                        let content = match std::fs::read_to_string(&path) {
583                            Ok(c) => c,
584                            Err(_) => continue,
585                        };
586                        let cached: CachedProvider = match serde_json::from_str(&content) {
587                            Ok(c) => c,
588                            Err(_) => continue,
589                        };
590
591                        // Skip and delete expired entries
592                        if cached.is_expired() {
593                            let _ = std::fs::remove_file(&path);
594                            continue;
595                        }
596
597                        // Skip if a permanent manifest with same provider name already exists
598                        if manifests.iter().any(|m| m.provider.name == cached.name) {
599                            continue;
600                        }
601
602                        let provider = cached.to_provider();
603
604                        let mut cached_tools = Vec::new();
605                        if cached.provider_type == "openapi" {
606                            if let Some(spec_content) = &cached.spec_content {
607                                if let Ok(spec) = crate::core::openapi::parse_spec(spec_content) {
608                                    let filters = crate::core::openapi::OpenApiFilters {
609                                        include_tags: vec![],
610                                        exclude_tags: vec![],
611                                        include_operations: vec![],
612                                        exclude_operations: vec![],
613                                        max_operations: None,
614                                    };
615                                    let defs = crate::core::openapi::extract_tools(&spec, &filters);
616                                    cached_tools = defs
617                                        .into_iter()
618                                        .map(|def| {
619                                            crate::core::openapi::to_ati_tool(
620                                                def,
621                                                &cached.name,
622                                                &HashMap::new(),
623                                            )
624                                        })
625                                        .collect();
626                                }
627                            }
628                        }
629                        // MCP providers have empty tools — lazy discovery at run time
630
631                        let mi = manifests.len();
632                        for (ti, tool) in cached_tools.iter().enumerate() {
633                            tool_index.insert(tool.name.clone(), (mi, ti));
634                        }
635                        manifests.push(Manifest {
636                            provider,
637                            tools: cached_tools,
638                        });
639                    }
640                }
641            }
642        }
643
644        Ok(ManifestRegistry {
645            manifests,
646            tool_index,
647        })
648    }
649
650    /// Create an empty registry (no manifests loaded).
651    pub fn empty() -> Self {
652        ManifestRegistry {
653            manifests: Vec::new(),
654            tool_index: HashMap::new(),
655        }
656    }
657
658    /// Look up a tool by name. Returns the provider and tool definition.
659    pub fn get_tool(&self, name: &str) -> Option<(&Provider, &Tool)> {
660        self.tool_index.get(name).map(|(mi, ti)| {
661            let m = &self.manifests[*mi];
662            (&m.provider, &m.tools[*ti])
663        })
664    }
665
666    /// List all tools across all providers.
667    pub fn list_tools(&self) -> Vec<(&Provider, &Tool)> {
668        self.manifests
669            .iter()
670            .flat_map(|m| m.tools.iter().map(move |t| (&m.provider, t)))
671            .collect()
672    }
673
674    /// List all providers.
675    pub fn list_providers(&self) -> Vec<&Provider> {
676        self.manifests.iter().map(|m| &m.provider).collect()
677    }
678
679    /// List all non-internal tools (excludes providers marked internal=true).
680    pub fn list_public_tools(&self) -> Vec<(&Provider, &Tool)> {
681        self.manifests
682            .iter()
683            .filter(|m| !m.provider.internal)
684            .flat_map(|m| m.tools.iter().map(move |t| (&m.provider, t)))
685            .collect()
686    }
687
688    /// Get the number of loaded tools.
689    pub fn tool_count(&self) -> usize {
690        self.tool_index.len()
691    }
692
693    /// Get the number of loaded providers.
694    pub fn provider_count(&self) -> usize {
695        self.manifests.len()
696    }
697
698    /// List all MCP providers (handler = "mcp").
699    pub fn list_mcp_providers(&self) -> Vec<&Provider> {
700        self.manifests
701            .iter()
702            .filter(|m| m.provider.handler == "mcp")
703            .map(|m| &m.provider)
704            .collect()
705    }
706
707    /// If `tool_name` has a `<provider>:<name>` prefix matching an MCP provider, return it.
708    pub fn find_mcp_provider_for_tool(&self, tool_name: &str) -> Option<&Provider> {
709        let prefix = tool_name.split(TOOL_SEP).next()?;
710        self.manifests
711            .iter()
712            .find(|m| m.provider.handler == "mcp" && m.provider.name == prefix)
713            .map(|m| &m.provider)
714    }
715
716    /// List all OpenAPI providers (handler = "openapi").
717    pub fn list_openapi_providers(&self) -> Vec<&Provider> {
718        self.manifests
719            .iter()
720            .filter(|m| m.provider.handler == "openapi")
721            .map(|m| &m.provider)
722            .collect()
723    }
724
725    /// Check if a provider with the given name exists.
726    pub fn has_provider(&self, name: &str) -> bool {
727        self.manifests.iter().any(|m| m.provider.name == name)
728    }
729
730    /// Get tools belonging to a specific provider.
731    pub fn tools_by_provider(&self, provider_name: &str) -> Vec<(&Provider, &Tool)> {
732        self.manifests
733            .iter()
734            .filter(|m| m.provider.name == provider_name)
735            .flat_map(|m| m.tools.iter().map(move |t| (&m.provider, t)))
736            .collect()
737    }
738
739    /// List all CLI providers (handler = "cli").
740    pub fn list_cli_providers(&self) -> Vec<&Provider> {
741        self.manifests
742            .iter()
743            .filter(|m| m.provider.handler == "cli")
744            .map(|m| &m.provider)
745            .collect()
746    }
747
748    /// Register dynamically discovered MCP tools for a provider.
749    /// Tools are prefixed with provider name: `"github:read_file"`.
750    pub fn register_mcp_tools(&mut self, provider_name: &str, mcp_tools: Vec<McpToolDef>) {
751        // Find the manifest for this provider
752        let mi = match self
753            .manifests
754            .iter()
755            .position(|m| m.provider.name == provider_name)
756        {
757            Some(idx) => idx,
758            None => return,
759        };
760
761        for mcp_tool in mcp_tools {
762            let prefixed_name = format!("{}{}{}", provider_name, TOOL_SEP_STR, mcp_tool.name);
763
764            let tool = Tool {
765                name: prefixed_name.clone(),
766                description: mcp_tool.description.unwrap_or_default(),
767                endpoint: String::new(),
768                method: HttpMethod::Post,
769                scope: Some(format!("tool:{prefixed_name}")),
770                input_schema: mcp_tool.input_schema,
771                response: None,
772                tags: Vec::new(),
773                hint: None,
774                examples: Vec::new(),
775            };
776
777            let ti = self.manifests[mi].tools.len();
778            self.manifests[mi].tools.push(tool);
779            self.tool_index.insert(prefixed_name, (mi, ti));
780        }
781    }
782}
783
784impl Provider {
785    /// Returns true if this provider uses MCP protocol.
786    pub fn is_mcp(&self) -> bool {
787        self.handler == "mcp"
788    }
789
790    /// Returns true if this provider uses OpenAPI spec-based tool discovery.
791    pub fn is_openapi(&self) -> bool {
792        self.handler == "openapi"
793    }
794
795    /// Returns true if this provider uses CLI handler.
796    pub fn is_cli(&self) -> bool {
797        self.handler == "cli"
798    }
799
800    /// Returns the MCP transport type, defaulting to "stdio".
801    pub fn mcp_transport_type(&self) -> &str {
802        self.mcp_transport.as_deref().unwrap_or("stdio")
803    }
804}