Skip to main content

acp_utils/
elicitation.rs

1use crate::notifications::AETHER_META_NAMESPACE;
2use agent_client_protocol::schema::v2::{
3    self as acp, CompleteElicitationNotification, CreateElicitationRequest, CreateElicitationResponse, Meta, SessionId,
4};
5use rmcp::model::{self as mcp, ElicitRequestParams, ElicitResult};
6use serde_json::{Value, json};
7
8#[derive(Debug, thiserror::Error)]
9pub enum ElicitationConversionError {
10    #[error("unsupported {0}")]
11    Unsupported(&'static str),
12    #[error("legacy MCP enum has a different number of values and titles")]
13    MismatchedEnumTitles,
14    #[error("elicitation could not be converted: {0}")]
15    Json(#[from] serde_json::Error),
16}
17
18pub fn map_mcp_elicitation_request_to_acp(
19    server_name: &str,
20    session_id: &SessionId,
21    request: &ElicitRequestParams,
22) -> Result<CreateElicitationRequest, ElicitationConversionError> {
23    let scope = || acp::ElicitationSessionScope::new(session_id.clone());
24    let (request, meta) = match request {
25        ElicitRequestParams::FormElicitationParams { meta, message, requested_schema } => (
26            CreateElicitationRequest::new(
27                acp::ElicitationFormMode::new(scope(), map_mcp_elicitation_schema_to_acp(requested_schema)?),
28                message.clone(),
29            ),
30            meta,
31        ),
32        ElicitRequestParams::UrlElicitationParams { meta, message, url, elicitation_id } => (
33            CreateElicitationRequest::new(
34                acp::ElicitationUrlMode::new(
35                    scope(),
36                    build_scoped_elicitation_id(session_id, server_name, elicitation_id),
37                    url.clone(),
38                ),
39                message.clone(),
40            ),
41            meta,
42        ),
43        _ => return Err(ElicitationConversionError::Unsupported("MCP elicitation request variant")),
44    };
45    Ok(request.meta(map_mcp_meta_to_acp(meta.as_ref(), server_name)))
46}
47
48pub fn map_acp_elicitation_response_to_mcp(
49    response: CreateElicitationResponse,
50) -> Result<ElicitResult, ElicitationConversionError> {
51    let mut result = match response.action {
52        acp::ElicitationAction::Accept(accept) => {
53            let mut result = ElicitResult::new(mcp::ElicitationAction::Accept);
54            result.content = accept.content.map(serde_json::to_value).transpose()?;
55            result
56        }
57        acp::ElicitationAction::Decline => ElicitResult::new(mcp::ElicitationAction::Decline),
58        acp::ElicitationAction::Cancel => ElicitResult::new(mcp::ElicitationAction::Cancel),
59        _ => return Err(ElicitationConversionError::Unsupported("ACP elicitation response action")),
60    };
61    result.meta = response.meta.map(mcp::MetaObject::from);
62    Ok(result)
63}
64
65pub fn build_acp_elicitation_completion_notification(
66    session_id: &SessionId,
67    server_name: &str,
68    elicitation_id: &str,
69) -> CompleteElicitationNotification {
70    CompleteElicitationNotification::new(build_scoped_elicitation_id(session_id, server_name, elicitation_id))
71}
72
73/// The MCP server a converted elicitation originated from, if any.
74pub fn source_mcp_server_name(meta: Option<&Meta>) -> Option<&str> {
75    meta.and_then(|meta| meta.get(AETHER_META_NAMESPACE))
76        .and_then(Value::as_object)
77        .and_then(|source| source.get("mcpServer"))
78        .and_then(Value::as_str)
79}
80
81fn map_mcp_elicitation_schema_to_acp(
82    schema: &mcp::ElicitationSchema,
83) -> Result<acp::ElicitationSchema, ElicitationConversionError> {
84    let mut schema = serde_json::to_value(schema)?;
85    if let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) {
86        properties.values_mut().try_for_each(title_legacy_enum)?;
87    }
88    Ok(serde_json::from_value(schema)?)
89}
90
91fn title_legacy_enum(property: &mut Value) -> Result<(), ElicitationConversionError> {
92    let Some(property) = property.as_object_mut() else { return Ok(()) };
93    let Some(Value::Array(titles)) = property.remove("enumNames") else { return Ok(()) };
94    let values = match property.remove("enum") {
95        Some(Value::Array(values)) if values.len() == titles.len() => values,
96        _ => return Err(ElicitationConversionError::MismatchedEnumTitles),
97    };
98    let options = values.into_iter().zip(titles).map(|(value, title)| json!({ "const": value, "title": title }));
99    property.insert("oneOf".to_string(), options.collect());
100    Ok(())
101}
102
103fn map_mcp_meta_to_acp(meta: Option<&mcp::RequestMetaObject>, server_name: &str) -> Meta {
104    let mut meta: Meta =
105        meta.map(|meta| meta.iter().map(|(key, value)| (key.clone(), value.clone())).collect()).unwrap_or_default();
106    let source = meta.entry(AETHER_META_NAMESPACE.to_string()).or_insert_with(|| json!({}));
107    if !source.is_object() {
108        *source = json!({});
109    }
110    source["mcpServer"] = json!(server_name);
111    meta
112}
113
114fn build_scoped_elicitation_id(session_id: &SessionId, server_name: &str, elicitation_id: &str) -> String {
115    json!([session_id.0.as_ref(), server_name, elicitation_id]).to_string()
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use agent_client_protocol::schema::v2::{
122        ElicitationAction, ElicitationMode, ElicitationPropertySchema, ElicitationScope,
123    };
124    use serde_json::Map;
125
126    #[test]
127    fn form_request_converts_with_session_scope_and_source_server() {
128        let request = ElicitRequestParams::FormElicitationParams {
129            meta: Some(mcp::RequestMetaObject::from(Map::from_iter([("ui".to_string(), json!("artifactReview"))]))),
130            message: "Review the plan".to_string(),
131            requested_schema: rmcp::model::ElicitationSchema::builder().required_bool("approved").build().unwrap(),
132        };
133
134        let converted = map_mcp_elicitation_request_to_acp("review", &SessionId::new("session-1"), &request).unwrap();
135
136        let ElicitationMode::Form(form) = converted.mode else { panic!("expected form mode") };
137        let ElicitationScope::Session(scope) = form.scope else { panic!("expected session scope") };
138        assert_eq!(&*scope.session_id.0, "session-1");
139        assert!(form.requested_schema.properties.contains_key("approved"));
140        assert_eq!(converted.message, "Review the plan");
141        assert_eq!(converted.meta.as_ref().and_then(|meta| meta.get("ui")), Some(&json!("artifactReview")));
142        assert_eq!(source_mcp_server_name(converted.meta.as_ref()), Some("review"));
143    }
144
145    #[test]
146    fn every_mcp_property_kind_converts_to_the_matching_acp_schema() {
147        let requested_schema = serde_json::from_value(json!({
148            "type": "object",
149            "title": "Settings",
150            "description": "Pick settings",
151            "properties": {
152                "email": { "type": "string", "title": "Email", "format": "email", "minLength": 3, "maxLength": 50, "default": "a@b.c" },
153                "ratio": { "type": "number", "description": "Ratio", "minimum": 0.5, "maximum": 2.5, "default": 1.5 },
154                "count": { "type": "integer", "minimum": 1, "maximum": 9, "default": 3 },
155                "enabled": { "type": "boolean", "default": true },
156                "color": { "type": "string", "enum": ["red", "blue"], "default": "red" },
157                "size": { "type": "string", "oneOf": [{ "const": "s", "title": "Small" }, { "const": "l", "title": "Large" }] },
158                "tags": { "type": "array", "items": { "type": "string", "enum": ["a", "b"] }, "minItems": 1, "maxItems": 2, "default": ["a"] },
159                "labels": { "type": "array", "items": { "anyOf": [{ "const": "x", "title": "X" }] } },
160                "legacy": { "type": "string", "enum": ["p", "q"], "enumNames": ["P", "Q"] }
161            },
162            "required": ["count", "email"]
163        }))
164        .unwrap();
165        let request =
166            ElicitRequestParams::FormElicitationParams { meta: None, message: "Configure".into(), requested_schema };
167
168        let converted = map_mcp_elicitation_request_to_acp("settings", &SessionId::new("session-1"), &request).unwrap();
169        let ElicitationMode::Form(form) = converted.mode else { panic!("expected form mode") };
170
171        assert_eq!(
172            serde_json::to_value(&form.requested_schema).unwrap(),
173            json!({
174                "type": "object",
175                "title": "Settings",
176                "description": "Pick settings",
177                "properties": {
178                    "email": { "type": "string", "title": "Email", "format": "email", "minLength": 3, "maxLength": 50, "default": "a@b.c" },
179                    "ratio": { "type": "number", "description": "Ratio", "minimum": 0.5, "maximum": 2.5, "default": 1.5 },
180                    "count": { "type": "integer", "minimum": 1, "maximum": 9, "default": 3 },
181                    "enabled": { "type": "boolean", "default": true },
182                    "color": { "type": "string", "enum": ["red", "blue"], "default": "red" },
183                    "size": { "type": "string", "oneOf": [{ "const": "s", "title": "Small" }, { "const": "l", "title": "Large" }] },
184                    "tags": { "type": "array", "items": { "type": "string", "enum": ["a", "b"] }, "minItems": 1, "maxItems": 2, "default": ["a"] },
185                    "labels": { "type": "array", "items": { "anyOf": [{ "const": "x", "title": "X" }] } },
186                    "legacy": { "type": "string", "oneOf": [{ "const": "p", "title": "P" }, { "const": "q", "title": "Q" }] }
187                },
188                "required": ["count", "email"]
189            })
190        );
191    }
192
193    #[test]
194    fn legacy_enum_names_convert_to_titled_acp_options() {
195        let mut legacy = rmcp::model::LegacyEnumSchema::new(vec!["small".into(), "large".into()]);
196        legacy.enum_names = Some(vec!["Small".into(), "Large".into()]);
197        legacy.default = Some("large".into());
198        let request = ElicitRequestParams::FormElicitationParams {
199            meta: None,
200            message: "Pick a size".to_string(),
201            requested_schema: rmcp::model::ElicitationSchema::builder()
202                .required_property(
203                    "size",
204                    rmcp::model::PrimitiveSchemaDefinition::Enum(rmcp::model::EnumSchema::Legacy(legacy)),
205                )
206                .build()
207                .unwrap(),
208        };
209
210        let converted = map_mcp_elicitation_request_to_acp("catalog", &SessionId::new("session-1"), &request).unwrap();
211        let ElicitationMode::Form(form) = converted.mode else { panic!("expected form mode") };
212        let Some(ElicitationPropertySchema::String(size)) = form.requested_schema.properties.get("size") else {
213            panic!("expected string property")
214        };
215
216        assert_eq!(size.default.as_deref(), Some("large"));
217        assert_eq!(
218            size.one_of
219                .as_ref()
220                .unwrap()
221                .iter()
222                .map(|option| (option.value.as_str(), option.title.as_str()))
223                .collect::<Vec<_>>(),
224            vec![("small", "Small"), ("large", "Large")]
225        );
226    }
227
228    #[test]
229    fn legacy_enum_with_mismatched_titles_is_rejected() {
230        let mut legacy = rmcp::model::LegacyEnumSchema::new(vec!["small".into(), "large".into()]);
231        legacy.enum_names = Some(vec!["Small".into()]);
232        let request = ElicitRequestParams::FormElicitationParams {
233            meta: None,
234            message: "Pick a size".to_string(),
235            requested_schema: rmcp::model::ElicitationSchema::builder()
236                .required_property(
237                    "size",
238                    rmcp::model::PrimitiveSchemaDefinition::Enum(rmcp::model::EnumSchema::Legacy(legacy)),
239                )
240                .build()
241                .unwrap(),
242        };
243
244        let error = map_mcp_elicitation_request_to_acp("catalog", &SessionId::new("session-1"), &request).unwrap_err();
245
246        assert!(matches!(error, ElicitationConversionError::MismatchedEnumTitles), "{error}");
247    }
248
249    #[test]
250    fn url_ids_are_namespaced_per_session_and_mcp_server() {
251        let request = ElicitRequestParams::UrlElicitationParams {
252            meta: None,
253            message: "Authorize".to_string(),
254            url: "https://example.com/oauth".to_string(),
255            elicitation_id: "oauth".to_string(),
256        };
257
258        let alpha = map_mcp_elicitation_request_to_acp("alpha", &SessionId::new("session-1"), &request).unwrap();
259        let bravo = map_mcp_elicitation_request_to_acp("bravo", &SessionId::new("session-1"), &request).unwrap();
260        let other_session =
261            map_mcp_elicitation_request_to_acp("alpha", &SessionId::new("session-2"), &request).unwrap();
262        let ElicitationMode::Url(alpha_url) = alpha.mode else { panic!("expected URL mode") };
263        let ElicitationMode::Url(bravo_url) = bravo.mode else { panic!("expected URL mode") };
264        let ElicitationMode::Url(other_session_url) = other_session.mode else { panic!("expected URL mode") };
265
266        assert_ne!(alpha_url.elicitation_id, bravo_url.elicitation_id);
267        assert_ne!(alpha_url.elicitation_id, other_session_url.elicitation_id);
268        assert_eq!(
269            build_acp_elicitation_completion_notification(&SessionId::new("session-1"), "alpha", "oauth")
270                .elicitation_id,
271            alpha_url.elicitation_id
272        );
273        assert_eq!(alpha_url.url, "https://example.com/oauth");
274    }
275
276    #[test]
277    fn url_id_namespacing_is_unambiguous() {
278        let first = build_acp_elicitation_completion_notification(&SessionId::new("session"), "alpha:bravo", "oauth");
279        let second = build_acp_elicitation_completion_notification(&SessionId::new("session"), "alpha", "bravo:oauth");
280
281        assert_ne!(first, second);
282    }
283
284    #[test]
285    fn responses_convert_explicitly_and_unknown_actions_fail() {
286        let accept = CreateElicitationResponse::new(acp::ElicitationAcceptAction::new().content(
287            std::collections::BTreeMap::from([
288                ("approved".to_string(), acp::ElicitationContentValue::Boolean(true)),
289                ("count".to_string(), acp::ElicitationContentValue::Integer(3)),
290                ("name".to_string(), acp::ElicitationContentValue::String("Ada".to_string())),
291                (
292                    "tags".to_string(),
293                    acp::ElicitationContentValue::StringArray(vec!["rust".to_string(), "acp".to_string()]),
294                ),
295            ]),
296        ))
297        .meta(Map::from_iter([("traceId".to_string(), json!("trace-1"))]));
298        let result = map_acp_elicitation_response_to_mcp(accept).unwrap();
299        assert_eq!(result.action, rmcp::model::ElicitationAction::Accept);
300        assert_eq!(
301            result.content,
302            Some(json!({ "approved": true, "count": 3, "name": "Ada", "tags": ["rust", "acp"] }))
303        );
304        assert_eq!(result.meta.as_ref().and_then(|meta| meta.get("traceId")), Some(&json!("trace-1")));
305
306        let decline =
307            map_acp_elicitation_response_to_mcp(CreateElicitationResponse::new(ElicitationAction::Decline)).unwrap();
308        assert_eq!(decline.action, rmcp::model::ElicitationAction::Decline);
309
310        let unknown = CreateElicitationResponse::new(acp::OtherElicitationAction::new(
311            "_defer",
312            std::collections::BTreeMap::new(),
313        ));
314        assert!(map_acp_elicitation_response_to_mcp(unknown).is_err());
315    }
316}