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