diff --git a/docs/protocol/draft/schema-v2.mdx b/docs/protocol/draft/schema-v2.mdx index 155d074f..3bcba911 100644 --- a/docs/protocol/draft/schema-v2.mdx +++ b/docs/protocol/draft/schema-v2.mdx @@ -1903,6 +1903,10 @@ URL-based elicitation where the client directs the user to a URL. The URL to direct the user to. + + If provided, the user must visit the URL and enter this code to complete the + elicitation. + @@ -4220,6 +4224,10 @@ URL-based elicitation mode where the client directs the user to a URL. The URL to direct the user to. + + If provided, the user must visit the URL and enter this code to complete the + elicitation. + **Variants:** diff --git a/docs/protocol/draft/schema.mdx b/docs/protocol/draft/schema.mdx index 155d074f..3bcba911 100644 --- a/docs/protocol/draft/schema.mdx +++ b/docs/protocol/draft/schema.mdx @@ -1903,6 +1903,10 @@ URL-based elicitation where the client directs the user to a URL. The URL to direct the user to. + + If provided, the user must visit the URL and enter this code to complete the + elicitation. + @@ -4220,6 +4224,10 @@ URL-based elicitation mode where the client directs the user to a URL. The URL to direct the user to. + + If provided, the user must visit the URL and enter this code to complete the + elicitation. + **Variants:** diff --git a/docs/rfds/elicitation.mdx b/docs/rfds/elicitation.mdx index ab69c108..5fb3b23c 100644 --- a/docs/rfds/elicitation.mdx +++ b/docs/rfds/elicitation.mdx @@ -191,6 +191,8 @@ Following MCP's approach (specifically [SEP-1036](https://modelcontextprotocol.i This distinction is reflected in the client capabilities model, allowing clients to declare support for one or both modalities. +URL mode supports an optional `userCode` parameter. If present, the user must enter this code when they visit the external URL. + **Normative requirements:** - Clients declaring the `elicitation` capability MUST support at least one mode (`form` or `url`). diff --git a/schema/schema.unstable.json b/schema/schema.unstable.json index a76dac3b..4fc8b70f 100644 --- a/schema/schema.unstable.json +++ b/schema/schema.unstable.json @@ -2713,6 +2713,10 @@ "description": "The URL to direct the user to.", "format": "uri", "type": "string" + }, + "userCode": { + "description": "If provided, the user must visit the URL and enter this code to complete the elicitation.", + "type": ["string", "null"] } }, "required": ["elicitationId", "url"], diff --git a/schema/schema.v2.unstable.json b/schema/schema.v2.unstable.json index a76dac3b..4fc8b70f 100644 --- a/schema/schema.v2.unstable.json +++ b/schema/schema.v2.unstable.json @@ -2713,6 +2713,10 @@ "description": "The URL to direct the user to.", "format": "uri", "type": "string" + }, + "userCode": { + "description": "If provided, the user must visit the URL and enter this code to complete the elicitation.", + "type": ["string", "null"] } }, "required": ["elicitationId", "url"], diff --git a/src/v1/elicitation.rs b/src/v1/elicitation.rs index adc80a22..bcb8f5fb 100644 --- a/src/v1/elicitation.rs +++ b/src/v1/elicitation.rs @@ -1075,6 +1075,8 @@ pub struct ElicitationUrlMode { /// The URL to direct the user to. #[schemars(extend("format" = "uri"))] pub url: String, + /// If provided, the user must visit the URL and enter this code to complete the elicitation. + pub user_code: Option, } impl ElicitationUrlMode { @@ -1088,8 +1090,16 @@ impl ElicitationUrlMode { scope: scope.into(), elicitation_id: elicitation_id.into(), url: url.into(), + user_code: None, } } + + /// If provided, the user must visit the URL and enter this code to complete the elicitation. + #[must_use] + pub fn user_code(mut self, user_code: impl IntoOption) -> Self { + self.user_code = user_code.into_option(); + self + } } /// **UNSTABLE** @@ -1330,6 +1340,8 @@ pub struct UrlElicitationRequiredItem { pub url: String, /// A human-readable message describing what input is needed. pub message: String, + /// If provided, the user must visit the URL and enter this code to complete the elicitation. + pub user_code: Option, } /// Type discriminator for URL-only elicitation error items. @@ -1354,8 +1366,16 @@ impl UrlElicitationRequiredItem { elicitation_id: elicitation_id.into(), url: url.into(), message: message.into(), + user_code: None, } } + + /// If provided, the user must visit the URL and enter this code to complete the elicitation. + #[must_use] + pub fn user_code(mut self, user_code: impl IntoOption) -> Self { + self.user_code = user_code.into_option(); + self + } } #[cfg(test)] @@ -1421,6 +1441,37 @@ mod tests { assert!(matches!(roundtripped.mode, ElicitationMode::Url(_))); } + #[test] + fn url_mode_request_with_user_code_serialization() { + let req = CreateElicitationRequest::new( + ElicitationUrlMode::new( + ElicitationSessionScope::new("sess_2").tool_call_id("tc_1"), + "elic_1", + "https://example.com/auth", + ) + .user_code("ABCDEF-123456"), + "Please authenticate", + ); + + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["sessionId"], "sess_2"); + assert_eq!(json["toolCallId"], "tc_1"); + assert_eq!(json["mode"], "url"); + assert_eq!(json["elicitationId"], "elic_1"); + assert_eq!(json["url"], "https://example.com/auth"); + assert_eq!(json["message"], "Please authenticate"); + assert_eq!(json["userCode"], "ABCDEF-123456"); + + let roundtripped: CreateElicitationRequest = serde_json::from_value(json).unwrap(); + assert_eq!( + *roundtripped.scope(), + ElicitationSessionScope::new("sess_2") + .tool_call_id("tc_1") + .into() + ); + assert!(matches!(roundtripped.mode, ElicitationMode::Url(_))); + } + #[test] fn response_accept_serialization() { let resp = CreateElicitationResponse::new(ElicitationAction::Accept( @@ -1665,6 +1716,31 @@ mod tests { ); } + #[test] + fn url_elicitation_required_with_user_code_data_serialization() { + let data = UrlElicitationRequiredData::new(vec![ + UrlElicitationRequiredItem::new( + "elic_1", + "https://example.com/auth", + "Please authenticate", + ) + .user_code("ABCDE-12345"), + ]); + + let json = serde_json::to_value(&data).unwrap(); + assert_eq!(json["elicitations"][0]["mode"], "url"); + assert_eq!(json["elicitations"][0]["elicitationId"], "elic_1"); + assert_eq!(json["elicitations"][0]["url"], "https://example.com/auth"); + assert_eq!(json["elicitations"][0]["userCode"], "ABCDE-12345"); + + let roundtripped: UrlElicitationRequiredData = serde_json::from_value(json).unwrap(); + assert_eq!(roundtripped.elicitations.len(), 1); + assert_eq!( + roundtripped.elicitations[0].mode, + ElicitationUrlOnlyMode::Url + ); + } + #[test] fn schema_default_sets_object_type() { let schema = ElicitationSchema::default(); diff --git a/src/v2/conversion.rs b/src/v2/conversion.rs index f1c31102..24f4cdc3 100644 --- a/src/v2/conversion.rs +++ b/src/v2/conversion.rs @@ -8587,11 +8587,13 @@ impl IntoV1 for super::ElicitationUrlMode { scope, elicitation_id, url, + user_code, } = self; Ok(crate::v1::ElicitationUrlMode { scope: scope.into_v1()?, elicitation_id: elicitation_id.into_v1()?, url: url.into_v1()?, + user_code: user_code.into_v1()?, }) } } @@ -8605,11 +8607,13 @@ impl IntoV2 for crate::v1::ElicitationUrlMode { scope, elicitation_id, url, + user_code, } = self; Ok(super::ElicitationUrlMode { scope: scope.into_v2()?, elicitation_id: elicitation_id.into_v2()?, url: url.into_v2()?, + user_code: user_code.into_v2()?, }) } } @@ -8790,12 +8794,14 @@ impl IntoV1 for super::UrlElicitationRequiredItem { elicitation_id, url, message, + user_code, } = self; Ok(crate::v1::UrlElicitationRequiredItem { mode: mode.into_v1()?, elicitation_id: elicitation_id.into_v1()?, url: url.into_v1()?, message: message.into_v1()?, + user_code: user_code.into_v1()?, }) } } @@ -8810,12 +8816,14 @@ impl IntoV2 for crate::v1::UrlElicitationRequiredItem { elicitation_id, url, message, + user_code, } = self; Ok(super::UrlElicitationRequiredItem { mode: mode.into_v2()?, elicitation_id: elicitation_id.into_v2()?, url: url.into_v2()?, message: message.into_v2()?, + user_code: user_code.into_v2()?, }) } } diff --git a/src/v2/elicitation.rs b/src/v2/elicitation.rs index ba97a8b2..74defbbc 100644 --- a/src/v2/elicitation.rs +++ b/src/v2/elicitation.rs @@ -1076,6 +1076,8 @@ pub struct ElicitationUrlMode { /// The URL to direct the user to. #[schemars(extend("format" = "uri"))] pub url: String, + /// If provided, the user must visit the URL and enter this code to complete the elicitation. + pub user_code: Option, } impl ElicitationUrlMode { @@ -1089,8 +1091,16 @@ impl ElicitationUrlMode { scope: scope.into(), elicitation_id: elicitation_id.into(), url: url.into(), + user_code: None, } } + + /// If provided, the user must visit the URL and enter this code to complete the elicitation. + #[must_use] + pub fn user_code(mut self, user_code: impl IntoOption) -> Self { + self.user_code = user_code.into_option(); + self + } } /// **UNSTABLE** @@ -1331,6 +1341,8 @@ pub struct UrlElicitationRequiredItem { pub url: String, /// A human-readable message describing what input is needed. pub message: String, + /// If provided, the user must visit the URL and enter this code to complete the elicitation. + pub user_code: Option, } /// Type discriminator for URL-only elicitation error items. @@ -1355,8 +1367,16 @@ impl UrlElicitationRequiredItem { elicitation_id: elicitation_id.into(), url: url.into(), message: message.into(), + user_code: None, } } + + /// If provided, the user must visit the URL and enter this code to complete the elicitation. + #[must_use] + pub fn user_code(mut self, user_code: impl IntoOption) -> Self { + self.user_code = user_code.into_option(); + self + } } #[cfg(test)] @@ -1422,6 +1442,37 @@ mod tests { assert!(matches!(roundtripped.mode, ElicitationMode::Url(_))); } + #[test] + fn url_mode_request_with_user_code_serialization() { + let req = CreateElicitationRequest::new( + ElicitationUrlMode::new( + ElicitationSessionScope::new("sess_2").tool_call_id("tc_1"), + "elic_1", + "https://example.com/auth", + ) + .user_code("ABCDEF-123456"), + "Please authenticate", + ); + + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["sessionId"], "sess_2"); + assert_eq!(json["toolCallId"], "tc_1"); + assert_eq!(json["mode"], "url"); + assert_eq!(json["elicitationId"], "elic_1"); + assert_eq!(json["url"], "https://example.com/auth"); + assert_eq!(json["message"], "Please authenticate"); + assert_eq!(json["userCode"], "ABCDEF-123456"); + + let roundtripped: CreateElicitationRequest = serde_json::from_value(json).unwrap(); + assert_eq!( + *roundtripped.scope(), + ElicitationSessionScope::new("sess_2") + .tool_call_id("tc_1") + .into() + ); + assert!(matches!(roundtripped.mode, ElicitationMode::Url(_))); + } + #[test] fn response_accept_serialization() { let resp = CreateElicitationResponse::new(ElicitationAction::Accept( @@ -1666,6 +1717,31 @@ mod tests { ); } + #[test] + fn url_elicitation_required_with_user_code_data_serialization() { + let data = UrlElicitationRequiredData::new(vec![ + UrlElicitationRequiredItem::new( + "elic_1", + "https://example.com/auth", + "Please authenticate", + ) + .user_code("ABCDE-12345"), + ]); + + let json = serde_json::to_value(&data).unwrap(); + assert_eq!(json["elicitations"][0]["mode"], "url"); + assert_eq!(json["elicitations"][0]["elicitationId"], "elic_1"); + assert_eq!(json["elicitations"][0]["url"], "https://example.com/auth"); + assert_eq!(json["elicitations"][0]["userCode"], "ABCDE-12345"); + + let roundtripped: UrlElicitationRequiredData = serde_json::from_value(json).unwrap(); + assert_eq!(roundtripped.elicitations.len(), 1); + assert_eq!( + roundtripped.elicitations[0].mode, + ElicitationUrlOnlyMode::Url + ); + } + #[test] fn schema_default_sets_object_type() { let schema = ElicitationSchema::default();