Skip to main content

mcp_skill_framework/
validation.rs

1//! Structured input validation for the [`Skill`](crate::Skill) contract.
2//!
3//! Every skill declares a list of [`Rule`]s in its
4//! [`Skill::validation_rules`](crate::Skill::validation_rules)
5//! implementation. The dispatcher evaluates them after the arguments
6//! arrive and BEFORE the call body runs; on failure the call returns a
7//! structured `{"validation_failed": [...]}` payload describing exactly
8//! which fields broke which rules, so an LLM caller can correct itself
9//! without parsing English error strings.
10//!
11//! The same rule tree can be surfaced through an introspection tool (see
12//! [`crate::describe`]) so a caller can audit the constraints up-front. It
13//! complements the JSON Schema that comes from `schemars` derives — JSON
14//! Schema tells the caller the shape (types, required fields),
15//! `validation_rules` tells it the domain constraints (range, mutual
16//! exclusion, allowed enum values, regex shape).
17//!
18//! ## Composability
19//!
20//! Rules nest with `All` (AND), `Any` (OR), and `Not`. `ExactlyOne` and
21//! `AtLeastOne` over a set of field names express the common
22//! mutually-exclusive / "supply one of" patterns natively, so skills
23//! don't have to roll their own. The `Custom` variant is the escape
24//! hatch for anything the declarative DSL can't express.
25
26use rmcp::model::JsonObject;
27use serde_json::{json, Value};
28
29/// One field-level constraint violation, structured for a caller to read.
30#[derive(Debug, Clone)]
31pub struct FieldViolation {
32    /// JSON-pointer-ish field path. Top-level field names ("`code`"), nested
33    /// dotted paths ("`config.timeout`"), or array elements ("`items[2]`").
34    pub field: String,
35    /// Short rule identifier — `"range"`, `"one_of"`, `"regex"`, `"length"`,
36    /// `"exactly_one"`, `"at_least_one"`, `"all_of"`, `"any_of"`, `"not"`,
37    /// `"custom"`.
38    pub rule: &'static str,
39    /// Human-readable description of what's wrong.
40    pub message: String,
41    /// Machine-readable description of the expected shape, e.g.
42    /// `{"min":100, "max":599}` or `{"one_of":["a","b"]}` — exactly what
43    /// the rule asserts.
44    pub expected: Value,
45    /// The actual value that violated, when extractable.
46    pub got: Option<Value>,
47}
48
49/// Outcome of [`Skill::validate`](crate::Skill::validate).
50#[derive(Debug, Clone)]
51pub enum ValidationResult {
52    /// Args passed every rule.
53    Pass,
54    /// One or more rules failed. The list is preserved in declaration
55    /// order so the dispatcher can surface them deterministically.
56    Fail(Vec<FieldViolation>),
57}
58
59impl ValidationResult {
60    /// `true` iff validation passed.
61    pub fn is_pass(&self) -> bool {
62        matches!(self, ValidationResult::Pass)
63    }
64    /// Render as the structured JSON the dispatcher returns to the caller.
65    pub fn to_payload(&self) -> Value {
66        match self {
67            ValidationResult::Pass => json!({"validation": "pass"}),
68            ValidationResult::Fail(violations) => {
69                let arr: Vec<Value> = violations
70                    .iter()
71                    .map(|v| {
72                        let mut obj = json!({
73                            "field": v.field,
74                            "rule": v.rule,
75                            "message": v.message,
76                            "expected": v.expected,
77                        });
78                        if let Some(g) = &v.got {
79                            obj["got"] = g.clone();
80                        }
81                        obj
82                    })
83                    .collect();
84                json!({"validation_failed": arr})
85            }
86        }
87    }
88}
89
90/// Declarative validation rule. Built once per skill (typically as a
91/// `&'static [Rule]`) so there's no per-call allocation cost.
92#[derive(Debug, Clone)]
93pub enum Rule {
94    /// Numeric `field` must satisfy `min <= value <= max`. Either bound is
95    /// optional. Works for any field that parses as `f64`.
96    Range {
97        field: &'static str,
98        min: Option<f64>,
99        max: Option<f64>,
100    },
101    /// String `field` must equal one of the listed values (case-sensitive).
102    OneOf {
103        field: &'static str,
104        values: &'static [&'static str],
105    },
106    /// String `field` must match the given Rust regex. `summary` is a
107    /// short human description ("ISO-3166 alpha-2") shown in the error.
108    Regex {
109        field: &'static str,
110        pattern: &'static str,
111        summary: &'static str,
112    },
113    /// String / array `field` length bounds (Unicode chars for strings, len for arrays).
114    Length {
115        field: &'static str,
116        min: Option<usize>,
117        max: Option<usize>,
118    },
119    /// Exactly one of the named fields must be present + non-null.
120    ExactlyOne { fields: &'static [&'static str] },
121    /// At least one of the named fields must be present + non-null.
122    AtLeastOne { fields: &'static [&'static str] },
123    /// Conjunction — every sub-rule must pass.
124    All(&'static [Rule]),
125    /// Disjunction — at least one sub-rule must pass. Failures are reported
126    /// only when EVERY branch fails (aggregated).
127    Any(&'static [Rule]),
128    /// Negation — the inner rule must NOT match. Useful as the dual of `OneOf`.
129    Not(&'static Rule),
130    /// Custom validator. Receives the full args object, returns Ok or one
131    /// FieldViolation. Use sparingly — declarative variants are preferred
132    /// because [`crate::describe`] can render them.
133    Custom {
134        /// Stable identifier shown in error output and introspection.
135        name: &'static str,
136        /// One-line summary shown in introspection.
137        summary: &'static str,
138        eval: fn(&JsonObject) -> Result<(), FieldViolation>,
139    },
140}
141
142/// Evaluate every rule against the parsed arg object.
143pub fn evaluate(rules: &[Rule], args: &JsonObject) -> ValidationResult {
144    let mut out: Vec<FieldViolation> = Vec::new();
145    for r in rules {
146        if let Err(mut v) = eval_one(r, args) {
147            out.append(&mut v);
148        }
149    }
150    if out.is_empty() {
151        ValidationResult::Pass
152    } else {
153        ValidationResult::Fail(out)
154    }
155}
156
157fn eval_one(rule: &Rule, args: &JsonObject) -> Result<(), Vec<FieldViolation>> {
158    match rule {
159        Rule::Range { field, min, max } => {
160            let v = lookup(args, field);
161            // None / null is treated as "absent" — skip; let required-field shape catch it.
162            let Some(value) = v else { return Ok(()) };
163            let n = match value.as_f64() {
164                Some(n) => n,
165                None => {
166                    return Err(vec![FieldViolation {
167                        field: (*field).to_string(),
168                        rule: "range",
169                        message: format!("`{field}` must be a number"),
170                        expected: json!({"type": "number"}),
171                        got: Some(value.clone()),
172                    }]);
173                }
174            };
175            let lo = min.unwrap_or(f64::NEG_INFINITY);
176            let hi = max.unwrap_or(f64::INFINITY);
177            if n < lo || n > hi {
178                return Err(vec![FieldViolation {
179                    field: (*field).to_string(),
180                    rule: "range",
181                    message: format!(
182                        "`{field}` must be in [{}..{}], got {n}",
183                        min.map(|x| x.to_string()).unwrap_or_else(|| "-∞".into()),
184                        max.map(|x| x.to_string()).unwrap_or_else(|| "+∞".into()),
185                    ),
186                    expected: json!({"min": min, "max": max}),
187                    got: Some(json!(n)),
188                }]);
189            }
190            Ok(())
191        }
192        Rule::OneOf { field, values } => {
193            let v = lookup(args, field);
194            let Some(value) = v else { return Ok(()) };
195            let s = match value.as_str() {
196                Some(s) => s,
197                None => {
198                    return Err(vec![FieldViolation {
199                        field: (*field).to_string(),
200                        rule: "one_of",
201                        message: format!("`{field}` must be a string"),
202                        expected: json!({"one_of": values}),
203                        got: Some(value.clone()),
204                    }]);
205                }
206            };
207            if values.contains(&s) {
208                Ok(())
209            } else {
210                Err(vec![FieldViolation {
211                    field: (*field).to_string(),
212                    rule: "one_of",
213                    message: format!("`{field}` must be one of {values:?}, got `{s}`"),
214                    expected: json!({"one_of": values}),
215                    got: Some(json!(s)),
216                }])
217            }
218        }
219        Rule::Regex {
220            field,
221            pattern,
222            summary,
223        } => {
224            let v = lookup(args, field);
225            let Some(value) = v else { return Ok(()) };
226            let s = match value.as_str() {
227                Some(s) => s,
228                None => {
229                    return Err(vec![FieldViolation {
230                        field: (*field).to_string(),
231                        rule: "regex",
232                        message: format!("`{field}` must be a string"),
233                        expected: json!({"pattern": pattern, "summary": summary}),
234                        got: Some(value.clone()),
235                    }]);
236                }
237            };
238            // Compile per-call; regex caching across calls is a follow-up.
239            match regex::Regex::new(pattern) {
240                Ok(re) if re.is_match(s) => Ok(()),
241                Ok(_) => Err(vec![FieldViolation {
242                    field: (*field).to_string(),
243                    rule: "regex",
244                    message: format!("`{field}` must match {summary} (regex `{pattern}`)"),
245                    expected: json!({"pattern": pattern, "summary": summary}),
246                    got: Some(json!(s)),
247                }]),
248                Err(_) => Ok(()), // bad pattern at code time — don't reject the user
249            }
250        }
251        Rule::Length { field, min, max } => {
252            let v = lookup(args, field);
253            let Some(value) = v else { return Ok(()) };
254            let n = if let Some(s) = value.as_str() {
255                s.chars().count()
256            } else if let Some(arr) = value.as_array() {
257                arr.len()
258            } else {
259                return Err(vec![FieldViolation {
260                    field: (*field).to_string(),
261                    rule: "length",
262                    message: format!("`{field}` must be a string or array"),
263                    expected: json!({"min": min, "max": max}),
264                    got: Some(value.clone()),
265                }]);
266            };
267            let lo = min.unwrap_or(0);
268            let hi = max.unwrap_or(usize::MAX);
269            if n < lo || n > hi {
270                return Err(vec![FieldViolation {
271                    field: (*field).to_string(),
272                    rule: "length",
273                    message: format!(
274                        "`{field}` length must be in [{}..{}], got {n}",
275                        min.map(|x| x.to_string()).unwrap_or_else(|| "0".into()),
276                        max.map(|x| x.to_string()).unwrap_or_else(|| "∞".into()),
277                    ),
278                    expected: json!({"min": min, "max": max}),
279                    got: Some(json!(n)),
280                }]);
281            }
282            Ok(())
283        }
284        Rule::ExactlyOne { fields } => {
285            let present: Vec<&&str> = fields
286                .iter()
287                .filter(|f| lookup(args, f).is_some_and(|v| !v.is_null()))
288                .collect();
289            if present.len() == 1 {
290                Ok(())
291            } else {
292                Err(vec![FieldViolation {
293                    field: fields.join(", "),
294                    rule: "exactly_one",
295                    message: format!("exactly one of {fields:?} must be supplied; got {present:?}"),
296                    expected: json!({"exactly_one": fields}),
297                    got: Some(json!(present)),
298                }])
299            }
300        }
301        Rule::AtLeastOne { fields } => {
302            let present: Vec<&&str> = fields
303                .iter()
304                .filter(|f| lookup(args, f).is_some_and(|v| !v.is_null()))
305                .collect();
306            if !present.is_empty() {
307                Ok(())
308            } else {
309                Err(vec![FieldViolation {
310                    field: fields.join(", "),
311                    rule: "at_least_one",
312                    message: format!("at least one of {fields:?} must be supplied"),
313                    expected: json!({"at_least_one": fields}),
314                    got: Some(json!([])),
315                }])
316            }
317        }
318        Rule::All(sub) => {
319            // Every sub-rule must pass. Aggregate all failures so the caller
320            // sees the full list, not just the first.
321            let mut out: Vec<FieldViolation> = Vec::new();
322            for r in *sub {
323                if let Err(mut v) = eval_one(r, args) {
324                    out.append(&mut v);
325                }
326            }
327            if out.is_empty() {
328                Ok(())
329            } else {
330                Err(out)
331            }
332        }
333        Rule::Any(sub) => {
334            // At least one branch must pass. Only surface failures when
335            // every branch fails — and then surface ALL of them as a hint
336            // about which paths were tried.
337            let mut all_failures: Vec<FieldViolation> = Vec::new();
338            for r in *sub {
339                match eval_one(r, args) {
340                    Ok(()) => return Ok(()),
341                    Err(mut v) => all_failures.append(&mut v),
342                }
343            }
344            Err(all_failures)
345        }
346        Rule::Not(inner) => {
347            // The inner rule must NOT match. We swallow its failure list
348            // and emit a single "not matched the negated rule" hint.
349            match eval_one(inner, args) {
350                Ok(()) => Err(vec![FieldViolation {
351                    field: "<combinator>".into(),
352                    rule: "not",
353                    message: "negated rule unexpectedly matched".into(),
354                    expected: json!({"not": format!("{inner:?}")}),
355                    got: None,
356                }]),
357                Err(_) => Ok(()),
358            }
359        }
360        Rule::Custom { eval, .. } => match eval(args) {
361            Ok(()) => Ok(()),
362            Err(v) => Err(vec![v]),
363        },
364    }
365}
366
367/// Dotted-path lookup. `"foo"` -> top-level; `"a.b"` -> nested; array
368/// elements aren't supported by the path syntax yet (caller can write a
369/// `Custom` rule for those rare cases).
370fn lookup<'a>(args: &'a JsonObject, path: &str) -> Option<&'a Value> {
371    // Fast path: single-segment lookup against the original object.
372    if !path.contains('.') {
373        return args.get(path);
374    }
375    // Multi-segment: walk the nested objects. Deep paths are rare.
376    let mut cur: Option<&Value> = args.get(path.split('.').next().unwrap_or(""));
377    for seg in path.split('.').skip(1) {
378        cur = cur.and_then(|v| v.get(seg));
379    }
380    cur
381}
382
383/// Render a rule tree as a JSON shape suitable for an introspection tool.
384pub fn rules_to_json(rules: &[Rule]) -> Value {
385    Value::Array(rules.iter().map(rule_to_json).collect())
386}
387
388fn rule_to_json(r: &Rule) -> Value {
389    match r {
390        Rule::Range { field, min, max } => {
391            json!({"rule": "range", "field": field, "min": min, "max": max})
392        }
393        Rule::OneOf { field, values } => {
394            json!({"rule": "one_of", "field": field, "values": values})
395        }
396        Rule::Regex {
397            field,
398            pattern,
399            summary,
400        } => {
401            json!({"rule": "regex", "field": field, "pattern": pattern, "summary": summary})
402        }
403        Rule::Length { field, min, max } => {
404            json!({"rule": "length", "field": field, "min": min, "max": max})
405        }
406        Rule::ExactlyOne { fields } => json!({"rule": "exactly_one", "fields": fields}),
407        Rule::AtLeastOne { fields } => json!({"rule": "at_least_one", "fields": fields}),
408        Rule::All(sub) => {
409            json!({"rule": "all_of", "rules": sub.iter().map(rule_to_json).collect::<Vec<_>>()})
410        }
411        Rule::Any(sub) => {
412            json!({"rule": "any_of", "rules": sub.iter().map(rule_to_json).collect::<Vec<_>>()})
413        }
414        Rule::Not(inner) => json!({"rule": "not", "inner": rule_to_json(inner)}),
415        Rule::Custom { name, summary, .. } => {
416            json!({"rule": "custom", "name": name, "summary": summary})
417        }
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use serde_json::Map;
425
426    fn args(json: Value) -> JsonObject {
427        let Value::Object(m) = json else {
428            panic!("not an object")
429        };
430        m.into_iter().collect::<Map<_, _>>()
431    }
432
433    #[test]
434    fn range_in_bounds_passes() {
435        let r = [Rule::Range {
436            field: "code",
437            min: Some(100.0),
438            max: Some(599.0),
439        }];
440        let a = args(json!({"code": 200}));
441        assert!(evaluate(&r, &a).is_pass());
442    }
443
444    #[test]
445    fn range_out_of_bounds_fails() {
446        let r = [Rule::Range {
447            field: "code",
448            min: Some(100.0),
449            max: Some(599.0),
450        }];
451        let a = args(json!({"code": 700}));
452        match evaluate(&r, &a) {
453            ValidationResult::Fail(v) => {
454                assert_eq!(v[0].rule, "range");
455                assert_eq!(v[0].field, "code");
456            }
457            _ => panic!(),
458        }
459    }
460
461    #[test]
462    fn one_of_passes() {
463        let r = [Rule::OneOf {
464            field: "kind",
465            values: &["a", "b", "c"],
466        }];
467        let a = args(json!({"kind": "b"}));
468        assert!(evaluate(&r, &a).is_pass());
469    }
470
471    #[test]
472    fn one_of_rejects() {
473        let r = [Rule::OneOf {
474            field: "kind",
475            values: &["a", "b"],
476        }];
477        let a = args(json!({"kind": "x"}));
478        let res = evaluate(&r, &a);
479        assert!(matches!(res, ValidationResult::Fail(_)));
480    }
481
482    #[test]
483    fn length_bounds_enforced() {
484        let r = [Rule::Length {
485            field: "name",
486            min: Some(2),
487            max: Some(4),
488        }];
489        assert!(evaluate(&r, &args(json!({"name": "abc"}))).is_pass());
490        assert!(matches!(
491            evaluate(&r, &args(json!({"name": "a"}))),
492            ValidationResult::Fail(_)
493        ));
494        assert!(matches!(
495            evaluate(&r, &args(json!({"name": "abcde"}))),
496            ValidationResult::Fail(_)
497        ));
498    }
499
500    #[test]
501    fn regex_matches_and_rejects() {
502        let r = [Rule::Regex {
503            field: "cc",
504            pattern: "^[A-Z]{2}$",
505            summary: "ISO-3166 alpha-2",
506        }];
507        assert!(evaluate(&r, &args(json!({"cc": "US"}))).is_pass());
508        assert!(matches!(
509            evaluate(&r, &args(json!({"cc": "usa"}))),
510            ValidationResult::Fail(_)
511        ));
512    }
513
514    #[test]
515    fn nested_dotted_path_resolves() {
516        let r = [Rule::Range {
517            field: "config.timeout",
518            min: Some(1.0),
519            max: Some(60.0),
520        }];
521        assert!(evaluate(&r, &args(json!({"config": {"timeout": 30}}))).is_pass());
522        assert!(matches!(
523            evaluate(&r, &args(json!({"config": {"timeout": 120}}))),
524            ValidationResult::Fail(_)
525        ));
526    }
527
528    #[test]
529    fn exactly_one_enforced() {
530        let r = [Rule::ExactlyOne {
531            fields: &["a", "b"],
532        }];
533        // Both → fail.
534        let two = args(json!({"a": 1, "b": 2}));
535        assert!(matches!(evaluate(&r, &two), ValidationResult::Fail(_)));
536        // None → fail.
537        let zero = args(json!({}));
538        assert!(matches!(evaluate(&r, &zero), ValidationResult::Fail(_)));
539        // Exactly one → pass.
540        let one = args(json!({"a": 1}));
541        assert!(evaluate(&r, &one).is_pass());
542    }
543
544    #[test]
545    fn any_or_passes_when_one_branch_does() {
546        static SUB: &[Rule] = &[
547            Rule::OneOf {
548                field: "kind",
549                values: &["x"],
550            },
551            Rule::Range {
552                field: "code",
553                min: Some(0.0),
554                max: Some(10.0),
555            },
556        ];
557        let r = [Rule::Any(SUB)];
558        let a = args(json!({"kind": "wrong", "code": 5}));
559        assert!(evaluate(&r, &a).is_pass());
560    }
561
562    #[test]
563    fn any_or_fails_when_all_branches_do() {
564        static SUB: &[Rule] = &[
565            Rule::OneOf {
566                field: "kind",
567                values: &["x"],
568            },
569            Rule::Range {
570                field: "code",
571                min: Some(0.0),
572                max: Some(10.0),
573            },
574        ];
575        let r = [Rule::Any(SUB)];
576        let a = args(json!({"kind": "wrong", "code": 100}));
577        match evaluate(&r, &a) {
578            ValidationResult::Fail(v) => assert_eq!(v.len(), 2),
579            _ => panic!(),
580        }
581    }
582
583    #[test]
584    fn payload_shape() {
585        let r = [Rule::Range {
586            field: "p",
587            min: Some(0.0),
588            max: Some(100.0),
589        }];
590        let a = args(json!({"p": 150}));
591        let p = evaluate(&r, &a).to_payload();
592        assert!(p["validation_failed"].is_array());
593        assert_eq!(p["validation_failed"][0]["field"], "p");
594        assert_eq!(p["validation_failed"][0]["rule"], "range");
595    }
596
597    #[test]
598    fn rules_to_json_round_trips_shape() {
599        let r = [
600            Rule::OneOf {
601                field: "style",
602                values: &["a", "b"],
603            },
604            Rule::Range {
605                field: "n",
606                min: Some(0.0),
607                max: None,
608            },
609        ];
610        let j = rules_to_json(&r);
611        assert_eq!(j[0]["rule"], "one_of");
612        assert_eq!(j[0]["field"], "style");
613        assert_eq!(j[1]["rule"], "range");
614    }
615}