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