1use crate::core::Prompt;
7use llm::{LlmModel, ModelSettings, 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 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, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
90#[serde(untagged)]
91pub enum ToolMatcher {
92 Name(String),
93 Annotations(ToolAnnotationMatcher),
94}
95
96impl ToolMatcher {
97 pub fn name(pattern: impl Into<String>) -> Self {
98 Self::Name(pattern.into())
99 }
100
101 pub fn read_only() -> Self {
102 Self::Annotations(ToolAnnotationMatcher { read_only: Some(true), ..ToolAnnotationMatcher::default() })
103 }
104
105 pub fn annotations(matcher: ToolAnnotationMatcher) -> Self {
106 Self::Annotations(matcher)
107 }
108
109 pub fn matches(&self, tool: &ToolDefinition) -> bool {
110 match self {
111 Self::Name(pattern) => matches_pattern(pattern, &tool.name),
112 Self::Annotations(matcher) => matcher.matches(tool),
113 }
114 }
115}
116
117#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
118#[serde(rename_all = "camelCase", deny_unknown_fields)]
119pub struct ToolAnnotationMatcher {
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub read_only: Option<bool>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub destructive: Option<bool>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub idempotent: Option<bool>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub open_world: Option<bool>,
128}
129
130impl ToolAnnotationMatcher {
131 pub fn matches(&self, tool: &ToolDefinition) -> bool {
132 let Some(annotations) = tool.annotations.as_ref() else {
133 return false;
134 };
135 let pairs = [
136 (self.read_only, annotations.read_only_hint),
137 (self.destructive, annotations.destructive_hint),
138 (self.idempotent, annotations.idempotent_hint),
139 (self.open_world, annotations.open_world_hint),
140 ];
141 if pairs.iter().all(|(field, _)| field.is_none()) {
142 return false;
143 }
144 pairs.iter().all(|(field, hint)| field.is_none_or(|value| *hint == Some(value)))
145 }
146}
147
148#[doc = ""]
154#[doc = include_str!("docs/tool_filter.md")]
155#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
156#[serde(rename_all = "camelCase", deny_unknown_fields)]
157pub struct ToolFilter {
158 #[serde(default, skip_serializing_if = "Vec::is_empty")]
160 pub allow: Vec<ToolMatcher>,
161 #[serde(default, skip_serializing_if = "Vec::is_empty")]
163 pub deny: Vec<ToolMatcher>,
164}
165
166impl ToolFilter {
167 pub fn is_empty(&self) -> bool {
168 self.allow.is_empty() && self.deny.is_empty()
169 }
170
171 pub fn apply(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition> {
173 tools.into_iter().filter(|tool| self.is_tool_allowed(tool)).collect()
174 }
175
176 pub fn is_tool_allowed(&self, tool: &ToolDefinition) -> bool {
177 let allowed = self.allow.is_empty() || self.allow.iter().any(|matcher| matcher.matches(tool));
178 let denied = self.deny.iter().any(|matcher| matcher.matches(tool));
179 allowed && !denied
180 }
181}
182
183fn matches_pattern(pattern: &str, name: &str) -> bool {
185 if let Some(prefix) = pattern.strip_suffix('*') { name.starts_with(prefix) } else { pattern == name }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
190pub struct AgentSpecExposure {
191 pub user_invocable: bool,
193 pub agent_invocable: bool,
195}
196
197impl AgentSpecExposure {
198 pub fn none() -> Self {
204 Self { user_invocable: false, agent_invocable: false }
205 }
206
207 pub fn user_only() -> Self {
209 Self { user_invocable: true, agent_invocable: false }
210 }
211
212 pub fn agent_only() -> Self {
214 Self { user_invocable: false, agent_invocable: true }
215 }
216
217 pub fn both() -> Self {
219 Self { user_invocable: true, agent_invocable: true }
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use llm::ToolAnnotations;
227
228 #[test]
229 fn default_spec_has_expected_fields() {
230 let model: LlmModel = "anthropic:claude-sonnet-4-5".parse().unwrap();
231 let prompts = vec![Prompt::file(PathBuf::from("/tmp/BASE.md"), PathBuf::from("/tmp"))];
232 let spec = AgentSpec::bare(&model, None, prompts.clone());
233
234 assert_eq!(spec.name, "__default__");
235 assert_eq!(spec.description, "Default agent");
236 assert_eq!(spec.model, model.to_string());
237 assert!(spec.reasoning_effort.is_none());
238 assert_eq!(spec.prompts.len(), 1);
239 assert!(spec.mcp_config_sources.is_empty());
240 assert_eq!(spec.exposure, AgentSpecExposure::none());
241 }
242
243 fn make_tool(name: &str) -> ToolDefinition {
244 ToolDefinition::new(name, "", serde_json::json!({}))
245 }
246
247 fn make_annotated_tool(name: &str, annotations: ToolAnnotations) -> ToolDefinition {
248 ToolDefinition::new(name, "", serde_json::json!({})).with_annotations(annotations)
249 }
250
251 #[test]
252 fn empty_filter_allows_all_tools() {
253 let filter = ToolFilter::default();
254 let tools = vec![make_tool("bash"), make_tool("read_file")];
255 let result = filter.apply(tools);
256 assert_eq!(result.len(), 2);
257 }
258
259 #[test]
260 fn allow_keeps_only_matching_tools() {
261 let filter =
262 ToolFilter { allow: vec![ToolMatcher::name("read_file"), ToolMatcher::name("grep")], deny: vec![] };
263 let tools = vec![make_tool("bash"), make_tool("read_file"), make_tool("grep")];
264 let result = filter.apply(tools);
265 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
266 assert_eq!(names, vec!["read_file", "grep"]);
267 }
268
269 #[test]
270 fn deny_removes_matching_tools() {
271 let filter = ToolFilter { allow: vec![], deny: vec![ToolMatcher::name("bash")] };
272 let tools = vec![make_tool("bash"), make_tool("read_file")];
273 let result = filter.apply(tools);
274 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
275 assert_eq!(names, vec!["read_file"]);
276 }
277
278 #[test]
279 fn wildcard_matching() {
280 let filter = ToolFilter { allow: vec![ToolMatcher::name("coding__*")], deny: vec![] };
281 let tools = vec![make_tool("coding__grep"), make_tool("coding__read_file"), make_tool("plugins__bash")];
282 let result = filter.apply(tools);
283 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
284 assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
285 }
286
287 #[test]
288 fn combined_allow_and_deny() {
289 let filter = ToolFilter {
290 allow: vec![ToolMatcher::name("coding__*")],
291 deny: vec![ToolMatcher::name("coding__write_file")],
292 };
293 let tools = vec![
294 make_tool("coding__grep"),
295 make_tool("coding__write_file"),
296 make_tool("coding__read_file"),
297 make_tool("plugins__bash"),
298 ];
299 let result = filter.apply(tools);
300 let names: Vec<_> = result.iter().map(|t| t.name.as_str()).collect();
301 assert_eq!(names, vec!["coding__grep", "coding__read_file"]);
302 }
303
304 #[test]
305 fn annotation_allow_matches_present_values() {
306 let filter = ToolFilter { allow: vec![ToolMatcher::read_only()], deny: vec![] };
307 let tools = vec![
308 make_tool("unknown"),
309 make_annotated_tool("read", ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() }),
310 make_annotated_tool("write", ToolAnnotations { read_only_hint: Some(false), ..ToolAnnotations::default() }),
311 ];
312 let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
313 assert_eq!(names, vec!["read"]);
314 }
315
316 #[test]
317 fn deny_annotation_removes_destructive_tools() {
318 let filter = ToolFilter {
319 allow: vec![],
320 deny: vec![ToolMatcher::annotations(ToolAnnotationMatcher {
321 destructive: Some(true),
322 ..ToolAnnotationMatcher::default()
323 })],
324 };
325 let tools = vec![
326 make_tool("unknown"),
327 make_annotated_tool(
328 "safe_update",
329 ToolAnnotations {
330 read_only_hint: Some(false),
331 destructive_hint: Some(false),
332 ..ToolAnnotations::default()
333 },
334 ),
335 ];
336 let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
337 assert_eq!(names, vec!["unknown", "safe_update"]);
338 }
339
340 #[test]
341 fn annotation_matchers_do_not_match_missing_fields() {
342 let filter = ToolFilter {
343 allow: vec![],
344 deny: vec![
345 ToolMatcher::annotations(ToolAnnotationMatcher {
346 destructive: Some(true),
347 ..ToolAnnotationMatcher::default()
348 }),
349 ToolMatcher::annotations(ToolAnnotationMatcher {
350 open_world: Some(true),
351 ..ToolAnnotationMatcher::default()
352 }),
353 ToolMatcher::annotations(ToolAnnotationMatcher {
354 idempotent: Some(false),
355 ..ToolAnnotationMatcher::default()
356 }),
357 ToolMatcher::annotations(ToolAnnotationMatcher {
358 read_only: Some(false),
359 ..ToolAnnotationMatcher::default()
360 }),
361 ],
362 };
363 let tools = vec![make_tool("unknown")];
364 let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
365 assert_eq!(names, vec!["unknown"]);
366 }
367
368 #[test]
369 fn annotation_matchers_do_not_infer_fields_from_read_only_hint() {
370 let filter = ToolFilter {
371 allow: vec![ToolMatcher::annotations(ToolAnnotationMatcher {
372 destructive: Some(false),
373 ..ToolAnnotationMatcher::default()
374 })],
375 deny: vec![],
376 };
377 let tools = vec![make_annotated_tool("read", ToolAnnotations::read_only())];
378 assert!(filter.apply(tools).is_empty());
379 }
380
381 #[test]
382 fn deny_wins_over_allow() {
383 let filter =
384 ToolFilter { allow: vec![ToolMatcher::read_only()], deny: vec![ToolMatcher::name("coding__read_file")] };
385 let tools = vec![make_annotated_tool(
386 "coding__read_file",
387 ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() },
388 )];
389 assert!(filter.apply(tools).is_empty());
390 }
391
392 #[test]
393 fn mixed_allow_entries_are_ored() {
394 let filter = ToolFilter { allow: vec![ToolMatcher::read_only(), ToolMatcher::name("plan__*")], deny: vec![] };
395 let tools = vec![
396 make_annotated_tool(
397 "coding__grep",
398 ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() },
399 ),
400 make_tool("plan__write_plan"),
401 make_tool("coding__bash"),
402 ];
403 let names: Vec<_> = filter.apply(tools).into_iter().map(|tool| tool.name).collect();
404 assert_eq!(names, vec!["coding__grep", "plan__write_plan"]);
405 }
406
407 #[test]
408 fn empty_annotation_matcher_matches_nothing() {
409 let filter =
410 ToolFilter { allow: vec![ToolMatcher::annotations(ToolAnnotationMatcher::default())], deny: vec![] };
411 let tools = vec![make_annotated_tool(
412 "coding__grep",
413 ToolAnnotations { read_only_hint: Some(true), ..ToolAnnotations::default() },
414 )];
415 assert!(filter.apply(tools).is_empty());
416 }
417
418 #[test]
419 fn exact_name_match_is_not_a_prefix_match() {
420 let filter = ToolFilter { allow: vec![ToolMatcher::name("bash")], deny: vec![] };
421 let names: Vec<_> =
422 filter.apply(vec![make_tool("bash"), make_tool("bash_extended")]).into_iter().map(|t| t.name).collect();
423 assert_eq!(names, vec!["bash"]);
424 }
425
426 #[test]
427 fn matches_pattern_exact_and_wildcard() {
428 assert!(matches_pattern("foo", "foo"));
429 assert!(!matches_pattern("foo", "foobar"));
430 assert!(matches_pattern("foo*", "foobar"));
431 assert!(matches_pattern("foo*", "foo"));
432 assert!(!matches_pattern("bar*", "foo"));
433 }
434}