use serde_json::{json, Value as JsonValue};
use crate::schema::{elicitation_validate, elicitation_validate_schema, json_to_vm_value};
use crate::stdlib::host::{dispatch_host_call_bridge, dispatch_mock_host_call};
use crate::value::VmDictExt;
use crate::value::{VmError, VmValue};
pub const ELICITATION_METHOD: &str = "elicitation/create";
pub(crate) fn elicit_form(
message: String,
requested_schema: JsonValue,
) -> Result<VmValue, VmError> {
validate_requested_schema(&requested_schema)?;
let result = crate::mcp_input::request_input(
ELICITATION_METHOD,
json!({
"mode": "form",
"message": message,
"requestedSchema": requested_schema,
}),
"mcp_elicit",
)?;
envelope_from_response(&result, &requested_schema)
}
fn validate_requested_schema(schema: &JsonValue) -> Result<(), VmError> {
let object = schema.as_object().ok_or_else(|| {
VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
"mcp_elicit: requestedSchema must be a JSON object",
)))
})?;
match object.get("type").and_then(|value| value.as_str()) {
Some("object") => Ok(()),
Some(other) => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
format!("mcp_elicit: requestedSchema.type must be \"object\" (got {other:?})"),
)))),
None => Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
"mcp_elicit: requestedSchema.type is required and must be \"object\"",
)))),
}
}
pub(crate) fn envelope_from_response(
result: &JsonValue,
requested_schema: &JsonValue,
) -> Result<VmValue, VmError> {
let action = result
.get("action")
.and_then(|value| value.as_str())
.ok_or_else(|| {
VmError::Thrown(VmValue::String(arcstr::ArcStr::from(
"mcp_elicit: client response missing 'action'",
)))
})?;
if !matches!(action, "accept" | "decline" | "cancel") {
return Err(VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
"mcp_elicit: client response action must be 'accept'/'decline'/'cancel' (got {action:?})"
)))));
}
let mut envelope: crate::value::DictMap = crate::value::DictMap::new();
envelope.put_str("action", action);
if action == "accept" {
let content = result
.get("content")
.cloned()
.unwrap_or(JsonValue::Object(Default::default()));
let validated = validate_accepted_content(&content, requested_schema)?;
envelope.insert(crate::value::intern_key("content"), validated);
}
Ok(VmValue::dict(envelope))
}
pub(crate) fn validate_accepted_content(
content: &JsonValue,
requested_schema: &JsonValue,
) -> Result<VmValue, VmError> {
let canonical_schema = elicitation_validate_schema(&json_to_vm_value(requested_schema))
.map_err(|error| match error {
VmError::Thrown(VmValue::String(s)) => VmError::Thrown(VmValue::String(
arcstr::ArcStr::from(format!("mcp_elicit: invalid requestedSchema: {s}")),
)),
other => other,
})?;
let content_vm = json_to_vm_value(content);
elicitation_validate(&content_vm, &canonical_schema).map_err(|error| match error {
VmError::Thrown(VmValue::String(s)) => VmError::Thrown(VmValue::String(
arcstr::ArcStr::from(format!("mcp_elicit: content failed schema validation: {s}")),
)),
other => other,
})
}
pub(crate) async fn dispatch_inbound_elicitation(
server_name: &str,
request: &JsonValue,
fixtures: Option<&crate::harness::CapabilityFixtureState>,
) -> JsonValue {
let id = request.get("id").cloned().unwrap_or(JsonValue::Null);
let params = request.get("params").cloned().unwrap_or_else(|| json!({}));
let message = params
.get("message")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string();
let mode = params
.get("mode")
.and_then(JsonValue::as_str)
.unwrap_or("form")
.to_string();
let requested_schema = params
.get("requestedSchema")
.cloned()
.unwrap_or_else(|| json!({}));
if let Some(session_id) = crate::llm::current_agent_session_id() {
crate::agent_events::emit_event(&crate::agent_events::AgentEvent::McpNotification {
session_id,
server: server_name.to_string(),
method: ELICITATION_METHOD.to_string(),
direction: "request".to_string(),
params: params.clone(),
});
}
let mut bridge_params: crate::value::DictMap = crate::value::DictMap::new();
bridge_params.put_str("server", server_name);
bridge_params.put_str("mode", &mode);
bridge_params.put_str("message", message.as_str());
if mode == "form" {
bridge_params.insert(
crate::value::intern_key("requestedSchema"),
json_to_vm_value(&requested_schema),
);
}
if let Some(url) = params.get("url") {
bridge_params.insert(crate::value::intern_key("url"), json_to_vm_value(url));
}
if let Some(elicitation_id) = params.get("elicitationId") {
bridge_params.insert(
crate::value::intern_key("elicitationId"),
json_to_vm_value(elicitation_id),
);
}
let bridge_result = match fixtures
.and_then(|fixtures| fixtures.dispatch_host("mcp", "elicit", &bridge_params))
{
Some(result) => Some(result),
None => match dispatch_mock_host_call("mcp", "elicit", &bridge_params) {
Some(result) => Some(result),
None => dispatch_host_call_bridge("mcp", "elicit", &bridge_params).await,
},
};
let envelope_value: JsonValue = match bridge_result {
Some(Ok(value)) => crate::mcp::vm_value_to_serde(&value),
Some(Err(error)) => {
let detail = match error {
VmError::Thrown(VmValue::String(s)) => s.to_string(),
VmError::Thrown(other) => other.display(),
VmError::Runtime(s) | VmError::TypeError(s) => s,
other => format!("{other:?}"),
};
return crate::jsonrpc::error_response(id, -32000, &detail);
}
None => {
json!({ "action": "decline" })
}
};
let envelope = normalize_inbound_envelope(envelope_value);
if mode == "form" && envelope.get("action").and_then(JsonValue::as_str) == Some("accept") {
if let Some(content) = envelope.get("content") {
if let Err(error) = validate_accepted_content(content, &requested_schema) {
let detail = match error {
VmError::Thrown(VmValue::String(s)) => s.to_string(),
other => format!("{other:?}"),
};
return crate::jsonrpc::error_response(id, -32602, &detail);
}
}
}
crate::jsonrpc::response(id, envelope)
}
fn normalize_inbound_envelope(value: JsonValue) -> JsonValue {
let object = match value {
JsonValue::Object(map) => map,
JsonValue::Null => return json!({ "action": "decline" }),
other => {
return json!({ "action": "accept", "content": other });
}
};
if object.contains_key("action") {
return JsonValue::Object(object);
}
let mut out = serde_json::Map::new();
if object.is_empty() {
out.insert("action".into(), JsonValue::String("decline".into()));
} else {
out.insert("action".into(), JsonValue::String("accept".into()));
out.insert("content".into(), JsonValue::Object(object));
}
JsonValue::Object(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_requested_schema_rejects_non_object() {
assert!(validate_requested_schema(&json!({"type": "string"})).is_err());
assert!(validate_requested_schema(&json!("not an object")).is_err());
}
#[test]
fn validate_requested_schema_accepts_object() {
assert!(validate_requested_schema(&json!({"type": "object"})).is_ok());
}
#[test]
fn envelope_from_response_decline_omits_content() {
let envelope =
envelope_from_response(&json!({"action": "decline"}), &json!({"type": "object"}))
.unwrap();
let dict = envelope.as_dict().unwrap();
assert_eq!(dict.get("action").unwrap().display(), "decline");
assert!(dict.get("content").is_none());
}
#[test]
fn envelope_from_response_accept_validates_content() {
let schema = json!({
"type": "object",
"properties": {"choice": {"type": "string"}},
"required": ["choice"]
});
let envelope = envelope_from_response(
&json!({"action": "accept", "content": {"choice": "A"}}),
&schema,
)
.unwrap();
let dict = envelope.as_dict().unwrap();
let content = dict.get("content").unwrap().as_dict().unwrap();
assert_eq!(content.get("choice").unwrap().display(), "A");
}
#[test]
fn envelope_from_response_accept_rejects_invalid_content() {
let schema = json!({
"type": "object",
"properties": {"choice": {"type": "string"}},
"required": ["choice"]
});
let result = envelope_from_response(
&json!({"action": "accept", "content": {"choice": 7}}),
&schema,
);
assert!(result.is_err());
}
#[test]
fn envelope_from_response_rejects_unknown_action() {
let result = envelope_from_response(&json!({"action": "wat"}), &json!({"type": "object"}));
assert!(result.is_err());
}
#[test]
fn normalize_inbound_envelope_passes_action_through() {
let v = normalize_inbound_envelope(json!({"action": "decline"}));
assert_eq!(v["action"], json!("decline"));
}
#[test]
fn normalize_inbound_envelope_synthesizes_accept_for_bare_dict() {
let v = normalize_inbound_envelope(json!({"choice": "A"}));
assert_eq!(v["action"], json!("accept"));
assert_eq!(v["content"]["choice"], json!("A"));
}
#[test]
fn normalize_inbound_envelope_decline_for_null() {
let v = normalize_inbound_envelope(JsonValue::Null);
assert_eq!(v["action"], json!("decline"));
}
#[tokio::test]
async fn elicit_reentry_validates_accept() {
let result = crate::mcp_input::scope_input_context(
&json!({"inputResponses": {
"harn-input-0": {"action": "accept", "content": {"choice": "A"}}
}}),
json!({"elicitation": {"form": {}}}),
async {
elicit_form(
"Pick one".to_string(),
json!({
"type": "object",
"properties": {"choice": {"type": "string"}},
"required": ["choice"],
}),
)
},
)
.await
.unwrap()
.expect("elicit succeeds");
let dict = result.as_dict().unwrap();
assert_eq!(dict.get("action").unwrap().display(), "accept");
}
}