turul_mcp_protocol_2025_06_18/
elicitation.rs

1//! MCP Elicitation Protocol Types
2//!
3//! This module defines the types used for MCP elicitation functionality,
4//! which enables structured user input collection via restricted primitive schemas.
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9
10/// StringSchema (per MCP spec)
11#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct StringSchema {
14    #[serde(rename = "type")]
15    pub schema_type: String, // "string"
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub title: Option<String>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub description: Option<String>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub min_length: Option<usize>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub max_length: Option<usize>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub format: Option<StringFormat>,
26}
27
28/// NumberSchema (per MCP spec) - handles both "number" and "integer"
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase")]
31pub struct NumberSchema {
32    #[serde(rename = "type")]
33    pub schema_type: String, // "number" or "integer"
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub title: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub description: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub minimum: Option<f64>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub maximum: Option<f64>,
42}
43
44/// BooleanSchema (per MCP spec)
45#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct BooleanSchema {
48    #[serde(rename = "type")]
49    pub schema_type: String, // "boolean"
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub title: Option<String>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub description: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub default: Option<bool>,
56}
57
58/// EnumSchema (per MCP spec) - string type with enum values
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct EnumSchema {
62    #[serde(rename = "type")]
63    pub schema_type: String, // "string"
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub title: Option<String>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub description: Option<String>,
68    #[serde(rename = "enum")]
69    pub enum_values: Vec<String>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub enum_names: Option<Vec<String>>, // Display names for enum values
72}
73
74/// Restricted schema definitions that only allow primitive types
75/// without nested objects or arrays (per MCP spec).
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(untagged)]
78pub enum PrimitiveSchemaDefinition {
79    String(StringSchema),
80    Number(NumberSchema),
81    Boolean(BooleanSchema),
82    Enum(EnumSchema),
83}
84
85/// String format constraints
86#[derive(Debug, Clone, Serialize, Deserialize)]
87#[serde(rename_all = "kebab-case")]
88pub enum StringFormat {
89    Email,
90    Uri,
91    Date,
92    #[serde(rename = "date-time")]
93    DateTime,
94}
95
96/// Restricted schema for elicitation (only primitive types, no nesting) - per MCP spec
97#[derive(Debug, Clone, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub struct ElicitationSchema {
100    #[serde(rename = "type")]
101    pub schema_type: String, // Always "object"
102    pub properties: HashMap<String, PrimitiveSchemaDefinition>,
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub required: Option<Vec<String>>,
105}
106
107/// Parameters for elicitation/create request (per MCP spec)
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct ElicitCreateParams {
111    /// The message to present to the user
112    pub message: String,
113    /// A restricted subset of JSON Schema - only top-level properties, no nesting
114    pub requested_schema: ElicitationSchema,
115    /// Meta information (optional _meta field inside params)
116    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
117    pub meta: Option<HashMap<String, Value>>,
118}
119
120/// Complete elicitation/create request (matches TypeScript ElicitRequest interface)
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct ElicitCreateRequest {
124    /// Method name (always "elicitation/create")
125    pub method: String,
126    /// Request parameters
127    pub params: ElicitCreateParams,
128}
129
130impl ElicitCreateRequest {
131    pub fn new(message: impl Into<String>, requested_schema: ElicitationSchema) -> Self {
132        Self {
133            method: "elicitation/create".to_string(),
134            params: ElicitCreateParams {
135                message: message.into(),
136                requested_schema,
137                meta: None,
138            },
139        }
140    }
141
142    pub fn with_meta(mut self, meta: HashMap<String, Value>) -> Self {
143        self.params.meta = Some(meta);
144        self
145    }
146}
147
148impl ElicitCreateParams {
149    pub fn new(message: impl Into<String>, requested_schema: ElicitationSchema) -> Self {
150        Self {
151            message: message.into(),
152            requested_schema,
153            meta: None,
154        }
155    }
156
157    pub fn with_meta(mut self, meta: HashMap<String, Value>) -> Self {
158        self.meta = Some(meta);
159        self
160    }
161}
162
163// Trait implementations for protocol compliance
164use crate::traits::*;
165
166impl Params for ElicitCreateParams {}
167
168impl HasMetaParam for ElicitCreateParams {
169    fn meta(&self) -> Option<&HashMap<String, Value>> {
170        self.meta.as_ref()
171    }
172}
173
174impl HasMethod for ElicitCreateRequest {
175    fn method(&self) -> &str {
176        &self.method
177    }
178}
179
180impl HasParams for ElicitCreateRequest {
181    fn params(&self) -> Option<&dyn Params> {
182        Some(&self.params)
183    }
184}
185
186impl HasData for ElicitResult {
187    fn data(&self) -> HashMap<String, Value> {
188        let mut data = HashMap::new();
189        data.insert("action".to_string(), serde_json::to_value(&self.action).unwrap_or(Value::String("cancel".to_string())));
190        if let Some(ref content) = self.content {
191            data.insert("content".to_string(), serde_json::to_value(content).unwrap_or(Value::Null));
192        }
193        data
194    }
195}
196
197impl HasMeta for ElicitResult {
198    fn meta(&self) -> Option<HashMap<String, Value>> {
199        self.meta.clone()
200    }
201}
202
203impl RpcResult for ElicitResult {}
204
205impl ElicitationSchema {
206    pub fn new() -> Self {
207        Self {
208            schema_type: "object".to_string(),
209            properties: HashMap::new(),
210            required: None,
211        }
212    }
213
214    pub fn with_property(mut self, name: impl Into<String>, schema: PrimitiveSchemaDefinition) -> Self {
215        self.properties.insert(name.into(), schema);
216        self
217    }
218
219    pub fn with_required(mut self, required: Vec<String>) -> Self {
220        self.required = Some(required);
221        self
222    }
223}
224
225/// User action in response to elicitation
226#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
227#[serde(rename_all = "lowercase")]
228pub enum ElicitAction {
229    /// User submitted the form/confirmed the action
230    Accept,
231    /// User explicitly declined the action
232    Decline,
233    /// User dismissed without making an explicit choice
234    Cancel,
235}
236
237/// The client's response to an elicitation request (per MCP spec)
238#[derive(Debug, Clone, Serialize, Deserialize)]
239#[serde(rename_all = "camelCase")]
240pub struct ElicitResult {
241    /// The user action in response to the elicitation
242    pub action: ElicitAction,
243    /// The submitted form data, only present when action is "accept"
244    /// Contains values matching the requested schema
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub content: Option<HashMap<String, Value>>,
247    /// Optional metadata
248    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
249    pub meta: Option<HashMap<String, Value>>,
250}
251
252impl ElicitResult {
253    pub fn accept(content: HashMap<String, Value>) -> Self {
254        Self {
255            action: ElicitAction::Accept,
256            content: Some(content),
257            meta: None,
258        }
259    }
260
261    pub fn decline() -> Self {
262        Self {
263            action: ElicitAction::Decline,
264            content: None,
265            meta: None,
266        }
267    }
268
269    pub fn cancel() -> Self {
270        Self {
271            action: ElicitAction::Cancel,
272            content: None,
273            meta: None,
274        }
275    }
276
277    pub fn with_meta(mut self, meta: HashMap<String, Value>) -> Self {
278        self.meta = Some(meta);
279        self
280    }
281}
282
283// Convenience constructors for schema types
284impl StringSchema {
285    pub fn new() -> Self {
286        Self {
287            schema_type: "string".to_string(),
288            title: None,
289            description: None,
290            min_length: None,
291            max_length: None,
292            format: None,
293        }
294    }
295
296    pub fn with_description(mut self, description: impl Into<String>) -> Self {
297        self.description = Some(description.into());
298        self
299    }
300}
301
302impl NumberSchema {
303    pub fn new() -> Self {
304        Self {
305            schema_type: "number".to_string(),
306            title: None,
307            description: None,
308            minimum: None,
309            maximum: None,
310        }
311    }
312
313    pub fn integer() -> Self {
314        Self {
315            schema_type: "integer".to_string(),
316            title: None,
317            description: None,
318            minimum: None,
319            maximum: None,
320        }
321    }
322
323    pub fn with_description(mut self, description: impl Into<String>) -> Self {
324        self.description = Some(description.into());
325        self
326    }
327}
328
329impl BooleanSchema {
330    pub fn new() -> Self {
331        Self {
332            schema_type: "boolean".to_string(),
333            title: None,
334            description: None,
335            default: None,
336        }
337    }
338
339    pub fn with_description(mut self, description: impl Into<String>) -> Self {
340        self.description = Some(description.into());
341        self
342    }
343}
344
345impl EnumSchema {
346    pub fn new(enum_values: Vec<String>) -> Self {
347        Self {
348            schema_type: "string".to_string(),
349            title: None,
350            description: None,
351            enum_values,
352            enum_names: None,
353        }
354    }
355
356    pub fn with_description(mut self, description: impl Into<String>) -> Self {
357        self.description = Some(description.into());
358        self
359    }
360
361    pub fn with_enum_names(mut self, enum_names: Vec<String>) -> Self {
362        self.enum_names = Some(enum_names);
363        self
364    }
365}
366
367// Convenience constructors for PrimitiveSchemaDefinition
368impl PrimitiveSchemaDefinition {
369    pub fn string() -> Self {
370        Self::String(StringSchema::new())
371    }
372
373    pub fn string_with_description(description: impl Into<String>) -> Self {
374        Self::String(StringSchema::new().with_description(description))
375    }
376
377    pub fn number() -> Self {
378        Self::Number(NumberSchema::new())
379    }
380
381    pub fn integer() -> Self {
382        Self::Number(NumberSchema::integer())
383    }
384
385    pub fn boolean() -> Self {
386        Self::Boolean(BooleanSchema::new())
387    }
388
389    pub fn enum_values(values: Vec<String>) -> Self {
390        Self::Enum(EnumSchema::new(values))
391    }
392}
393
394/// Builder for creating common elicitation patterns
395pub struct ElicitationBuilder;
396
397impl ElicitationBuilder {
398    /// Create a simple text input elicitation (MCP spec compliant)
399    pub fn text_input(
400        message: impl Into<String>, 
401        field_name: impl Into<String>, 
402        field_description: impl Into<String>
403    ) -> ElicitCreateRequest {
404        let field_name = field_name.into();
405        let schema = ElicitationSchema::new()
406            .with_property(field_name.clone(), PrimitiveSchemaDefinition::string_with_description(field_description))
407            .with_required(vec![field_name]);
408        
409        ElicitCreateRequest::new(message, schema)
410    }
411
412    /// Create a number input elicitation (MCP spec compliant)
413    pub fn number_input(
414        message: impl Into<String>, 
415        field_name: impl Into<String>, 
416        field_description: impl Into<String>,
417        min: Option<f64>,
418        max: Option<f64>
419    ) -> ElicitCreateRequest {
420        let field_name = field_name.into();
421        let mut number_schema = NumberSchema::new().with_description(field_description);
422        number_schema.minimum = min;
423        number_schema.maximum = max;
424        let number_schema = PrimitiveSchemaDefinition::Number(number_schema);
425        
426        let schema = ElicitationSchema::new()
427            .with_property(field_name.clone(), number_schema)
428            .with_required(vec![field_name]);
429        
430        ElicitCreateRequest::new(message, schema)
431    }
432
433    /// Create a boolean confirmation elicitation (MCP spec compliant)
434    pub fn confirm(message: impl Into<String>) -> ElicitCreateRequest {
435        let schema = ElicitationSchema::new()
436            .with_property("confirmed".to_string(), PrimitiveSchemaDefinition::boolean())
437            .with_required(vec!["confirmed".to_string()]);
438        
439        ElicitCreateRequest::new(message, schema)
440    }
441}
442
443// ===========================================
444// === Fine-Grained Elicitation Traits ===
445// ===========================================
446
447/// Trait for elicitation metadata (message, title)
448pub trait HasElicitationMetadata {
449    /// The message to present to the user
450    fn message(&self) -> &str;
451    
452    /// Optional title for the elicitation dialog
453    fn title(&self) -> Option<&str> {
454        None
455    }
456}
457
458/// Trait for elicitation schema definition (restricted to primitive types per MCP spec)
459pub trait HasElicitationSchema {
460    /// Restricted schema defining structure of input to collect (primitives only)
461    fn requested_schema(&self) -> &ElicitationSchema;
462    
463    /// Validate that schema only contains primitive types (per MCP spec)
464    fn validate_schema(&self) -> Result<(), String> {
465        // All schemas in ElicitationSchema are already primitive-only by design
466        Ok(())
467    }
468}
469
470
471/// Trait for elicitation validation and handling
472pub trait HasElicitationHandling {
473    /// Validate submitted content against the schema
474    fn validate_content(&self, _content: &HashMap<String, Value>) -> Result<(), String> {
475        // Basic validation - can be extended
476        Ok(())
477    }
478    
479    /// Process accepted content (transform, normalize, etc.)
480    fn process_content(&self, content: HashMap<String, Value>) -> Result<HashMap<String, Value>, String> {
481        Ok(content)
482    }
483}
484
485/// Composed elicitation definition trait (automatically implemented via blanket impl)
486pub trait ElicitationDefinition: 
487    HasElicitationMetadata + 
488    HasElicitationSchema + 
489    HasElicitationHandling 
490{
491    /// Convert this elicitation definition to a protocol ElicitCreateRequest
492    fn to_create_request(&self) -> ElicitCreateRequest {
493        ElicitCreateRequest::new(self.message(), self.requested_schema().clone())
494    }
495}
496
497// Blanket implementation: any type implementing the fine-grained traits automatically gets ElicitationDefinition
498impl<T> ElicitationDefinition for T 
499where 
500    T: HasElicitationMetadata + HasElicitationSchema + HasElicitationHandling 
501{}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use serde_json::json;
507
508    #[test]
509    fn test_primitive_schema_creation() {
510        let string_schema = PrimitiveSchemaDefinition::string_with_description("Enter your name");
511        let number_schema = PrimitiveSchemaDefinition::number();
512        let boolean_schema = PrimitiveSchemaDefinition::boolean();
513        
514        assert!(matches!(string_schema, PrimitiveSchemaDefinition::String { .. }));
515        assert!(matches!(number_schema, PrimitiveSchemaDefinition::Number { .. }));
516        assert!(matches!(boolean_schema, PrimitiveSchemaDefinition::Boolean { .. }));
517    }
518
519    #[test]
520    fn test_elicitation_schema() {
521        let schema = ElicitationSchema::new()
522            .with_property("name".to_string(), PrimitiveSchemaDefinition::string_with_description("Your name"))
523            .with_property("age".to_string(), PrimitiveSchemaDefinition::integer())
524            .with_required(vec!["name".to_string()]);
525        
526        assert_eq!(schema.schema_type, "object");
527        assert_eq!(schema.properties.len(), 2);
528        assert_eq!(schema.required, Some(vec!["name".to_string()]));
529    }
530
531    #[test]
532    fn test_elicit_create_request() {
533        let schema = ElicitationSchema::new()
534            .with_property("username".to_string(), PrimitiveSchemaDefinition::string_with_description("Username"));
535        
536        let request = ElicitCreateRequest::new("Please enter your username", schema);
537        
538        assert_eq!(request.method, "elicitation/create");
539        assert_eq!(request.params.message, "Please enter your username");
540    }
541
542    #[test]
543    fn test_elicit_result() {
544        let mut content = HashMap::new();
545        content.insert("name".to_string(), json!("John"));
546        
547        let accept_result = ElicitResult::accept(content);
548        let decline_result = ElicitResult::decline();
549        let cancel_result = ElicitResult::cancel();
550        
551        assert!(matches!(accept_result.action, ElicitAction::Accept));
552        assert!(accept_result.content.is_some());
553        
554        assert!(matches!(decline_result.action, ElicitAction::Decline));
555        assert!(decline_result.content.is_none());
556        
557        assert!(matches!(cancel_result.action, ElicitAction::Cancel));
558        assert!(cancel_result.content.is_none());
559    }
560
561    #[test]
562    fn test_elicitation_builder() {
563        let text_request = ElicitationBuilder::text_input(
564            "Enter your name",
565            "name", 
566            "Your full name"
567        );
568        
569        let confirm_request = ElicitationBuilder::confirm("Do you agree?");
570        
571        assert_eq!(text_request.method, "elicitation/create");
572        assert!(text_request.params.requested_schema.properties.contains_key("name"));
573        
574        assert_eq!(confirm_request.method, "elicitation/create");
575        assert!(confirm_request.params.requested_schema.properties.contains_key("confirmed"));
576    }
577
578    #[test]
579    fn test_serialization() {
580        let schema = ElicitationSchema::new()
581            .with_property("test".to_string(), PrimitiveSchemaDefinition::string());
582        let request = ElicitCreateRequest::new("Test message", schema);
583        
584        let json = serde_json::to_string(&request).unwrap();
585        assert!(json.contains("elicitation/create"));
586        assert!(json.contains("Test message"));
587        
588        let parsed: ElicitCreateRequest = serde_json::from_str(&json).unwrap();
589        assert_eq!(parsed.method, "elicitation/create");
590        assert_eq!(parsed.params.message, "Test message");
591    }
592
593    #[test]
594    fn test_elicit_request_matches_typescript_spec() {
595        // Test ElicitRequest matches: { method: string, params: { message: string, requestedSchema: {...}, _meta?: {...} } }
596        let mut meta = HashMap::new();
597        meta.insert("requestId".to_string(), json!("req-123"));
598        
599        let schema = ElicitationSchema::new()
600            .with_property("name".to_string(), PrimitiveSchemaDefinition::string_with_description("Your name"))
601            .with_property("age".to_string(), PrimitiveSchemaDefinition::integer())
602            .with_required(vec!["name".to_string()]);
603        
604        let request = ElicitCreateRequest::new("Please provide your details", schema)
605            .with_meta(meta);
606        
607        let json_value = serde_json::to_value(&request).unwrap();
608        
609        assert_eq!(json_value["method"], "elicitation/create");
610        assert!(json_value["params"].is_object());
611        assert_eq!(json_value["params"]["message"], "Please provide your details");
612        assert!(json_value["params"]["requestedSchema"].is_object());
613        assert_eq!(json_value["params"]["requestedSchema"]["type"], "object");
614        assert!(json_value["params"]["requestedSchema"]["properties"].is_object());
615        assert_eq!(json_value["params"]["_meta"]["requestId"], "req-123");
616    }
617
618    #[test]
619    fn test_elicit_result_matches_typescript_spec() {
620        // Test ElicitResult matches: { action: "accept" | "decline" | "cancel", content?: {...}, _meta?: {...} }
621        let mut meta = HashMap::new();
622        meta.insert("responseTime".to_string(), json!(1234));
623        
624        let mut content = HashMap::new();
625        content.insert("name".to_string(), json!("John Doe"));
626        content.insert("age".to_string(), json!(30));
627        
628        let result = ElicitResult::accept(content.clone())
629            .with_meta(meta);
630        
631        let json_value = serde_json::to_value(&result).unwrap();
632        
633        assert_eq!(json_value["action"], "accept");
634        assert!(json_value["content"].is_object());
635        assert_eq!(json_value["content"]["name"], "John Doe");
636        assert_eq!(json_value["content"]["age"], 30);
637        assert_eq!(json_value["_meta"]["responseTime"], 1234);
638        
639        // Test decline without content
640        let decline_result = ElicitResult::decline();
641        let decline_json = serde_json::to_value(&decline_result).unwrap();
642        
643        assert_eq!(decline_json["action"], "decline");
644        assert!(decline_json["content"].is_null() || !decline_json.as_object().unwrap().contains_key("content"));
645    }
646
647    #[test]
648    fn test_primitive_schema_definitions_match_typescript() {
649        // Test StringSchema
650        let string_schema = PrimitiveSchemaDefinition::string_with_description("Enter text");
651        let string_json = serde_json::to_value(&string_schema).unwrap();
652        assert_eq!(string_json["type"], "string");
653        assert_eq!(string_json["description"], "Enter text");
654        
655        // Test NumberSchema
656        let number_schema = PrimitiveSchemaDefinition::number();
657        let number_json = serde_json::to_value(&number_schema).unwrap();
658        assert_eq!(number_json["type"], "number");
659        
660        // Test IntegerSchema  
661        let integer_schema = PrimitiveSchemaDefinition::integer();
662        let integer_json = serde_json::to_value(&integer_schema).unwrap();
663        assert_eq!(integer_json["type"], "integer");
664        
665        // Test BooleanSchema
666        let boolean_schema = PrimitiveSchemaDefinition::boolean();
667        let boolean_json = serde_json::to_value(&boolean_schema).unwrap();
668        assert_eq!(boolean_json["type"], "boolean");
669        
670        // Test EnumSchema
671        let enum_schema = PrimitiveSchemaDefinition::enum_values(vec!["red".to_string(), "green".to_string(), "blue".to_string()]);
672        let enum_json = serde_json::to_value(&enum_schema).unwrap();
673        assert_eq!(enum_json["type"], "string");
674        assert!(enum_json["enum"].is_array());
675        assert_eq!(enum_json["enum"].as_array().unwrap().len(), 3);
676    }
677}