Skip to main content

myko_server/mcp/
filter.rs

1//! Per-client MCP filters, aligned to the two error categories the MCP spec
2//! defines for tools ([Tools / Error Handling][mcp-tool-errors]):
3//!
4//! 1. **Tool visibility** — glob allow/deny over tool names. A hidden tool
5//!    disappears from `tools/list` and a `tools/call` against it returns the
6//!    MCP **Protocol Error** `{"code": -32602, "message": "Unknown tool: …"}`.
7//!    Source:
8//!    - HTTP/WS: `X-Myko-Tool-Visibility-Allow` and
9//!      `X-Myko-Tool-Visibility-Deny` request headers.
10//!    - Stdio: `MYKO_MCP_TOOL_VISIBILITY_ALLOW` /
11//!      `MYKO_MCP_TOOL_VISIBILITY_DENY` env vars.
12//!
13//! 2. **Tool callability** — per-tool, per-argument value allow/deny lists.
14//!    A failure surfaces as an MCP **Tool Execution Error** (`isError: true`
15//!    content with a descriptive message), the spec's "Invalid input data"
16//!    category — distinct from a Protocol Error. Source:
17//!    - HTTP/WS: `X-Myko-Tool-Callable-Allow` and
18//!      `X-Myko-Tool-Callable-Deny` request headers (JSON).
19//!    - Stdio: `MYKO_MCP_TOOL_CALLABLE_ALLOW` /
20//!      `MYKO_MCP_TOOL_CALLABLE_DENY` env vars (JSON).
21//!
22//! [mcp-tool-errors]: https://modelcontextprotocol.io/specification/2025-06-18/server/tools#error-handling
23//!
24//! ### Callability JSON shape
25//!
26//! Each callable header carries one JSON object: `tool -> arg -> [values]`.
27//! Polarity comes from which header the JSON is in.
28//!
29//! ```json
30//! // X-Myko-Tool-Callable-Allow
31//! {
32//!   "command:RunPlaybook": { "playbook_id": ["site", "deploy"] }
33//! }
34//!
35//! // X-Myko-Tool-Callable-Deny
36//! {
37//!   "command:Tag":         { "namespace":   ["prod"] }
38//! }
39//! ```
40//!
41//! Semantics, per tool/arg:
42//! - **Allow** is positive: the arg must be present on the call and its
43//!   value must appear in the list.
44//! - **Deny** excludes: if the arg is present and its value appears in the
45//!   list, the call is rejected.
46//! - Deny wins. If the same tool/arg/value appears on both sides, deny.
47
48use std::collections::HashMap;
49
50use serde_json::Value;
51
52// ─── Header / env names ────────────────────────────────────────────────────
53
54/// HTTP header carrying the tool-visibility allowlist (glob patterns).
55pub const VISIBILITY_ALLOW_HEADER: &str = "X-Myko-Tool-Visibility-Allow";
56/// HTTP header carrying the tool-visibility denylist (glob patterns).
57pub const VISIBILITY_DENY_HEADER: &str = "X-Myko-Tool-Visibility-Deny";
58/// HTTP header carrying the JSON tool-callable allowlist.
59pub const CALLABLE_ALLOW_HEADER: &str = "X-Myko-Tool-Callable-Allow";
60/// HTTP header carrying the JSON tool-callable denylist.
61pub const CALLABLE_DENY_HEADER: &str = "X-Myko-Tool-Callable-Deny";
62
63/// Stdio env var carrying the tool-visibility allowlist.
64pub const VISIBILITY_ALLOW_ENV: &str = "MYKO_MCP_TOOL_VISIBILITY_ALLOW";
65/// Stdio env var carrying the tool-visibility denylist.
66pub const VISIBILITY_DENY_ENV: &str = "MYKO_MCP_TOOL_VISIBILITY_DENY";
67/// Stdio env var carrying the JSON tool-callable allowlist.
68pub const CALLABLE_ALLOW_ENV: &str = "MYKO_MCP_TOOL_CALLABLE_ALLOW";
69/// Stdio env var carrying the JSON tool-callable denylist.
70pub const CALLABLE_DENY_ENV: &str = "MYKO_MCP_TOOL_CALLABLE_DENY";
71
72// ─── Name patterns ─────────────────────────────────────────────────────────
73
74/// A glob pattern for matching tool names.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum Pattern {
77    /// `*` — matches everything.
78    Any,
79    /// `prefix*` — matches names starting with `prefix`.
80    Prefix(String),
81    /// `*suffix` — matches names ending with `suffix`.
82    Suffix(String),
83    /// Exact match.
84    Exact(String),
85}
86
87impl Pattern {
88    /// Parse a single glob pattern. Empty string returns `None`.
89    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    /// Test whether `name` matches this pattern.
106    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
116// ─── Callability map ───────────────────────────────────────────────────────
117
118/// `tool_name -> { arg_name -> [values] }`.
119type CallabilityMap = HashMap<String, HashMap<String, Vec<Value>>>;
120
121// ─── ClientFilters ─────────────────────────────────────────────────────────
122
123/// Per-client filter combining tool-visibility (name-level) and
124/// tool-callability (argument-level) rules. Driven by request headers
125/// (HTTP/WS) or environment variables (stdio).
126#[derive(Debug, Clone, Default)]
127pub struct ClientFilters {
128    /// Glob patterns the tool name must match. Empty = visibility unrestricted.
129    visibility_allow: Vec<Pattern>,
130    /// Glob patterns that hide a tool. Deny wins.
131    visibility_deny: Vec<Pattern>,
132    /// Tools/args whose values must appear in the listed values to be
133    /// callable. Empty = no positive callability constraint.
134    callable_allow: CallabilityMap,
135    /// Tools/args whose values must *not* appear in the listed values to be
136    /// callable. Deny wins.
137    callable_deny: CallabilityMap,
138}
139
140impl ClientFilters {
141    /// A filter that permits everything (no headers / no env vars set).
142    pub fn allow_all() -> Self {
143        Self::default()
144    }
145
146    /// Build from raw strings. Callability inputs are JSON; malformed JSON
147    /// is treated as no constraints (logged at WARN) — bricking a request
148    /// on bad filter config would be a footgun for ops.
149    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    /// `true` if the tool name is visible to this client.
164    ///
165    /// A `false` return means a `tools/call` against this name produces an
166    /// MCP **Protocol Error** (`-32602`, "Unknown tool: …") and the tool is
167    /// omitted from `tools/list` / `resources/list`. Deny wins; an empty
168    /// allow list means "visible unless explicitly denied".
169    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    /// Check whether a `tools/call` is callable for this client given its
180    /// JSON `arguments`.
181    ///
182    /// `Ok(())` if no callability constraints apply or every constraint
183    /// passes. `Err(message)` surfaces as an MCP **Tool Execution Error**
184    /// (`isError: true` content with the message), the spec's
185    /// "Invalid input data" category.
186    ///
187    /// Visibility is *not* re-checked here; callers run
188    /// [`tool_visible`](Self::tool_visible) first.
189    pub fn tool_callable(&self, tool_name: &str, arguments: &Value) -> Result<(), String> {
190        let args_obj = arguments.as_object();
191
192        // Deny wins. Reject the call if any arg's value appears in the deny
193        // list for this tool.
194        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        // Positive allow: if a tool/arg appears in the allow map, the call
206        // must supply that arg and its value must appear in the list.
207        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    // ─── Visibility ────────────────────────────────────────────────────────
256
257    #[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    // ─── Callability ───────────────────────────────────────────────────────
322
323    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}