Skip to content

Commit 70ea42e

Browse files
authored
feat(mcp): add MCP Elicitation support (#2486) (#2521)
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 468e9e1 commit 70ea42e

File tree

19 files changed

+858
-13
lines changed

19 files changed

+858
-13
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
3131
- feat(memory): `StoreRoutingConfig` (`[memory.store_routing]`) is now wired into `build_router()` — strategy `heuristic`/`llm`/`hybrid` and `routing_classifier_provider` are resolved at config-apply time; the router is constructed each turn and uses the async `route_async()` path so LLM-based classification actually fires (closes #2484)
3232
- feat(memory): `goal_text` from raw user input is now propagated to A-MAC admission control — `MemoryState.goal_text` is set at the start of each user turn and passed to `remember()` and `remember_with_parts()` enabling goal-conditioned write gating when `goal_conditioned_write = true` (closes #2483)
3333
- feat(memory): `AsyncMemoryRouter` trait now implemented for `HeuristicRouter` and `HybridRouter`; `SemanticMemory::recall_routed_async()` added to dispatch routing via the async path; `parse_route_str` is now public
34+
- 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)
35+
- 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
3436

3537
### Removed
3638

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: 118 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,120 @@ 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+
if let Some(value) = coerce_field_value(&trimmed, &field.field_type) {
384+
values.insert(field_name, value);
385+
} else {
386+
println!(
387+
"Invalid input for '{}' (expected {:?}), declining.",
388+
field_name, field.field_type
389+
);
390+
return Ok(ElicitationResponse::Declined);
391+
}
392+
}
393+
ReadLineResult::Interrupted | ReadLineResult::Eof => {
394+
return Ok(ElicitationResponse::Cancelled);
395+
}
396+
}
397+
}
398+
399+
Ok(ElicitationResponse::Accepted(serde_json::Value::Object(
400+
values,
401+
)))
402+
}
403+
}
404+
405+
fn build_field_prompt(field: &ElicitationField) -> String {
406+
let type_hint = match &field.field_type {
407+
ElicitationFieldType::Boolean => " [true/false]",
408+
ElicitationFieldType::Integer | ElicitationFieldType::Number => " [number]",
409+
ElicitationFieldType::Enum(opts) if !opts.is_empty() => {
410+
// Build hint dynamically below
411+
return format!(
412+
"{}{}: ",
413+
field.name,
414+
field
415+
.description
416+
.as_deref()
417+
.map(|d| format!(" ({d})"))
418+
.unwrap_or_default()
419+
) + &format!("[{}]: ", opts.join("/"));
420+
}
421+
_ => "",
422+
};
423+
format!(
424+
"{}{}{}",
425+
field.name,
426+
field
427+
.description
428+
.as_deref()
429+
.map(|d| format!(" ({d})"))
430+
.unwrap_or_default(),
431+
if type_hint.is_empty() {
432+
": ".to_owned()
433+
} else {
434+
format!("{type_hint}: ")
435+
}
436+
)
437+
}
438+
439+
/// Coerce a raw user-input string into the JSON type required by the field.
440+
/// Returns `None` if the input cannot be converted to the declared type.
441+
fn coerce_field_value(raw: &str, field_type: &ElicitationFieldType) -> Option<serde_json::Value> {
442+
match field_type {
443+
ElicitationFieldType::String => Some(serde_json::Value::String(raw.to_owned())),
444+
ElicitationFieldType::Boolean => match raw.to_ascii_lowercase().as_str() {
445+
"true" | "yes" | "1" => Some(serde_json::Value::Bool(true)),
446+
"false" | "no" | "0" => Some(serde_json::Value::Bool(false)),
447+
_ => None,
448+
},
449+
ElicitationFieldType::Integer => raw
450+
.parse::<i64>()
451+
.ok()
452+
.map(|n| serde_json::Value::Number(n.into())),
453+
ElicitationFieldType::Number => raw
454+
.parse::<f64>()
455+
.ok()
456+
.and_then(serde_json::Number::from_f64)
457+
.map(serde_json::Value::Number),
458+
ElicitationFieldType::Enum(opts) => {
459+
if opts.iter().any(|o| o == raw) {
460+
Some(serde_json::Value::String(raw.to_owned()))
461+
} else {
462+
None
463+
}
464+
}
465+
}
349466
}
350467

351468
#[cfg(test)]

crates/zeph-config/src/channels.rs

Lines changed: 20 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`.
@@ -512,6 +531,7 @@ impl std::fmt::Debug for McpServerConfig {
512531
"tool_metadata_keys",
513532
&self.tool_metadata.keys().collect::<Vec<_>>(),
514533
)
534+
.field("elicitation_enabled", &self.elicitation_enabled)
515535
.finish()
516536
}
517537
}

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

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

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

0 commit comments

Comments
 (0)