1use crate::core::Prompt;
7use llm::{LlmModel, ProviderConnectionOverrides, ReasoningEffort, ToolDefinition};
8use mcp_utils::client::McpConfig;
9use std::path::PathBuf;
10
11#[derive(Debug, Clone)]
12pub enum McpConfigSource {
13 File { path: PathBuf, proxy: bool },
14 Json(String),
15 Inline(McpConfig),
16}
17
18impl McpConfigSource {
19 pub fn file(path: PathBuf, proxy: bool) -> Self {
20 Self::File { path, proxy }
21 }
22
23 pub fn direct(path: PathBuf) -> Self {
24 Self::file(path, false)
25 }
26
27 pub fn proxied(path: PathBuf) -> Self {
28 Self::file(path, true)
29 }
30}
31
32#[derive(Debug, Clone)]
37pub struct AgentSpec {
38 pub name: String,
40 pub description: String,
42 pub model: String,
48 pub reasoning_effort: Option<ReasoningEffort>,
50 pub context_window: Option<u32>,
52 pub prompts: Vec<Prompt>,
54 pub provider_connections: ProviderConnectionOverrides,
56 pub mcp_config_sources: Vec<McpConfigSource>,
61 pub exposure: AgentSpecExposure,
63 pub tools: ToolFilter,
65}
66
67impl AgentSpec {
68 pub fn default_spec(model: &LlmModel, reasoning_effort: Option<ReasoningEffort>, prompts: Vec<Prompt>) -> Self {
70 Self {
71 name: "__default__".to_string(),
72 description: "Default agent".to_string(),
73 model: model.to_string(),
74 reasoning_effort,
75 context_window: None,
76 prompts,
77 provider_connections: ProviderConnectionOverrides::default(),
78 mcp_config_sources: Vec::new(),
79 exposure: AgentSpecExposure::none(),
80 tools: ToolFilter::default(),
81 }
82 }
83}
84
85#[doc = ""]
91#[doc = include_str!("docs/tool_filter.md")]
92#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
93pub struct ToolFilter {
94 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub allow: Vec<String>,
97 #[serde(default, skip_serializing_if = "Vec::is_empty")]
99 pub deny: Vec<String>,
100}
101
102impl ToolFilter {
103 pub fn is_empty(&self) -> bool {
104 self.allow.is_empty() && self.deny.is_empty()
105 }
106
107 pub fn apply(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
109 tools.into_iter().filter(|t| self.is_allowed(&t.name)).collect()
110 }
111
112 pub fn is_allowed(&self, tool_name: &str) -> bool {
114 let allowed = self.allow.is_empty() || self.allow.iter().any(|p| matches_pattern(p, tool_name));
115 allowed && !self.deny.iter().any(|p| matches_pattern(p, tool_name))
116 }
117}
118
119fn matches_pattern(pattern: &str, name: &str) -> bool {
121 if let Some(prefix) = pattern.strip_suffix('*') { name.starts_with(prefix) } else { pattern == name }
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
126pub struct AgentSpecExposure {
127 pub user_invocable: bool,
129 pub agent_invocable: bool,
131}
132
133impl AgentSpecExposure {
134 pub fn none() -> Self {
140 Self { user_invocable: false, agent_invocable: false }
141 }
142
143 pub fn user_only() -> Self {
145 Self { user_invocable: true, agent_invocable: false }
146 }
147
148 pub fn agent_only() -> Self {
150 Self { user_invocable: false, agent_invocable: true }
151 }
152
153 pub fn both() -> Self {
155 Self { user_invocable: true, agent_invocable: true }
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn default_spec_has_expected_fields() {
165 let model: LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
166 let prompts = vec![Prompt::file(PathBuf::from("/tmp/BASE.md"), PathBuf::from("/tmp"))];
167 let spec = AgentSpec::default_spec(&model, None, prompts.clone());
168
169 assert_eq!(spec.name, "__default__");
170 assert_eq!(spec.description, "Default agent");
171 assert_eq!(spec.model, model.to_string());
172 assert!(spec.reasoning_effort.is_none());
173 assert_eq!(spec.prompts.len(), 1);
174 assert!(spec.mcp_config_sources.is_empty());
175 assert_eq!(spec.exposure, AgentSpecExposure::none());
176 }
177
178 fn make_tool(name: &str) -> ToolDefinition {
179 ToolDefinition { name: name.to_string(), description: String::new(), parameters: String::new(), server: None }
180 }
181
182 #[test]
183 fn empty_filter_allows_all_tools() {
184 let filter = ToolFilter::default();
185 let tools = vec![make_tool("bash"), make_tool("read_file")];
186 let result = filter.apply(tools);
187 assert_eq!(result.len(), 2);
188 }
189
190 #[test]
191 fn allow_keeps_only_matching_tools() {
192 let filter = ToolFilter { allow: vec!["read_file".to_string(), "grep".to_string()], deny: vec![] };
193 let tools = vec![make_tool("bash"), make_tool("read_file"), make_tool("grep")];
194 let result = filter.apply(tools);
195 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
196 assert_eq!(names, vec!["read_file", "grep"]);
197 }
198
199 #[test]
200 fn deny_removes_matching_tools() {
201 let filter = ToolFilter { allow: vec![], deny: vec!["bash".to_string()] };
202 let tools = vec![make_tool("bash"), make_tool("read_file")];
203 let result = filter.apply(tools);
204 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
205 assert_eq!(names, vec!["read_file"]);
206 }
207
208 #[test]
209 fn wildcard_matching() {
210 let filter = ToolFilter { allow: vec!["coding__*".to_string()], deny: vec![] };
211 let tools = vec![make_tool("coding__grep"), make_tool("coding__read_file"), make_tool("plugins__bash")];
212 let result = filter.apply(tools);
213 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
214 assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
215 }
216
217 #[test]
218 fn combined_allow_and_deny() {
219 let filter =
220 { ToolFilter { allow: vec!["coding__*".to_string()], deny: vec!["coding__write_file".to_string()] } };
221 let tools = vec![
222 make_tool("coding__grep"),
223 make_tool("coding__write_file"),
224 make_tool("coding__read_file"),
225 make_tool("plugins__bash"),
226 ];
227 let result = filter.apply(tools);
228 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
229 assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
230 }
231
232 #[test]
233 fn is_allowed_exact_match() {
234 let filter = ToolFilter { allow: vec!["bash".to_string()], deny: vec![] };
235 assert!(filter.is_allowed("bash"));
236 assert!(!filter.is_allowed("bash_extended"));
237 }
238
239 #[test]
240 fn matches_pattern_exact_and_wildcard() {
241 assert!(matches_pattern("foo", "foo"));
242 assert!(!matches_pattern("foo", "foobar"));
243 assert!(matches_pattern("foo*", "foobar"));
244 assert!(matches_pattern("foo*", "foo"));
245 assert!(!matches_pattern("bar*", "foo"));
246 }
247}