1use strum::IntoStaticStr;
2
3#[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 pub const fn as_str(self) -> &'static str {
20 self.into_str()
21 }
22}
23
24#[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 pub const fn as_str(self) -> &'static str {
38 self.into_str()
39 }
40
41 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61pub enum McpInputShape {
62 Unsupported,
64 Scalar(McpPrimitiveKind),
66 List(McpPrimitiveKind),
68 Set(McpPrimitiveKind),
70 Range(McpRangeBoundKind),
72 Object,
74}
75
76#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
78pub enum McpToolMetadataError {
79 #[error("tool name cannot be empty")]
81 EmptyName,
82 #[error("tool name must start with an ASCII letter or number")]
84 InvalidNameStart,
85 #[error("tool name may only contain ASCII letters, digits, '_' '-' '.'")]
87 InvalidNameCharacter,
88 #[error("tool {label} cannot be empty")]
90 EmptyText { label: String },
91}
92
93pub 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
125pub 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 pub const fn supported(self) -> bool {
147 !matches!(self, Self::Unsupported)
148 }
149}
150
151#[derive(Clone, Copy, Debug, Eq, PartialEq)]
156pub struct McpInput {
157 input_shape: McpInputShape,
158}
159
160impl McpInput {
161 pub const fn unsupported() -> Self {
163 Self {
164 input_shape: McpInputShape::Unsupported,
165 }
166 }
167
168 pub const fn any() -> Self {
170 Self::scalar(McpPrimitiveKind::Any)
171 }
172
173 pub const fn boolean() -> Self {
175 Self::scalar(McpPrimitiveKind::Boolean)
176 }
177
178 pub const fn integer() -> Self {
180 Self::scalar(McpPrimitiveKind::Integer)
181 }
182
183 pub const fn number() -> Self {
185 Self::scalar(McpPrimitiveKind::Number)
186 }
187
188 pub const fn decimal() -> Self {
190 Self::scalar(McpPrimitiveKind::Decimal)
191 }
192
193 pub const fn string() -> Self {
195 Self::scalar(McpPrimitiveKind::String)
196 }
197
198 pub const fn date() -> Self {
200 Self::scalar(McpPrimitiveKind::Date)
201 }
202
203 pub const fn date_time() -> Self {
205 Self::scalar(McpPrimitiveKind::DateTime)
206 }
207
208 pub const fn scalar(kind: McpPrimitiveKind) -> Self {
210 Self {
211 input_shape: McpInputShape::Scalar(kind),
212 }
213 }
214
215 pub const fn string_list() -> Self {
217 Self::list(McpPrimitiveKind::String)
218 }
219
220 pub const fn boolean_list() -> Self {
222 Self::list(McpPrimitiveKind::Boolean)
223 }
224
225 pub const fn integer_list() -> Self {
227 Self::list(McpPrimitiveKind::Integer)
228 }
229
230 pub const fn number_list() -> Self {
232 Self::list(McpPrimitiveKind::Number)
233 }
234
235 pub const fn decimal_list() -> Self {
237 Self::list(McpPrimitiveKind::Decimal)
238 }
239
240 pub const fn date_list() -> Self {
242 Self::list(McpPrimitiveKind::Date)
243 }
244
245 pub const fn date_time_list() -> Self {
247 Self::list(McpPrimitiveKind::DateTime)
248 }
249
250 pub const fn list(items: McpPrimitiveKind) -> Self {
252 Self {
253 input_shape: McpInputShape::List(items),
254 }
255 }
256
257 pub const fn string_set() -> Self {
259 Self::set(McpPrimitiveKind::String)
260 }
261
262 pub const fn boolean_set() -> Self {
264 Self::set(McpPrimitiveKind::Boolean)
265 }
266
267 pub const fn integer_set() -> Self {
269 Self::set(McpPrimitiveKind::Integer)
270 }
271
272 pub const fn number_set() -> Self {
274 Self::set(McpPrimitiveKind::Number)
275 }
276
277 pub const fn decimal_set() -> Self {
279 Self::set(McpPrimitiveKind::Decimal)
280 }
281
282 pub const fn date_set() -> Self {
284 Self::set(McpPrimitiveKind::Date)
285 }
286
287 pub const fn date_time_set() -> Self {
289 Self::set(McpPrimitiveKind::DateTime)
290 }
291
292 pub const fn set(items: McpPrimitiveKind) -> Self {
294 Self {
295 input_shape: McpInputShape::Set(items),
296 }
297 }
298
299 pub const fn decimal_range() -> Self {
301 Self::range(McpRangeBoundKind::Decimal)
302 }
303
304 pub const fn integer_range() -> Self {
306 Self::range(McpRangeBoundKind::Integer)
307 }
308
309 pub const fn number_range() -> Self {
311 Self::range(McpRangeBoundKind::Number)
312 }
313
314 pub const fn date_range() -> Self {
316 Self::range(McpRangeBoundKind::Date)
317 }
318
319 pub const fn date_time_range() -> Self {
321 Self::range(McpRangeBoundKind::DateTime)
322 }
323
324 pub const fn range(bound: McpRangeBoundKind) -> Self {
326 Self {
327 input_shape: McpInputShape::Range(bound),
328 }
329 }
330
331 pub const fn object() -> Self {
333 Self {
334 input_shape: McpInputShape::Object,
335 }
336 }
337
338 pub const fn input_shape(self) -> McpInputShape {
340 self.input_shape
341 }
342
343 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}