feat(mcp): elicitation support (#5965)

This commit is contained in:
Alex Hancock
2025-12-05 21:29:59 -05:00
committed by GitHub
parent 1db40709bb
commit 06155ce0ca
24 changed files with 992 additions and 22 deletions
+164
View File
@@ -0,0 +1,164 @@
use console::style;
use serde_json::Value;
use std::collections::HashMap;
use std::io::{self, BufRead, IsTerminal, Write};
pub fn collect_elicitation_input(
message: &str,
schema: &Value,
) -> io::Result<Option<HashMap<String, Value>>> {
if !message.is_empty() {
println!("\n{}", style(message).cyan());
}
let properties = match schema.get("properties").and_then(|p| p.as_object()) {
Some(props) => props,
None => return Ok(Some(HashMap::new())),
};
let required: Vec<&str> = schema
.get("required")
.and_then(|r| r.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let mut data: HashMap<String, Value> = HashMap::new();
for (name, field_schema) in properties {
let is_required = required.contains(&name.as_str());
let field_type = field_schema
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("string");
let description = field_schema.get("description").and_then(|d| d.as_str());
let default = field_schema.get("default");
let enum_values = field_schema.get("enum").and_then(|e| e.as_array());
// makes a little true/false toggle
if field_type == "boolean" {
let label = match description {
Some(desc) => format!("{} ({})", name, desc),
None => name.clone(),
};
let default_bool = default.and_then(|v| v.as_bool()).unwrap_or(false);
match cliclack::confirm(&label)
.initial_value(default_bool)
.interact()
{
Ok(v) => {
data.insert(name.clone(), Value::Bool(v));
}
Err(e) if e.kind() == io::ErrorKind::Interrupted => return Ok(None),
Err(e) => return Err(e),
}
continue;
}
if let Some(options) = enum_values {
let opts: Vec<&str> = options.iter().filter_map(|v| v.as_str()).collect();
println!(" {}: {}", style("Options").dim(), opts.join(", "));
}
print!("{}", style(name).yellow());
if let Some(desc) = description {
print!(" {}", style(format!("({})", desc)).dim());
}
if is_required {
print!("{}", style("*").red());
}
if let Some(def) = default {
print!(" {}", style(format!("[{}]", format_default(def))).dim());
}
print!(": ");
io::stdout().flush()?;
let input = read_line()?;
// Handle Ctrl+C / EOF for cancellation
if input.is_none() {
return Ok(None);
}
let input = input.unwrap();
let value = if input.is_empty() {
default.cloned()
} else {
Some(parse_value(&input, field_type, enum_values))
};
if let Some(v) = value {
if !v.is_null() {
data.insert(name.clone(), v);
}
}
if is_required && !data.contains_key(name) {
println!(
"{}",
style(format!("Required field '{}' is missing", name)).red()
);
return Ok(None);
}
}
println!();
Ok(Some(data))
}
fn read_line() -> io::Result<Option<String>> {
if !std::io::stdin().is_terminal() {
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
return Ok(Some(line.trim().to_string()));
}
let mut line = String::new();
match io::stdin().lock().read_line(&mut line) {
Ok(0) => Ok(None), // EOF
Ok(_) => Ok(Some(line.trim().to_string())),
Err(e) if e.kind() == io::ErrorKind::Interrupted => Ok(None),
Err(e) => Err(e),
}
}
fn format_default(value: &Value) -> String {
match value {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
_ => value.to_string(),
}
}
fn parse_value(input: &str, field_type: &str, enum_values: Option<&Vec<Value>>) -> Value {
if let Some(options) = enum_values {
let valid: Vec<&str> = options.iter().filter_map(|v| v.as_str()).collect();
if valid.contains(&input) {
return Value::String(input.to_string());
}
if let Ok(idx) = input.parse::<usize>() {
if idx > 0 && idx <= valid.len() {
return Value::String(valid[idx - 1].to_string());
}
}
}
match field_type {
"boolean" => {
let lower = input.to_lowercase();
Value::Bool(matches!(lower.as_str(), "true" | "yes" | "y" | "1"))
}
"integer" => input
.parse::<i64>()
.map(|n| Value::Number(n.into()))
.unwrap_or(Value::Null),
"number" => input
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map(Value::Number)
.unwrap_or(Value::Null),
_ => Value::String(input.to_string()),
}
}
+14
View File
@@ -349,6 +349,20 @@ pub fn message_to_markdown(message: &Message, export_all_content: bool) -> Strin
tool_name
));
}
ActionRequiredData::Elicitation { message, .. } => {
md.push_str(&format!(
"**Action Required** (elicitation): {}\n\n",
message
));
}
ActionRequiredData::ElicitationResponse { id, user_data } => {
md.push_str(&format!(
"**Action Required** (elicitation_response): {}\n```json\n{}\n```\n\n",
id,
serde_json::to_string_pretty(user_data)
.unwrap_or_else(|_| "{}".to_string())
));
}
},
MessageContent::Text(text) => {
md.push_str(&text.text);
+55
View File
@@ -1,5 +1,6 @@
mod builder;
mod completion;
mod elicitation;
mod export;
mod input;
mod output;
@@ -865,6 +866,18 @@ impl CliSession {
}
});
let elicitation_request = message.content.iter().find_map(|content| {
if let MessageContent::ActionRequired(action) = content {
if let ActionRequiredData::Elicitation { id, message, requested_schema } = &action.data {
Some((id.clone(), message.clone(), requested_schema.clone()))
} else {
None
}
} else {
None
}
});
if let Some((id, _tool_name, _arguments, security_prompt)) = tool_call_confirmation {
output::hide_thinking();
@@ -924,6 +937,48 @@ impl CliSession {
}).await;
}
}
else if let Some((elicitation_id, elicitation_message, schema)) = elicitation_request {
output::hide_thinking();
let _ = progress_bars.hide();
match elicitation::collect_elicitation_input(&elicitation_message, &schema) {
Ok(Some(user_data)) => {
let user_data_value = serde_json::to_value(user_data)
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
let response_message = Message::user()
.with_content(MessageContent::action_required_elicitation_response(
elicitation_id.clone(),
user_data_value,
))
.with_visibility(false, true);
self.messages.push(response_message.clone());
// Elicitation responses return an empty stream - the response
// unblocks the waiting tool call via ActionRequiredManager
let _ = self
.agent
.reply(
response_message,
session_config.clone(),
Some(cancel_token.clone()),
)
.await?;
}
Ok(None) => {
output::render_text("Information request cancelled.", Some(Color::Yellow), true);
cancel_token_clone.cancel();
drop(stream);
break;
}
Err(e) => {
output::render_error(&format!("Failed to collect input: {}", e));
cancel_token_clone.cancel();
drop(stream);
break;
}
}
}
else {
for content in &message.content {
if let MessageContent::ToolRequest(tool_request) = content {
+6
View File
@@ -172,6 +172,12 @@ pub fn render_message(message: &Message, debug: bool) {
ActionRequiredData::ToolConfirmation { tool_name, .. } => {
println!("action_required(tool_confirmation): {}", tool_name)
}
ActionRequiredData::Elicitation { message, .. } => {
println!("action_required(elicitation): {}", message)
}
ActionRequiredData::ElicitationResponse { id, .. } => {
println!("action_required(elicitation_response): {}", id)
}
},
MessageContent::Text(text) => print_markdown(&text.text, theme),
MessageContent::ToolRequest(req) => render_tool_request(req, theme, debug),