1use crate::core::Prompt;
7use llm::{LlmModel, ReasoningEffort, ToolDefinition};
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct McpJsonFileRef {
13 pub path: PathBuf,
14 pub proxy: bool,
15}
16
17impl McpJsonFileRef {
18 pub fn direct(path: PathBuf) -> Self {
19 Self { path, proxy: false }
20 }
21
22 pub fn proxied(path: PathBuf) -> Self {
23 Self { path, proxy: true }
24 }
25}
26
27#[derive(Debug, Clone)]
32pub struct AgentSpec {
33 pub name: String,
35 pub description: String,
37 pub model: String,
43 pub reasoning_effort: Option<ReasoningEffort>,
45 pub prompts: Vec<Prompt>,
53 pub mcp_config_refs: Vec<McpJsonFileRef>,
59 pub exposure: AgentSpecExposure,
61 pub tools: ToolFilter,
63}
64
65impl AgentSpec {
66 pub fn default_spec(model: &LlmModel, reasoning_effort: Option<ReasoningEffort>, prompts: Vec<Prompt>) -> Self {
68 Self {
69 name: "__default__".to_string(),
70 description: "Default agent".to_string(),
71 model: model.to_string(),
72 reasoning_effort,
73 prompts,
74 mcp_config_refs: Vec::new(),
75 exposure: AgentSpecExposure::none(),
76 tools: ToolFilter::default(),
77 }
78 }
79
80 pub fn resolve_mcp_config(&mut self, inherited: &[McpJsonFileRef], cwd: &Path) {
85 if !self.mcp_config_refs.is_empty() {
86 return;
87 }
88 if !inherited.is_empty() {
89 self.mcp_config_refs = inherited.to_vec();
90 return;
91 }
92 let cwd_mcp = cwd.join("mcp.json");
93 if cwd_mcp.is_file() {
94 self.mcp_config_refs = vec![McpJsonFileRef::direct(cwd_mcp)];
95 }
96 }
97}
98
99#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
105pub struct ToolFilter {
106 #[serde(default, skip_serializing_if = "Vec::is_empty")]
108 pub allow: Vec<String>,
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub deny: Vec<String>,
112}
113
114impl ToolFilter {
115 pub fn is_empty(&self) -> bool {
116 self.allow.is_empty() && self.deny.is_empty()
117 }
118
119 pub fn apply(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
121 tools.into_iter().filter(|t| self.is_allowed(&t.name)).collect()
122 }
123
124 pub fn is_allowed(&self, tool_name: &str) -> bool {
126 let allowed = self.allow.is_empty() || self.allow.iter().any(|p| matches_pattern(p, tool_name));
127 allowed && !self.deny.iter().any(|p| matches_pattern(p, tool_name))
128 }
129}
130
131fn matches_pattern(pattern: &str, name: &str) -> bool {
133 if let Some(prefix) = pattern.strip_suffix('*') { name.starts_with(prefix) } else { pattern == name }
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
138pub struct AgentSpecExposure {
139 pub user_invocable: bool,
141 pub agent_invocable: bool,
143}
144
145impl AgentSpecExposure {
146 pub fn none() -> Self {
152 Self { user_invocable: false, agent_invocable: false }
153 }
154
155 pub fn user_only() -> Self {
157 Self { user_invocable: true, agent_invocable: false }
158 }
159
160 pub fn agent_only() -> Self {
162 Self { user_invocable: false, agent_invocable: true }
163 }
164
165 pub fn both() -> Self {
167 Self { user_invocable: true, agent_invocable: true }
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use std::fs;
175
176 fn make_spec() -> AgentSpec {
177 AgentSpec {
178 name: "test".to_string(),
179 description: "Test agent".to_string(),
180 model: "anthropic:claude-sonnet-4-5".to_string(),
181 reasoning_effort: None,
182 prompts: vec![],
183 mcp_config_refs: Vec::new(),
184 exposure: AgentSpecExposure::both(),
185 tools: ToolFilter::default(),
186 }
187 }
188
189 #[test]
190 fn default_spec_has_expected_fields() {
191 let model: LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
192 let prompts = vec![Prompt::from_globs(vec!["BASE.md".to_string()], PathBuf::from("/tmp"))];
193 let spec = AgentSpec::default_spec(&model, None, prompts.clone());
194
195 assert_eq!(spec.name, "__default__");
196 assert_eq!(spec.description, "Default agent");
197 assert_eq!(spec.model, model.to_string());
198 assert!(spec.reasoning_effort.is_none());
199 assert_eq!(spec.prompts.len(), 1);
200 assert!(spec.mcp_config_refs.is_empty());
201 assert_eq!(spec.exposure, AgentSpecExposure::none());
202 }
203
204 #[test]
205 fn resolve_mcp_prefers_agent_local_refs() {
206 let dir = tempfile::tempdir().unwrap();
207 let agent_path = dir.path().join("agent-mcp.json");
208 let inherited_path = dir.path().join("inherited-mcp.json");
209 fs::write(&agent_path, "{}").unwrap();
210 fs::write(&inherited_path, "{}").unwrap();
211
212 let mut spec = make_spec();
213 spec.mcp_config_refs = vec![McpJsonFileRef::direct(agent_path.clone())];
214
215 spec.resolve_mcp_config(&[McpJsonFileRef::direct(inherited_path)], dir.path());
216 assert_eq!(spec.mcp_config_refs, vec![McpJsonFileRef::direct(agent_path)]);
217 }
218
219 #[test]
220 fn resolve_mcp_falls_back_to_inherited() {
221 let dir = tempfile::tempdir().unwrap();
222 let inherited_path = dir.path().join("inherited-mcp.json");
223 fs::write(&inherited_path, "{}").unwrap();
224 fs::write(dir.path().join("mcp.json"), "{}").unwrap();
225
226 let mut spec = make_spec();
227 let inherited_ref = McpJsonFileRef::direct(inherited_path.clone());
228 spec.resolve_mcp_config(std::slice::from_ref(&inherited_ref), dir.path());
229 assert_eq!(spec.mcp_config_refs, vec![McpJsonFileRef::direct(inherited_path)]);
230 }
231
232 #[test]
233 fn resolve_mcp_falls_back_to_cwd() {
234 let dir = tempfile::tempdir().unwrap();
235 fs::write(dir.path().join("mcp.json"), "{}").unwrap();
236
237 let mut spec = make_spec();
238 spec.resolve_mcp_config(&[], dir.path());
239 assert_eq!(spec.mcp_config_refs, vec![McpJsonFileRef::direct(dir.path().join("mcp.json"))]);
240 }
241
242 #[test]
243 fn resolve_mcp_yields_empty_when_nothing_found() {
244 let dir = tempfile::tempdir().unwrap();
245 let mut spec = make_spec();
246 spec.resolve_mcp_config(&[], dir.path());
247 assert!(spec.mcp_config_refs.is_empty());
248 }
249
250 fn make_tool(name: &str) -> ToolDefinition {
251 ToolDefinition { name: name.to_string(), description: String::new(), parameters: String::new(), server: None }
252 }
253
254 #[test]
255 fn empty_filter_allows_all_tools() {
256 let filter = ToolFilter::default();
257 let tools = vec![make_tool("bash"), make_tool("read_file")];
258 let result = filter.apply(tools);
259 assert_eq!(result.len(), 2);
260 }
261
262 #[test]
263 fn allow_keeps_only_matching_tools() {
264 let filter = ToolFilter { allow: vec!["read_file".to_string(), "grep".to_string()], deny: vec![] };
265 let tools = vec![make_tool("bash"), make_tool("read_file"), make_tool("grep")];
266 let result = filter.apply(tools);
267 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
268 assert_eq!(names, vec!["read_file", "grep"]);
269 }
270
271 #[test]
272 fn deny_removes_matching_tools() {
273 let filter = ToolFilter { allow: vec![], deny: vec!["bash".to_string()] };
274 let tools = vec![make_tool("bash"), make_tool("read_file")];
275 let result = filter.apply(tools);
276 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
277 assert_eq!(names, vec!["read_file"]);
278 }
279
280 #[test]
281 fn wildcard_matching() {
282 let filter = ToolFilter { allow: vec!["coding__*".to_string()], deny: vec![] };
283 let tools = vec![make_tool("coding__grep"), make_tool("coding__read_file"), make_tool("plugins__bash")];
284 let result = filter.apply(tools);
285 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
286 assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
287 }
288
289 #[test]
290 fn combined_allow_and_deny() {
291 let filter = ToolFilter { allow: vec!["coding__*".to_string()], deny: vec!["coding__write_file".to_string()] };
292 let tools = vec![
293 make_tool("coding__grep"),
294 make_tool("coding__write_file"),
295 make_tool("coding__read_file"),
296 make_tool("plugins__bash"),
297 ];
298 let result = filter.apply(tools);
299 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
300 assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
301 }
302
303 #[test]
304 fn is_allowed_exact_match() {
305 let filter = ToolFilter { allow: vec!["bash".to_string()], deny: vec![] };
306 assert!(filter.is_allowed("bash"));
307 assert!(!filter.is_allowed("bash_extended"));
308 }
309
310 #[test]
311 fn matches_pattern_exact_and_wildcard() {
312 assert!(matches_pattern("foo", "foo"));
313 assert!(!matches_pattern("foo", "foobar"));
314 assert!(matches_pattern("foo*", "foobar"));
315 assert!(matches_pattern("foo*", "foo"));
316 assert!(!matches_pattern("bar*", "foo"));
317 }
318}