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(
190            "action".to_string(),
191            serde_json::to_value(self.action).unwrap_or(Value::String("cancel".to_string())),
192        );
193        if let Some(ref content) = self.content {
194            data.insert(
195                "content".to_string(),
196                serde_json::to_value(content).unwrap_or(Value::Null),
197            );
198        }
199        data
200    }
201}
202
203impl HasMeta for ElicitResult {
204    fn meta(&self) -> Option<HashMap<String, Value>> {
205        self.meta.clone()
206    }
207}
208
209impl RpcResult for ElicitResult {}
210
211impl Default for ElicitationSchema {
212    fn default() -> Self {
213        Self::new()
214    }
215}
216
217impl ElicitationSchema {
218    pub fn new() -> Self {
219        Self {
220            schema_type: "object".to_string(),
221            properties: HashMap::new(),
222            required: None,
223        }
224    }
225
226    pub fn with_property(
227        mut self,
228        name: impl Into<String>,
229        schema: PrimitiveSchemaDefinition,
230    ) -> Self {
231        self.properties.insert(name.into(), schema);
232        self
233    }
234
235    pub fn with_required(mut self, required: Vec<String>) -> Self {
236        self.required = Some(required);
237        self
238    }
239}
240
241/// User action in response to elicitation
242#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
243#[serde(rename_all = "lowercase")]
244pub enum ElicitAction {
245    /// User submitted the form/confirmed the action
246    Accept,
247    /// User explicitly declined the action
248    Decline,
249    /// User dismissed without making an explicit choice
250    Cancel,
251}
252
253/// The client's response to an elicitation request (per MCP spec)
254#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase")]
256pub struct ElicitResult {
257    /// The user action in response to the elicitation
258    pub action: ElicitAction,
259    /// The submitted form data, only present when action is "accept"
260    /// Contains values matching the requested schema
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub content: Option<HashMap<String, Value>>,
263    /// Optional metadata
264    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
265    pub meta: Option<HashMap<String, Value>>,
266}
267
268impl ElicitResult {
269    pub fn accept(content: HashMap<String, Value>) -> Self {
270        Self {
271            action: ElicitAction::Accept,
272            content: Some(content),
273            meta: None,
274        }
275    }
276
277    pub fn decline() -> Self {
278        Self {
279            action: ElicitAction::Decline,
280            content: None,
281            meta: None,
282        }
283    }
284
285    pub fn cancel() -> Self {
286        Self {
287            action: ElicitAction::Cancel,
288            content: None,
289            meta: None,
290        }
291    }
292
293    pub fn with_meta(mut self, meta: HashMap<String, Value>) -> Self {
294        self.meta = Some(meta);
295        self
296    }
297}
298
299// Convenience constructors for schema types
300impl Default for StringSchema {
301    fn default() -> Self {
302        Self::new()
303    }
304}
305
306impl StringSchema {
307    pub fn new() -> Self {
308        Self {
309            schema_type: "string".to_string(),
310            title: None,
311            description: None,
312            min_length: None,
313            max_length: None,
314            format: None,
315        }
316    }
317
318    pub fn with_description(mut self, description: impl Into<String>) -> Self {
319        self.description = Some(description.into());
320        self
321    }
322}
323
324impl Default for NumberSchema {
325    fn default() -> Self {
326        Self::new()
327    }
328}
329
330impl NumberSchema {
331    pub fn new() -> Self {
332        Self {
333            schema_type: "number".to_string(),
334            title: None,
335            description: None,
336            minimum: None,
337            maximum: None,
338        }
339    }
340
341    pub fn integer() -> Self {
342        Self {
343            schema_type: "integer".to_string(),
344            title: None,
345            description: None,
346            minimum: None,
347            maximum: None,
348        }
349    }
350
351    pub fn with_description(mut self, description: impl Into<String>) -> Self {
352        self.description = Some(description.into());
353        self
354    }
355}
356
357impl Default for BooleanSchema {
358    fn default() -> Self {
359        Self::new()
360    }
361}
362
363impl BooleanSchema {
364    pub fn new() -> Self {
365        Self {
366            schema_type: "boolean".to_string(),
367            title: None,
368            description: None,
369            default: None,
370        }
371    }
372
373    pub fn with_description(mut self, description: impl Into<String>) -> Self {
374        self.description = Some(description.into());
375        self
376    }
377}
378
379impl EnumSchema {
380    pub fn new(enum_values: Vec<String>) -> Self {
381        Self {
382            schema_type: "string".to_string(),
383            title: None,
384            description: None,
385            enum_values,
386            enum_names: None,
387        }
388    }
389
390    pub fn with_description(mut self, description: impl Into<String>) -> Self {
391        self.description = Some(description.into());
392        self
393    }
394
395    pub fn with_enum_names(mut self, enum_names: Vec<String>) -> Self {
396        self.enum_names = Some(enum_names);
397        self
398    }
399}
400
401// Convenience constructors for PrimitiveSchemaDefinition
402impl PrimitiveSchemaDefinition {
403    pub fn string() -> Self {
404        Self::String(StringSchema::new())
405    }
406
407    pub fn string_with_description(description: impl Into<String>) -> Self {
408        Self::String(StringSchema::new().with_description(description))
409    }
410
411    pub fn number() -> Self {
412        Self::Number(NumberSchema::new())
413    }
414
415    pub fn integer() -> Self {
416        Self::Number(NumberSchema::integer())
417    }
418
419    pub fn boolean() -> Self {
420        Self::Boolean(BooleanSchema::new())
421    }
422
423    pub fn enum_values(values: Vec<String>) -> Self {
424        Self::Enum(EnumSchema::new(values))
425    }
426}
427
428/// Builder for creating common elicitation patterns
429pub struct ElicitationBuilder;
430
431impl ElicitationBuilder {
432    /// Create a simple text input elicitation (MCP spec compliant)
433    pub fn text_input(
434        message: impl Into<String>,
435        field_name: impl Into<String>,
436        field_description: impl Into<String>,
437    ) -> ElicitCreateRequest {
438        let field_name = field_name.into();
439        let schema = ElicitationSchema::new()
440            .with_property(
441                field_name.clone(),
442                PrimitiveSchemaDefinition::string_with_description(field_description),
443            )
444            .with_required(vec![field_name]);
445
446        ElicitCreateRequest::new(message, schema)
447    }
448
449    /// Create a number input elicitation (MCP spec compliant)
450    pub fn number_input(
451        message: impl Into<String>,
452        field_name: impl Into<String>,
453        field_description: impl Into<String>,
454        min: Option<f64>,
455        max: Option<f64>,
456    ) -> ElicitCreateRequest {
457        let field_name = field_name.into();
458        let mut number_schema = NumberSchema::new().with_description(field_description);
459        number_schema.minimum = min;
460        number_schema.maximum = max;
461        let number_schema = PrimitiveSchemaDefinition::Number(number_schema);
462
463        let schema = ElicitationSchema::new()
464            .with_property(field_name.clone(), number_schema)
465            .with_required(vec![field_name]);
466
467        ElicitCreateRequest::new(message, schema)
468    }
469
470    /// Create a boolean confirmation elicitation (MCP spec compliant)
471    pub fn confirm(message: impl Into<String>) -> ElicitCreateRequest {
472        let schema = ElicitationSchema::new()
473            .with_property(
474                "confirmed".to_string(),
475                PrimitiveSchemaDefinition::boolean(),
476            )
477            .with_required(vec!["confirmed".to_string()]);
478
479        ElicitCreateRequest::new(message, schema)
480    }
481}
482
483// ===========================================
484// === Fine-Grained Elicitation Traits ===
485// ===========================================
486
487/// Trait for elicitation metadata (message, title)
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use serde_json::json;
493
494    #[test]
495    fn test_primitive_schema_creation() {
496        let string_schema = PrimitiveSchemaDefinition::string_with_description("Enter your name");
497        let number_schema = PrimitiveSchemaDefinition::number();
498        let boolean_schema = PrimitiveSchemaDefinition::boolean();
499
500        assert!(matches!(
501            string_schema,
502            PrimitiveSchemaDefinition::String { .. }
503        ));
504        assert!(matches!(
505            number_schema,
506            PrimitiveSchemaDefinition::Number { .. }
507        ));
508        assert!(matches!(
509            boolean_schema,
510            PrimitiveSchemaDefinition::Boolean { .. }
511        ));
512    }
513
514    #[test]
515    fn test_elicitation_schema() {
516        let schema = ElicitationSchema::new()
517            .with_property(
518                "name".to_string(),
519                PrimitiveSchemaDefinition::string_with_description("Your name"),
520            )
521            .with_property("age".to_string(), PrimitiveSchemaDefinition::integer())
522            .with_required(vec!["name".to_string()]);
523
524        assert_eq!(schema.schema_type, "object");
525        assert_eq!(schema.properties.len(), 2);
526        assert_eq!(schema.required, Some(vec!["name".to_string()]));
527    }
528
529    #[test]
530    fn test_elicit_create_request() {
531        let schema = ElicitationSchema::new().with_property(
532            "username".to_string(),
533            PrimitiveSchemaDefinition::string_with_description("Username"),
534        );
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 =
564            ElicitationBuilder::text_input("Enter your name", "name", "Your full name");
565
566        let confirm_request = ElicitationBuilder::confirm("Do you agree?");
567
568        assert_eq!(text_request.method, "elicitation/create");
569        assert!(
570            text_request
571                .params
572                .requested_schema
573                .properties
574                .contains_key("name")
575        );
576
577        assert_eq!(confirm_request.method, "elicitation/create");
578        assert!(
579            confirm_request
580                .params
581                .requested_schema
582                .properties
583                .contains_key("confirmed")
584        );
585    }
586
587    #[test]
588    fn test_serialization() {
589        let schema = ElicitationSchema::new()
590            .with_property("test".to_string(), PrimitiveSchemaDefinition::string());
591        let request = ElicitCreateRequest::new("Test message", schema);
592
593        let json = serde_json::to_string(&request).unwrap();
594        assert!(json.contains("elicitation/create"));
595        assert!(json.contains("Test message"));
596
597        let parsed: ElicitCreateRequest = serde_json::from_str(&json).unwrap();
598        assert_eq!(parsed.method, "elicitation/create");
599        assert_eq!(parsed.params.message, "Test message");
600    }
601
602    #[test]
603    fn test_elicit_request_matches_typescript_spec() {
604        // Test ElicitRequest matches: { method: string, params: { message: string, requestedSchema: {...}, _meta?: {...} } }
605        let mut meta = HashMap::new();
606        meta.insert("requestId".to_string(), json!("req-123"));
607
608        let schema = ElicitationSchema::new()
609            .with_property(
610                "name".to_string(),
611                PrimitiveSchemaDefinition::string_with_description("Your name"),
612            )
613            .with_property("age".to_string(), PrimitiveSchemaDefinition::integer())
614            .with_required(vec!["name".to_string()]);
615
616        let request =
617            ElicitCreateRequest::new("Please provide your details", schema).with_meta(meta);
618
619        let json_value = serde_json::to_value(&request).unwrap();
620
621        assert_eq!(json_value["method"], "elicitation/create");
622        assert!(json_value["params"].is_object());
623        assert_eq!(
624            json_value["params"]["message"],
625            "Please provide your details"
626        );
627        assert!(json_value["params"]["requestedSchema"].is_object());
628        assert_eq!(json_value["params"]["requestedSchema"]["type"], "object");
629        assert!(json_value["params"]["requestedSchema"]["properties"].is_object());
630        assert_eq!(json_value["params"]["_meta"]["requestId"], "req-123");
631    }
632
633    #[test]
634    fn test_elicit_result_matches_typescript_spec() {
635        // Test ElicitResult matches: { action: "accept" | "decline" | "cancel", content?: {...}, _meta?: {...} }
636        let mut meta = HashMap::new();
637        meta.insert("responseTime".to_string(), json!(1234));
638
639        let mut content = HashMap::new();
640        content.insert("name".to_string(), json!("John Doe"));
641        content.insert("age".to_string(), json!(30));
642
643        let result = ElicitResult::accept(content.clone()).with_meta(meta);
644
645        let json_value = serde_json::to_value(&result).unwrap();
646
647        assert_eq!(json_value["action"], "accept");
648        assert!(json_value["content"].is_object());
649        assert_eq!(json_value["content"]["name"], "John Doe");
650        assert_eq!(json_value["content"]["age"], 30);
651        assert_eq!(json_value["_meta"]["responseTime"], 1234);
652
653        // Test decline without content
654        let decline_result = ElicitResult::decline();
655        let decline_json = serde_json::to_value(&decline_result).unwrap();
656
657        assert_eq!(decline_json["action"], "decline");
658        assert!(
659            decline_json["content"].is_null()
660                || !decline_json.as_object().unwrap().contains_key("content")
661        );
662    }
663
664    #[test]
665    fn test_primitive_schema_definitions_match_typescript() {
666        // Test StringSchema
667        let string_schema = PrimitiveSchemaDefinition::string_with_description("Enter text");
668        let string_json = serde_json::to_value(&string_schema).unwrap();
669        assert_eq!(string_json["type"], "string");
670        assert_eq!(string_json["description"], "Enter text");
671
672        // Test NumberSchema
673        let number_schema = PrimitiveSchemaDefinition::number();
674        let number_json = serde_json::to_value(&number_schema).unwrap();
675        assert_eq!(number_json["type"], "number");
676
677        // Test IntegerSchema
678        let integer_schema = PrimitiveSchemaDefinition::integer();
679        let integer_json = serde_json::to_value(&integer_schema).unwrap();
680        assert_eq!(integer_json["type"], "integer");
681
682        // Test BooleanSchema
683        let boolean_schema = PrimitiveSchemaDefinition::boolean();
684        let boolean_json = serde_json::to_value(&boolean_schema).unwrap();
685        assert_eq!(boolean_json["type"], "boolean");
686
687        // Test EnumSchema
688        let enum_schema = PrimitiveSchemaDefinition::enum_values(vec![
689            "red".to_string(),
690            "green".to_string(),
691            "blue".to_string(),
692        ]);
693        let enum_json = serde_json::to_value(&enum_schema).unwrap();
694        assert_eq!(enum_json["type"], "string");
695        assert!(enum_json["enum"].is_array());
696        assert_eq!(enum_json["enum"].as_array().unwrap().len(), 3);
697    }
698}