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