Skip to main content

component_shape/
mcp.rs

1use strum::IntoStaticStr;
2
3/// Primitive value kinds a component shape can expose to model-controlled tools.
4#[derive(Clone, Copy, Debug, Eq, IntoStaticStr, PartialEq)]
5#[strum(serialize_all = "snake_case", const_into_str)]
6pub enum McpPrimitiveKind {
7    Any,
8    Boolean,
9    Integer,
10    Number,
11    Decimal,
12    String,
13    Date,
14    DateTime,
15}
16
17impl McpPrimitiveKind {
18    /// Returns the stable schema label for this primitive kind.
19    pub const fn as_str(self) -> &'static str {
20        self.into_str()
21    }
22}
23
24/// Primitive kinds that can be used as `{ "min": ..., "max": ... }` MCP range bounds.
25#[derive(Clone, Copy, Debug, Eq, IntoStaticStr, PartialEq)]
26#[strum(serialize_all = "snake_case", const_into_str)]
27pub enum McpRangeBoundKind {
28    Integer,
29    Number,
30    Decimal,
31    Date,
32    DateTime,
33}
34
35impl McpRangeBoundKind {
36    /// Returns the stable schema label for this range-bound kind.
37    pub const fn as_str(self) -> &'static str {
38        self.into_str()
39    }
40
41    /// Returns the primitive kind used by this range-bound kind.
42    pub const fn primitive_kind(self) -> McpPrimitiveKind {
43        match self {
44            Self::Integer => McpPrimitiveKind::Integer,
45            Self::Number => McpPrimitiveKind::Number,
46            Self::Decimal => McpPrimitiveKind::Decimal,
47            Self::Date => McpPrimitiveKind::Date,
48            Self::DateTime => McpPrimitiveKind::DateTime,
49        }
50    }
51}
52
53impl From<McpRangeBoundKind> for McpPrimitiveKind {
54    fn from(kind: McpRangeBoundKind) -> Self {
55        kind.primitive_kind()
56    }
57}
58
59/// Framework-neutral shape of structured MCP input accepted by a component.
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum McpInputShape {
62    /// The component does not expose model-controlled MCP input.
63    Unsupported,
64    /// A single primitive value.
65    Scalar(McpPrimitiveKind),
66    /// An ordered array of primitive values.
67    List(McpPrimitiveKind),
68    /// A unique array of primitive values.
69    Set(McpPrimitiveKind),
70    /// A range object with `min` and `max` bounds.
71    Range(McpRangeBoundKind),
72    /// An object with integration-defined structure.
73    Object,
74}
75
76/// Validation error for generated MCP tool metadata.
77#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
78pub enum McpToolMetadataError {
79    /// The tool name is empty or contains only whitespace.
80    #[error("tool name cannot be empty")]
81    EmptyName,
82    /// The tool name starts with an unsupported character.
83    #[error("tool name must start with an ASCII letter or number")]
84    InvalidNameStart,
85    /// The tool name contains an unsupported character.
86    #[error("tool name may only contain ASCII letters, digits, '_' '-' '.'")]
87    InvalidNameCharacter,
88    /// A human-readable metadata field is empty or contains only whitespace.
89    #[error("tool {label} cannot be empty")]
90    EmptyText { label: String },
91}
92
93/// Validate the MCP tool-name subset used by generated integrations.
94///
95/// # Errors
96///
97/// Returns [`McpToolMetadataError`] when `name` is empty, starts with an
98/// unsupported character, or contains a character outside the generated tool
99/// name subset.
100pub fn validate_mcp_tool_name(name: &str) -> Result<(), McpToolMetadataError> {
101    if name.trim().is_empty() {
102        return Err(McpToolMetadataError::EmptyName);
103    }
104
105    let mut chars = name.chars();
106    let Some(first) = chars.next() else {
107        return Err(McpToolMetadataError::EmptyName);
108    };
109
110    if !first.is_ascii_alphanumeric() {
111        return Err(McpToolMetadataError::InvalidNameStart);
112    }
113
114    if chars.any(|ch| !is_mcp_tool_name_char(ch)) {
115        return Err(McpToolMetadataError::InvalidNameCharacter);
116    }
117
118    Ok(())
119}
120
121fn is_mcp_tool_name_char(ch: char) -> bool {
122    ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.'
123}
124
125/// Validate human-readable MCP tool metadata text.
126///
127/// # Errors
128///
129/// Returns [`McpToolMetadataError`] when `value` is empty or contains only
130/// whitespace.
131pub fn validate_mcp_tool_metadata_text(
132    label: &str,
133    value: &str,
134) -> Result<(), McpToolMetadataError> {
135    if value.trim().is_empty() {
136        Err(McpToolMetadataError::EmptyText {
137            label: label.to_string(),
138        })
139    } else {
140        Ok(())
141    }
142}
143
144impl McpInputShape {
145    /// Returns whether this shape accepts model-controlled MCP input.
146    pub const fn supported(self) -> bool {
147        !matches!(self, Self::Unsupported)
148    }
149}
150
151/// Shape-owned metadata for model-controlled MCP input.
152///
153/// This is metadata only. Protocol handling, JSON decoding, validation, and
154/// application authorization stay in MCP integration crates.
155#[derive(Clone, Copy, Debug, Eq, PartialEq)]
156pub struct McpInput {
157    input_shape: McpInputShape,
158}
159
160impl McpInput {
161    /// No model-controlled MCP input is supported.
162    pub const fn unsupported() -> Self {
163        Self {
164            input_shape: McpInputShape::Unsupported,
165        }
166    }
167
168    /// Accept any JSON value.
169    pub const fn any() -> Self {
170        Self::scalar(McpPrimitiveKind::Any)
171    }
172
173    /// Accept a boolean scalar.
174    pub const fn boolean() -> Self {
175        Self::scalar(McpPrimitiveKind::Boolean)
176    }
177
178    /// Accept an integer scalar.
179    pub const fn integer() -> Self {
180        Self::scalar(McpPrimitiveKind::Integer)
181    }
182
183    /// Accept a number scalar.
184    pub const fn number() -> Self {
185        Self::scalar(McpPrimitiveKind::Number)
186    }
187
188    /// Accept a decimal scalar encoded as a JSON number or string.
189    pub const fn decimal() -> Self {
190        Self::scalar(McpPrimitiveKind::Decimal)
191    }
192
193    /// Accept a string scalar.
194    pub const fn string() -> Self {
195        Self::scalar(McpPrimitiveKind::String)
196    }
197
198    /// Accept an RFC 3339 full-date string.
199    pub const fn date() -> Self {
200        Self::scalar(McpPrimitiveKind::Date)
201    }
202
203    /// Accept an RFC 3339 date-time string.
204    pub const fn date_time() -> Self {
205        Self::scalar(McpPrimitiveKind::DateTime)
206    }
207
208    /// Accept a scalar of the given primitive kind.
209    pub const fn scalar(kind: McpPrimitiveKind) -> Self {
210        Self {
211            input_shape: McpInputShape::Scalar(kind),
212        }
213    }
214
215    /// Accept an ordered array of strings.
216    pub const fn string_list() -> Self {
217        Self::list(McpPrimitiveKind::String)
218    }
219
220    /// Accept an ordered array of booleans.
221    pub const fn boolean_list() -> Self {
222        Self::list(McpPrimitiveKind::Boolean)
223    }
224
225    /// Accept an ordered array of integers.
226    pub const fn integer_list() -> Self {
227        Self::list(McpPrimitiveKind::Integer)
228    }
229
230    /// Accept an ordered array of numbers.
231    pub const fn number_list() -> Self {
232        Self::list(McpPrimitiveKind::Number)
233    }
234
235    /// Accept an ordered array of decimals encoded as JSON numbers or strings.
236    pub const fn decimal_list() -> Self {
237        Self::list(McpPrimitiveKind::Decimal)
238    }
239
240    /// Accept an ordered array of RFC 3339 full-date strings.
241    pub const fn date_list() -> Self {
242        Self::list(McpPrimitiveKind::Date)
243    }
244
245    /// Accept an ordered array of RFC 3339 date-time strings.
246    pub const fn date_time_list() -> Self {
247        Self::list(McpPrimitiveKind::DateTime)
248    }
249
250    /// Accept an ordered array of primitive values.
251    pub const fn list(items: McpPrimitiveKind) -> Self {
252        Self {
253            input_shape: McpInputShape::List(items),
254        }
255    }
256
257    /// Accept a unique array of strings.
258    pub const fn string_set() -> Self {
259        Self::set(McpPrimitiveKind::String)
260    }
261
262    /// Accept a unique array of booleans.
263    pub const fn boolean_set() -> Self {
264        Self::set(McpPrimitiveKind::Boolean)
265    }
266
267    /// Accept a unique array of integers.
268    pub const fn integer_set() -> Self {
269        Self::set(McpPrimitiveKind::Integer)
270    }
271
272    /// Accept a unique array of numbers.
273    pub const fn number_set() -> Self {
274        Self::set(McpPrimitiveKind::Number)
275    }
276
277    /// Accept a unique array of decimals encoded as JSON numbers or strings.
278    pub const fn decimal_set() -> Self {
279        Self::set(McpPrimitiveKind::Decimal)
280    }
281
282    /// Accept a unique array of RFC 3339 full-date strings.
283    pub const fn date_set() -> Self {
284        Self::set(McpPrimitiveKind::Date)
285    }
286
287    /// Accept a unique array of RFC 3339 date-time strings.
288    pub const fn date_time_set() -> Self {
289        Self::set(McpPrimitiveKind::DateTime)
290    }
291
292    /// Accept a unique array of primitive values.
293    pub const fn set(items: McpPrimitiveKind) -> Self {
294        Self {
295            input_shape: McpInputShape::Set(items),
296        }
297    }
298
299    /// Accept a decimal `{ "min": ..., "max": ... }` range object.
300    pub const fn decimal_range() -> Self {
301        Self::range(McpRangeBoundKind::Decimal)
302    }
303
304    /// Accept an integer `{ "min": ..., "max": ... }` range object.
305    pub const fn integer_range() -> Self {
306        Self::range(McpRangeBoundKind::Integer)
307    }
308
309    /// Accept a number `{ "min": ..., "max": ... }` range object.
310    pub const fn number_range() -> Self {
311        Self::range(McpRangeBoundKind::Number)
312    }
313
314    /// Accept a date `{ "min": ..., "max": ... }` range object.
315    pub const fn date_range() -> Self {
316        Self::range(McpRangeBoundKind::Date)
317    }
318
319    /// Accept a date-time `{ "min": ..., "max": ... }` range object.
320    pub const fn date_time_range() -> Self {
321        Self::range(McpRangeBoundKind::DateTime)
322    }
323
324    /// Accept a `{ "min": ..., "max": ... }` range object of the given kind.
325    pub const fn range(bound: McpRangeBoundKind) -> Self {
326        Self {
327            input_shape: McpInputShape::Range(bound),
328        }
329    }
330
331    /// Accept an object with integration-defined structure.
332    pub const fn object() -> Self {
333        Self {
334            input_shape: McpInputShape::Object,
335        }
336    }
337
338    /// Return the structured input shape exposed to MCP integrations.
339    pub const fn input_shape(self) -> McpInputShape {
340        self.input_shape
341    }
342
343    /// Whether this metadata describes any supported input shape.
344    pub const fn supported(self) -> bool {
345        self.input_shape.supported()
346    }
347}
348
349impl Default for McpInput {
350    fn default() -> Self {
351        Self::unsupported()
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::{
358        McpInput, McpInputShape, McpPrimitiveKind, McpRangeBoundKind,
359        validate_mcp_tool_metadata_text, validate_mcp_tool_name,
360    };
361
362    #[test]
363    fn mcp_input_defaults_to_unsupported() {
364        let input = McpInput::default();
365
366        assert!(!input.supported());
367        assert_eq!(input.input_shape(), McpInputShape::Unsupported);
368    }
369
370    #[test]
371    fn mcp_input_records_shape() {
372        let input = McpInput::range(McpRangeBoundKind::Date);
373
374        assert!(input.supported());
375        assert_eq!(
376            input.input_shape(),
377            McpInputShape::Range(McpRangeBoundKind::Date)
378        );
379    }
380
381    #[test]
382    fn mcp_input_convenience_constructors_match_common_shapes() {
383        assert_eq!(
384            McpInput::string().input_shape(),
385            McpInputShape::Scalar(McpPrimitiveKind::String)
386        );
387        assert_eq!(
388            McpInput::string_list().input_shape(),
389            McpInputShape::List(McpPrimitiveKind::String)
390        );
391        assert_eq!(
392            McpInput::string_set().input_shape(),
393            McpInputShape::Set(McpPrimitiveKind::String)
394        );
395        assert_eq!(
396            McpInput::date_range().input_shape(),
397            McpInputShape::Range(McpRangeBoundKind::Date)
398        );
399        assert_eq!(
400            McpInput::decimal_range().input_shape(),
401            McpInputShape::Range(McpRangeBoundKind::Decimal)
402        );
403        assert_eq!(
404            McpInput::integer_range().input_shape(),
405            McpInputShape::Range(McpRangeBoundKind::Integer)
406        );
407        assert_eq!(
408            McpInput::date_time_range().input_shape(),
409            McpInputShape::Range(McpRangeBoundKind::DateTime)
410        );
411        assert_eq!(
412            McpInput::decimal_list().input_shape(),
413            McpInputShape::List(McpPrimitiveKind::Decimal)
414        );
415        assert_eq!(
416            McpInput::decimal_set().input_shape(),
417            McpInputShape::Set(McpPrimitiveKind::Decimal)
418        );
419    }
420
421    #[test]
422    fn all_mcp_input_convenience_constructors_match_their_shapes() {
423        let scalar_cases: [(fn() -> McpInput, McpPrimitiveKind); 8] = [
424            (McpInput::any, McpPrimitiveKind::Any),
425            (McpInput::boolean, McpPrimitiveKind::Boolean),
426            (McpInput::integer, McpPrimitiveKind::Integer),
427            (McpInput::number, McpPrimitiveKind::Number),
428            (McpInput::decimal, McpPrimitiveKind::Decimal),
429            (McpInput::string, McpPrimitiveKind::String),
430            (McpInput::date, McpPrimitiveKind::Date),
431            (McpInput::date_time, McpPrimitiveKind::DateTime),
432        ];
433        for (constructor, kind) in scalar_cases {
434            assert_eq!(constructor().input_shape(), McpInputShape::Scalar(kind));
435        }
436
437        let collection_cases: [(fn() -> McpInput, McpInputShape); 14] = [
438            (
439                McpInput::string_list,
440                McpInputShape::List(McpPrimitiveKind::String),
441            ),
442            (
443                McpInput::boolean_list,
444                McpInputShape::List(McpPrimitiveKind::Boolean),
445            ),
446            (
447                McpInput::integer_list,
448                McpInputShape::List(McpPrimitiveKind::Integer),
449            ),
450            (
451                McpInput::number_list,
452                McpInputShape::List(McpPrimitiveKind::Number),
453            ),
454            (
455                McpInput::decimal_list,
456                McpInputShape::List(McpPrimitiveKind::Decimal),
457            ),
458            (
459                McpInput::date_list,
460                McpInputShape::List(McpPrimitiveKind::Date),
461            ),
462            (
463                McpInput::date_time_list,
464                McpInputShape::List(McpPrimitiveKind::DateTime),
465            ),
466            (
467                McpInput::string_set,
468                McpInputShape::Set(McpPrimitiveKind::String),
469            ),
470            (
471                McpInput::boolean_set,
472                McpInputShape::Set(McpPrimitiveKind::Boolean),
473            ),
474            (
475                McpInput::integer_set,
476                McpInputShape::Set(McpPrimitiveKind::Integer),
477            ),
478            (
479                McpInput::number_set,
480                McpInputShape::Set(McpPrimitiveKind::Number),
481            ),
482            (
483                McpInput::decimal_set,
484                McpInputShape::Set(McpPrimitiveKind::Decimal),
485            ),
486            (
487                McpInput::date_set,
488                McpInputShape::Set(McpPrimitiveKind::Date),
489            ),
490            (
491                McpInput::date_time_set,
492                McpInputShape::Set(McpPrimitiveKind::DateTime),
493            ),
494        ];
495        for (constructor, shape) in collection_cases {
496            assert_eq!(constructor().input_shape(), shape);
497        }
498
499        let range_cases: [(fn() -> McpInput, McpRangeBoundKind); 5] = [
500            (McpInput::integer_range, McpRangeBoundKind::Integer),
501            (McpInput::number_range, McpRangeBoundKind::Number),
502            (McpInput::decimal_range, McpRangeBoundKind::Decimal),
503            (McpInput::date_range, McpRangeBoundKind::Date),
504            (McpInput::date_time_range, McpRangeBoundKind::DateTime),
505        ];
506        for (constructor, kind) in range_cases {
507            assert_eq!(constructor().input_shape(), McpInputShape::Range(kind));
508            assert_eq!(McpPrimitiveKind::from(kind), kind.primitive_kind());
509        }
510
511        assert_eq!(McpInput::object().input_shape(), McpInputShape::Object);
512    }
513
514    #[test]
515    fn mcp_kind_names_are_stable_metadata() {
516        assert_eq!(McpPrimitiveKind::Any.as_str(), "any");
517        assert_eq!(McpPrimitiveKind::Boolean.as_str(), "boolean");
518        assert_eq!(McpPrimitiveKind::Integer.as_str(), "integer");
519        assert_eq!(McpPrimitiveKind::Number.as_str(), "number");
520        assert_eq!(McpPrimitiveKind::Decimal.as_str(), "decimal");
521        assert_eq!(McpPrimitiveKind::String.as_str(), "string");
522        assert_eq!(McpPrimitiveKind::Date.as_str(), "date");
523        assert_eq!(McpPrimitiveKind::DateTime.as_str(), "date_time");
524
525        assert_eq!(McpRangeBoundKind::Integer.as_str(), "integer");
526        assert_eq!(McpRangeBoundKind::Number.as_str(), "number");
527        assert_eq!(McpRangeBoundKind::Decimal.as_str(), "decimal");
528        assert_eq!(McpRangeBoundKind::Date.as_str(), "date");
529        assert_eq!(McpRangeBoundKind::DateTime.as_str(), "date_time");
530    }
531
532    #[test]
533    fn mcp_tool_name_validation_matches_generated_tool_contract() {
534        assert!(validate_mcp_tool_name("query.users-1").is_ok());
535        assert_eq!(
536            validate_mcp_tool_name(""),
537            Err(super::McpToolMetadataError::EmptyName)
538        );
539        assert_eq!(
540            validate_mcp_tool_name("_query"),
541            Err(super::McpToolMetadataError::InvalidNameStart)
542        );
543        assert_eq!(
544            validate_mcp_tool_name("query users"),
545            Err(super::McpToolMetadataError::InvalidNameCharacter)
546        );
547    }
548
549    #[test]
550    fn mcp_tool_metadata_text_validation_rejects_blank_text() {
551        let error =
552            validate_mcp_tool_metadata_text("title", "  ").expect_err("blank title should fail");
553
554        assert_eq!(error.to_string(), "tool title cannot be empty");
555        assert!(validate_mcp_tool_metadata_text("title", "Readable title").is_ok());
556    }
557}