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) -> JsonValue {
156 let id = request.get("id").cloned().unwrap_or(JsonValue::Null);
157 let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
158 let message = params
159 .get("message")
160 .and_then(|value| value.as_str())
161 .unwrap_or("")
162 .to_string();
163 let requested_schema = params
164 .get("requestedSchema")
165 .cloned()
166 .unwrap_or_else(|| json!({}));
167
168 if let Some(session_id) = crate::llm::current_agent_session_id() {
174 crate::agent_events::emit_event(&crate::agent_events::AgentEvent::McpNotification {
175 session_id,
176 server: server_name.to_string(),
177 method: ELICITATION_METHOD.to_string(),
178 direction: "request".to_string(),
179 params,
180 });
181 }
182
183 let mut bridge_params: crate::value::DictMap = crate::value::DictMap::new();
187 bridge_params.put_str("server", server_name);
188 bridge_params.put_str("message", message.as_str());
189 bridge_params.insert(
190 crate::value::intern_key("requestedSchema"),
191 json_to_vm_value(&requested_schema),
192 );
193
194 let bridge_result = dispatch_mock_host_call("mcp", "elicit", &bridge_params)
195 .or_else(|| dispatch_host_call_bridge("mcp", "elicit", &bridge_params));
196
197 let envelope_value: JsonValue = match bridge_result {
198 Some(Ok(value)) => crate::mcp::vm_value_to_serde(&value),
199 Some(Err(error)) => {
200 let detail = match error {
201 VmError::Thrown(VmValue::String(s)) => s.to_string(),
202 VmError::Thrown(other) => other.display(),
203 VmError::Runtime(s) | VmError::TypeError(s) => s,
204 other => format!("{other:?}"),
205 };
206 return crate::jsonrpc::error_response(id, -32000, &detail);
207 }
208 None => {
209 json!({ "action": "decline" })
211 }
212 };
213
214 let envelope = normalize_inbound_envelope(envelope_value);
219
220 if envelope.get("action").and_then(JsonValue::as_str) == Some("accept") {
223 if let Some(content) = envelope.get("content") {
224 if let Err(error) = validate_accepted_content(content, &requested_schema) {
225 let detail = match error {
226 VmError::Thrown(VmValue::String(s)) => s.to_string(),
227 other => format!("{other:?}"),
228 };
229 return crate::jsonrpc::error_response(id, -32602, &detail);
230 }
231 }
232 }
233
234 crate::jsonrpc::response(id, envelope)
235}
236
237fn normalize_inbound_envelope(value: JsonValue) -> JsonValue {
238 let object = match value {
239 JsonValue::Object(map) => map,
240 JsonValue::Null => return json!({ "action": "decline" }),
241 other => {
242 return json!({ "action": "accept", "content": other });
244 }
245 };
246
247 if object.contains_key("action") {
248 return JsonValue::Object(object);
249 }
250 let mut out = serde_json::Map::new();
252 if object.is_empty() {
253 out.insert("action".into(), JsonValue::String("decline".into()));
254 } else {
255 out.insert("action".into(), JsonValue::String("accept".into()));
256 out.insert("content".into(), JsonValue::Object(object));
257 }
258 JsonValue::Object(out)
259}
260
261#[cfg(test)]
262mod tests {
263 use tokio::sync::mpsc;
264
265 use super::*;
266
267 #[test]
268 fn validate_requested_schema_rejects_non_object() {
269 assert!(validate_requested_schema(&json!({"type": "string"})).is_err());
270 assert!(validate_requested_schema(&json!("not an object")).is_err());
271 }
272
273 #[test]
274 fn validate_requested_schema_accepts_object() {
275 assert!(validate_requested_schema(&json!({"type": "object"})).is_ok());
276 }
277
278 #[test]
279 fn envelope_from_response_decline_omits_content() {
280 let envelope =
281 envelope_from_response(&json!({"action": "decline"}), &json!({"type": "object"}))
282 .unwrap();
283 let dict = envelope.as_dict().unwrap();
284 assert_eq!(dict.get("action").unwrap().display(), "decline");
285 assert!(dict.get("content").is_none());
286 }
287
288 #[test]
289 fn envelope_from_response_accept_validates_content() {
290 let schema = json!({
291 "type": "object",
292 "properties": {"choice": {"type": "string"}},
293 "required": ["choice"]
294 });
295 let envelope = envelope_from_response(
296 &json!({"action": "accept", "content": {"choice": "A"}}),
297 &schema,
298 )
299 .unwrap();
300 let dict = envelope.as_dict().unwrap();
301 let content = dict.get("content").unwrap().as_dict().unwrap();
302 assert_eq!(content.get("choice").unwrap().display(), "A");
303 }
304
305 #[test]
306 fn envelope_from_response_accept_rejects_invalid_content() {
307 let schema = json!({
308 "type": "object",
309 "properties": {"choice": {"type": "string"}},
310 "required": ["choice"]
311 });
312 let result = envelope_from_response(
313 &json!({"action": "accept", "content": {"choice": 7}}),
314 &schema,
315 );
316 assert!(result.is_err());
317 }
318
319 #[test]
320 fn envelope_from_response_rejects_unknown_action() {
321 let result = envelope_from_response(&json!({"action": "wat"}), &json!({"type": "object"}));
322 assert!(result.is_err());
323 }
324
325 #[test]
326 fn route_response_returns_false_for_request() {
327 let (tx, _rx) = mpsc::unbounded_channel();
328 let bus = ElicitationBus::new(tx);
329 assert!(!bus.route_response(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})));
330 assert!(
331 !bus.route_response(&json!({"jsonrpc": "2.0", "method": "notifications/cancelled"}))
332 );
333 }
334
335 #[test]
336 fn route_response_ignores_unknown_id() {
337 let (tx, _rx) = mpsc::unbounded_channel();
338 let bus = ElicitationBus::new(tx);
339 assert!(!bus.route_response(&json!({"jsonrpc": "2.0", "id": "ghost", "result": {}})));
340 }
341
342 #[test]
343 fn normalize_inbound_envelope_passes_action_through() {
344 let v = normalize_inbound_envelope(json!({"action": "decline"}));
345 assert_eq!(v["action"], json!("decline"));
346 }
347
348 #[test]
349 fn normalize_inbound_envelope_synthesizes_accept_for_bare_dict() {
350 let v = normalize_inbound_envelope(json!({"choice": "A"}));
351 assert_eq!(v["action"], json!("accept"));
352 assert_eq!(v["content"]["choice"], json!("A"));
353 }
354
355 #[test]
356 fn normalize_inbound_envelope_decline_for_null() {
357 let v = normalize_inbound_envelope(JsonValue::Null);
358 assert_eq!(v["action"], json!("decline"));
359 }
360
361 #[tokio::test]
362 async fn elicit_round_trip_validates_accept() {
363 let (tx, mut rx) = mpsc::unbounded_channel();
364 let bus = ElicitationBus::new(tx);
365 let bus_for_responder = bus.clone();
366 tokio::spawn(async move {
367 let outbound = rx.recv().await.expect("elicit request emitted");
368 let id = outbound["id"].clone();
369 assert_eq!(outbound["method"], json!(ELICITATION_METHOD));
370 let response = json!({
371 "jsonrpc": "2.0",
372 "id": id,
373 "result": {"action": "accept", "content": {"choice": "A"}}
374 });
375 assert!(bus_for_responder.route_response(&response));
376 });
377 let result = bus
378 .elicit(
379 "Pick one".to_string(),
380 json!({
381 "type": "object",
382 "properties": {"choice": {"type": "string"}},
383 "required": ["choice"],
384 }),
385 )
386 .await
387 .expect("elicit succeeds");
388 let dict = result.as_dict().unwrap();
389 assert_eq!(dict.get("action").unwrap().display(), "accept");
390 }
391
392 #[tokio::test]
393 async fn elicit_propagates_jsonrpc_error_from_client() {
394 let (tx, mut rx) = mpsc::unbounded_channel();
395 let bus = ElicitationBus::new(tx);
396 let bus_for_responder = bus.clone();
397 tokio::spawn(async move {
398 let outbound = rx.recv().await.expect("elicit request emitted");
399 let id = outbound["id"].clone();
400 let response = json!({
401 "jsonrpc": "2.0",
402 "id": id,
403 "error": {"code": -32601, "message": "client refused"}
404 });
405 assert!(bus_for_responder.route_response(&response));
406 });
407 let result = bus
408 .elicit("Pick one".to_string(), json!({"type": "object"}))
409 .await;
410 let err = result.expect_err("error is propagated");
411 let message = match err {
412 VmError::Thrown(VmValue::String(s)) => s.to_string(),
413 other => format!("{other:?}"),
414 };
415 assert!(message.contains("client refused"), "got: {message}");
416 }
417}