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 tool_callable(&self, tool_name: &str, arguments: &Value) -> Result<(), String> {
204 let tool_name = normalize_tool_name(tool_name);
207 let tool_name = tool_name.as_str();
208 let args_obj = arguments.as_object();
209
210 if let Some(deny_args) = self.callable_deny.get(tool_name) {
213 for (arg_name, denied_values) in deny_args {
214 let Some(value) = args_obj.and_then(|o| o.get(arg_name)) else {
215 continue;
216 };
217 if denied_values.contains(value) {
218 return Err(format!("argument `{}` value not allowed", arg_name));
219 }
220 }
221 }
222
223 if let Some(allow_args) = self.callable_allow.get(tool_name) {
226 for (arg_name, allowed_values) in allow_args {
227 let value = args_obj.and_then(|o| o.get(arg_name));
228 match value {
229 Some(v) if allowed_values.contains(v) => {}
230 Some(_) => {
231 return Err(format!("argument `{}` value not in allowlist", arg_name));
232 }
233 None => {
234 return Err(format!("argument `{}` is required by filter", arg_name));
235 }
236 }
237 }
238 }
239
240 Ok(())
241 }
242}
243
244fn parse_patterns(raw: Option<&str>) -> Vec<Pattern> {
245 let Some(raw) = raw else {
246 return Vec::new();
247 };
248 raw.split(',').filter_map(Pattern::parse).collect()
249}
250
251fn parse_callability(raw: Option<&str>, label: &str) -> CallabilityMap {
252 let Some(raw) = raw else {
253 return CallabilityMap::new();
254 };
255 let trimmed = raw.trim();
256 if trimmed.is_empty() {
257 return CallabilityMap::new();
258 }
259 match serde_json::from_str::<CallabilityMap>(trimmed) {
260 Ok(parsed) => parsed
261 .into_iter()
262 .map(|(k, v)| (normalize_tool_name(&k), v))
263 .collect(),
264 Err(e) => {
265 log::warn!("[mcp] ignoring malformed tool-{} spec: {}", label, e);
266 CallabilityMap::new()
267 }
268 }
269}
270
271fn normalize_tool_name(name: &str) -> String {
281 if let Some(pos) = name.find(':') {
282 let mut out = String::with_capacity(name.len());
283 out.push_str(&name[..pos]);
284 out.push('_');
285 out.push_str(&name[pos + 1..]);
286 out
287 } else {
288 name.to_string()
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use serde_json::json;
296
297 #[test]
300 fn empty_filter_allows_everything() {
301 let f = ClientFilters::allow_all();
302 assert!(f.tool_visible("anything"));
303 assert!(f.tool_visible("command:DeleteEverything"));
304 }
305
306 #[test]
307 fn star_allows_everything() {
308 let f = ClientFilters::from_strings(Some("*"), None, None, None);
309 assert!(f.tool_visible("query:GetAllTargets"));
310 }
311
312 #[test]
313 fn prefix_pattern() {
314 let f = ClientFilters::from_strings(Some("query:*"), None, None, None);
315 assert!(f.tool_visible("query:GetAllTargets"));
316 assert!(!f.tool_visible("command:DoStuff"));
317 }
318
319 #[test]
320 fn suffix_pattern() {
321 let f = ClientFilters::from_strings(Some("*Internal"), None, None, None);
322 assert!(f.tool_visible("query:GetThingInternal"));
323 assert!(!f.tool_visible("query:GetThing"));
324 }
325
326 #[test]
327 fn deny_wins_on_name_conflict() {
328 let f = ClientFilters::from_strings(Some("query:*"), Some("query:GetSecret"), None, None);
329 assert!(f.tool_visible("query:GetAllTargets"));
330 assert!(!f.tool_visible("query:GetSecret"));
331 }
332
333 #[test]
334 fn empty_allow_with_deny_means_allow_all_minus_denied() {
335 let f = ClientFilters::from_strings(None, Some("command:Delete*"), None, None);
336 assert!(f.tool_visible("query:GetAllTargets"));
337 assert!(!f.tool_visible("command:DeleteThing"));
338 }
339
340 #[test]
341 fn comma_separated_allow_list() {
342 let f = ClientFilters::from_strings(Some("query:*,report:HealthCheck"), None, None, None);
343 assert!(f.tool_visible("query:Anything"));
344 assert!(f.tool_visible("report:HealthCheck"));
345 assert!(!f.tool_visible("report:OtherReport"));
346 assert!(!f.tool_visible("command:DoStuff"));
347 }
348
349 #[test]
350 fn whitespace_around_patterns_is_stripped() {
351 let f = ClientFilters::from_strings(Some(" query:* , report:H "), None, None, None);
352 assert!(f.tool_visible("query:GetAll"));
353 assert!(f.tool_visible("report:H"));
354 }
355
356 #[test]
357 fn exact_match() {
358 let f = ClientFilters::from_strings(Some("query:GetAllTargets"), None, None, None);
359 assert!(f.tool_visible("query:GetAllTargets"));
360 assert!(!f.tool_visible("query:GetAllTargetsExtra"));
361 }
362
363 fn run_playbook_allow() -> &'static str {
366 r#"{"command:RunPlaybook":{"playbook_id":["site","deploy"]}}"#
367 }
368
369 #[test]
370 fn no_callability_rules_passes() {
371 let f = ClientFilters::allow_all();
372 assert!(f.tool_callable("any:tool", &json!({"x": 1})).is_ok());
373 }
374
375 #[test]
376 fn allow_list_passes_matching_arg() {
377 let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
378 assert!(
379 f.tool_callable("command:RunPlaybook", &json!({"playbook_id": "site"}))
380 .is_ok()
381 );
382 }
383
384 #[test]
385 fn allow_list_rejects_non_matching_arg() {
386 let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
387 let err = f
388 .tool_callable("command:RunPlaybook", &json!({"playbook_id": "danger"}))
389 .unwrap_err();
390 assert!(err.contains("playbook_id"));
391 assert!(err.contains("allowlist"));
392 }
393
394 #[test]
395 fn allow_list_rejects_missing_arg() {
396 let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
397 let err = f
398 .tool_callable("command:RunPlaybook", &json!({}))
399 .unwrap_err();
400 assert!(err.contains("required"));
401 }
402
403 #[test]
404 fn deny_list_rejects_matching_arg() {
405 let f = ClientFilters::from_strings(
406 None,
407 None,
408 None,
409 Some(r#"{"command:Tag":{"namespace":["prod"]}}"#),
410 );
411 assert!(
412 f.tool_callable("command:Tag", &json!({"namespace": "staging"}))
413 .is_ok()
414 );
415 let err = f
416 .tool_callable("command:Tag", &json!({"namespace": "prod"}))
417 .unwrap_err();
418 assert!(err.contains("namespace"));
419 }
420
421 #[test]
422 fn deny_wins_when_both_allow_and_deny_listed() {
423 let f = ClientFilters::from_strings(
424 None,
425 None,
426 Some(r#"{"command:X":{"a":["1","2"]}}"#),
427 Some(r#"{"command:X":{"a":["2"]}}"#),
428 );
429 assert!(f.tool_callable("command:X", &json!({"a": "1"})).is_ok());
430 assert!(f.tool_callable("command:X", &json!({"a": "2"})).is_err());
431 }
432
433 #[test]
434 fn unrelated_tools_pass_through() {
435 let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
436 assert!(
437 f.tool_callable("command:Other", &json!({"anything": "goes"}))
438 .is_ok()
439 );
440 }
441
442 #[test]
443 fn malformed_callability_json_is_ignored() {
444 let f = ClientFilters::from_strings(None, None, Some("not json"), Some("not json"));
445 assert!(f.tool_callable("any:tool", &json!({})).is_ok());
446 }
447
448 #[test]
451 fn underscore_form_is_accepted_for_visibility() {
452 let f = ClientFilters::from_strings(Some("query_*"), None, None, None);
454 assert!(f.tool_visible("query_GetAllTargets"));
455 assert!(f.tool_visible("query:GetAllTargets")); assert!(!f.tool_visible("command_DoStuff"));
457 }
458
459 #[test]
460 fn colon_pattern_matches_underscore_name() {
461 let f = ClientFilters::from_strings(Some("query:*"), None, None, None);
464 assert!(f.tool_visible("query_GetAllTargets"));
465 }
466
467 #[test]
468 fn callability_map_normalizes_keys() {
469 let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
472 assert!(
473 f.tool_callable("command_RunPlaybook", &json!({"playbook_id": "site"}))
474 .is_ok()
475 );
476 let err = f
477 .tool_callable("command_RunPlaybook", &json!({"playbook_id": "danger"}))
478 .unwrap_err();
479 assert!(err.contains("allowlist"));
480 }
481
482 #[test]
483 fn normalize_tool_name_idempotent_on_underscore_form() {
484 assert_eq!(normalize_tool_name("command_X"), "command_X");
485 assert_eq!(normalize_tool_name("command:X"), "command_X");
486 assert_eq!(normalize_tool_name("plain"), "plain");
487 assert_eq!(normalize_tool_name("a:b:c"), "a_b:c");
489 }
490}