1use std::collections::HashMap;
54
55use serde_json::Value;
56
57pub const VISIBILITY_ALLOW_HEADER: &str = "X-Myko-Tool-Visibility-Allow";
61pub const VISIBILITY_DENY_HEADER: &str = "X-Myko-Tool-Visibility-Deny";
63pub const CALLABLE_ALLOW_HEADER: &str = "X-Myko-Tool-Callable-Allow";
65pub const CALLABLE_DENY_HEADER: &str = "X-Myko-Tool-Callable-Deny";
67
68pub const VISIBILITY_ALLOW_ENV: &str = "MYKO_MCP_TOOL_VISIBILITY_ALLOW";
70pub const VISIBILITY_DENY_ENV: &str = "MYKO_MCP_TOOL_VISIBILITY_DENY";
72pub const CALLABLE_ALLOW_ENV: &str = "MYKO_MCP_TOOL_CALLABLE_ALLOW";
74pub const CALLABLE_DENY_ENV: &str = "MYKO_MCP_TOOL_CALLABLE_DENY";
76
77#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum Pattern {
82 Any,
84 Prefix(String),
86 Suffix(String),
88 Exact(String),
90}
91
92impl Pattern {
93 pub fn parse(s: &str) -> Option<Self> {
100 let s = normalize_tool_name(s.trim());
101 if s.is_empty() {
102 return None;
103 }
104 if s == "*" {
105 return Some(Pattern::Any);
106 }
107 match (s.starts_with('*'), s.ends_with('*')) {
108 (true, true) if s.len() == 2 => Some(Pattern::Any),
109 (false, true) => Some(Pattern::Prefix(s[..s.len() - 1].to_string())),
110 (true, false) => Some(Pattern::Suffix(s[1..].to_string())),
111 _ => Some(Pattern::Exact(s)),
112 }
113 }
114
115 pub fn matches(&self, name: &str) -> bool {
117 match self {
118 Pattern::Any => true,
119 Pattern::Prefix(p) => name.starts_with(p),
120 Pattern::Suffix(s) => name.ends_with(s),
121 Pattern::Exact(e) => name == e,
122 }
123 }
124}
125
126type CallabilityMap = HashMap<String, HashMap<String, Vec<Value>>>;
130
131#[derive(Debug, Clone, Default)]
137pub struct ClientFilters {
138 visibility_allow: Vec<Pattern>,
140 visibility_deny: Vec<Pattern>,
142 callable_allow: CallabilityMap,
145 callable_deny: CallabilityMap,
148}
149
150impl ClientFilters {
151 pub fn allow_all() -> Self {
153 Self::default()
154 }
155
156 pub fn from_strings(
160 visibility_allow: Option<&str>,
161 visibility_deny: Option<&str>,
162 callable_allow_json: Option<&str>,
163 callable_deny_json: Option<&str>,
164 ) -> Self {
165 Self {
166 visibility_allow: parse_patterns(visibility_allow),
167 visibility_deny: parse_patterns(visibility_deny),
168 callable_allow: parse_callability(callable_allow_json, "callable-allow"),
169 callable_deny: parse_callability(callable_deny_json, "callable-deny"),
170 }
171 }
172
173 pub fn tool_visible(&self, name: &str) -> bool {
180 let name = normalize_tool_name(name);
183 let name = name.as_str();
184 if self.visibility_deny.iter().any(|p| p.matches(name)) {
185 return false;
186 }
187 if self.visibility_allow.is_empty() {
188 return true;
189 }
190 self.visibility_allow.iter().any(|p| p.matches(name))
191 }
192
193 pub fn meta_tool_visible(&self, name: &str) -> bool {
210 let name = normalize_tool_name(name);
211 !self.visibility_deny.iter().any(|p| p.matches(&name))
212 }
213
214 pub fn tool_callable(&self, tool_name: &str, arguments: &Value) -> Result<(), String> {
225 let tool_name = normalize_tool_name(tool_name);
228 let tool_name = tool_name.as_str();
229 let args_obj = arguments.as_object();
230
231 if let Some(deny_args) = self.callable_deny.get(tool_name) {
234 for (arg_name, denied_values) in deny_args {
235 let Some(value) = args_obj.and_then(|o| o.get(arg_name)) else {
236 continue;
237 };
238 if denied_values.contains(value) {
239 return Err(format!("argument `{}` value not allowed", arg_name));
240 }
241 }
242 }
243
244 if let Some(allow_args) = self.callable_allow.get(tool_name) {
247 for (arg_name, allowed_values) in allow_args {
248 let value = args_obj.and_then(|o| o.get(arg_name));
249 match value {
250 Some(v) if allowed_values.contains(v) => {}
251 Some(_) => {
252 return Err(format!("argument `{}` value not in allowlist", arg_name));
253 }
254 None => {
255 return Err(format!("argument `{}` is required by filter", arg_name));
256 }
257 }
258 }
259 }
260
261 Ok(())
262 }
263}
264
265fn parse_patterns(raw: Option<&str>) -> Vec<Pattern> {
266 let Some(raw) = raw else {
267 return Vec::new();
268 };
269 raw.split(',').filter_map(Pattern::parse).collect()
270}
271
272fn parse_callability(raw: Option<&str>, label: &str) -> CallabilityMap {
273 let Some(raw) = raw else {
274 return CallabilityMap::new();
275 };
276 let trimmed = raw.trim();
277 if trimmed.is_empty() {
278 return CallabilityMap::new();
279 }
280 match serde_json::from_str::<CallabilityMap>(trimmed) {
281 Ok(parsed) => parsed
282 .into_iter()
283 .map(|(k, v)| (normalize_tool_name(&k), v))
284 .collect(),
285 Err(e) => {
286 log::warn!("[mcp] ignoring malformed tool-{} spec: {}", label, e);
287 CallabilityMap::new()
288 }
289 }
290}
291
292fn normalize_tool_name(name: &str) -> String {
302 if let Some(pos) = name.find(':') {
303 let mut out = String::with_capacity(name.len());
304 out.push_str(&name[..pos]);
305 out.push('_');
306 out.push_str(&name[pos + 1..]);
307 out
308 } else {
309 name.to_string()
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use serde_json::json;
317
318 #[test]
321 fn empty_filter_allows_everything() {
322 let f = ClientFilters::allow_all();
323 assert!(f.tool_visible("anything"));
324 assert!(f.tool_visible("command:DeleteEverything"));
325 }
326
327 #[test]
328 fn star_allows_everything() {
329 let f = ClientFilters::from_strings(Some("*"), None, None, None);
330 assert!(f.tool_visible("query:GetAllTargets"));
331 }
332
333 #[test]
334 fn prefix_pattern() {
335 let f = ClientFilters::from_strings(Some("query:*"), None, None, None);
336 assert!(f.tool_visible("query:GetAllTargets"));
337 assert!(!f.tool_visible("command:DoStuff"));
338 }
339
340 #[test]
341 fn suffix_pattern() {
342 let f = ClientFilters::from_strings(Some("*Internal"), None, None, None);
343 assert!(f.tool_visible("query:GetThingInternal"));
344 assert!(!f.tool_visible("query:GetThing"));
345 }
346
347 #[test]
348 fn deny_wins_on_name_conflict() {
349 let f = ClientFilters::from_strings(Some("query:*"), Some("query:GetSecret"), None, None);
350 assert!(f.tool_visible("query:GetAllTargets"));
351 assert!(!f.tool_visible("query:GetSecret"));
352 }
353
354 #[test]
355 fn empty_allow_with_deny_means_allow_all_minus_denied() {
356 let f = ClientFilters::from_strings(None, Some("command:Delete*"), None, None);
357 assert!(f.tool_visible("query:GetAllTargets"));
358 assert!(!f.tool_visible("command:DeleteThing"));
359 }
360
361 #[test]
362 fn comma_separated_allow_list() {
363 let f = ClientFilters::from_strings(Some("query:*,report:HealthCheck"), None, None, None);
364 assert!(f.tool_visible("query:Anything"));
365 assert!(f.tool_visible("report:HealthCheck"));
366 assert!(!f.tool_visible("report:OtherReport"));
367 assert!(!f.tool_visible("command:DoStuff"));
368 }
369
370 #[test]
371 fn whitespace_around_patterns_is_stripped() {
372 let f = ClientFilters::from_strings(Some(" query:* , report:H "), None, None, None);
373 assert!(f.tool_visible("query:GetAll"));
374 assert!(f.tool_visible("report:H"));
375 }
376
377 #[test]
378 fn exact_match() {
379 let f = ClientFilters::from_strings(Some("query:GetAllTargets"), None, None, None);
380 assert!(f.tool_visible("query:GetAllTargets"));
381 assert!(!f.tool_visible("query:GetAllTargetsExtra"));
382 }
383
384 fn run_playbook_allow() -> &'static str {
387 r#"{"command:RunPlaybook":{"playbook_id":["site","deploy"]}}"#
388 }
389
390 #[test]
391 fn no_callability_rules_passes() {
392 let f = ClientFilters::allow_all();
393 assert!(f.tool_callable("any:tool", &json!({"x": 1})).is_ok());
394 }
395
396 #[test]
397 fn allow_list_passes_matching_arg() {
398 let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
399 assert!(
400 f.tool_callable("command:RunPlaybook", &json!({"playbook_id": "site"}))
401 .is_ok()
402 );
403 }
404
405 #[test]
406 fn allow_list_rejects_non_matching_arg() {
407 let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
408 let err = f
409 .tool_callable("command:RunPlaybook", &json!({"playbook_id": "danger"}))
410 .unwrap_err();
411 assert!(err.contains("playbook_id"));
412 assert!(err.contains("allowlist"));
413 }
414
415 #[test]
416 fn allow_list_rejects_missing_arg() {
417 let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
418 let err = f
419 .tool_callable("command:RunPlaybook", &json!({}))
420 .unwrap_err();
421 assert!(err.contains("required"));
422 }
423
424 #[test]
425 fn deny_list_rejects_matching_arg() {
426 let f = ClientFilters::from_strings(
427 None,
428 None,
429 None,
430 Some(r#"{"command:Tag":{"namespace":["prod"]}}"#),
431 );
432 assert!(
433 f.tool_callable("command:Tag", &json!({"namespace": "staging"}))
434 .is_ok()
435 );
436 let err = f
437 .tool_callable("command:Tag", &json!({"namespace": "prod"}))
438 .unwrap_err();
439 assert!(err.contains("namespace"));
440 }
441
442 #[test]
443 fn deny_wins_when_both_allow_and_deny_listed() {
444 let f = ClientFilters::from_strings(
445 None,
446 None,
447 Some(r#"{"command:X":{"a":["1","2"]}}"#),
448 Some(r#"{"command:X":{"a":["2"]}}"#),
449 );
450 assert!(f.tool_callable("command:X", &json!({"a": "1"})).is_ok());
451 assert!(f.tool_callable("command:X", &json!({"a": "2"})).is_err());
452 }
453
454 #[test]
455 fn unrelated_tools_pass_through() {
456 let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
457 assert!(
458 f.tool_callable("command:Other", &json!({"anything": "goes"}))
459 .is_ok()
460 );
461 }
462
463 #[test]
464 fn malformed_callability_json_is_ignored() {
465 let f = ClientFilters::from_strings(None, None, Some("not json"), Some("not json"));
466 assert!(f.tool_callable("any:tool", &json!({})).is_ok());
467 }
468
469 #[test]
472 fn underscore_form_is_accepted_for_visibility() {
473 let f = ClientFilters::from_strings(Some("query_*"), None, None, None);
475 assert!(f.tool_visible("query_GetAllTargets"));
476 assert!(f.tool_visible("query:GetAllTargets")); assert!(!f.tool_visible("command_DoStuff"));
478 }
479
480 #[test]
481 fn colon_pattern_matches_underscore_name() {
482 let f = ClientFilters::from_strings(Some("query:*"), None, None, None);
485 assert!(f.tool_visible("query_GetAllTargets"));
486 }
487
488 #[test]
489 fn callability_map_normalizes_keys() {
490 let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
493 assert!(
494 f.tool_callable("command_RunPlaybook", &json!({"playbook_id": "site"}))
495 .is_ok()
496 );
497 let err = f
498 .tool_callable("command_RunPlaybook", &json!({"playbook_id": "danger"}))
499 .unwrap_err();
500 assert!(err.contains("allowlist"));
501 }
502
503 #[test]
504 fn normalize_tool_name_idempotent_on_underscore_form() {
505 assert_eq!(normalize_tool_name("command_X"), "command_X");
506 assert_eq!(normalize_tool_name("command:X"), "command_X");
507 assert_eq!(normalize_tool_name("plain"), "plain");
508 assert_eq!(normalize_tool_name("a:b:c"), "a_b:c");
510 }
511
512 #[test]
515 fn meta_tool_visible_ignores_op_level_allow_list() {
516 let f = ClientFilters::from_strings(
522 Some("report:GetContextRecordsByAttribute,command:WriteContextRecord"),
523 None,
524 None,
525 None,
526 );
527 assert!(f.meta_tool_visible("search"));
528 assert!(f.meta_tool_visible("execute"));
529 assert!(f.meta_tool_visible("connection_status"));
530 assert!(f.tool_visible("report_GetContextRecordsByAttribute"));
533 assert!(!f.tool_visible("command_DeleteEverything"));
534 }
535
536 #[test]
537 fn meta_tool_visible_still_respects_explicit_deny() {
538 let f = ClientFilters::from_strings(None, Some("execute"), None, None);
539 assert!(!f.meta_tool_visible("execute"));
540 assert!(f.meta_tool_visible("search"));
541 assert!(f.meta_tool_visible("connection_status"));
542 }
543
544 #[test]
545 fn meta_tool_visible_allows_everything_by_default() {
546 let f = ClientFilters::allow_all();
547 assert!(f.meta_tool_visible("search"));
548 assert!(f.meta_tool_visible("execute"));
549 assert!(f.meta_tool_visible("connection_status"));
550 }
551}