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    /// `true` if the top-level `search`/`execute`/`connection_status` tools
194    /// are visible to this client — unlike [`tool_visible`](Self::tool_visible),
195    /// only `deny` applies; a non-empty `allow` list does *not* hide these.
196    ///
197    /// These three are entry points, not operations: an operator's allow
198    /// list is written in terms of operation names (`report:Foo`,
199    /// `command:Bar`) to scope *which operations* a client can reach — that
200    /// scoping is enforced separately, per call, inside `search`'s index
201    /// filtering and `execute`'s sandbox (both still call `tool_visible` on
202    /// the operation's own `{kind}_{id}` name). If `search`/`execute`
203    /// themselves were gated by the same allow list, an operation-scoped
204    /// allow list with no explicit `search`/`execute` entry would hide both
205    /// tools entirely — making the server unreachable despite the
206    /// operator's intent being to scope operations, not remove entry
207    /// points. Explicit `deny` still works normally for operators who want
208    /// to lock a client out of a tool entirely.
209    pub fn meta_tool_visible(&self, name: &str) -> bool {
210        let name = normalize_tool_name(name);
211        !self.visibility_deny.iter().any(|p| p.matches(&name))
212    }
213
214    /// Check whether a `tools/call` is callable for this client given its
215    /// JSON `arguments`.
216    ///
217    /// `Ok(())` if no callability constraints apply or every constraint
218    /// passes. `Err(message)` surfaces as an MCP **Tool Execution Error**
219    /// (`isError: true` content with the message), the spec's
220    /// "Invalid input data" category.
221    ///
222    /// Visibility is *not* re-checked here; callers run
223    /// [`tool_visible`](Self::tool_visible) first.
224    pub fn tool_callable(&self, tool_name: &str, arguments: &Value) -> Result<(), String> {
225        // Normalize at the boundary so legacy `kind:Id` configs match the
226        // canonical `kind_Id` we use as the map key.
227        let tool_name = normalize_tool_name(tool_name);
228        let tool_name = tool_name.as_str();
229        let args_obj = arguments.as_object();
230
231        // Deny wins. Reject the call if any arg's value appears in the deny
232        // list for this tool.
233        if let Some(deny_args) = self.callable_deny.get(tool_name) {
234            for (arg_name, denied_values) in deny_args {
235                let Some(value) = args_obj.and_then(|o| o.get(arg_name)) else {
236                    continue;
237                };
238                if denied_values.contains(value) {
239                    return Err(format!("argument `{}` value not allowed", arg_name));
240                }
241            }
242        }
243
244        // Positive allow: if a tool/arg appears in the allow map, the call
245        // must supply that arg and its value must appear in the list.
246        if let Some(allow_args) = self.callable_allow.get(tool_name) {
247            for (arg_name, allowed_values) in allow_args {
248                let value = args_obj.and_then(|o| o.get(arg_name));
249                match value {
250                    Some(v) if allowed_values.contains(v) => {}
251                    Some(_) => {
252                        return Err(format!("argument `{}` value not in allowlist", arg_name));
253                    }
254                    None => {
255                        return Err(format!("argument `{}` is required by filter", arg_name));
256                    }
257                }
258            }
259        }
260
261        Ok(())
262    }
263}
264
265fn parse_patterns(raw: Option<&str>) -> Vec<Pattern> {
266    let Some(raw) = raw else {
267        return Vec::new();
268    };
269    raw.split(',').filter_map(Pattern::parse).collect()
270}
271
272fn parse_callability(raw: Option<&str>, label: &str) -> CallabilityMap {
273    let Some(raw) = raw else {
274        return CallabilityMap::new();
275    };
276    let trimmed = raw.trim();
277    if trimmed.is_empty() {
278        return CallabilityMap::new();
279    }
280    match serde_json::from_str::<CallabilityMap>(trimmed) {
281        Ok(parsed) => parsed
282            .into_iter()
283            .map(|(k, v)| (normalize_tool_name(&k), v))
284            .collect(),
285        Err(e) => {
286            log::warn!("[mcp] ignoring malformed tool-{} spec: {}", label, e);
287            CallabilityMap::new()
288        }
289    }
290}
291
292/// Convert a leading `kind:` separator to `kind_`. Entity ids never contain
293/// `:` (PascalCase from `#[myko_item]`), so the first colon is unambiguous.
294///
295/// Lets legacy callers continue using configs / patterns / tool-call names
296/// written as `command:RunPlaybook` while the MCP wire advertises the
297/// underscore form `command_RunPlaybook` (required because some LLM
298/// tool-call serializers — confirmed against gpt-oss-20b on 2026-06-02 —
299/// drop the `arguments` field when names contain `:`). The underscore form
300/// also matches the OpenAI tool-name regex `[a-zA-Z0-9_-]+`.
301fn normalize_tool_name(name: &str) -> String {
302    if let Some(pos) = name.find(':') {
303        let mut out = String::with_capacity(name.len());
304        out.push_str(&name[..pos]);
305        out.push('_');
306        out.push_str(&name[pos + 1..]);
307        out
308    } else {
309        name.to_string()
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use serde_json::json;
317
318    // ─── Visibility ────────────────────────────────────────────────────────
319
320    #[test]
321    fn empty_filter_allows_everything() {
322        let f = ClientFilters::allow_all();
323        assert!(f.tool_visible("anything"));
324        assert!(f.tool_visible("command:DeleteEverything"));
325    }
326
327    #[test]
328    fn star_allows_everything() {
329        let f = ClientFilters::from_strings(Some("*"), None, None, None);
330        assert!(f.tool_visible("query:GetAllTargets"));
331    }
332
333    #[test]
334    fn prefix_pattern() {
335        let f = ClientFilters::from_strings(Some("query:*"), None, None, None);
336        assert!(f.tool_visible("query:GetAllTargets"));
337        assert!(!f.tool_visible("command:DoStuff"));
338    }
339
340    #[test]
341    fn suffix_pattern() {
342        let f = ClientFilters::from_strings(Some("*Internal"), None, None, None);
343        assert!(f.tool_visible("query:GetThingInternal"));
344        assert!(!f.tool_visible("query:GetThing"));
345    }
346
347    #[test]
348    fn deny_wins_on_name_conflict() {
349        let f = ClientFilters::from_strings(Some("query:*"), Some("query:GetSecret"), None, None);
350        assert!(f.tool_visible("query:GetAllTargets"));
351        assert!(!f.tool_visible("query:GetSecret"));
352    }
353
354    #[test]
355    fn empty_allow_with_deny_means_allow_all_minus_denied() {
356        let f = ClientFilters::from_strings(None, Some("command:Delete*"), None, None);
357        assert!(f.tool_visible("query:GetAllTargets"));
358        assert!(!f.tool_visible("command:DeleteThing"));
359    }
360
361    #[test]
362    fn comma_separated_allow_list() {
363        let f = ClientFilters::from_strings(Some("query:*,report:HealthCheck"), None, None, None);
364        assert!(f.tool_visible("query:Anything"));
365        assert!(f.tool_visible("report:HealthCheck"));
366        assert!(!f.tool_visible("report:OtherReport"));
367        assert!(!f.tool_visible("command:DoStuff"));
368    }
369
370    #[test]
371    fn whitespace_around_patterns_is_stripped() {
372        let f = ClientFilters::from_strings(Some(" query:* , report:H "), None, None, None);
373        assert!(f.tool_visible("query:GetAll"));
374        assert!(f.tool_visible("report:H"));
375    }
376
377    #[test]
378    fn exact_match() {
379        let f = ClientFilters::from_strings(Some("query:GetAllTargets"), None, None, None);
380        assert!(f.tool_visible("query:GetAllTargets"));
381        assert!(!f.tool_visible("query:GetAllTargetsExtra"));
382    }
383
384    // ─── Callability ───────────────────────────────────────────────────────
385
386    fn run_playbook_allow() -> &'static str {
387        r#"{"command:RunPlaybook":{"playbook_id":["site","deploy"]}}"#
388    }
389
390    #[test]
391    fn no_callability_rules_passes() {
392        let f = ClientFilters::allow_all();
393        assert!(f.tool_callable("any:tool", &json!({"x": 1})).is_ok());
394    }
395
396    #[test]
397    fn allow_list_passes_matching_arg() {
398        let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
399        assert!(
400            f.tool_callable("command:RunPlaybook", &json!({"playbook_id": "site"}))
401                .is_ok()
402        );
403    }
404
405    #[test]
406    fn allow_list_rejects_non_matching_arg() {
407        let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
408        let err = f
409            .tool_callable("command:RunPlaybook", &json!({"playbook_id": "danger"}))
410            .unwrap_err();
411        assert!(err.contains("playbook_id"));
412        assert!(err.contains("allowlist"));
413    }
414
415    #[test]
416    fn allow_list_rejects_missing_arg() {
417        let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
418        let err = f
419            .tool_callable("command:RunPlaybook", &json!({}))
420            .unwrap_err();
421        assert!(err.contains("required"));
422    }
423
424    #[test]
425    fn deny_list_rejects_matching_arg() {
426        let f = ClientFilters::from_strings(
427            None,
428            None,
429            None,
430            Some(r#"{"command:Tag":{"namespace":["prod"]}}"#),
431        );
432        assert!(
433            f.tool_callable("command:Tag", &json!({"namespace": "staging"}))
434                .is_ok()
435        );
436        let err = f
437            .tool_callable("command:Tag", &json!({"namespace": "prod"}))
438            .unwrap_err();
439        assert!(err.contains("namespace"));
440    }
441
442    #[test]
443    fn deny_wins_when_both_allow_and_deny_listed() {
444        let f = ClientFilters::from_strings(
445            None,
446            None,
447            Some(r#"{"command:X":{"a":["1","2"]}}"#),
448            Some(r#"{"command:X":{"a":["2"]}}"#),
449        );
450        assert!(f.tool_callable("command:X", &json!({"a": "1"})).is_ok());
451        assert!(f.tool_callable("command:X", &json!({"a": "2"})).is_err());
452    }
453
454    #[test]
455    fn unrelated_tools_pass_through() {
456        let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
457        assert!(
458            f.tool_callable("command:Other", &json!({"anything": "goes"}))
459                .is_ok()
460        );
461    }
462
463    #[test]
464    fn malformed_callability_json_is_ignored() {
465        let f = ClientFilters::from_strings(None, None, Some("not json"), Some("not json"));
466        assert!(f.tool_callable("any:tool", &json!({})).is_ok());
467    }
468
469    // ─── Separator normalization (`:` legacy ↔ `_` canonical) ──────────────
470
471    #[test]
472    fn underscore_form_is_accepted_for_visibility() {
473        // Configs in either form should produce equivalent decisions.
474        let f = ClientFilters::from_strings(Some("query_*"), None, None, None);
475        assert!(f.tool_visible("query_GetAllTargets"));
476        assert!(f.tool_visible("query:GetAllTargets")); // legacy form still matches
477        assert!(!f.tool_visible("command_DoStuff"));
478    }
479
480    #[test]
481    fn colon_pattern_matches_underscore_name() {
482        // Operator wrote `query:*` in their config; wire now advertises
483        // `query_GetAllTargets`. Normalization makes this transparent.
484        let f = ClientFilters::from_strings(Some("query:*"), None, None, None);
485        assert!(f.tool_visible("query_GetAllTargets"));
486    }
487
488    #[test]
489    fn callability_map_normalizes_keys() {
490        // Config uses legacy `command:RunPlaybook`; runtime calls
491        // `command_RunPlaybook`. Both should resolve to the same row.
492        let f = ClientFilters::from_strings(None, None, Some(run_playbook_allow()), None);
493        assert!(
494            f.tool_callable("command_RunPlaybook", &json!({"playbook_id": "site"}))
495                .is_ok()
496        );
497        let err = f
498            .tool_callable("command_RunPlaybook", &json!({"playbook_id": "danger"}))
499            .unwrap_err();
500        assert!(err.contains("allowlist"));
501    }
502
503    #[test]
504    fn normalize_tool_name_idempotent_on_underscore_form() {
505        assert_eq!(normalize_tool_name("command_X"), "command_X");
506        assert_eq!(normalize_tool_name("command:X"), "command_X");
507        assert_eq!(normalize_tool_name("plain"), "plain");
508        // Only the first separator is replaced; ids never contain `:` anyway.
509        assert_eq!(normalize_tool_name("a:b:c"), "a_b:c");
510    }
511
512    // ─── meta_tool_visible ─────────────────────────────────────────────────
513
514    #[test]
515    fn meta_tool_visible_ignores_op_level_allow_list() {
516        // Reproduces the pulse-ctx upgrade scenario: an operation-scoped
517        // allow list (written for the old one-tool-per-operation wire
518        // shape) must not hide search/execute/connection_status, which
519        // carry no capability of their own — capability is enforced
520        // per-operation inside search/execute instead.
521        let f = ClientFilters::from_strings(
522            Some("report:GetContextRecordsByAttribute,command:WriteContextRecord"),
523            None,
524            None,
525            None,
526        );
527        assert!(f.meta_tool_visible("search"));
528        assert!(f.meta_tool_visible("execute"));
529        assert!(f.meta_tool_visible("connection_status"));
530        // The op-level allow list still fully applies to `tool_visible`,
531        // which is what search/execute use internally per operation.
532        assert!(f.tool_visible("report_GetContextRecordsByAttribute"));
533        assert!(!f.tool_visible("command_DeleteEverything"));
534    }
535
536    #[test]
537    fn meta_tool_visible_still_respects_explicit_deny() {
538        let f = ClientFilters::from_strings(None, Some("execute"), None, None);
539        assert!(!f.meta_tool_visible("execute"));
540        assert!(f.meta_tool_visible("search"));
541        assert!(f.meta_tool_visible("connection_status"));
542    }
543
544    #[test]
545    fn meta_tool_visible_allows_everything_by_default() {
546        let f = ClientFilters::allow_all();
547        assert!(f.meta_tool_visible("search"));
548        assert!(f.meta_tool_visible("execute"));
549        assert!(f.meta_tool_visible("connection_status"));
550    }
551}