[elicitations] Switch to use MCP style elicitation payload for mcp tool approvals. (#13621)

- [x] Switch to use MCP style elicitation payload for mcp tool
approvals.
- [ ] TODO: Update the UI to support the full spec.
This commit is contained in:
Matthew Zeng
2026-03-06 01:50:26 -08:00
committed by GitHub
parent ee1a20258a
commit 98dca99db7
59 changed files with 5165 additions and 100 deletions

View File

@@ -1058,21 +1058,23 @@ mod tests {
#[test]
fn serialize_mcp_server_elicitation_request() -> Result<()> {
let requested_schema: v2::McpElicitationSchema = serde_json::from_value(json!({
"type": "object",
"properties": {
"confirmed": {
"type": "boolean"
}
},
"required": ["confirmed"]
}))?;
let params = v2::McpServerElicitationRequestParams {
thread_id: "thr_123".to_string(),
turn_id: Some("turn_123".to_string()),
server_name: "codex_apps".to_string(),
request: v2::McpServerElicitationRequest::Form {
meta: None,
message: "Allow this request?".to_string(),
requested_schema: json!({
"type": "object",
"properties": {
"confirmed": {
"type": "boolean"
}
},
"required": ["confirmed"]
}),
requested_schema,
},
};
let request = ServerRequest::McpServerElicitationRequest {
@@ -1089,6 +1091,7 @@ mod tests {
"turnId": "turn_123",
"serverName": "codex_apps",
"mode": "form",
"_meta": null,
"message": "Allow this request?",
"requestedSchema": {
"type": "object",

View File

@@ -4210,6 +4210,323 @@ pub struct McpServerElicitationRequestParams {
// association.
}
/// Typed form schema for MCP `elicitation/create` requests.
///
/// This matches the `requestedSchema` shape from the MCP 2025-11-25
/// `ElicitRequestFormParams` schema.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct McpElicitationSchema {
#[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
#[ts(optional, rename = "$schema")]
pub schema_uri: Option<String>,
#[serde(rename = "type")]
#[ts(rename = "type")]
pub type_: McpElicitationObjectType,
pub properties: BTreeMap<String, McpElicitationPrimitiveSchema>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub required: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export_to = "v2/")]
pub enum McpElicitationObjectType {
Object,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(untagged)]
#[ts(export_to = "v2/")]
pub enum McpElicitationPrimitiveSchema {
Enum(McpElicitationEnumSchema),
String(McpElicitationStringSchema),
Number(McpElicitationNumberSchema),
Boolean(McpElicitationBooleanSchema),
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct McpElicitationStringSchema {
#[serde(rename = "type")]
#[ts(rename = "type")]
pub type_: McpElicitationStringType,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub min_length: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub max_length: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub format: Option<McpElicitationStringFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub default: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export_to = "v2/")]
pub enum McpElicitationStringType {
String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "kebab-case")]
#[ts(rename_all = "kebab-case", export_to = "v2/")]
pub enum McpElicitationStringFormat {
Email,
Uri,
Date,
DateTime,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct McpElicitationNumberSchema {
#[serde(rename = "type")]
#[ts(rename = "type")]
pub type_: McpElicitationNumberType,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub minimum: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub maximum: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub default: Option<f64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export_to = "v2/")]
pub enum McpElicitationNumberType {
Number,
Integer,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct McpElicitationBooleanSchema {
#[serde(rename = "type")]
#[ts(rename = "type")]
pub type_: McpElicitationBooleanType,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub default: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export_to = "v2/")]
pub enum McpElicitationBooleanType {
Boolean,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(untagged)]
#[ts(export_to = "v2/")]
pub enum McpElicitationEnumSchema {
SingleSelect(McpElicitationSingleSelectEnumSchema),
MultiSelect(McpElicitationMultiSelectEnumSchema),
Legacy(McpElicitationLegacyTitledEnumSchema),
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct McpElicitationLegacyTitledEnumSchema {
#[serde(rename = "type")]
#[ts(rename = "type")]
pub type_: McpElicitationStringType,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub description: Option<String>,
#[serde(rename = "enum")]
#[ts(rename = "enum")]
pub enum_: Vec<String>,
#[serde(rename = "enumNames", skip_serializing_if = "Option::is_none")]
#[ts(optional, rename = "enumNames")]
pub enum_names: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub default: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(untagged)]
#[ts(export_to = "v2/")]
pub enum McpElicitationSingleSelectEnumSchema {
Untitled(McpElicitationUntitledSingleSelectEnumSchema),
Titled(McpElicitationTitledSingleSelectEnumSchema),
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct McpElicitationUntitledSingleSelectEnumSchema {
#[serde(rename = "type")]
#[ts(rename = "type")]
pub type_: McpElicitationStringType,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub description: Option<String>,
#[serde(rename = "enum")]
#[ts(rename = "enum")]
pub enum_: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub default: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct McpElicitationTitledSingleSelectEnumSchema {
#[serde(rename = "type")]
#[ts(rename = "type")]
pub type_: McpElicitationStringType,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub description: Option<String>,
#[serde(rename = "oneOf")]
#[ts(rename = "oneOf")]
pub one_of: Vec<McpElicitationConstOption>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub default: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(untagged)]
#[ts(export_to = "v2/")]
pub enum McpElicitationMultiSelectEnumSchema {
Untitled(McpElicitationUntitledMultiSelectEnumSchema),
Titled(McpElicitationTitledMultiSelectEnumSchema),
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct McpElicitationUntitledMultiSelectEnumSchema {
#[serde(rename = "type")]
#[ts(rename = "type")]
pub type_: McpElicitationArrayType,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub min_items: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub max_items: Option<u64>,
pub items: McpElicitationUntitledEnumItems,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub default: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct McpElicitationTitledMultiSelectEnumSchema {
#[serde(rename = "type")]
#[ts(rename = "type")]
pub type_: McpElicitationArrayType,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub min_items: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub max_items: Option<u64>,
pub items: McpElicitationTitledEnumItems,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub default: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export_to = "v2/")]
pub enum McpElicitationArrayType {
Array,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct McpElicitationUntitledEnumItems {
#[serde(rename = "type")]
#[ts(rename = "type")]
pub type_: McpElicitationStringType,
#[serde(rename = "enum")]
#[ts(rename = "enum")]
pub enum_: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct McpElicitationTitledEnumItems {
#[serde(rename = "anyOf", alias = "oneOf")]
#[ts(rename = "anyOf")]
pub any_of: Vec<McpElicitationConstOption>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(deny_unknown_fields)]
#[ts(export_to = "v2/")]
pub struct McpElicitationConstOption {
#[serde(rename = "const")]
#[ts(rename = "const")]
pub const_: String,
pub title: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "mode", rename_all = "camelCase")]
#[ts(tag = "mode")]
@@ -4218,37 +4535,49 @@ pub enum McpServerElicitationRequest {
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Form {
#[serde(rename = "_meta")]
#[ts(rename = "_meta")]
meta: Option<JsonValue>,
message: String,
requested_schema: JsonValue,
requested_schema: McpElicitationSchema,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Url {
#[serde(rename = "_meta")]
#[ts(rename = "_meta")]
meta: Option<JsonValue>,
message: String,
url: String,
elicitation_id: String,
},
}
impl From<CoreElicitationRequest> for McpServerElicitationRequest {
fn from(value: CoreElicitationRequest) -> Self {
impl TryFrom<CoreElicitationRequest> for McpServerElicitationRequest {
type Error = serde_json::Error;
fn try_from(value: CoreElicitationRequest) -> Result<Self, Self::Error> {
match value {
CoreElicitationRequest::Form {
meta,
message,
requested_schema,
} => Self::Form {
} => Ok(Self::Form {
meta,
message,
requested_schema,
},
requested_schema: serde_json::from_value(requested_schema)?,
}),
CoreElicitationRequest::Url {
meta,
message,
url,
elicitation_id,
} => Self::Url {
} => Ok(Self::Url {
meta,
message,
url,
elicitation_id,
},
}),
}
}
}
@@ -4262,6 +4591,10 @@ pub struct McpServerElicitationRequestResponse {
///
/// This is nullable because decline/cancel responses have no content.
pub content: Option<JsonValue>,
/// Optional client metadata for form-mode action handling.
#[serde(rename = "_meta")]
#[ts(rename = "_meta")]
pub meta: Option<JsonValue>,
}
impl From<McpServerElicitationRequestResponse> for rmcp::model::CreateElicitationResult {
@@ -4278,6 +4611,7 @@ impl From<rmcp::model::CreateElicitationResult> for McpServerElicitationRequestR
Self {
action: value.action.into(),
content: value.content,
meta: None,
}
}
}
@@ -4675,6 +5009,7 @@ mod tests {
content: Some(json!({
"confirmed": true,
})),
meta: None,
}
);
assert_eq!(
@@ -4685,15 +5020,18 @@ mod tests {
#[test]
fn mcp_server_elicitation_request_from_core_url_request() {
let request = McpServerElicitationRequest::from(CoreElicitationRequest::Url {
let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Url {
meta: None,
message: "Finish sign-in".to_string(),
url: "https://example.com/complete".to_string(),
elicitation_id: "elicitation-123".to_string(),
});
})
.expect("URL request should convert");
assert_eq!(
request,
McpServerElicitationRequest::Url {
meta: None,
message: "Finish sign-in".to_string(),
url: "https://example.com/complete".to_string(),
elicitation_id: "elicitation-123".to_string(),
@@ -4701,11 +5039,178 @@ mod tests {
);
}
#[test]
fn mcp_server_elicitation_request_from_core_form_request() {
let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form {
meta: None,
message: "Allow this request?".to_string(),
requested_schema: json!({
"type": "object",
"properties": {
"confirmed": {
"type": "boolean",
}
},
"required": ["confirmed"],
}),
})
.expect("form request should convert");
let expected_schema: McpElicitationSchema = serde_json::from_value(json!({
"type": "object",
"properties": {
"confirmed": {
"type": "boolean",
}
},
"required": ["confirmed"],
}))
.expect("expected schema should deserialize");
assert_eq!(
request,
McpServerElicitationRequest::Form {
meta: None,
message: "Allow this request?".to_string(),
requested_schema: expected_schema,
}
);
}
#[test]
fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() {
let schema: McpElicitationSchema = serde_json::from_value(json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"email": {
"type": "string",
"title": "Email",
"description": "Work email address",
"format": "email",
"default": "dev@example.com",
},
"count": {
"type": "integer",
"title": "Count",
"description": "How many items to create",
"minimum": 1,
"maximum": 5,
"default": 3,
},
"confirmed": {
"type": "boolean",
"title": "Confirm",
"description": "Approve the pending action",
"default": true,
},
"legacyChoice": {
"type": "string",
"title": "Action",
"description": "Legacy titled enum form",
"enum": ["allow", "deny"],
"enumNames": ["Allow", "Deny"],
"default": "allow",
},
},
"required": ["email", "confirmed"],
}))
.expect("schema should deserialize");
assert_eq!(
schema,
McpElicitationSchema {
schema_uri: Some("https://json-schema.org/draft/2020-12/schema".to_string()),
type_: McpElicitationObjectType::Object,
properties: BTreeMap::from([
(
"confirmed".to_string(),
McpElicitationPrimitiveSchema::Boolean(McpElicitationBooleanSchema {
type_: McpElicitationBooleanType::Boolean,
title: Some("Confirm".to_string()),
description: Some("Approve the pending action".to_string()),
default: Some(true),
}),
),
(
"count".to_string(),
McpElicitationPrimitiveSchema::Number(McpElicitationNumberSchema {
type_: McpElicitationNumberType::Integer,
title: Some("Count".to_string()),
description: Some("How many items to create".to_string()),
minimum: Some(1.0),
maximum: Some(5.0),
default: Some(3.0),
}),
),
(
"email".to_string(),
McpElicitationPrimitiveSchema::String(McpElicitationStringSchema {
type_: McpElicitationStringType::String,
title: Some("Email".to_string()),
description: Some("Work email address".to_string()),
min_length: None,
max_length: None,
format: Some(McpElicitationStringFormat::Email),
default: Some("dev@example.com".to_string()),
}),
),
(
"legacyChoice".to_string(),
McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::Legacy(
McpElicitationLegacyTitledEnumSchema {
type_: McpElicitationStringType::String,
title: Some("Action".to_string()),
description: Some("Legacy titled enum form".to_string()),
enum_: vec!["allow".to_string(), "deny".to_string()],
enum_names: Some(vec!["Allow".to_string(), "Deny".to_string(),]),
default: Some("allow".to_string()),
},
)),
),
]),
required: Some(vec!["email".to_string(), "confirmed".to_string()]),
}
);
}
#[test]
fn mcp_server_elicitation_request_rejects_null_core_form_schema() {
let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form {
meta: Some(json!({
"persist": "session",
})),
message: "Allow this request?".to_string(),
requested_schema: JsonValue::Null,
});
assert!(result.is_err());
}
#[test]
fn mcp_server_elicitation_request_rejects_invalid_core_form_schema() {
let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form {
meta: None,
message: "Allow this request?".to_string(),
requested_schema: json!({
"type": "object",
"properties": {
"confirmed": {
"type": "object",
}
},
}),
});
assert!(result.is_err());
}
#[test]
fn mcp_server_elicitation_response_serializes_nullable_content() {
let response = McpServerElicitationRequestResponse {
action: McpServerElicitationAction::Decline,
content: None,
meta: None,
};
assert_eq!(
@@ -4713,6 +5218,7 @@ mod tests {
json!({
"action": "decline",
"content": null,
"_meta": null,
})
);
}