use std::collections::HashMap;
use serde_json::Value;
pub const VISIBILITY_ALLOW_HEADER: &str = "X-Myko-Tool-Visibility-Allow";
pub const VISIBILITY_DENY_HEADER: &str = "X-Myko-Tool-Visibility-Deny";
pub const CALLABLE_ALLOW_HEADER: &str = "X-Myko-Tool-Callable-Allow";
pub const CALLABLE_DENY_HEADER: &str = "X-Myko-Tool-Callable-Deny";
pub const VISIBILITY_ALLOW_ENV: &str = "MYKO_MCP_TOOL_VISIBILITY_ALLOW";
pub const VISIBILITY_DENY_ENV: &str = "MYKO_MCP_TOOL_VISIBILITY_DENY";
pub const CALLABLE_ALLOW_ENV: &str = "MYKO_MCP_TOOL_CALLABLE_ALLOW";
pub const CALLABLE_DENY_ENV: &str = "MYKO_MCP_TOOL_CALLABLE_DENY";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pattern {
Any,
Prefix(String),
Suffix(String),
Exact(String),
}
impl Pattern {
pub fn parse(s: &str) -> Option<Self> {
let s = normalize_tool_name(s.trim());
if s.is_empty() {
return None;
}
if s == "*" {
return Some(Pattern::Any);
}
match (s.starts_with('*'), s.ends_with('*')) {
(true, true) if s.len() == 2 => Some(Pattern::Any),
(false, true) => Some(Pattern::Prefix(s[..s.len() - 1].to_string())),
(true, false) => Some(Pattern::Suffix(s[1..].to_string())),
_ => Some(Pattern::Exact(s)),
}
}
pub fn matches(&self, name: &str) -> bool {
match self {
Pattern::Any => true,
Pattern::Prefix(p) => name.starts_with(p),
Pattern::Suffix(s) => name.ends_with(s),
Pattern::Exact(e) => name == e,
}
}
}
type CallabilityMap = HashMap<String, HashMap<String, Vec<Value>>>;
#[derive(Debug, Clone, Default)]
pub struct ClientFilters {
visibility_allow: Vec<Pattern>,
visibility_deny: Vec<Pattern>,
callable_allow: CallabilityMap,
callable_deny: CallabilityMap,
}
impl ClientFilters {
pub fn allow_all() -> Self {
Self::default()
}
pub fn from_strings(
visibility_allow: Option<&str>,
visibility_deny: Option<&str>,
callable_allow_json: Option<&str>,
callable_deny_json: Option<&str>,
) -> Self {
Self {
visibility_allow: parse_patterns(visibility_allow),
visibility_deny: parse_patterns(visibility_deny),
callable_allow: parse_callability(callable_allow_json, "callable-allow"),
callable_deny: parse_callability(callable_deny_json, "callable-deny"),
}
}
pub fn tool_visible(&self, name: &str) -> bool {
let name = normalize_tool_name(name);
let name = name.as_str();
if self.visibility_deny.iter().any(|p| p.matches(name)) {
return false;
}
if self.visibility_allow.is_empty() {
return true;
}
self.visibility_allow.iter().any(|p| p.matches(name))
}
pub fn tool_callable(&self, tool_name: &str, arguments: &Value) -> Result<(), String> {
let tool_name = normalize_tool_name(tool_name);
let tool_name = tool_name.as_str();
let args_obj = arguments.as_object();
if let Some(deny_args) = self.callable_deny.get(tool_name) {
for (arg_name, denied_values) in deny_args {
let Some(value) = args_obj.and_then(|o| o.get(arg_name)) else {
continue;
};
if denied_values.contains(value) {
return Err(format!("argument `{}` value not allowed", arg_name));
}
}
}
if let Some(allow_args) = self.callable_allow.get(tool_name) {
for (arg_name, allowed_values) in allow_args {
let value = args_obj.and_then(|o| o.get(arg_name));
match value {
Some(v) if allowed_values.contains(v) => {}
Some(_) => {
return Err(format!("argument `{}` value not in allowlist", arg_name));
}
None => {
return Err(format!("argument `{}` is required by filter", arg_name));
}
}
}
}
Ok(())
}
}
fn parse_patterns(raw: Option<&str>) -> Vec<Pattern> {
let Some(raw) = raw else {
return Vec::new();
};
raw.split(',').filter_map(Pattern::parse).collect()
}
fn parse_callability(raw: Option<&str>, label: &str) -> CallabilityMap {
let Some(raw) = raw else {
return CallabilityMap::new();
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return CallabilityMap::new();
}
match serde_json::from_str::<CallabilityMap>(trimmed) {
Ok(parsed) => parsed
.into_iter()
.map(|(k, v)| (normalize_tool_name(&k), v))
.collect(),
Err(e) => {
log::warn!("[mcp] ignoring malformed tool-{} spec: {}", label, e);
CallabilityMap::new()
}
}
}
fn normalize_tool_name(name: &str) -> String {
if let Some(pos) = name.find(':') {
let mut out = String::with_capacity(name.len());
out.push_str(&name[..pos]);
out.push('_');
out.push_str(&name[pos + 1..]);
out
} else {
name.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn empty_filter_allows_everything() {
let f = ClientFilters::allow_all();
assert!(f.tool_visible("anything"));
assert!(f.tool_visible("command:DeleteEverything"));
}
#[test]
fn star_allows_everything() {
let f = ClientFilters::from_strings(Some("*"), None, None, None);
assert!(f.tool_visible("query:GetAllTargets"));
}
#[test]
fn prefix_pattern() {
let f = ClientFilters::from_strings(Some("query:*"), None, None, None);
assert!(f.tool_visible("query:GetAllTargets"));
assert!(!f.tool_visible("command:DoStuff"));
}
#[test]
fn suffix_pattern() {
let f = ClientFilters::from_strings(Some("*Internal"), None, None, None);
assert!(f.tool_visible("query:GetThingInternal"));
assert!(!f.tool_visible("query:GetThing"));
}
#[test]
fn deny_wins_on_name_conflict() {
let f = ClientFilters::from_strings(Some("query:*"), Some("query:GetSecret"), None, None);
assert!(f.tool_visible("query:GetAllTargets"));
assert!(!f.tool_visible("query:GetSecret"));
}
#[test]
fn empty_allow_with_deny_means_allow_all_minus_denied() {
let f = ClientFilters::from_strings(None, Some("command:Delete*"), None, None);
assert!(f.tool_visible("query:GetAllTargets"));
assert!(!f.tool_visible("command:DeleteThing"));
}
#[test]
fn comma_separated_allow_list() {
let f = ClientFilters::from_strings(Some("query:*,report:HealthCheck"), None, None, None);
assert!(f.tool_visible("query:Anything"));
assert!(f.tool_visible("report:HealthCheck"));
assert!(!f.tool_visible("report:OtherReport"));
assert!(!f.tool_visible("command:DoStuff"));
}
#[test]
fn whitespace_around_patterns_is_stripped() {
let f = ClientFilters::from_strings(Some(" query:* , report:H "), None, None, None);
assert!(f.tool_visible("query:GetAll"));
assert!(f.tool_visible("report:H"));
}
#[test]
fn exact_match() {
let f = ClientFilters::from_strings(Some("query:GetAllTargets"), None, None, None);
assert!(f.tool_visible("query:GetAllTargets"));
assert!(!f.tool_visible("query:GetAllTargetsExtra"));
}
fn run_playbook_allow() -> &'static str {
r#"{"command:RunPlaybook":{"playbook_id":["site","deploy"]}}"#
}
#[test]
fn no_callability_rules_passes() {
let f = ClientFilters::allow_all();
assert!(f.tool_callable("any:tool", &json!({"x": 1})).is_ok());
}
#[test]
fn allow_list_passes_matching_arg() {
let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
assert!(
f.tool_callable("command:RunPlaybook", &json!({"playbook_id": "site"}))
.is_ok()
);
}
#[test]
fn allow_list_rejects_non_matching_arg() {
let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
let err = f
.tool_callable("command:RunPlaybook", &json!({"playbook_id": "danger"}))
.unwrap_err();
assert!(err.contains("playbook_id"));
assert!(err.contains("allowlist"));
}
#[test]
fn allow_list_rejects_missing_arg() {
let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
let err = f
.tool_callable("command:RunPlaybook", &json!({}))
.unwrap_err();
assert!(err.contains("required"));
}
#[test]
fn deny_list_rejects_matching_arg() {
let f = ClientFilters::from_strings(
None,
None,
None,
Some(r#"{"command:Tag":{"namespace":["prod"]}}"#),
);
assert!(
f.tool_callable("command:Tag", &json!({"namespace": "staging"}))
.is_ok()
);
let err = f
.tool_callable("command:Tag", &json!({"namespace": "prod"}))
.unwrap_err();
assert!(err.contains("namespace"));
}
#[test]
fn deny_wins_when_both_allow_and_deny_listed() {
let f = ClientFilters::from_strings(
None,
None,
Some(r#"{"command:X":{"a":["1","2"]}}"#),
Some(r#"{"command:X":{"a":["2"]}}"#),
);
assert!(f.tool_callable("command:X", &json!({"a": "1"})).is_ok());
assert!(f.tool_callable("command:X", &json!({"a": "2"})).is_err());
}
#[test]
fn unrelated_tools_pass_through() {
let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
assert!(
f.tool_callable("command:Other", &json!({"anything": "goes"}))
.is_ok()
);
}
#[test]
fn malformed_callability_json_is_ignored() {
let f = ClientFilters::from_strings(None, None, Some("not json"), Some("not json"));
assert!(f.tool_callable("any:tool", &json!({})).is_ok());
}
#[test]
fn underscore_form_is_accepted_for_visibility() {
let f = ClientFilters::from_strings(Some("query_*"), None, None, None);
assert!(f.tool_visible("query_GetAllTargets"));
assert!(f.tool_visible("query:GetAllTargets")); assert!(!f.tool_visible("command_DoStuff"));
}
#[test]
fn colon_pattern_matches_underscore_name() {
let f = ClientFilters::from_strings(Some("query:*"), None, None, None);
assert!(f.tool_visible("query_GetAllTargets"));
}
#[test]
fn callability_map_normalizes_keys() {
let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
assert!(
f.tool_callable("command_RunPlaybook", &json!({"playbook_id": "site"}))
.is_ok()
);
let err = f
.tool_callable("command_RunPlaybook", &json!({"playbook_id": "danger"}))
.unwrap_err();
assert!(err.contains("allowlist"));
}
#[test]
fn normalize_tool_name_idempotent_on_underscore_form() {
assert_eq!(normalize_tool_name("command_X"), "command_X");
assert_eq!(normalize_tool_name("command:X"), "command_X");
assert_eq!(normalize_tool_name("plain"), "plain");
assert_eq!(normalize_tool_name("a:b:c"), "a_b:c");
}
}