mcpkit_core/
schema.rs

1//! JSON Schema utilities for MCP type validation.
2//!
3//! This module provides utilities for working with JSON Schema, which is used
4//! throughout the MCP protocol for defining tool input schemas, resource
5//! schemas, and elicitation schemas.
6//!
7//! # Features
8//!
9//! - Schema building with a fluent API
10//! - Common schema patterns (string, number, object, array)
11//! - Schema validation helpers
12//!
13//! # Example
14//!
15//! ```rust
16//! use mcpkit_core::schema::{SchemaBuilder, SchemaType};
17//!
18//! // Build a schema for a search tool input
19//! let schema = SchemaBuilder::object()
20//!     .property("query", SchemaBuilder::string().description("Search query"))
21//!     .property("limit", SchemaBuilder::integer().minimum(1).maximum(100).default_value(10))
22//!     .required(["query"])
23//!     .build();
24//! ```
25
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28use std::collections::HashMap;
29
30/// JSON Schema types as defined by the JSON Schema specification.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum SchemaType {
34    /// A string value.
35    String,
36    /// A numeric value (integer or float).
37    Number,
38    /// An integer value.
39    Integer,
40    /// A boolean value.
41    Boolean,
42    /// An array value.
43    Array,
44    /// An object value.
45    Object,
46    /// A null value.
47    Null,
48}
49
50impl std::fmt::Display for SchemaType {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            Self::String => write!(f, "string"),
54            Self::Number => write!(f, "number"),
55            Self::Integer => write!(f, "integer"),
56            Self::Boolean => write!(f, "boolean"),
57            Self::Array => write!(f, "array"),
58            Self::Object => write!(f, "object"),
59            Self::Null => write!(f, "null"),
60        }
61    }
62}
63
64/// A JSON Schema definition.
65///
66/// This struct represents a JSON Schema that can be used for validating
67/// tool inputs, resource contents, or elicitation responses.
68#[derive(Debug, Clone, Default, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct Schema {
71    /// The schema type.
72    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
73    pub schema_type: Option<SchemaType>,
74
75    /// Human-readable description.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub description: Option<String>,
78
79    /// Human-readable title.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub title: Option<String>,
82
83    /// Default value.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub default: Option<Value>,
86
87    /// Enumerated allowed values.
88    #[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
89    pub enum_values: Option<Vec<Value>>,
90
91    /// Constant value (must be exactly this value).
92    #[serde(rename = "const", skip_serializing_if = "Option::is_none")]
93    pub const_value: Option<Value>,
94
95    // String constraints
96    /// Minimum string length.
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub min_length: Option<u64>,
99
100    /// Maximum string length.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub max_length: Option<u64>,
103
104    /// Regex pattern for string validation.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub pattern: Option<String>,
107
108    /// Format hint (e.g., "email", "uri", "date-time").
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub format: Option<String>,
111
112    // Numeric constraints
113    /// Minimum numeric value (inclusive).
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub minimum: Option<f64>,
116
117    /// Maximum numeric value (inclusive).
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub maximum: Option<f64>,
120
121    /// Minimum numeric value (exclusive).
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub exclusive_minimum: Option<f64>,
124
125    /// Maximum numeric value (exclusive).
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub exclusive_maximum: Option<f64>,
128
129    /// Value must be a multiple of this number.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub multiple_of: Option<f64>,
132
133    // Array constraints
134    /// Schema for array items.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub items: Option<Box<Schema>>,
137
138    /// Minimum number of items.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub min_items: Option<u64>,
141
142    /// Maximum number of items.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub max_items: Option<u64>,
145
146    /// Whether items must be unique.
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub unique_items: Option<bool>,
149
150    // Object constraints
151    /// Property schemas for object type.
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub properties: Option<HashMap<String, Schema>>,
154
155    /// Required property names.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub required: Option<Vec<String>>,
158
159    /// Schema for additional properties.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub additional_properties: Option<AdditionalProperties>,
162
163    /// Minimum number of properties.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub min_properties: Option<u64>,
166
167    /// Maximum number of properties.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub max_properties: Option<u64>,
170
171    // Composition
172    /// All of these schemas must match.
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub all_of: Option<Vec<Schema>>,
175
176    /// Any of these schemas must match.
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub any_of: Option<Vec<Schema>>,
179
180    /// Exactly one of these schemas must match.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub one_of: Option<Vec<Schema>>,
183
184    /// This schema must not match.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub not: Option<Box<Schema>>,
187}
188
189/// Represents the `additionalProperties` field which can be a boolean or a schema.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(untagged)]
192pub enum AdditionalProperties {
193    /// Boolean: true allows any additional properties, false forbids them.
194    Boolean(bool),
195    /// Schema: additional properties must match this schema.
196    Schema(Box<Schema>),
197}
198
199impl Schema {
200    /// Create an empty schema (matches anything).
201    #[must_use]
202    pub fn any() -> Self {
203        Self::default()
204    }
205
206    /// Create a string schema.
207    #[must_use]
208    pub fn string() -> Self {
209        Self {
210            schema_type: Some(SchemaType::String),
211            ..Default::default()
212        }
213    }
214
215    /// Create a number schema.
216    #[must_use]
217    pub fn number() -> Self {
218        Self {
219            schema_type: Some(SchemaType::Number),
220            ..Default::default()
221        }
222    }
223
224    /// Create an integer schema.
225    #[must_use]
226    pub fn integer() -> Self {
227        Self {
228            schema_type: Some(SchemaType::Integer),
229            ..Default::default()
230        }
231    }
232
233    /// Create a boolean schema.
234    #[must_use]
235    pub fn boolean() -> Self {
236        Self {
237            schema_type: Some(SchemaType::Boolean),
238            ..Default::default()
239        }
240    }
241
242    /// Create an array schema.
243    #[must_use]
244    pub fn array() -> Self {
245        Self {
246            schema_type: Some(SchemaType::Array),
247            ..Default::default()
248        }
249    }
250
251    /// Create an object schema.
252    #[must_use]
253    pub fn object() -> Self {
254        Self {
255            schema_type: Some(SchemaType::Object),
256            ..Default::default()
257        }
258    }
259
260    /// Create a null schema.
261    #[must_use]
262    pub fn null() -> Self {
263        Self {
264            schema_type: Some(SchemaType::Null),
265            ..Default::default()
266        }
267    }
268
269    /// Convert this schema to a JSON value.
270    #[must_use]
271    pub fn to_value(&self) -> Value {
272        serde_json::to_value(self).unwrap_or_else(|_| Value::Object(serde_json::Map::new()))
273    }
274}
275
276/// A fluent builder for constructing JSON Schemas.
277///
278/// # Example
279///
280/// ```rust
281/// use mcpkit_core::schema::SchemaBuilder;
282///
283/// let schema = SchemaBuilder::object()
284///     .title("SearchInput")
285///     .description("Input for search tool")
286///     .property("query", SchemaBuilder::string().min_length(1))
287///     .property("page", SchemaBuilder::integer().minimum(1).default_value(1))
288///     .required(["query"])
289///     .build();
290/// ```
291#[derive(Debug, Clone, Default)]
292pub struct SchemaBuilder {
293    schema: Schema,
294}
295
296impl SchemaBuilder {
297    /// Create a new schema builder.
298    #[must_use]
299    pub fn new() -> Self {
300        Self::default()
301    }
302
303    /// Create a string schema builder.
304    #[must_use]
305    pub fn string() -> Self {
306        Self {
307            schema: Schema::string(),
308        }
309    }
310
311    /// Create a number schema builder.
312    #[must_use]
313    pub fn number() -> Self {
314        Self {
315            schema: Schema::number(),
316        }
317    }
318
319    /// Create an integer schema builder.
320    #[must_use]
321    pub fn integer() -> Self {
322        Self {
323            schema: Schema::integer(),
324        }
325    }
326
327    /// Create a boolean schema builder.
328    #[must_use]
329    pub fn boolean() -> Self {
330        Self {
331            schema: Schema::boolean(),
332        }
333    }
334
335    /// Create an array schema builder.
336    #[must_use]
337    pub fn array() -> Self {
338        Self {
339            schema: Schema::array(),
340        }
341    }
342
343    /// Create an object schema builder.
344    #[must_use]
345    pub fn object() -> Self {
346        Self {
347            schema: Schema::object(),
348        }
349    }
350
351    /// Create a null schema builder.
352    #[must_use]
353    pub fn null() -> Self {
354        Self {
355            schema: Schema::null(),
356        }
357    }
358
359    /// Set the schema title.
360    #[must_use]
361    pub fn title(mut self, title: impl Into<String>) -> Self {
362        self.schema.title = Some(title.into());
363        self
364    }
365
366    /// Set the schema description.
367    #[must_use]
368    pub fn description(mut self, description: impl Into<String>) -> Self {
369        self.schema.description = Some(description.into());
370        self
371    }
372
373    /// Set the default value.
374    #[must_use]
375    pub fn default_value(mut self, default: impl Into<Value>) -> Self {
376        self.schema.default = Some(default.into());
377        self
378    }
379
380    /// Set enumerated allowed values.
381    #[must_use]
382    pub fn enum_values<I, V>(mut self, values: I) -> Self
383    where
384        I: IntoIterator<Item = V>,
385        V: Into<Value>,
386    {
387        self.schema.enum_values = Some(values.into_iter().map(Into::into).collect());
388        self
389    }
390
391    /// Set a constant value.
392    #[must_use]
393    pub fn const_value(mut self, value: impl Into<Value>) -> Self {
394        self.schema.const_value = Some(value.into());
395        self
396    }
397
398    // String constraints
399
400    /// Set minimum string length.
401    #[must_use]
402    pub const fn min_length(mut self, min: u64) -> Self {
403        self.schema.min_length = Some(min);
404        self
405    }
406
407    /// Set maximum string length.
408    #[must_use]
409    pub const fn max_length(mut self, max: u64) -> Self {
410        self.schema.max_length = Some(max);
411        self
412    }
413
414    /// Set regex pattern.
415    #[must_use]
416    pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
417        self.schema.pattern = Some(pattern.into());
418        self
419    }
420
421    /// Set string format hint.
422    #[must_use]
423    pub fn format(mut self, format: impl Into<String>) -> Self {
424        self.schema.format = Some(format.into());
425        self
426    }
427
428    // Numeric constraints
429
430    /// Set minimum value (inclusive).
431    #[must_use]
432    pub fn minimum(mut self, min: impl Into<f64>) -> Self {
433        self.schema.minimum = Some(min.into());
434        self
435    }
436
437    /// Set maximum value (inclusive).
438    #[must_use]
439    pub fn maximum(mut self, max: impl Into<f64>) -> Self {
440        self.schema.maximum = Some(max.into());
441        self
442    }
443
444    /// Set exclusive minimum value.
445    #[must_use]
446    pub fn exclusive_minimum(mut self, min: impl Into<f64>) -> Self {
447        self.schema.exclusive_minimum = Some(min.into());
448        self
449    }
450
451    /// Set exclusive maximum value.
452    #[must_use]
453    pub fn exclusive_maximum(mut self, max: impl Into<f64>) -> Self {
454        self.schema.exclusive_maximum = Some(max.into());
455        self
456    }
457
458    /// Set multiple-of constraint.
459    #[must_use]
460    pub fn multiple_of(mut self, multiple: impl Into<f64>) -> Self {
461        self.schema.multiple_of = Some(multiple.into());
462        self
463    }
464
465    // Array constraints
466
467    /// Set schema for array items.
468    #[must_use]
469    pub fn items(mut self, items: Self) -> Self {
470        self.schema.items = Some(Box::new(items.schema));
471        self
472    }
473
474    /// Set minimum number of items.
475    #[must_use]
476    pub const fn min_items(mut self, min: u64) -> Self {
477        self.schema.min_items = Some(min);
478        self
479    }
480
481    /// Set maximum number of items.
482    #[must_use]
483    pub const fn max_items(mut self, max: u64) -> Self {
484        self.schema.max_items = Some(max);
485        self
486    }
487
488    /// Require unique items.
489    #[must_use]
490    pub const fn unique_items(mut self, unique: bool) -> Self {
491        self.schema.unique_items = Some(unique);
492        self
493    }
494
495    // Object constraints
496
497    /// Add a property to the schema.
498    #[must_use]
499    pub fn property(mut self, name: impl Into<String>, schema: Self) -> Self {
500        let properties = self.schema.properties.get_or_insert_with(HashMap::new);
501        properties.insert(name.into(), schema.schema);
502        self
503    }
504
505    /// Set required properties.
506    #[must_use]
507    pub fn required<I, S>(mut self, required: I) -> Self
508    where
509        I: IntoIterator<Item = S>,
510        S: Into<String>,
511    {
512        self.schema.required = Some(required.into_iter().map(Into::into).collect());
513        self
514    }
515
516    /// Set whether additional properties are allowed.
517    #[must_use]
518    pub fn additional_properties(mut self, allowed: bool) -> Self {
519        self.schema.additional_properties = Some(AdditionalProperties::Boolean(allowed));
520        self
521    }
522
523    /// Set schema for additional properties.
524    #[must_use]
525    pub fn additional_properties_schema(mut self, schema: Self) -> Self {
526        self.schema.additional_properties =
527            Some(AdditionalProperties::Schema(Box::new(schema.schema)));
528        self
529    }
530
531    /// Set minimum number of properties.
532    #[must_use]
533    pub const fn min_properties(mut self, min: u64) -> Self {
534        self.schema.min_properties = Some(min);
535        self
536    }
537
538    /// Set maximum number of properties.
539    #[must_use]
540    pub const fn max_properties(mut self, max: u64) -> Self {
541        self.schema.max_properties = Some(max);
542        self
543    }
544
545    // Composition
546
547    /// Require all of the given schemas to match.
548    #[must_use]
549    pub fn all_of<I>(mut self, schemas: I) -> Self
550    where
551        I: IntoIterator<Item = Self>,
552    {
553        self.schema.all_of = Some(schemas.into_iter().map(|b| b.schema).collect());
554        self
555    }
556
557    /// Require any of the given schemas to match.
558    #[must_use]
559    pub fn any_of<I>(mut self, schemas: I) -> Self
560    where
561        I: IntoIterator<Item = Self>,
562    {
563        self.schema.any_of = Some(schemas.into_iter().map(|b| b.schema).collect());
564        self
565    }
566
567    /// Require exactly one of the given schemas to match.
568    #[must_use]
569    pub fn one_of<I>(mut self, schemas: I) -> Self
570    where
571        I: IntoIterator<Item = Self>,
572    {
573        self.schema.one_of = Some(schemas.into_iter().map(|b| b.schema).collect());
574        self
575    }
576
577    /// Require the given schema to not match.
578    #[must_use]
579    pub fn not(mut self, schema: Self) -> Self {
580        self.schema.not = Some(Box::new(schema.schema));
581        self
582    }
583
584    /// Build the schema.
585    #[must_use]
586    pub fn build(self) -> Schema {
587        self.schema
588    }
589
590    /// Build and convert to a JSON value.
591    #[must_use]
592    pub fn to_value(self) -> Value {
593        self.schema.to_value()
594    }
595}
596
597/// Common string format hints.
598pub mod formats {
599    /// Email address format.
600    pub const EMAIL: &str = "email";
601    /// URI format.
602    pub const URI: &str = "uri";
603    /// URI reference format.
604    pub const URI_REFERENCE: &str = "uri-reference";
605    /// Date-time format (RFC 3339).
606    pub const DATE_TIME: &str = "date-time";
607    /// Date format.
608    pub const DATE: &str = "date";
609    /// Time format.
610    pub const TIME: &str = "time";
611    /// Duration format (ISO 8601).
612    pub const DURATION: &str = "duration";
613    /// UUID format.
614    pub const UUID: &str = "uuid";
615    /// Hostname format.
616    pub const HOSTNAME: &str = "hostname";
617    /// IPv4 address format.
618    pub const IPV4: &str = "ipv4";
619    /// IPv6 address format.
620    pub const IPV6: &str = "ipv6";
621    /// JSON Pointer format.
622    pub const JSON_POINTER: &str = "json-pointer";
623    /// Regex format.
624    pub const REGEX: &str = "regex";
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn test_string_schema() {
633        let schema = SchemaBuilder::string()
634            .description("A test string")
635            .min_length(1)
636            .max_length(100)
637            .pattern(r"^[a-z]+$")
638            .build();
639
640        assert_eq!(schema.schema_type, Some(SchemaType::String));
641        assert_eq!(schema.description.as_deref(), Some("A test string"));
642        assert_eq!(schema.min_length, Some(1));
643        assert_eq!(schema.max_length, Some(100));
644    }
645
646    #[test]
647    fn test_number_schema() {
648        let schema = SchemaBuilder::number().minimum(0).maximum(100).build();
649
650        assert_eq!(schema.schema_type, Some(SchemaType::Number));
651        assert_eq!(schema.minimum, Some(0.0));
652        assert_eq!(schema.maximum, Some(100.0));
653    }
654
655    #[test]
656    fn test_object_schema() {
657        let schema = SchemaBuilder::object()
658            .property("name", SchemaBuilder::string())
659            .property("age", SchemaBuilder::integer().minimum(0))
660            .required(["name"])
661            .additional_properties(false)
662            .build();
663
664        assert_eq!(schema.schema_type, Some(SchemaType::Object));
665        assert!(schema.properties.is_some());
666        let props = schema.properties.as_ref().unwrap();
667        assert!(props.contains_key("name"));
668        assert!(props.contains_key("age"));
669        assert_eq!(schema.required, Some(vec!["name".to_string()]));
670    }
671
672    #[test]
673    fn test_array_schema() {
674        let schema = SchemaBuilder::array()
675            .items(SchemaBuilder::string())
676            .min_items(1)
677            .unique_items(true)
678            .build();
679
680        assert_eq!(schema.schema_type, Some(SchemaType::Array));
681        assert!(schema.items.is_some());
682        assert_eq!(schema.min_items, Some(1));
683        assert_eq!(schema.unique_items, Some(true));
684    }
685
686    #[test]
687    fn test_enum_schema() {
688        let schema = SchemaBuilder::string()
689            .enum_values(["red", "green", "blue"])
690            .build();
691
692        assert!(schema.enum_values.is_some());
693        let values = schema.enum_values.as_ref().unwrap();
694        assert_eq!(values.len(), 3);
695    }
696
697    #[test]
698    fn test_composition() {
699        let schema = SchemaBuilder::new()
700            .one_of([SchemaBuilder::string(), SchemaBuilder::integer()])
701            .build();
702
703        assert!(schema.one_of.is_some());
704        assert_eq!(schema.one_of.as_ref().unwrap().len(), 2);
705    }
706
707    #[test]
708    fn test_to_value() {
709        let schema = SchemaBuilder::object()
710            .property("query", SchemaBuilder::string())
711            .required(["query"])
712            .to_value();
713
714        assert!(schema.is_object());
715        let obj = schema.as_object().unwrap();
716        assert_eq!(obj.get("type").and_then(|v| v.as_str()), Some("object"));
717    }
718
719    #[test]
720    fn test_tool_input_schema() {
721        // Example: Schema for a search tool
722        let schema = SchemaBuilder::object()
723            .title("SearchInput")
724            .description("Input parameters for the search tool")
725            .property(
726                "query",
727                SchemaBuilder::string()
728                    .description("The search query")
729                    .min_length(1),
730            )
731            .property(
732                "limit",
733                SchemaBuilder::integer()
734                    .description("Maximum number of results")
735                    .minimum(1)
736                    .maximum(100)
737                    .default_value(10),
738            )
739            .property(
740                "filters",
741                SchemaBuilder::array()
742                    .items(SchemaBuilder::string())
743                    .description("Optional filter tags"),
744            )
745            .required(["query"])
746            .additional_properties(false)
747            .build();
748
749        let value = schema.to_value();
750        assert!(value.is_object());
751
752        // Verify structure
753        let obj = value.as_object().unwrap();
754        assert_eq!(obj.get("type").and_then(|v| v.as_str()), Some("object"));
755        assert_eq!(
756            obj.get("title").and_then(|v| v.as_str()),
757            Some("SearchInput")
758        );
759    }
760}