1use crate::core::Prompt;
7use llm::{LlmModel, 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 prompts: Vec<Prompt>,
58 pub mcp_config_sources: Vec<McpConfigSource>,
63 pub exposure: AgentSpecExposure,
65 pub tools: ToolFilter,
67}
68
69impl AgentSpec {
70 pub fn default_spec(model: &LlmModel, reasoning_effort: Option<ReasoningEffort>, prompts: Vec<Prompt>) -> Self {
72 Self {
73 name: "__default__".to_string(),
74 description: "Default agent".to_string(),
75 model: model.to_string(),
76 reasoning_effort,
77 prompts,
78 mcp_config_sources: Vec::new(),
79 exposure: AgentSpecExposure::none(),
80 tools: ToolFilter::default(),
81 }
82 }
83}
84
85#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
91pub struct ToolFilter {
92 #[serde(default, skip_serializing_if = "Vec::is_empty")]
94 pub allow: Vec<String>,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
97 pub deny: Vec<String>,
98}
99
100impl ToolFilter {
101 pub fn is_empty(&self) -> bool {
102 self.allow.is_empty() && self.deny.is_empty()
103 }
104
105 pub fn apply(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
107 tools.into_iter().filter(|t| self.is_allowed(&t.name)).collect()
108 }
109
110 pub fn is_allowed(&self, tool_name: &str) -> bool {
112 let allowed = self.allow.is_empty() || self.allow.iter().any(|p| matches_pattern(p, tool_name));
113 allowed && !self.deny.iter().any(|p| matches_pattern(p, tool_name))
114 }
115}
116
117fn matches_pattern(pattern: &str, name: &str) -> bool {
119 if let Some(prefix) = pattern.strip_suffix('*') { name.starts_with(prefix) } else { pattern == name }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
124pub struct AgentSpecExposure {
125 pub user_invocable: bool,
127 pub agent_invocable: bool,
129}
130
131impl AgentSpecExposure {
132 pub fn none() -> Self {
138 Self { user_invocable: false, agent_invocable: false }
139 }
140
141 pub fn user_only() -> Self {
143 Self { user_invocable: true, agent_invocable: false }
144 }
145
146 pub fn agent_only() -> Self {
148 Self { user_invocable: false, agent_invocable: true }
149 }
150
151 pub fn both() -> Self {
153 Self { user_invocable: true, agent_invocable: true }
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn default_spec_has_expected_fields() {
163 let model: LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
164 let prompts = vec![Prompt::from_globs(vec!["BASE.md".to_string()], PathBuf::from("/tmp"))];
165 let spec = AgentSpec::default_spec(&model, None, prompts.clone());
166
167 assert_eq!(spec.name, "__default__");
168 assert_eq!(spec.description, "Default agent");
169 assert_eq!(spec.model, model.to_string());
170 assert!(spec.reasoning_effort.is_none());
171 assert_eq!(spec.prompts.len(), 1);
172 assert!(spec.mcp_config_sources.is_empty());
173 assert_eq!(spec.exposure, AgentSpecExposure::none());
174 }
175
176 fn make_tool(name: &str) -> ToolDefinition {
177 ToolDefinition { name: name.to_string(), description: String::new(), parameters: String::new(), server: None }
178 }
179
180 #[test]
181 fn empty_filter_allows_all_tools() {
182 let filter = ToolFilter::default();
183 let tools = vec![make_tool("bash"), make_tool("read_file")];
184 let result = filter.apply(tools);
185 assert_eq!(result.len(), 2);
186 }
187
188 #[test]
189 fn allow_keeps_only_matching_tools() {
190 let filter = ToolFilter { allow: vec!["read_file".to_string(), "grep".to_string()], deny: vec![] };
191 let tools = vec![make_tool("bash"), make_tool("read_file"), make_tool("grep")];
192 let result = filter.apply(tools);
193 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
194 assert_eq!(names, vec!["read_file", "grep"]);
195 }
196
197 #[test]
198 fn deny_removes_matching_tools() {
199 let filter = ToolFilter { allow: vec![], deny: vec!["bash".to_string()] };
200 let tools = vec![make_tool("bash"), make_tool("read_file")];
201 let result = filter.apply(tools);
202 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
203 assert_eq!(names, vec!["read_file"]);
204 }
205
206 #[test]
207 fn wildcard_matching() {
208 let filter = ToolFilter { allow: vec!["coding__*".to_string()], deny: vec![] };
209 let tools = vec![make_tool("coding__grep"), make_tool("coding__read_file"), make_tool("plugins__bash")];
210 let result = filter.apply(tools);
211 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
212 assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
213 }
214
215 #[test]
216 fn combined_allow_and_deny() {
217 let filter =
218 { ToolFilter { allow: vec!["coding__*".to_string()], deny: vec!["coding__write_file".to_string()] } };
219 let tools = vec![
220 make_tool("coding__grep"),
221 make_tool("coding__write_file"),
222 make_tool("coding__read_file"),
223 make_tool("plugins__bash"),
224 ];
225 let result = filter.apply(tools);
226 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
227 assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
228 }
229
230 #[test]
231 fn is_allowed_exact_match() {
232 let filter = ToolFilter { allow: vec!["bash".to_string()], deny: vec![] };
233 assert!(filter.is_allowed("bash"));
234 assert!(!filter.is_allowed("bash_extended"));
235 }
236
237 #[test]
238 fn matches_pattern_exact_and_wildcard() {
239 assert!(matches_pattern("foo", "foo"));
240 assert!(!matches_pattern("foo", "foobar"));
241 assert!(matches_pattern("foo*", "foobar"));
242 assert!(matches_pattern("foo*", "foo"));
243 assert!(!matches_pattern("bar*", "foo"));
244 }
245}