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//! Legacy `command:RunPlaybook` / `query:*` syntax in configs is accepted
42//! transparently — see [`normalize_tool_name`]. New configs should use the
43//! `_` form, which matches the OpenAI tool-name regex `[a-zA-Z0-9_-]+` and
44//! avoids tool-call-serializer bugs in some LLMs.
45//!
46//! Semantics, per tool/arg:
47//! - **Allow** is positive: the arg must be present on the call and its
48//!   value must appear in the list.
49//! - **Deny** excludes: if the arg is present and its value appears in the
50//!   list, the call is rejected.
51//! - Deny wins. If the same tool/arg/value appears on both sides, deny.
52
53use std::collections::HashMap;
54
55use serde_json::Value;
56
57// ─── Header / env names ────────────────────────────────────────────────────
58
59/// HTTP header carrying the tool-visibility allowlist (glob patterns).
60pub const VISIBILITY_ALLOW_HEADER: &str = "X-Myko-Tool-Visibility-Allow";
61/// HTTP header carrying the tool-visibility denylist (glob patterns).
62pub const VISIBILITY_DENY_HEADER: &str = "X-Myko-Tool-Visibility-Deny";
63/// HTTP header carrying the JSON tool-callable allowlist.
64pub const CALLABLE_ALLOW_HEADER: &str = "X-Myko-Tool-Callable-Allow";
65/// HTTP header carrying the JSON tool-callable denylist.
66pub const CALLABLE_DENY_HEADER: &str = "X-Myko-Tool-Callable-Deny";
67
68/// Stdio env var carrying the tool-visibility allowlist.
69pub const VISIBILITY_ALLOW_ENV: &str = "MYKO_MCP_TOOL_VISIBILITY_ALLOW";
70/// Stdio env var carrying the tool-visibility denylist.
71pub const VISIBILITY_DENY_ENV: &str = "MYKO_MCP_TOOL_VISIBILITY_DENY";
72/// Stdio env var carrying the JSON tool-callable allowlist.
73pub const CALLABLE_ALLOW_ENV: &str = "MYKO_MCP_TOOL_CALLABLE_ALLOW";
74/// Stdio env var carrying the JSON tool-callable denylist.
75pub const CALLABLE_DENY_ENV: &str = "MYKO_MCP_TOOL_CALLABLE_DENY";
76
77// ─── Name patterns ─────────────────────────────────────────────────────────
78
79/// A glob pattern for matching tool names.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum Pattern {
82    /// `*` — matches everything.
83    Any,
84    /// `prefix*` — matches names starting with `prefix`.
85    Prefix(String),
86    /// `*suffix` — matches names ending with `suffix`.
87    Suffix(String),
88    /// Exact match.
89    Exact(String),
90}
91
92impl Pattern {
93    /// Parse a single glob pattern. Empty string returns `None`.
94    ///
95    /// The pattern is normalized: a leading `kind:` separator becomes
96    /// `kind_` so legacy configs (e.g. `command:Run*`) keep matching after
97    /// myko's MCP wire switched to the underscore form. See
98    /// [`normalize_tool_name`].
99    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    /// Test whether `name` matches this pattern.
116    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
126// ─── Callability map ───────────────────────────────────────────────────────
127
128/// `tool_name -> { arg_name -> [values] }`.
129type CallabilityMap = HashMap<String, HashMap<String, Vec<Value>>>;
130
131// ─── ClientFilters ─────────────────────────────────────────────────────────
132
133/// Per-client filter combining tool-visibility (name-level) and
134/// tool-callability (argument-level) rules. Driven by request headers
135/// (HTTP/WS) or environment variables (stdio).
136#[derive(Debug, Clone, Default)]
137pub struct ClientFilters {
138    /// Glob patterns the tool name must match. Empty = visibility unrestricted.
139    visibility_allow: Vec<Pattern>,
140    /// Glob patterns that hide a tool. Deny wins.
141    visibility_deny: Vec<Pattern>,
142    /// Tools/args whose values must appear in the listed values to be
143    /// callable. Empty = no positive callability constraint.
144    callable_allow: CallabilityMap,
145    /// Tools/args whose values must *not* appear in the listed values to be
146    /// callable. Deny wins.
147    callable_deny: CallabilityMap,
148}
149
150impl ClientFilters {
151    /// A filter that permits everything (no headers / no env vars set).
152    pub fn allow_all() -> Self {
153        Self::default()
154    }
155
156    /// Build from raw strings. Callability inputs are JSON; malformed JSON
157    /// is treated as no constraints (logged at WARN) — bricking a request
158    /// on bad filter config would be a footgun for ops.
159    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    /// `true` if the tool name is visible to this client.
174    ///
175    /// A `false` return means a `tools/call` against this name produces an
176    /// MCP **Protocol Error** (`-32602`, "Unknown tool: …") and the tool is
177    /// omitted from `tools/list` / `resources/list`. Deny wins; an empty
178    /// allow list means "visible unless explicitly denied".
179    pub fn tool_visible(&self, name: &str) -> bool {
180        // Normalize at the boundary so legacy `kind:Id` and canonical
181        // `kind_Id` produce the same visibility decision.
182        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    /// Check whether a `tools/call` is callable for this client given its
194    /// JSON `arguments`.
195    ///
196    /// `Ok(())` if no callability constraints apply or every constraint
197    /// passes. `Err(message)` surfaces as an MCP **Tool Execution Error**
198    /// (`isError: true` content with the message), the spec's
199    /// "Invalid input data" category.
200    ///
201    /// Visibility is *not* re-checked here; callers run
202    /// [`tool_visible`](Self::tool_visible) first.
203    pub fn tool_callable(&self, tool_name: &str, arguments: &Value) -> Result<(), String> {
204        // Normalize at the boundary so legacy `kind:Id` configs match the
205        // canonical `kind_Id` we use as the map key.
206        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        // Deny wins. Reject the call if any arg's value appears in the deny
211        // list for this tool.
212        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        // Positive allow: if a tool/arg appears in the allow map, the call
224        // must supply that arg and its value must appear in the list.
225        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
271/// Convert a leading `kind:` separator to `kind_`. Entity ids never contain
272/// `:` (PascalCase from `#[myko_item]`), so the first colon is unambiguous.
273///
274/// Lets legacy callers continue using configs / patterns / tool-call names
275/// written as `command:RunPlaybook` while the MCP wire advertises the
276/// underscore form `command_RunPlaybook` (required because some LLM
277/// tool-call serializers — confirmed against gpt-oss-20b on 2026-06-02 —
278/// drop the `arguments` field when names contain `:`). The underscore form
279/// also matches the OpenAI tool-name regex `[a-zA-Z0-9_-]+`.
280fn 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    // ─── Visibility ────────────────────────────────────────────────────────
298
299    #[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    // ─── Callability ───────────────────────────────────────────────────────
364
365    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    // ─── Separator normalization (`:` legacy ↔ `_` canonical) ──────────────
449
450    #[test]
451    fn underscore_form_is_accepted_for_visibility() {
452        // Configs in either form should produce equivalent decisions.
453        let f = ClientFilters::from_strings(Some("query_*"), None, None, None);
454        assert!(f.tool_visible("query_GetAllTargets"));
455        assert!(f.tool_visible("query:GetAllTargets")); // legacy form still matches
456        assert!(!f.tool_visible("command_DoStuff"));
457    }
458
459    #[test]
460    fn colon_pattern_matches_underscore_name() {
461        // Operator wrote `query:*` in their config; wire now advertises
462        // `query_GetAllTargets`. Normalization makes this transparent.
463        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        // Config uses legacy `command:RunPlaybook`; runtime calls
470        // `command_RunPlaybook`. Both should resolve to the same row.
471        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        // Only the first separator is replaced; ids never contain `:` anyway.
488        assert_eq!(normalize_tool_name("a:b:c"), "a_b:c");
489    }
490}