feat(mcp): elicitation support (#5965)
This commit is contained in:
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user