Skip to content

Commit 9e6b819

Browse files
louis030195claude
andcommitted
feat(mcp): add ask_user tool for AI to request user clarification
- Add ask_user tool that uses MCP elicitation protocol - Store elicitation-capable peer on client init for cross-peer elicitation - Add UserResponse schema for simple Q&A flow - Remove elicit param from run_command (moved to dedicated tool) - AI can now ask questions when uncertain about business logic or UI elements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a2db033 commit 9e6b819

File tree

6 files changed

+400
-28
lines changed

6 files changed

+400
-28
lines changed

crates/terminator-mcp-agent/src/elicitation/helpers.rs

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
//! while gracefully handling clients that don't support elicitation.
55
66
use rmcp::service::{ElicitationSafe, Peer, RoleServer};
7+
use std::sync::Arc;
8+
use tokio::sync::Mutex as TokioMutex;
79

810
/// Elicit structured data from the user with graceful fallback
911
///
@@ -55,17 +57,27 @@ where
5557

5658
/// Try to elicit data, returning None if not supported or declined
5759
///
58-
/// Similar to `elicit_with_fallback` but returns `Option<T>` instead of
59-
/// requiring a default value. Useful when you want to know whether the
60-
/// user actually provided input.
60+
/// This function attempts to use a stored elicitation-capable peer first.
61+
/// This is necessary because tool calls may come from a peer (like Claude Code/ACP)
62+
/// that doesn't support elicitation, while another connected peer (like mediar-app)
63+
/// does support it.
64+
///
65+
/// # Arguments
66+
/// * `stored_peer` - Optional reference to a stored peer that supports elicitation
67+
/// * `calling_peer` - The peer that invoked the tool (may not support elicitation)
68+
/// * `message` - The message to display to the user
6169
///
6270
/// # Example
6371
/// ```ignore
6472
/// use terminator_mcp_agent::elicitation::{try_elicit, ActionConfirmation};
6573
///
66-
/// async fn dangerous_operation(peer: &Peer<RoleServer>) -> Result<(), Error> {
74+
/// async fn dangerous_operation(
75+
/// stored_peer: &Arc<TokioMutex<Option<Peer<RoleServer>>>>,
76+
/// calling_peer: &Peer<RoleServer>,
77+
/// ) -> Result<(), Error> {
6778
/// if let Some(confirm) = try_elicit::<ActionConfirmation>(
68-
/// peer,
79+
/// stored_peer,
80+
/// calling_peer,
6981
/// "This will delete all files. Are you sure?",
7082
/// ).await {
7183
/// if confirm.confirmed {
@@ -75,29 +87,72 @@ where
7587
/// Ok(())
7688
/// }
7789
/// ```
78-
pub async fn try_elicit<T>(peer: &Peer<RoleServer>, message: &str) -> Option<T>
90+
pub async fn try_elicit<T>(
91+
stored_peer: &Arc<TokioMutex<Option<Peer<RoleServer>>>>,
92+
calling_peer: &Peer<RoleServer>,
93+
message: &str,
94+
) -> Option<T>
7995
where
8096
T: ElicitationSafe + serde::de::DeserializeOwned + Send + 'static,
8197
{
82-
if !peer.supports_elicitation() {
83-
tracing::debug!(
84-
"[elicitation] Client does not support elicitation: {}",
98+
// First, try to use the stored elicitation-capable peer
99+
let peer_to_use: Option<Peer<RoleServer>> = {
100+
let guard = stored_peer.lock().await;
101+
if let Some(ref stored) = *guard {
102+
if stored.supports_elicitation() {
103+
tracing::info!(
104+
"[elicitation] Using stored elicitation-capable peer for: {}",
105+
message
106+
);
107+
Some(stored.clone())
108+
} else {
109+
tracing::info!(
110+
"[elicitation] Stored peer doesn't support elicitation, trying calling peer"
111+
);
112+
None
113+
}
114+
} else {
115+
tracing::info!("[elicitation] No stored peer, trying calling peer");
116+
None
117+
}
118+
};
119+
120+
// Determine which peer to use
121+
let peer = if let Some(ref p) = peer_to_use {
122+
p
123+
} else {
124+
// Fall back to calling peer
125+
let supports = calling_peer.supports_elicitation();
126+
tracing::info!(
127+
"[elicitation] Calling peer supports_elicitation() = {}, message: {}",
128+
supports,
85129
message
86130
);
87-
return None;
88-
}
131+
if !supports {
132+
tracing::info!("[elicitation] No elicitation-capable peer available");
133+
return None;
134+
}
135+
calling_peer
136+
};
89137

90-
match peer.elicit::<T>(message).await {
138+
tracing::info!("[elicitation] Attempting elicitation...");
139+
let result = peer.elicit::<T>(message).await;
140+
tracing::info!(
141+
"[elicitation] peer.elicit() returned: {:?}",
142+
result.as_ref().map(|r| r.is_some())
143+
);
144+
145+
match result {
91146
Ok(Some(data)) => {
92147
tracing::info!("[elicitation] User provided data for: {}", message);
93148
Some(data)
94149
}
95150
Ok(None) => {
96-
tracing::debug!("[elicitation] User declined/cancelled: {}", message);
151+
tracing::info!("[elicitation] User declined/cancelled: {}", message);
97152
None
98153
}
99154
Err(e) => {
100-
tracing::debug!("[elicitation] Error: {} ({})", message, e);
155+
tracing::info!("[elicitation] Error calling peer.elicit(): {:?}", e);
101156
None
102157
}
103158
}

crates/terminator-mcp-agent/src/elicitation/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ mod tests;
4242
// Re-export schemas
4343
pub use schemas::{
4444
ActionConfirmation, ElementDisambiguation, ElementTypeHint, ErrorRecoveryAction,
45-
ErrorRecoveryChoice, SelectorRefinement, WorkflowContext,
45+
ErrorRecoveryChoice, SelectorRefinement, UserResponse, WorkflowContext,
4646
};
4747

4848
// Re-export helpers

crates/terminator-mcp-agent/src/elicitation/schemas.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,20 @@ pub enum ElementTypeHint {
117117
Other,
118118
}
119119

120+
/// Simple user response for the ask_user tool
121+
/// Used when AI needs to ask clarifying questions
122+
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
123+
#[schemars(description = "Your response to the AI's question")]
124+
pub struct UserResponse {
125+
/// Your answer or response
126+
#[schemars(description = "Your answer to the question")]
127+
pub answer: String,
128+
}
129+
120130
// Mark types as safe for elicitation (generates proper JSON schemas)
121131
elicit_safe!(WorkflowContext);
122132
elicit_safe!(ElementDisambiguation);
123133
elicit_safe!(ErrorRecoveryChoice);
124134
elicit_safe!(ActionConfirmation);
125135
elicit_safe!(SelectorRefinement);
136+
elicit_safe!(UserResponse);

crates/terminator-mcp-agent/src/server.rs

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::elicitation::{try_elicit, UserResponse};
12
use crate::event_pipe::{create_event_channel, WorkflowEvent};
23
use crate::execution_logger;
34
use crate::helpers::*;
@@ -6,10 +7,10 @@ use crate::telemetry::StepSpan;
67
use crate::utils::find_and_execute_with_retry_with_fallback;
78
pub use crate::utils::DesktopWrapper;
89
use crate::utils::{
9-
get_timeout, ActivateElementArgs, CaptureScreenshotArgs, ClickElementArgs, CopyContentArgs,
10-
DelayArgs, EditFileArgs, ExecuteBrowserScriptArgs, ExecuteSequenceArgs, GeminiComputerUseArgs,
11-
GetApplicationsArgs, GetWindowTreeArgs, GlobFilesArgs, GlobalKeyArgs, GrepFilesArgs,
12-
HighlightElementArgs, InvokeElementArgs, MouseDragArgs, NavigateBrowserArgs,
10+
get_timeout, ActivateElementArgs, AskUserArgs, CaptureScreenshotArgs, ClickElementArgs,
11+
CopyContentArgs, DelayArgs, EditFileArgs, ExecuteBrowserScriptArgs, ExecuteSequenceArgs,
12+
GeminiComputerUseArgs, GetApplicationsArgs, GetWindowTreeArgs, GlobFilesArgs, GlobalKeyArgs,
13+
GrepFilesArgs, HighlightElementArgs, InvokeElementArgs, MouseDragArgs, NavigateBrowserArgs,
1314
OpenApplicationArgs, PressKeyArgs, ReadFileArgs, RunCommandArgs, ScrollElementArgs,
1415
SelectOptionArgs, SetSelectedArgs, SetValueArgs, StopHighlightingArgs, TypeIntoElementArgs,
1516
ValidateElementArgs, WaitForElementArgs, WriteFileArgs,
@@ -39,7 +40,7 @@ use tracing::{info, warn, Instrument};
3940
use base64::{engine::general_purpose, Engine as _};
4041
use image::codecs::png::PngEncoder;
4142

42-
use rmcp::service::{Peer, RequestContext, RoleServer};
43+
use rmcp::service::{NotificationContext, Peer, RequestContext, RoleServer};
4344

4445
/// Extracts JSON data from Content objects without double serialization
4546
pub fn extract_content_json(content: &Content) -> Result<serde_json::Value, serde_json::Error> {
@@ -807,6 +808,7 @@ impl DesktopWrapper {
807808
inspect_overlay_handle: Arc::new(std::sync::Mutex::new(None)),
808809
current_mode: Arc::new(Mutex::new(None)),
809810
blocked_tools: Arc::new(Mutex::new(std::collections::HashSet::new())),
811+
elicitation_peer: Arc::new(Mutex::new(None)),
810812
})
811813
}
812814

@@ -4833,6 +4835,57 @@ DATA PASSING:
48334835
))
48344836
}
48354837

4838+
#[tool(
4839+
description = "Ask the user a clarifying question when you need more information to proceed. Use this when uncertain about business logic, which element to interact with, or any decision that requires human judgment. The user will see a modal with your question and can provide an answer."
4840+
)]
4841+
async fn ask_user(
4842+
&self,
4843+
peer: Peer<RoleServer>,
4844+
Parameters(args): Parameters<AskUserArgs>,
4845+
) -> Result<CallToolResult, McpError> {
4846+
// Build the message to show the user
4847+
let mut message = args.question.clone();
4848+
4849+
// Add context if provided
4850+
if let Some(ctx) = &args.context {
4851+
message = format!("{}\n\nContext: {}", message, ctx);
4852+
}
4853+
4854+
// Add choices if provided
4855+
if let Some(choices) = &args.choices {
4856+
message = format!("{}\n\nOptions:\n{}", message,
4857+
choices.iter().enumerate()
4858+
.map(|(i, c)| format!("{}. {}", i + 1, c))
4859+
.collect::<Vec<_>>()
4860+
.join("\n")
4861+
);
4862+
}
4863+
4864+
tracing::info!("[ask_user] Requesting user input: {}", args.question);
4865+
4866+
// Use elicitation to get user response
4867+
match try_elicit::<UserResponse>(&self.elicitation_peer, &peer, &message).await {
4868+
Some(response) => {
4869+
tracing::info!("[ask_user] User responded: {}", response.answer);
4870+
Ok(CallToolResult::success(vec![Content::json(json!({
4871+
"action": "ask_user",
4872+
"status": "answered",
4873+
"question": args.question,
4874+
"answer": response.answer
4875+
}))?]))
4876+
}
4877+
None => {
4878+
tracing::info!("[ask_user] User declined or elicitation not supported");
4879+
Ok(CallToolResult::success(vec![Content::json(json!({
4880+
"action": "ask_user",
4881+
"status": "declined",
4882+
"question": args.question,
4883+
"message": "User declined to answer or elicitation is not supported by the client"
4884+
}))?]))
4885+
}
4886+
}
4887+
}
4888+
48364889
#[tool(
48374890
description = "Performs a mouse drag operation from start to end coordinates. Use ui_diff_before_after:true to see changes (no need to call get_window_tree after)."
48384891
)]
@@ -10128,4 +10181,21 @@ impl ServerHandler for DesktopWrapper {
1012810181
self.tool_router.list_all(),
1012910182
))
1013010183
}
10184+
10185+
/// Called after a client completes initialization
10186+
/// We check if this client supports elicitation and store the peer if so
10187+
async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
10188+
let peer = context.peer;
10189+
let supports = peer.supports_elicitation();
10190+
tracing::info!(
10191+
"[on_initialized] Client initialized. supports_elicitation: {}",
10192+
supports
10193+
);
10194+
10195+
if supports {
10196+
tracing::info!("[on_initialized] Storing elicitation-capable peer");
10197+
let mut guard = self.elicitation_peer.lock().await;
10198+
*guard = Some(peer);
10199+
}
10200+
}
1013110201
}

0 commit comments

Comments
 (0)