Skip to main content

acp_utils/
elicitation.rs

1use crate::notifications::AETHER_META_NAMESPACE;
2use agent_client_protocol::schema::v1::{
3    self as acp, CompleteElicitationNotification, CreateElicitationRequest, CreateElicitationResponse, Meta, SessionId,
4};
5use rmcp::model::{self as mcp, ElicitRequestParams, ElicitResult};
6use serde_json::{Map, Number, 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("ACP elicitation response contains a non-finite number")]
15    NonFiniteNumber,
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(map_acp_elicitation_content_to_mcp).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 required = schema.required.as_deref().unwrap_or_default();
85    schema.properties.iter().try_fold(
86        acp::ElicitationSchema::new()
87            .title(owned_string(schema.title.as_deref()))
88            .description(owned_string(schema.description.as_deref())),
89        |converted, (name, property)| {
90            Ok(converted.property(name, map_mcp_primitive_schema_to_acp(property)?, required.contains(name)))
91        },
92    )
93}
94
95fn map_mcp_primitive_schema_to_acp(
96    property: &mcp::PrimitiveSchemaDefinition,
97) -> Result<acp::ElicitationPropertySchema, ElicitationConversionError> {
98    match property {
99        mcp::PrimitiveSchemaDefinition::String(schema) => Ok(map_mcp_string_schema_to_acp(schema)?.into()),
100        mcp::PrimitiveSchemaDefinition::Number(schema) => Ok(acp::NumberPropertySchema::new()
101            .title(owned_string(schema.title.as_deref()))
102            .description(owned_string(schema.description.as_deref()))
103            .minimum(schema.minimum)
104            .maximum(schema.maximum)
105            .default_value(schema.default)
106            .into()),
107        mcp::PrimitiveSchemaDefinition::Integer(schema) => Ok(acp::IntegerPropertySchema::new()
108            .title(owned_string(schema.title.as_deref()))
109            .description(owned_string(schema.description.as_deref()))
110            .minimum(schema.minimum)
111            .maximum(schema.maximum)
112            .default_value(schema.default)
113            .into()),
114        mcp::PrimitiveSchemaDefinition::Boolean(schema) => Ok(acp::BooleanPropertySchema::new()
115            .title(owned_string(schema.title.as_deref()))
116            .description(owned_string(schema.description.as_deref()))
117            .default_value(schema.default)
118            .into()),
119        mcp::PrimitiveSchemaDefinition::Enum(schema) => map_mcp_enum_schema_to_acp(schema),
120        _ => Err(ElicitationConversionError::Unsupported("MCP elicitation schema variant")),
121    }
122}
123
124fn map_mcp_string_schema_to_acp(
125    schema: &mcp::StringSchema,
126) -> Result<acp::StringPropertySchema, ElicitationConversionError> {
127    let mut converted = acp::StringPropertySchema::new()
128        .title(owned_string(schema.title.as_deref()))
129        .description(owned_string(schema.description.as_deref()))
130        .min_length(schema.min_length)
131        .max_length(schema.max_length)
132        .default_value(schema.default.clone());
133    if let Some(format) = schema.format {
134        converted = converted.format(map_mcp_string_format_to_acp(format)?);
135    }
136    Ok(converted)
137}
138
139fn map_mcp_string_format_to_acp(format: mcp::StringFormat) -> Result<acp::StringFormat, ElicitationConversionError> {
140    match format {
141        mcp::StringFormat::Email => Ok(acp::StringFormat::Email),
142        mcp::StringFormat::Uri => Ok(acp::StringFormat::Uri),
143        mcp::StringFormat::Date => Ok(acp::StringFormat::Date),
144        mcp::StringFormat::DateTime => Ok(acp::StringFormat::DateTime),
145        _ => Err(ElicitationConversionError::Unsupported("MCP elicitation string format")),
146    }
147}
148
149fn map_mcp_enum_schema_to_acp(
150    schema: &mcp::EnumSchema,
151) -> Result<acp::ElicitationPropertySchema, ElicitationConversionError> {
152    match schema {
153        mcp::EnumSchema::Single(mcp::SingleSelectEnumSchema::Untitled(schema)) => Ok(build_acp_single_select_schema(
154            schema.title.as_deref(),
155            schema.description.as_deref(),
156            schema.default.clone(),
157        )
158        .enum_values(schema.enum_.clone())
159        .into()),
160        mcp::EnumSchema::Single(mcp::SingleSelectEnumSchema::Titled(schema)) => Ok(build_acp_single_select_schema(
161            schema.title.as_deref(),
162            schema.description.as_deref(),
163            schema.default.clone(),
164        )
165        .one_of(map_mcp_enum_options_to_acp(&schema.one_of))
166        .into()),
167        mcp::EnumSchema::Multi(mcp::MultiSelectEnumSchema::Untitled(schema)) => {
168            Ok(acp::MultiSelectPropertySchema::new(schema.items.enum_.clone())
169                .title(owned_string(schema.title.as_deref()))
170                .description(owned_string(schema.description.as_deref()))
171                .min_items(schema.min_items)
172                .max_items(schema.max_items)
173                .default_value(schema.default.clone())
174                .into())
175        }
176        mcp::EnumSchema::Multi(mcp::MultiSelectEnumSchema::Titled(schema)) => {
177            Ok(acp::MultiSelectPropertySchema::titled(map_mcp_enum_options_to_acp(&schema.items.any_of))
178                .title(owned_string(schema.title.as_deref()))
179                .description(owned_string(schema.description.as_deref()))
180                .min_items(schema.min_items)
181                .max_items(schema.max_items)
182                .default_value(schema.default.clone())
183                .into())
184        }
185        mcp::EnumSchema::Legacy(schema) => {
186            let (enum_values, one_of) = match &schema.enum_names {
187                Some(titles) if titles.len() == schema.enum_.len() => (
188                    None,
189                    Some(
190                        schema
191                            .enum_
192                            .iter()
193                            .zip(titles)
194                            .map(|(value, title)| acp::EnumOption::new(value, title))
195                            .collect::<Vec<_>>(),
196                    ),
197                ),
198                Some(_) => return Err(ElicitationConversionError::MismatchedEnumTitles),
199                None => (Some(schema.enum_.clone()), None),
200            };
201            Ok(build_acp_single_select_schema(
202                schema.title.as_deref(),
203                schema.description.as_deref(),
204                schema.default.clone(),
205            )
206            .enum_values(enum_values)
207            .one_of(one_of)
208            .into())
209        }
210        _ => Err(ElicitationConversionError::Unsupported("MCP elicitation schema variant")),
211    }
212}
213
214fn build_acp_single_select_schema(
215    title: Option<&str>,
216    description: Option<&str>,
217    default: Option<String>,
218) -> acp::StringPropertySchema {
219    acp::StringPropertySchema::new()
220        .title(title.map(str::to_owned))
221        .description(description.map(str::to_owned))
222        .default_value(default)
223}
224
225fn map_mcp_enum_options_to_acp(options: &[mcp::ConstTitle]) -> Vec<acp::EnumOption> {
226    options.iter().map(|option| acp::EnumOption::new(&option.const_, &option.title)).collect()
227}
228
229fn map_mcp_meta_to_acp(meta: Option<&mcp::RequestMetaObject>, server_name: &str) -> Meta {
230    let mut meta: Meta =
231        meta.map(|meta| meta.iter().map(|(key, value)| (key.clone(), value.clone())).collect()).unwrap_or_default();
232    let source = meta.entry(AETHER_META_NAMESPACE.to_string()).or_insert_with(|| json!({}));
233    if !source.is_object() {
234        *source = json!({});
235    }
236    source["mcpServer"] = json!(server_name);
237    meta
238}
239
240fn map_acp_elicitation_content_to_mcp(
241    content: std::collections::BTreeMap<String, acp::ElicitationContentValue>,
242) -> Result<Value, ElicitationConversionError> {
243    Ok(Value::Object(
244        content
245            .into_iter()
246            .map(|(name, value)| map_acp_elicitation_content_value_to_mcp(value).map(|value| (name, value)))
247            .collect::<Result<Map<_, _>, _>>()?,
248    ))
249}
250
251fn map_acp_elicitation_content_value_to_mcp(
252    value: acp::ElicitationContentValue,
253) -> Result<Value, ElicitationConversionError> {
254    match value {
255        acp::ElicitationContentValue::String(value) => Ok(Value::String(value)),
256        acp::ElicitationContentValue::Integer(value) => Ok(Value::Number(value.into())),
257        acp::ElicitationContentValue::Number(value) => {
258            Number::from_f64(value).map(Value::Number).ok_or(ElicitationConversionError::NonFiniteNumber)
259        }
260        acp::ElicitationContentValue::Boolean(value) => Ok(Value::Bool(value)),
261        acp::ElicitationContentValue::StringArray(values) => {
262            Ok(Value::Array(values.into_iter().map(Value::String).collect()))
263        }
264        _ => Err(ElicitationConversionError::Unsupported("ACP elicitation response content")),
265    }
266}
267
268fn owned_string(value: Option<&str>) -> Option<String> {
269    value.map(str::to_owned)
270}
271
272fn build_scoped_elicitation_id(session_id: &SessionId, server_name: &str, elicitation_id: &str) -> String {
273    json!([session_id.0.as_ref(), server_name, elicitation_id]).to_string()
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use agent_client_protocol::schema::v1::{
280        ElicitationAction, ElicitationMode, ElicitationPropertySchema, ElicitationScope,
281    };
282
283    #[test]
284    fn form_request_converts_with_session_scope_and_source_server() {
285        let request = ElicitRequestParams::FormElicitationParams {
286            meta: Some(mcp::RequestMetaObject::from(Map::from_iter([("ui".to_string(), json!("planReview"))]))),
287            message: "Review the plan".to_string(),
288            requested_schema: rmcp::model::ElicitationSchema::builder().required_bool("approved").build().unwrap(),
289        };
290
291        let converted = map_mcp_elicitation_request_to_acp("plan", &SessionId::new("session-1"), &request).unwrap();
292
293        let ElicitationMode::Form(form) = converted.mode else { panic!("expected form mode") };
294        let ElicitationScope::Session(scope) = form.scope else { panic!("expected session scope") };
295        assert_eq!(&*scope.session_id.0, "session-1");
296        assert!(form.requested_schema.properties.contains_key("approved"));
297        assert_eq!(converted.message, "Review the plan");
298        assert_eq!(converted.meta.as_ref().and_then(|meta| meta.get("ui")), Some(&json!("planReview")));
299        assert_eq!(source_mcp_server_name(converted.meta.as_ref()), Some("plan"));
300    }
301
302    #[test]
303    fn legacy_enum_names_convert_to_titled_acp_options() {
304        let mut legacy = rmcp::model::LegacyEnumSchema::new(vec!["small".into(), "large".into()]);
305        legacy.enum_names = Some(vec!["Small".into(), "Large".into()]);
306        legacy.default = Some("large".into());
307        let request = ElicitRequestParams::FormElicitationParams {
308            meta: None,
309            message: "Pick a size".to_string(),
310            requested_schema: rmcp::model::ElicitationSchema::builder()
311                .required_property(
312                    "size",
313                    rmcp::model::PrimitiveSchemaDefinition::Enum(rmcp::model::EnumSchema::Legacy(legacy)),
314                )
315                .build()
316                .unwrap(),
317        };
318
319        let converted = map_mcp_elicitation_request_to_acp("catalog", &SessionId::new("session-1"), &request).unwrap();
320        let ElicitationMode::Form(form) = converted.mode else { panic!("expected form mode") };
321        let Some(ElicitationPropertySchema::String(size)) = form.requested_schema.properties.get("size") else {
322            panic!("expected string property")
323        };
324
325        assert_eq!(size.default.as_deref(), Some("large"));
326        assert_eq!(
327            size.one_of
328                .as_ref()
329                .unwrap()
330                .iter()
331                .map(|option| (option.value.as_str(), option.title.as_str()))
332                .collect::<Vec<_>>(),
333            vec![("small", "Small"), ("large", "Large")]
334        );
335    }
336
337    #[test]
338    fn url_ids_are_namespaced_per_session_and_mcp_server() {
339        let request = ElicitRequestParams::UrlElicitationParams {
340            meta: None,
341            message: "Authorize".to_string(),
342            url: "https://example.com/oauth".to_string(),
343            elicitation_id: "oauth".to_string(),
344        };
345
346        let alpha = map_mcp_elicitation_request_to_acp("alpha", &SessionId::new("session-1"), &request).unwrap();
347        let bravo = map_mcp_elicitation_request_to_acp("bravo", &SessionId::new("session-1"), &request).unwrap();
348        let other_session =
349            map_mcp_elicitation_request_to_acp("alpha", &SessionId::new("session-2"), &request).unwrap();
350        let ElicitationMode::Url(alpha_url) = alpha.mode else { panic!("expected URL mode") };
351        let ElicitationMode::Url(bravo_url) = bravo.mode else { panic!("expected URL mode") };
352        let ElicitationMode::Url(other_session_url) = other_session.mode else { panic!("expected URL mode") };
353
354        assert_ne!(alpha_url.elicitation_id, bravo_url.elicitation_id);
355        assert_ne!(alpha_url.elicitation_id, other_session_url.elicitation_id);
356        assert_eq!(
357            build_acp_elicitation_completion_notification(&SessionId::new("session-1"), "alpha", "oauth")
358                .elicitation_id,
359            alpha_url.elicitation_id
360        );
361        assert_eq!(alpha_url.url, "https://example.com/oauth");
362    }
363
364    #[test]
365    fn url_id_namespacing_is_unambiguous() {
366        let first = build_scoped_elicitation_id(&SessionId::new("session"), "alpha:bravo", "oauth");
367        let second = build_scoped_elicitation_id(&SessionId::new("session"), "alpha", "bravo:oauth");
368
369        assert_ne!(first, second);
370    }
371
372    #[test]
373    fn responses_convert_explicitly_and_unknown_actions_fail() {
374        let accept = CreateElicitationResponse::new(acp::ElicitationAcceptAction::new().content(
375            std::collections::BTreeMap::from([
376                ("approved".to_string(), acp::ElicitationContentValue::Boolean(true)),
377                ("count".to_string(), acp::ElicitationContentValue::Integer(3)),
378                ("name".to_string(), acp::ElicitationContentValue::String("Ada".to_string())),
379                (
380                    "tags".to_string(),
381                    acp::ElicitationContentValue::StringArray(vec!["rust".to_string(), "acp".to_string()]),
382                ),
383            ]),
384        ))
385        .meta(Map::from_iter([("traceId".to_string(), json!("trace-1"))]));
386        let result = map_acp_elicitation_response_to_mcp(accept).unwrap();
387        assert_eq!(result.action, rmcp::model::ElicitationAction::Accept);
388        assert_eq!(
389            result.content,
390            Some(json!({ "approved": true, "count": 3, "name": "Ada", "tags": ["rust", "acp"] }))
391        );
392        assert_eq!(result.meta.as_ref().and_then(|meta| meta.get("traceId")), Some(&json!("trace-1")));
393
394        let decline =
395            map_acp_elicitation_response_to_mcp(CreateElicitationResponse::new(ElicitationAction::Decline)).unwrap();
396        assert_eq!(decline.action, rmcp::model::ElicitationAction::Decline);
397
398        let unknown = CreateElicitationResponse::new(acp::OtherElicitationAction::new(
399            "_defer",
400            std::collections::BTreeMap::new(),
401        ));
402        assert!(map_acp_elicitation_response_to_mcp(unknown).is_err());
403    }
404}