Skip to content

Commit fa504fb

Browse files
committed
feat(mcp): add MCP Elicitation support (#2486)
MCP servers can now request structured user input mid-task via the Elicitation protocol. The agent routes ElicitationRequest events to the active channel at turn boundaries via a non-blocking drain loop. - CLI channel: interactive prompt with server name attribution and field-by-field input collection; supports string, boolean, integer, number, and enum field types with type coercion and validation - Non-interactive channels (TUI, Telegram, daemon, scheduler): auto-decline with a logged warning - Sandboxed trust level: elicitation is unconditionally blocked - Per-server override: `elicitation_enabled` in [[mcp.servers]] takes precedence over the global `[mcp] elicitation_enabled` setting - Timeout: configurable `elicitation_timeout_secs` (default 120s) - Phishing prevention: server name always shown before any prompt; message sanitized (control chars stripped, 500-char cap via char-level truncation to avoid UTF-8 boundary panics) - Input validation: invalid input declines gracefully with a message rather than storing a wrong-type value Known limitation: elicitation arriving during MCP tool call execution times out because the agent loop is blocked; tracked as phase-2 work. 9 new tests: sanitize_elicitation_message (control chars, cap, multi-byte boundary), build_elicitation_fields (type mapping, required flag), sandboxed trust blocks elicitation, elicitation_enabled gate. 6980/6980 tests pass.
1 parent 71f2eb8 commit fa504fb

File tree

19 files changed

+865
-6
lines changed

19 files changed

+865
-6
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
2323

2424
- metrics: add `sanitizer_injection_fp_local` counter for injection flags on local (`ToolResult`) sources (#2515)
2525
- metrics: add `pii_ner_timeouts` counter for NER classifier timeout events (#2516)
26+
- feat(mcp): MCP Elicitation support — MCP servers can now request structured user input mid-task via the `elicitation/create` protocol method; requests are routed to the active channel (CLI prompts interactively, non-interactive channels auto-decline); CLI channel renders a phishing-prevention header showing the requesting server name before prompting; `ElicitationSchema` properties are mapped to typed `ElicitationField` entries (string, integer, number, boolean, enum); URL elicitation variant deferred to a future phase — auto-declined with a log message (closes #2486)
27+
- feat(config): `[mcp] elicitation_enabled` (default `false`) and `elicitation_timeout` (default 120 s) global config options; per-server `elicitation_enabled` override (`Option<bool>`) in `[[mcp.servers]]` entries — `null`/absent inherits the global flag; `Sandboxed` trust-level servers are never allowed to elicit regardless of config
2628

2729
## [0.18.1] - 2026-03-31
2830

crates/zeph-acp/src/mcp_bridge.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ pub fn acp_mcp_servers_to_entries(servers: &[acp::McpServer]) -> Vec<ServerEntry
3838
expected_tools: Vec::new(),
3939
roots: Vec::new(),
4040
tool_metadata: HashMap::new(),
41+
elicitation_enabled: false,
42+
elicitation_timeout_secs: 120,
4143
})
4244
}
4345
acp::McpServer::Http(http) => Some(ServerEntry {
@@ -52,6 +54,8 @@ pub fn acp_mcp_servers_to_entries(servers: &[acp::McpServer]) -> Vec<ServerEntry
5254
expected_tools: Vec::new(),
5355
roots: Vec::new(),
5456
tool_metadata: HashMap::new(),
57+
elicitation_enabled: false,
58+
elicitation_timeout_secs: 120,
5559
}),
5660
acp::McpServer::Sse(sse) => {
5761
// SSE is a legacy MCP transport; map to Streamable HTTP which is
@@ -68,6 +72,8 @@ pub fn acp_mcp_servers_to_entries(servers: &[acp::McpServer]) -> Vec<ServerEntry
6872
expected_tools: Vec::new(),
6973
roots: Vec::new(),
7074
tool_metadata: HashMap::new(),
75+
elicitation_enabled: false,
76+
elicitation_timeout_secs: 120,
7177
})
7278
}
7379
_ => {

crates/zeph-channels/src/any.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
// SPDX-License-Identifier: MIT OR Apache-2.0
33

44
use zeph_core::channel::{
5-
Channel, ChannelError, ChannelMessage, StopHint, ToolOutputEvent, ToolStartEvent,
5+
Channel, ChannelError, ChannelMessage, ElicitationRequest, ElicitationResponse, StopHint,
6+
ToolOutputEvent, ToolStartEvent,
67
};
78

89
use crate::cli::CliChannel;
@@ -61,6 +62,13 @@ impl Channel for AnyChannel {
6162
dispatch_channel!(self, confirm, prompt)
6263
}
6364

65+
async fn elicit(
66+
&mut self,
67+
request: ElicitationRequest,
68+
) -> Result<ElicitationResponse, ChannelError> {
69+
dispatch_channel!(self, elicit, request)
70+
}
71+
6472
fn try_recv(&mut self) -> Option<ChannelMessage> {
6573
match self {
6674
Self::Cli(c) => c.try_recv(),

crates/zeph-channels/src/cli.rs

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ use std::collections::VecDeque;
55
use std::io::{BufReader, IsTerminal};
66

77
use tokio::sync::mpsc;
8-
use zeph_core::channel::{Attachment, AttachmentKind, Channel, ChannelError, ChannelMessage};
8+
use zeph_core::channel::{
9+
Attachment, AttachmentKind, Channel, ChannelError, ChannelMessage, ElicitationField,
10+
ElicitationFieldType, ElicitationRequest, ElicitationResponse,
11+
};
912

1013
use crate::line_editor::{self, ReadLineResult};
1114

@@ -346,6 +349,123 @@ impl Channel for CliChannel {
346349
ReadLineResult::Interrupted | ReadLineResult::Eof => Ok(false),
347350
}
348351
}
352+
353+
async fn elicit(
354+
&mut self,
355+
request: ElicitationRequest,
356+
) -> Result<ElicitationResponse, ChannelError> {
357+
if !std::io::stdin().is_terminal() {
358+
tracing::warn!(
359+
server = request.server_name,
360+
"non-interactive stdin, auto-declining elicitation"
361+
);
362+
return Ok(ElicitationResponse::Declined);
363+
}
364+
365+
println!(
366+
"\n[MCP server '{}' is requesting input]",
367+
request.server_name
368+
);
369+
println!("{}", request.message);
370+
371+
let mut values = serde_json::Map::new();
372+
for field in &request.fields {
373+
let prompt = build_field_prompt(field);
374+
let field_name = field.name.clone();
375+
let result = tokio::task::spawn_blocking(move || line_editor::read_line(&prompt, &[]))
376+
.await
377+
.map_err(ChannelError::other)?
378+
.map_err(ChannelError::Io)?;
379+
380+
match result {
381+
ReadLineResult::Line(line) => {
382+
let trimmed = line.trim().to_owned();
383+
match coerce_field_value(&trimmed, &field.field_type) {
384+
Some(value) => {
385+
values.insert(field_name, value);
386+
}
387+
None => {
388+
println!(
389+
"Invalid input for '{}' (expected {:?}), declining.",
390+
field_name, field.field_type
391+
);
392+
return Ok(ElicitationResponse::Declined);
393+
}
394+
}
395+
}
396+
ReadLineResult::Interrupted | ReadLineResult::Eof => {
397+
return Ok(ElicitationResponse::Cancelled);
398+
}
399+
}
400+
}
401+
402+
Ok(ElicitationResponse::Accepted(serde_json::Value::Object(
403+
values,
404+
)))
405+
}
406+
}
407+
408+
fn build_field_prompt(field: &ElicitationField) -> String {
409+
let type_hint = match &field.field_type {
410+
ElicitationFieldType::Boolean => " [true/false]",
411+
ElicitationFieldType::Integer | ElicitationFieldType::Number => " [number]",
412+
ElicitationFieldType::Enum(opts) if !opts.is_empty() => {
413+
// Build hint dynamically below
414+
return format!(
415+
"{}{}: ",
416+
field.name,
417+
field
418+
.description
419+
.as_deref()
420+
.map(|d| format!(" ({})", d))
421+
.unwrap_or_default()
422+
) + &format!("[{}]: ", opts.join("/"));
423+
}
424+
_ => "",
425+
};
426+
format!(
427+
"{}{}{}",
428+
field.name,
429+
field
430+
.description
431+
.as_deref()
432+
.map(|d| format!(" ({})", d))
433+
.unwrap_or_default(),
434+
if type_hint.is_empty() {
435+
": ".to_owned()
436+
} else {
437+
format!("{type_hint}: ")
438+
}
439+
)
440+
}
441+
442+
/// Coerce a raw user-input string into the JSON type required by the field.
443+
/// Returns `None` if the input cannot be converted to the declared type.
444+
fn coerce_field_value(raw: &str, field_type: &ElicitationFieldType) -> Option<serde_json::Value> {
445+
match field_type {
446+
ElicitationFieldType::String => Some(serde_json::Value::String(raw.to_owned())),
447+
ElicitationFieldType::Boolean => match raw.to_ascii_lowercase().as_str() {
448+
"true" | "yes" | "1" => Some(serde_json::Value::Bool(true)),
449+
"false" | "no" | "0" => Some(serde_json::Value::Bool(false)),
450+
_ => None,
451+
},
452+
ElicitationFieldType::Integer => raw
453+
.parse::<i64>()
454+
.ok()
455+
.map(|n| serde_json::Value::Number(n.into())),
456+
ElicitationFieldType::Number => raw
457+
.parse::<f64>()
458+
.ok()
459+
.and_then(serde_json::Number::from_f64)
460+
.map(serde_json::Value::Number),
461+
ElicitationFieldType::Enum(opts) => {
462+
if opts.iter().any(|o| o == raw) {
463+
Some(serde_json::Value::String(raw.to_owned()))
464+
} else {
465+
None
466+
}
467+
}
468+
}
349469
}
350470

351471
#[cfg(test)]

crates/zeph-config/src/channels.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,10 @@ fn default_max_instructions_bytes() -> usize {
339339
2048
340340
}
341341

342+
fn default_elicitation_timeout() -> u64 {
343+
120
344+
}
345+
342346
#[derive(Debug, Clone, Deserialize, Serialize)]
343347
pub struct McpConfig {
344348
#[serde(default)]
@@ -362,6 +366,14 @@ pub struct McpConfig {
362366
/// Maximum byte length for MCP server instructions. Truncated with "..." if exceeded. Default: 2048.
363367
#[serde(default = "default_max_instructions_bytes")]
364368
pub max_instructions_bytes: usize,
369+
/// Enable MCP elicitation (servers can request user input mid-task).
370+
/// Default: false — all elicitation requests are auto-declined.
371+
/// Opt-in because it interrupts agent flow and could be abused by malicious servers.
372+
#[serde(default)]
373+
pub elicitation_enabled: bool,
374+
/// Timeout for user to respond to an elicitation request (seconds). Default: 120.
375+
#[serde(default = "default_elicitation_timeout")]
376+
pub elicitation_timeout: u64,
365377
}
366378

367379
impl Default for McpConfig {
@@ -375,6 +387,8 @@ impl Default for McpConfig {
375387
tool_discovery: ToolDiscoveryConfig::default(),
376388
max_description_bytes: default_max_description_bytes(),
377389
max_instructions_bytes: default_max_instructions_bytes(),
390+
elicitation_enabled: false,
391+
elicitation_timeout: default_elicitation_timeout(),
378392
}
379393
}
380394
}
@@ -426,6 +440,11 @@ pub struct McpServerConfig {
426440
/// When absent for a tool, metadata is inferred from the tool name via heuristics.
427441
#[serde(default)]
428442
pub tool_metadata: HashMap<String, ToolSecurityMeta>,
443+
/// Per-server elicitation override. `None` = inherit global `elicitation_enabled`.
444+
/// `Some(true)` = allow this server to elicit regardless of global setting.
445+
/// `Some(false)` = always decline for this server.
446+
#[serde(default)]
447+
pub elicitation_enabled: Option<bool>,
429448
}
430449

431450
/// A filesystem root exposed to an MCP server via `roots/list`.

crates/zeph-core/src/agent/builder.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,19 @@ impl<C: Channel> Agent<C> {
609609
self
610610
}
611611

612+
/// Set the elicitation receiver for MCP elicitation requests from server handlers.
613+
///
614+
/// When set, the agent loop processes elicitation events concurrently with tool result
615+
/// awaiting to prevent deadlock.
616+
#[must_use]
617+
pub fn with_mcp_elicitation_rx(
618+
mut self,
619+
rx: tokio::sync::mpsc::UnboundedReceiver<zeph_mcp::ElicitationEvent>,
620+
) -> Self {
621+
self.mcp.elicitation_rx = Some(rx);
622+
self
623+
}
624+
612625
#[must_use]
613626
pub fn with_security(mut self, security: SecurityConfig, timeouts: TimeoutConfig) -> Self {
614627
self.security.sanitizer =

0 commit comments

Comments
 (0)