use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::error::{Error, Result};
use crate::mcp::{ElicitationAction, ElicitationRequest};
use crate::tools::{Tool, ToolContext};
pub const ASK_USER: &str = "ask_user";
pub const REQUEST_USER_INPUT: &str = "request_user_input";
#[derive(Clone)]
pub struct UserQuestionHandler(pub std::sync::Arc<dyn crate::mcp::McpElicitationHandler>);
impl std::fmt::Debug for UserQuestionHandler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("UserQuestionHandler(..)")
}
}
impl std::ops::Deref for UserQuestionHandler {
type Target = dyn crate::mcp::McpElicitationHandler;
fn deref(&self) -> &Self::Target {
&*self.0
}
}
pub const MAX_QUESTIONS: usize = 4;
pub const MAX_OPTIONS: usize = 4;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct QuestionOption {
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Question {
pub question: String,
#[serde(default)]
pub header: String,
#[serde(default, rename = "multiSelect", alias = "multi_select")]
pub multi_select: bool,
#[serde(default)]
pub options: Vec<QuestionOption>,
}
#[derive(Debug, Deserialize)]
struct AskUserArgs {
questions: Vec<Question>,
}
#[derive(Debug, Clone)]
pub struct AskUserTool {
name: &'static str,
}
impl AskUserTool {
pub fn new(name: &'static str) -> Self {
AskUserTool { name }
}
}
impl Default for AskUserTool {
fn default() -> Self {
AskUserTool::new(ASK_USER)
}
}
fn requested_schema(questions: &[Question]) -> Value {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
for (index, q) in questions.iter().enumerate() {
let key = format!("q{}", index + 1);
let labels: Vec<&str> = q.options.iter().map(|o| o.label.as_str()).collect();
let mut description = q.question.clone();
if !labels.is_empty() {
description.push_str(&format!(
" (options: {}; free text is also accepted)",
labels.join(" | ")
));
}
let mut prop = json!({
"title": if q.header.is_empty() { q.question.clone() } else { q.header.clone() },
"description": description,
"x-options": q.options,
"x-multi-select": q.multi_select,
});
if q.multi_select {
prop["type"] = json!("array");
prop["items"] = json!({"type": "string"});
} else {
prop["type"] = json!("string");
}
properties.insert(key.clone(), prop);
required.push(key);
}
json!({
"type": "object",
"properties": properties,
"required": required,
})
}
fn message_for(questions: &[Question]) -> String {
let mut out = String::new();
for (index, q) in questions.iter().enumerate() {
if index > 0 {
out.push_str("\n\n");
}
if !q.header.is_empty() {
out.push_str(&format!("[{}] ", q.header));
}
out.push_str(&q.question);
for opt in &q.options {
out.push_str(&format!("\n - {}", opt.label));
if let Some(d) = &opt.description {
out.push_str(&format!(" — {d}"));
}
}
if q.multi_select {
out.push_str("\n (multiple selections allowed)");
}
}
out
}
fn format_answers(questions: &[Question], content: &Value) -> String {
let mut lines = Vec::new();
for (index, q) in questions.iter().enumerate() {
let key = format!("q{}", index + 1);
let answer = content.get(&key).map(render_answer).unwrap_or_else(|| {
content
.get(&q.header)
.map(render_answer)
.unwrap_or_else(|| "(no answer)".to_string())
});
let label = if q.header.is_empty() {
q.question.clone()
} else {
q.header.clone()
};
lines.push(format!("{label}: {answer}"));
}
format!(
"The user answered:\n{}\n\nraw: {}",
lines.join("\n"),
content
)
}
fn render_answer(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Array(items) => items
.iter()
.map(render_answer)
.collect::<Vec<_>>()
.join(", "),
other => other.to_string(),
}
}
#[async_trait]
impl Tool for AskUserTool {
fn name(&self) -> &str {
self.name
}
fn description(&self) -> &str {
"Ask the user 1-4 structured questions and wait for the answers. Use it when a \
decision is genuinely the user's to make (a choice between real alternatives, a \
missing fact only they have) — never to ask permission for work you were already \
asked to do. Each question offers labelled options; the user may also answer in \
free text."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"questions": {
"type": "array",
"minItems": 1,
"maxItems": MAX_QUESTIONS,
"description": "1-4 questions to ask at once.",
"items": {
"type": "object",
"properties": {
"question": {"type": "string", "description": "The question text."},
"header": {
"type": "string",
"description": "Short label (a few words) naming what is being decided."
},
"multiSelect": {
"type": "boolean",
"description": "Whether the user may pick more than one option."
},
"options": {
"type": "array",
"maxItems": MAX_OPTIONS,
"items": {
"type": "object",
"properties": {
"label": {"type": "string"},
"description": {"type": "string"}
},
"required": ["label"],
"additionalProperties": false
}
}
},
"required": ["question", "options"],
"additionalProperties": false
}
}
},
"required": ["questions"],
"additionalProperties": false
})
}
async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
let a: AskUserArgs = serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
tool: self.name().to_string(),
message: e.to_string(),
})?;
if a.questions.is_empty() || a.questions.len() > MAX_QUESTIONS {
return Err(Error::InvalidArguments {
tool: self.name().to_string(),
message: format!(
"ask between 1 and {MAX_QUESTIONS} questions in one call (got {})",
a.questions.len()
),
});
}
for q in &a.questions {
if q.question.trim().is_empty() {
return Err(Error::InvalidArguments {
tool: self.name().to_string(),
message: "every question needs non-empty text".to_string(),
});
}
if q.options.len() > MAX_OPTIONS {
return Err(Error::InvalidArguments {
tool: self.name().to_string(),
message: format!("at most {MAX_OPTIONS} options per question"),
});
}
if q.options.iter().any(|o| o.label.trim().is_empty()) {
return Err(Error::InvalidArguments {
tool: self.name().to_string(),
message: "every option needs a non-empty label".to_string(),
});
}
}
let Some(handler) = ctx.question_handler.as_ref() else {
return Err(Error::tool(
self.name(),
"no interactive frontend is attached, so the user cannot be asked (headless \
run): make the best decision you can and say which assumption you made",
));
};
let request = ElicitationRequest {
message: message_for(&a.questions),
requested_schema: requested_schema(&a.questions),
};
let response = handler.handle(&request).await;
match response.action {
ElicitationAction::Accept => {
let content = response.content.unwrap_or_else(|| json!({}));
Ok(format_answers(&a.questions, &content))
}
ElicitationAction::Decline => Ok(
"The user declined to answer. Proceed with your own best judgement and say \
what you assumed."
.to_string(),
),
ElicitationAction::Cancel => Ok("The user dismissed the question without \
answering. Proceed with your own best judgement \
and say what you assumed."
.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mcp::{ElicitationResponse, McpElicitationHandler};
use std::sync::Arc;
struct Answering(Value);
#[async_trait]
impl McpElicitationHandler for Answering {
async fn handle(&self, _request: &ElicitationRequest) -> ElicitationResponse {
ElicitationResponse {
action: ElicitationAction::Accept,
content: Some(self.0.clone()),
}
}
}
struct Declining;
#[async_trait]
impl McpElicitationHandler for Declining {
async fn handle(&self, _request: &ElicitationRequest) -> ElicitationResponse {
ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
}
}
}
fn one_question() -> Value {
json!({
"questions": [{
"question": "Which database?",
"header": "Database",
"options": [{"label": "postgres"}, {"label": "sqlite", "description": "local"}]
}]
})
}
#[tokio::test]
async fn headless_is_deny_default() {
let ctx = ToolContext::new(std::env::temp_dir());
let err = AskUserTool::default()
.execute(one_question(), &ctx)
.await
.expect_err("no handler must refuse");
assert!(err.to_string().contains("no interactive frontend"), "{err}");
}
#[tokio::test]
async fn an_answer_comes_back_to_the_model() {
let mut ctx = ToolContext::new(std::env::temp_dir());
ctx.question_handler = Some(UserQuestionHandler(Arc::new(Answering(
json!({"q1": "sqlite"}),
))));
let out = AskUserTool::default()
.execute(one_question(), &ctx)
.await
.unwrap();
assert!(out.contains("Database: sqlite"), "{out}");
}
#[tokio::test]
async fn multi_select_answers_render_as_a_list() {
let mut ctx = ToolContext::new(std::env::temp_dir());
ctx.question_handler = Some(UserQuestionHandler(Arc::new(Answering(
json!({"q1": ["a", "b"]}),
))));
let out = AskUserTool::default()
.execute(
json!({"questions": [{
"question": "Which ones?",
"header": "Targets",
"multiSelect": true,
"options": [{"label": "a"}, {"label": "b"}]
}]}),
&ctx,
)
.await
.unwrap();
assert!(out.contains("Targets: a, b"), "{out}");
}
#[tokio::test]
async fn free_text_is_accepted_even_when_it_matches_no_option() {
let mut ctx = ToolContext::new(std::env::temp_dir());
ctx.question_handler = Some(UserQuestionHandler(Arc::new(Answering(
json!({"q1": "duckdb, actually"}),
))));
let out = AskUserTool::default()
.execute(one_question(), &ctx)
.await
.unwrap();
assert!(out.contains("duckdb, actually"), "{out}");
}
#[tokio::test]
async fn a_decline_is_reported_not_invented() {
let mut ctx = ToolContext::new(std::env::temp_dir());
ctx.question_handler = Some(UserQuestionHandler(Arc::new(Declining)));
let out = AskUserTool::default()
.execute(one_question(), &ctx)
.await
.unwrap();
assert!(out.contains("declined"), "{out}");
}
#[tokio::test]
async fn more_than_four_questions_is_refused() {
let mut ctx = ToolContext::new(std::env::temp_dir());
ctx.question_handler = Some(UserQuestionHandler(Arc::new(Answering(json!({})))));
let many: Vec<Value> = (0..5)
.map(|i| json!({"question": format!("q{i}"), "options": []}))
.collect();
let err = AskUserTool::default()
.execute(json!({"questions": many}), &ctx)
.await
.expect_err("five questions must be refused");
assert!(err.to_string().contains("between 1 and 4"), "{err}");
}
#[test]
fn the_requested_schema_never_constrains_the_answer_to_an_enum() {
let questions = vec![Question {
question: "Which database?".into(),
header: "Database".into(),
multi_select: false,
options: vec![QuestionOption {
label: "postgres".into(),
description: None,
}],
}];
let schema = requested_schema(&questions);
let prop = &schema["properties"]["q1"];
assert_eq!(prop["type"], "string");
assert!(prop.get("enum").is_none(), "free text must stay possible");
assert_eq!(prop["x-options"][0]["label"], "postgres");
}
#[test]
fn the_cx_alias_keeps_its_own_registered_name() {
assert_eq!(
AskUserTool::new(REQUEST_USER_INPUT).name(),
"request_user_input"
);
assert_eq!(AskUserTool::default().name(), "ask_user");
}
}