Skip to main content

agent_tools_interface/core/
manifest.rs

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