1use serde_json::{json, Value as JsonValue};
21
22use crate::mcp_client_request::ClientRequestBus;
23use crate::schema::{elicitation_validate, elicitation_validate_schema, json_to_vm_value};
24use crate::stdlib::host::{dispatch_host_call_bridge, dispatch_mock_host_call};
25use crate::value::VmDictExt;
26use crate::value::{VmError, VmValue};
27
28pub use crate::mcp_client_request::{
29 current_bus, install_bus, ClientRequestBus as ElicitationBus, OutboundSender,
30};
31
32pub const ELICITATION_METHOD: &str = "elicitation/create";
34
35impl ClientRequestBus {
36 pub async fn elicit(
41 &self,
42 message: String,
43 requested_schema: JsonValue,
44 ) -> Result<VmValue, VmError> {
45 validate_requested_schema(&requested_schema)?;
46
47 let result = self
48 .request(
49 "elicit",
50 ELICITATION_METHOD,
51 json!({
52 "message": message,
53 "requestedSchema": requested_schema,
54 }),
55 "mcp_elicit",
56 )
57 .await?;
58 envelope_from_response(&result, &requested_schema)
59 }
60}
61
62fn validate_requested_schema(schema: &JsonValue) -> Result<(), VmError> {
68 let object = schema.as_object().ok_or_else(|| {
69 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
70 "mcp_elicit: requestedSchema must be a JSON object",
71 )))
72 })?;
73 match object.get("type").and_then(|value| value.as_str()) {
74 Some("object") => Ok(()),
75 Some(other) => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
76 format!("mcp_elicit: requestedSchema.type must be \"object\" (got {other:?})"),
77 )))),
78 None => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
79 "mcp_elicit: requestedSchema.type is required and must be \"object\"",
80 )))),
81 }
82}
83
84pub(crate) fn envelope_from_response(
88 result: &JsonValue,
89 requested_schema: &JsonValue,
90) -> Result<VmValue, VmError> {
91 let action = result
92 .get("action")
93 .and_then(|value| value.as_str())
94 .ok_or_else(|| {
95 VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
96 "mcp_elicit: client response missing 'action'",
97 )))
98 })?;
99 if !matches!(action, "accept" | "decline" | "cancel") {
100 return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
101 "mcp_elicit: client response action must be 'accept'/'decline'/'cancel' (got {action:?})"
102 )))));
103 }
104
105 let mut envelope: crate::value::DictMap = crate::value::DictMap::new();
106 envelope.put_str("action", action);
107
108 if action == "accept" {
109 let content = result
110 .get("content")
111 .cloned()
112 .unwrap_or(JsonValue::Object(Default::default()));
113 let validated = validate_accepted_content(&content, requested_schema)?;
114 envelope.insert(crate::value::intern_key("content"), validated);
115 }
116
117 Ok(VmValue::dict(envelope))
118}
119
120pub(crate) fn validate_accepted_content(
124 content: &JsonValue,
125 requested_schema: &JsonValue,
126) -> Result<VmValue, VmError> {
127 let canonical_schema = elicitation_validate_schema(&json_to_vm_value(requested_schema))
128 .map_err(|error| match error {
129 VmError::Thrown(VmValue::String(s)) => VmError::Thrown(VmValue::String(
130 arcstr::ArcStr::from(format!("mcp_elicit: invalid requestedSchema: {s}")),
131 )),
132 other => other,
133 })?;
134 let content_vm = json_to_vm_value(content);
135 elicitation_validate(&content_vm, &canonical_schema).map_err(|error| match error {
136 VmError::Thrown(VmValue::String(s)) => VmError::Thrown(VmValue::String(
137 arcstr::ArcStr::from(format!("mcp_elicit: content failed schema validation: {s}")),
138 )),
139 other => other,
140 })
141}
142
143pub(crate) async fn dispatch_inbound_elicitation(
153 server_name: &str,
154 request: &JsonValue,
155 fixtures: Option<&crate::harness::CapabilityFixtureState>,
156) -> JsonValue {
157 let id = request.get("id").cloned().unwrap_or(JsonValue::Null);
158 let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
159 let message = params
160 .get("message")
161 .and_then(|value| value.as_str())
162 .unwrap_or("")
163 .to_string();
164 let requested_schema = params
165 .get("requestedSchema")
166 .cloned()
167 .unwrap_or_else(|| json!({}));
168
169 if let Some(session_id) = crate::llm::current_agent_session_id() {
175 crate::agent_events::emit_event(&crate::agent_events::AgentEvent::McpNotification {
176 session_id,
177 server: server_name.to_string(),
178 method: ELICITATION_METHOD.to_string(),
179 direction: "request".to_string(),
180 params,
181 });
182 }
183
184 let mut bridge_params: crate::value::DictMap = crate::value::DictMap::new();
188 bridge_params.put_str("server", server_name);
189 bridge_params.put_str("message", message.as_str());
190 bridge_params.insert(
191 crate::value::intern_key("requestedSchema"),
192 json_to_vm_value(&requested_schema),
193 );
194
195 let bridge_result = match fixtures
196 .and_then(|fixtures| fixtures.dispatch_host("mcp", "elicit", &bridge_params))
197 {
198 Some(result) => Some(result),
199 None => match dispatch_mock_host_call("mcp", "elicit", &bridge_params) {
200 Some(result) => Some(result),
201 None => dispatch_host_call_bridge("mcp", "elicit", &bridge_params).await,
202 },
203 };
204
205 let envelope_value: JsonValue = match bridge_result {
206 Some(Ok(value)) => crate::mcp::vm_value_to_serde(&value),
207 Some(Err(error)) => {
208 let detail = match error {
209 VmError::Thrown(VmValue::String(s)) => s.to_string(),
210 VmError::Thrown(other) => other.display(),
211 VmError::Runtime(s) | VmError::TypeError(s) => s,
212 other => format!("{other:?}"),
213 };
214 return crate::jsonrpc::error_response(id, -32000, &detail);
215 }
216 None => {
217 json!({ "action": "decline" })
219 }
220 };
221
222 let envelope = normalize_inbound_envelope(envelope_value);
227
228 if envelope.get("action").and_then(JsonValue::as_str) == Some("accept") {
231 if let Some(content) = envelope.get("content") {
232 if let Err(error) = validate_accepted_content(content, &requested_schema) {
233 let detail = match error {
234 VmError::Thrown(VmValue::String(s)) => s.to_string(),
235 other => format!("{other:?}"),
236 };
237 return crate::jsonrpc::error_response(id, -32602, &detail);
238 }
239 }
240 }
241
242 crate::jsonrpc::response(id, envelope)
243}
244
245fn normalize_inbound_envelope(value: JsonValue) -> JsonValue {
246 let object = match value {
247 JsonValue::Object(map) => map,
248 JsonValue::Null => return json!({ "action": "decline" }),
249 other => {
250 return json!({ "action": "accept", "content": other });
252 }
253 };
254
255 if object.contains_key("action") {
256 return JsonValue::Object(object);
257 }
258 let mut out = serde_json::Map::new();
260 if object.is_empty() {
261 out.insert("action".into(), JsonValue::String("decline".into()));
262 } else {
263 out.insert("action".into(), JsonValue::String("accept".into()));
264 out.insert("content".into(), JsonValue::Object(object));
265 }
266 JsonValue::Object(out)
267}
268
269#[cfg(test)]
270mod tests {
271 use tokio::sync::mpsc;
272
273 use super::*;
274
275 #[test]
276 fn validate_requested_schema_rejects_non_object() {
277 assert!(validate_requested_schema(&json!({"type": "string"})).is_err());
278 assert!(validate_requested_schema(&json!("not an object")).is_err());
279 }
280
281 #[test]
282 fn validate_requested_schema_accepts_object() {
283 assert!(validate_requested_schema(&json!({"type": "object"})).is_ok());
284 }
285
286 #[test]
287 fn envelope_from_response_decline_omits_content() {
288 let envelope =
289 envelope_from_response(&json!({"action": "decline"}), &json!({"type": "object"}))
290 .unwrap();
291 let dict = envelope.as_dict().unwrap();
292 assert_eq!(dict.get("action").unwrap().display(), "decline");
293 assert!(dict.get("content").is_none());
294 }
295
296 #[test]
297 fn envelope_from_response_accept_validates_content() {
298 let schema = json!({
299 "type": "object",
300 "properties": {"choice": {"type": "string"}},
301 "required": ["choice"]
302 });
303 let envelope = envelope_from_response(
304 &json!({"action": "accept", "content": {"choice": "A"}}),
305 &schema,
306 )
307 .unwrap();
308 let dict = envelope.as_dict().unwrap();
309 let content = dict.get("content").unwrap().as_dict().unwrap();
310 assert_eq!(content.get("choice").unwrap().display(), "A");
311 }
312
313 #[test]
314 fn envelope_from_response_accept_rejects_invalid_content() {
315 let schema = json!({
316 "type": "object",
317 "properties": {"choice": {"type": "string"}},
318 "required": ["choice"]
319 });
320 let result = envelope_from_response(
321 &json!({"action": "accept", "content": {"choice": 7}}),
322 &schema,
323 );
324 assert!(result.is_err());
325 }
326
327 #[test]
328 fn envelope_from_response_rejects_unknown_action() {
329 let result = envelope_from_response(&json!({"action": "wat"}), &json!({"type": "object"}));
330 assert!(result.is_err());
331 }
332
333 #[test]
334 fn route_response_returns_false_for_request() {
335 let (tx, _rx) = mpsc::unbounded_channel();
336 let bus = ElicitationBus::new(tx);
337 assert!(!bus.route_response(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})));
338 assert!(
339 !bus.route_response(&json!({"jsonrpc": "2.0", "method": "notifications/cancelled"}))
340 );
341 }
342
343 #[test]
344 fn route_response_ignores_unknown_id() {
345 let (tx, _rx) = mpsc::unbounded_channel();
346 let bus = ElicitationBus::new(tx);
347 assert!(!bus.route_response(&json!({"jsonrpc": "2.0", "id": "ghost", "result": {}})));
348 }
349
350 #[test]
351 fn normalize_inbound_envelope_passes_action_through() {
352 let v = normalize_inbound_envelope(json!({"action": "decline"}));
353 assert_eq!(v["action"], json!("decline"));
354 }
355
356 #[test]
357 fn normalize_inbound_envelope_synthesizes_accept_for_bare_dict() {
358 let v = normalize_inbound_envelope(json!({"choice": "A"}));
359 assert_eq!(v["action"], json!("accept"));
360 assert_eq!(v["content"]["choice"], json!("A"));
361 }
362
363 #[test]
364 fn normalize_inbound_envelope_decline_for_null() {
365 let v = normalize_inbound_envelope(JsonValue::Null);
366 assert_eq!(v["action"], json!("decline"));
367 }
368
369 #[tokio::test]
370 async fn elicit_round_trip_validates_accept() {
371 let (tx, mut rx) = mpsc::unbounded_channel();
372 let bus = ElicitationBus::new(tx);
373 let bus_for_responder = bus.clone();
374 tokio::spawn(async move {
375 let outbound = rx.recv().await.expect("elicit request emitted");
376 let id = outbound["id"].clone();
377 assert_eq!(outbound["method"], json!(ELICITATION_METHOD));
378 let response = json!({
379 "jsonrpc": "2.0",
380 "id": id,
381 "result": {"action": "accept", "content": {"choice": "A"}}
382 });
383 assert!(bus_for_responder.route_response(&response));
384 });
385 let result = bus
386 .elicit(
387 "Pick one".to_string(),
388 json!({
389 "type": "object",
390 "properties": {"choice": {"type": "string"}},
391 "required": ["choice"],
392 }),
393 )
394 .await
395 .expect("elicit succeeds");
396 let dict = result.as_dict().unwrap();
397 assert_eq!(dict.get("action").unwrap().display(), "accept");
398 }
399
400 #[tokio::test]
401 async fn elicit_propagates_jsonrpc_error_from_client() {
402 let (tx, mut rx) = mpsc::unbounded_channel();
403 let bus = ElicitationBus::new(tx);
404 let bus_for_responder = bus.clone();
405 tokio::spawn(async move {
406 let outbound = rx.recv().await.expect("elicit request emitted");
407 let id = outbound["id"].clone();
408 let response = json!({
409 "jsonrpc": "2.0",
410 "id": id,
411 "error": {"code": -32601, "message": "client refused"}
412 });
413 assert!(bus_for_responder.route_response(&response));
414 });
415 let result = bus
416 .elicit("Pick one".to_string(), json!({"type": "object"}))
417 .await;
418 let err = result.expect_err("error is propagated");
419 let message = match err {
420 VmError::Thrown(VmValue::String(s)) => s.to_string(),
421 other => format!("{other:?}"),
422 };
423 assert!(message.contains("client refused"), "got: {message}");
424 }
425}