1use crate::core::Prompt;
7use llm::{LlmModel, ModelSettings, ProviderConnectionOverrides, ReasoningEffort};
8use mcp_utils::client::{McpConfig, ToolFilter};
9use std::path::PathBuf;
10
11#[derive(Debug, Clone)]
12pub enum McpConfigSource {
13 File { path: PathBuf, defer_tools: bool },
14 Json(String),
15 Inline(McpConfig),
16}
17
18impl McpConfigSource {
19 pub fn file(path: PathBuf, defer_tools: bool) -> Self {
20 Self::File { path, defer_tools }
21 }
22
23 pub fn model_visible(path: PathBuf) -> Self {
24 Self::file(path, false)
25 }
26
27 pub fn deferred(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 model_settings: ModelSettings,
52 pub context_window: Option<u32>,
54 pub prompts: Vec<Prompt>,
56 pub provider_connections: ProviderConnectionOverrides,
58 pub mcp_config_sources: Vec<McpConfigSource>,
63 pub exposure: AgentSpecExposure,
65 pub tools: ToolFilter,
67}
68
69impl AgentSpec {
70 pub fn bare(model: &LlmModel, reasoning_effort: Option<ReasoningEffort>, prompts: Vec<Prompt>) -> Self {
73 Self {
74 name: "__default__".to_string(),
75 description: "Default agent".to_string(),
76 model: model.to_string(),
77 reasoning_effort,
78 model_settings: ModelSettings::default(),
79 context_window: None,
80 prompts,
81 provider_connections: ProviderConnectionOverrides::default(),
82 mcp_config_sources: Vec::new(),
83 exposure: AgentSpecExposure::none(),
84 tools: ToolFilter::default(),
85 }
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
91pub struct AgentSpecExposure {
92 pub user_invocable: bool,
94 pub agent_invocable: bool,
96}
97
98impl AgentSpecExposure {
99 pub fn none() -> Self {
105 Self { user_invocable: false, agent_invocable: false }
106 }
107
108 pub fn user_only() -> Self {
110 Self { user_invocable: true, agent_invocable: false }
111 }
112
113 pub fn agent_only() -> Self {
115 Self { user_invocable: false, agent_invocable: true }
116 }
117
118 pub fn both() -> Self {
120 Self { user_invocable: true, agent_invocable: true }
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use llm::{ToolAnnotations, ToolDefinition};
128 use mcp_utils::client::{ToolAnnotationMatcher, ToolMatcher};
129
130 #[test]
131 fn default_spec_has_expected_fields() {
132 let model: LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
133 let prompts = vec![Prompt::file(PathBuf::from("/tmp/BASE.md"), PathBuf::from("/tmp"))];
134 let spec = AgentSpec::bare(&model, None, prompts.clone());
135
136 assert_eq!(spec.name, "__default__");
137 assert_eq!(spec.description, "Default agent");
138 assert_eq!(spec.model, model.to_string());
139 assert!(spec.reasoning_effort.is_none());
140 assert_eq!(spec.prompts.len(), 1);
141 assert!(spec.mcp_config_sources.is_empty());
142 assert_eq!(spec.exposure, AgentSpecExposure::none());
143 }
144
145 fn make_tool(name: &str) -> ToolDefinition {
146 ToolDefinition::new(name, "", serde_json::json!({}))
147 }
148
149 fn make_annotated_tool(name: &str, annotations: ToolAnnotations) -> ToolDefinition {
150 ToolDefinition::new(name, "", serde_json::json!({})).with_annotations(annotations)
151 }
152
153 #[test]
154 fn empty_filter_allows_all_tools() {
155 let filter = ToolFilter::default();
156 let tools = vec![make_tool("bash"), make_tool("read_file")];
157 let result = filter.apply(tools);
158 assert_eq!(result.len(), 2);
159 }
160
161 #[test]
162 fn allow_keeps_only_matching_tools() {
163 let filter =
164 ToolFilter { allow: vec![ToolMatcher::name("read_file"), ToolMatcher::name("grep")], deny: vec![] };
165 let tools = vec![make_tool("bash"), make_tool("read_file"), make_tool("grep")];
166 let result = filter.apply(tools);
167 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
168 assert_eq!(names, vec!["read_file", "grep"]);
169 }
170
171 #[test]
172 fn deny_removes_matching_tools() {
173 let filter = ToolFilter { allow: vec![], deny: vec![ToolMatcher::name("bash")] };
174 let tools = vec![make_tool("bash"), make_tool("read_file")];
175 let result = filter.apply(tools);
176 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
177 assert_eq!(names, vec!["read_file"]);
178 }
179
180 #[test]
181 fn wildcard_matching() {
182 let filter = ToolFilter { allow: vec![ToolMatcher::name("coding__*")], deny: vec![] };
183 let tools = vec![make_tool("coding__grep"), make_tool("coding__read_file"), make_tool("plugins__bash")];
184 let result = filter.apply(tools);
185 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
186 assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
187 }
188
189 #[test]
190 fn combined_allow_and_deny() {
191 let filter = ToolFilter {
192 allow: vec![ToolMatcher::name("coding__*")],
193 deny: vec![ToolMatcher::name("coding__write_file")],
194 };
195 let tools = vec![
196 make_tool("coding__grep"),
197 make_tool("coding__write_file"),
198 make_tool("coding__read_file"),
199 make_tool("plugins__bash"),
200 ];
201 let result = filter.apply(tools);
202 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
203 assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
204 }
205
206 #[test]
207 fn annotation_allow_matches_present_values() {
208 let filter = ToolFilter { allow: vec![ToolMatcher::read_only()], deny: vec![] };
209 let tools = vec![
210 make_tool("unknown"),
211 make_annotated_tool("read", ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() }),
212 make_annotated_tool("write", ToolAnnotations { read_only_hint: Some(false), ..ToolAnnotations::default() }),
213 ];
214 let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
215 assert_eq!(names, vec!["read"]);
216 }
217
218 #[test]
219 fn deny_annotation_removes_destructive_tools() {
220 let filter = ToolFilter {
221 allow: vec![],
222 deny: vec![ToolMatcher::annotations(ToolAnnotationMatcher {
223 destructive: Some(true),
224 ..ToolAnnotationMatcher::default()
225 })],
226 };
227 let tools = vec![
228 make_tool("unknown"),
229 make_annotated_tool(
230 "safe_update",
231 ToolAnnotations {
232 read_only_hint: Some(false),
233 destructive_hint: Some(false),
234 ..ToolAnnotations::default()
235 },
236 ),
237 ];
238 let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
239 assert_eq!(names, vec!["unknown", "safe_update"]);
240 }
241
242 #[test]
243 fn annotation_matchers_do_not_match_missing_fields() {
244 let filter = ToolFilter {
245 allow: vec![],
246 deny: vec![
247 ToolMatcher::annotations(ToolAnnotationMatcher {
248 destructive: Some(true),
249 ..ToolAnnotationMatcher::default()
250 }),
251 ToolMatcher::annotations(ToolAnnotationMatcher {
252 open_world: Some(true),
253 ..ToolAnnotationMatcher::default()
254 }),
255 ToolMatcher::annotations(ToolAnnotationMatcher {
256 idempotent: Some(false),
257 ..ToolAnnotationMatcher::default()
258 }),
259 ToolMatcher::annotations(ToolAnnotationMatcher {
260 read_only: Some(false),
261 ..ToolAnnotationMatcher::default()
262 }),
263 ],
264 };
265 let tools = vec![make_tool("unknown")];
266 let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
267 assert_eq!(names, vec!["unknown"]);
268 }
269
270 #[test]
271 fn annotation_matchers_do_not_infer_fields_from_read_only_hint() {
272 let filter = ToolFilter {
273 allow: vec![ToolMatcher::annotations(ToolAnnotationMatcher {
274 destructive: Some(false),
275 ..ToolAnnotationMatcher::default()
276 })],
277 deny: vec![],
278 };
279 let tools = vec![make_annotated_tool("read", ToolAnnotations::read_only())];
280 assert!(filter.apply(tools).is_empty());
281 }
282
283 #[test]
284 fn deny_wins_over_allow() {
285 let filter =
286 ToolFilter { allow: vec![ToolMatcher::read_only()], deny: vec![ToolMatcher::name("coding__read_file")] };
287 let tools = vec![make_annotated_tool(
288 "coding__read_file",
289 ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() },
290 )];
291 assert!(filter.apply(tools).is_empty());
292 }
293
294 #[test]
295 fn mixed_allow_entries_are_ored() {
296 let filter = ToolFilter { allow: vec![ToolMatcher::read_only(), ToolMatcher::name("plan__*")], deny: vec![] };
297 let tools = vec![
298 make_annotated_tool(
299 "coding__grep",
300 ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() },
301 ),
302 make_tool("plan__write_plan"),
303 make_tool("coding__bash"),
304 ];
305 let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
306 assert_eq!(names, vec!["coding__grep", "plan__write_plan"]);
307 }
308
309 #[test]
310 fn empty_annotation_matcher_matches_nothing() {
311 let filter =
312 ToolFilter { allow: vec![ToolMatcher::annotations(ToolAnnotationMatcher::default())], deny: vec![] };
313 let tools = vec![make_annotated_tool(
314 "coding__grep",
315 ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() },
316 )];
317 assert!(filter.apply(tools).is_empty());
318 }
319
320 #[test]
321 fn exact_name_match_is_not_a_prefix_match() {
322 let filter = ToolFilter { allow: vec![ToolMatcher::name("bash")], deny: vec![] };
323 let names: Vec<_> =
324 filter.apply(vec![make_tool("bash"), make_tool("bash_extended")]).into_iter().map(|t| t.name).collect();
325 assert_eq!(names, vec!["bash"]);
326 }
327
328 #[test]
329 fn tool_matcher_uses_exact_and_trailing_wildcard_names() {
330 let exact = ToolMatcher::name("foo");
331 let wildcard = ToolMatcher::name("foo*");
332 assert!(exact.matches(&make_tool("foo")));
333 assert!(!exact.matches(&make_tool("foobar")));
334 assert!(wildcard.matches(&make_tool("foobar")));
335 assert!(wildcard.matches(&make_tool("foo")));
336 assert!(!wildcard.matches(&make_tool("bar")));
337 }
338}