1use std::collections::HashMap;
49
50use serde_json::Value;
51
52pub const VISIBILITY_ALLOW_HEADER: &str = "X-Myko-Tool-Visibility-Allow";
56pub const VISIBILITY_DENY_HEADER: &str = "X-Myko-Tool-Visibility-Deny";
58pub const CALLABLE_ALLOW_HEADER: &str = "X-Myko-Tool-Callable-Allow";
60pub const CALLABLE_DENY_HEADER: &str = "X-Myko-Tool-Callable-Deny";
62
63pub const VISIBILITY_ALLOW_ENV: &str = "MYKO_MCP_TOOL_VISIBILITY_ALLOW";
65pub const VISIBILITY_DENY_ENV: &str = "MYKO_MCP_TOOL_VISIBILITY_DENY";
67pub const CALLABLE_ALLOW_ENV: &str = "MYKO_MCP_TOOL_CALLABLE_ALLOW";
69pub const CALLABLE_DENY_ENV: &str = "MYKO_MCP_TOOL_CALLABLE_DENY";
71
72#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum Pattern {
77 Any,
79 Prefix(String),
81 Suffix(String),
83 Exact(String),
85}
86
87impl Pattern {
88 pub fn parse(s: &str) -> Option<Self> {
90 let s = s.trim();
91 if s.is_empty() {
92 return None;
93 }
94 if s == "*" {
95 return Some(Pattern::Any);
96 }
97 match (s.starts_with('*'), s.ends_with('*')) {
98 (true, true) if s.len() == 2 => Some(Pattern::Any),
99 (false, true) => Some(Pattern::Prefix(s[..s.len() - 1].to_string())),
100 (true, false) => Some(Pattern::Suffix(s[1..].to_string())),
101 _ => Some(Pattern::Exact(s.to_string())),
102 }
103 }
104
105 pub fn matches(&self, name: &str) -> bool {
107 match self {
108 Pattern::Any => true,
109 Pattern::Prefix(p) => name.starts_with(p),
110 Pattern::Suffix(s) => name.ends_with(s),
111 Pattern::Exact(e) => name == e,
112 }
113 }
114}
115
116type CallabilityMap = HashMap<String, HashMap<String, Vec<Value>>>;
120
121#[derive(Debug, Clone, Default)]
127pub struct ClientFilters {
128 visibility_allow: Vec<Pattern>,
130 visibility_deny: Vec<Pattern>,
132 callable_allow: CallabilityMap,
135 callable_deny: CallabilityMap,
138}
139
140impl ClientFilters {
141 pub fn allow_all() -> Self {
143 Self::default()
144 }
145
146 pub fn from_strings(
150 visibility_allow: Option<&str>,
151 visibility_deny: Option<&str>,
152 callable_allow_json: Option<&str>,
153 callable_deny_json: Option<&str>,
154 ) -> Self {
155 Self {
156 visibility_allow: parse_patterns(visibility_allow),
157 visibility_deny: parse_patterns(visibility_deny),
158 callable_allow: parse_callability(callable_allow_json, "callable-allow"),
159 callable_deny: parse_callability(callable_deny_json, "callable-deny"),
160 }
161 }
162
163 pub fn tool_visible(&self, name: &str) -> bool {
170 if self.visibility_deny.iter().any(|p| p.matches(name)) {
171 return false;
172 }
173 if self.visibility_allow.is_empty() {
174 return true;
175 }
176 self.visibility_allow.iter().any(|p| p.matches(name))
177 }
178
179 pub fn tool_callable(&self, tool_name: &str, arguments: &Value) -> Result<(), String> {
190 let args_obj = arguments.as_object();
191
192 if let Some(deny_args) = self.callable_deny.get(tool_name) {
195 for (arg_name, denied_values) in deny_args {
196 let Some(value) = args_obj.and_then(|o| o.get(arg_name)) else {
197 continue;
198 };
199 if denied_values.contains(value) {
200 return Err(format!("argument `{}` value not allowed", arg_name));
201 }
202 }
203 }
204
205 if let Some(allow_args) = self.callable_allow.get(tool_name) {
208 for (arg_name, allowed_values) in allow_args {
209 let value = args_obj.and_then(|o| o.get(arg_name));
210 match value {
211 Some(v) if allowed_values.contains(v) => {}
212 Some(_) => {
213 return Err(format!("argument `{}` value not in allowlist", arg_name));
214 }
215 None => {
216 return Err(format!("argument `{}` is required by filter", arg_name));
217 }
218 }
219 }
220 }
221
222 Ok(())
223 }
224}
225
226fn parse_patterns(raw: Option<&str>) -> Vec<Pattern> {
227 let Some(raw) = raw else {
228 return Vec::new();
229 };
230 raw.split(',').filter_map(Pattern::parse).collect()
231}
232
233fn parse_callability(raw: Option<&str>, label: &str) -> CallabilityMap {
234 let Some(raw) = raw else {
235 return CallabilityMap::new();
236 };
237 let trimmed = raw.trim();
238 if trimmed.is_empty() {
239 return CallabilityMap::new();
240 }
241 match serde_json::from_str(trimmed) {
242 Ok(parsed) => parsed,
243 Err(e) => {
244 log::warn!("[mcp] ignoring malformed tool-{} spec: {}", label, e);
245 CallabilityMap::new()
246 }
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use serde_json::json;
254
255 #[test]
258 fn empty_filter_allows_everything() {
259 let f = ClientFilters::allow_all();
260 assert!(f.tool_visible("anything"));
261 assert!(f.tool_visible("command:DeleteEverything"));
262 }
263
264 #[test]
265 fn star_allows_everything() {
266 let f = ClientFilters::from_strings(Some("*"), None, None, None);
267 assert!(f.tool_visible("query:GetAllTargets"));
268 }
269
270 #[test]
271 fn prefix_pattern() {
272 let f = ClientFilters::from_strings(Some("query:*"), None, None, None);
273 assert!(f.tool_visible("query:GetAllTargets"));
274 assert!(!f.tool_visible("command:DoStuff"));
275 }
276
277 #[test]
278 fn suffix_pattern() {
279 let f = ClientFilters::from_strings(Some("*Internal"), None, None, None);
280 assert!(f.tool_visible("query:GetThingInternal"));
281 assert!(!f.tool_visible("query:GetThing"));
282 }
283
284 #[test]
285 fn deny_wins_on_name_conflict() {
286 let f = ClientFilters::from_strings(Some("query:*"), Some("query:GetSecret"), None, None);
287 assert!(f.tool_visible("query:GetAllTargets"));
288 assert!(!f.tool_visible("query:GetSecret"));
289 }
290
291 #[test]
292 fn empty_allow_with_deny_means_allow_all_minus_denied() {
293 let f = ClientFilters::from_strings(None, Some("command:Delete*"), None, None);
294 assert!(f.tool_visible("query:GetAllTargets"));
295 assert!(!f.tool_visible("command:DeleteThing"));
296 }
297
298 #[test]
299 fn comma_separated_allow_list() {
300 let f = ClientFilters::from_strings(Some("query:*,report:HealthCheck"), None, None, None);
301 assert!(f.tool_visible("query:Anything"));
302 assert!(f.tool_visible("report:HealthCheck"));
303 assert!(!f.tool_visible("report:OtherReport"));
304 assert!(!f.tool_visible("command:DoStuff"));
305 }
306
307 #[test]
308 fn whitespace_around_patterns_is_stripped() {
309 let f = ClientFilters::from_strings(Some(" query:* , report:H "), None, None, None);
310 assert!(f.tool_visible("query:GetAll"));
311 assert!(f.tool_visible("report:H"));
312 }
313
314 #[test]
315 fn exact_match() {
316 let f = ClientFilters::from_strings(Some("query:GetAllTargets"), None, None, None);
317 assert!(f.tool_visible("query:GetAllTargets"));
318 assert!(!f.tool_visible("query:GetAllTargetsExtra"));
319 }
320
321 fn run_playbook_allow() -> &'static str {
324 r#"{"command:RunPlaybook":{"playbook_id":["site","deploy"]}}"#
325 }
326
327 #[test]
328 fn no_callability_rules_passes() {
329 let f = ClientFilters::allow_all();
330 assert!(f.tool_callable("any:tool", &json!({"x": 1})).is_ok());
331 }
332
333 #[test]
334 fn allow_list_passes_matching_arg() {
335 let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
336 assert!(
337 f.tool_callable("command:RunPlaybook", &json!({"playbook_id": "site"}))
338 .is_ok()
339 );
340 }
341
342 #[test]
343 fn allow_list_rejects_non_matching_arg() {
344 let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
345 let err = f
346 .tool_callable("command:RunPlaybook", &json!({"playbook_id": "danger"}))
347 .unwrap_err();
348 assert!(err.contains("playbook_id"));
349 assert!(err.contains("allowlist"));
350 }
351
352 #[test]
353 fn allow_list_rejects_missing_arg() {
354 let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
355 let err = f
356 .tool_callable("command:RunPlaybook", &json!({}))
357 .unwrap_err();
358 assert!(err.contains("required"));
359 }
360
361 #[test]
362 fn deny_list_rejects_matching_arg() {
363 let f = ClientFilters::from_strings(
364 None,
365 None,
366 None,
367 Some(r#"{"command:Tag":{"namespace":["prod"]}}"#),
368 );
369 assert!(
370 f.tool_callable("command:Tag", &json!({"namespace": "staging"}))
371 .is_ok()
372 );
373 let err = f
374 .tool_callable("command:Tag", &json!({"namespace": "prod"}))
375 .unwrap_err();
376 assert!(err.contains("namespace"));
377 }
378
379 #[test]
380 fn deny_wins_when_both_allow_and_deny_listed() {
381 let f = ClientFilters::from_strings(
382 None,
383 None,
384 Some(r#"{"command:X":{"a":["1","2"]}}"#),
385 Some(r#"{"command:X":{"a":["2"]}}"#),
386 );
387 assert!(f.tool_callable("command:X", &json!({"a": "1"})).is_ok());
388 assert!(f.tool_callable("command:X", &json!({"a": "2"})).is_err());
389 }
390
391 #[test]
392 fn unrelated_tools_pass_through() {
393 let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
394 assert!(
395 f.tool_callable("command:Other", &json!({"anything": "goes"}))
396 .is_ok()
397 );
398 }
399
400 #[test]
401 fn malformed_callability_json_is_ignored() {
402 let f = ClientFilters::from_strings(None, None, Some("not json"), Some("not json"));
403 assert!(f.tool_callable("any:tool", &json!({})).is_ok());
404 }
405}