@ondeinference / ed / commits / 99411cd

feat(ed-mcp): forward server elicitation/create to a client hook

MCP servers can ask the end user for structured input mid-call via `elicitation/create`, but ed-mcp's client handler declared no `elicitation` capability and `rmcp` declined every server-initiated request, so embedders (e.g. sigit forwarding to its ACP editor) had no seam to receive one. Add an `Elicitation` trait as that seam. `ToolsetConfig::with_elicitation` installs a handler shared by every server in the session; when set, ed-mcp advertises the `elicitation` capability and forwards each `elicitation/create` to the handler, mapping its answer to the MCP accept/decline/cancel semantics. With no handler, requests are declined (the spec's safe default) and the capability is not advertised, so conforming servers won't ask. Plumbed through `Connection::connect_with` (the existing `connect` is now a no-hook shim) and `SharedServer`, so built-in and client-supplied servers share one hook. The elicitation request/result/action types are re-exported so embedders need not depend on rmcp directly. Tests: the in-process HTTP server gains a `collect_feedback` tool that elicits; new tests prove the request reaches the hook (with the asking server's name) and the Accept+content flows back, and that it's declined when no hook is set. Closes #33. Co-Authored-By: SplitFire v1.1.1-acp <noreply@splitfire.ai>

Seto Elkahfi committed Oct 11, 2026 at 19:30 UTC 99411cda3677a70d3b7ac747f2c34d98cbdd42b7
4 files changed +248 -14
Cargo.lock
+1
@@ -6538,6 +6538,7 @@ dependencies = [
6538 "tokio-util",
6539 "tower-service",
6540 "tracing",
6541 + "url",
6542 "uuid 1.27.0",
6543 ]
6544
crates/ed-mcp/Cargo.toml
+3 -1
@@ -35,7 +35,9 @@ agent = ["dep:ed-agent", "dep:async-trait"]
35 test-server = []
36
37 [dev-dependencies]
38 -rmcp = { workspace = true, features = ["server", "transport-streamable-http-server"] }
38 +# `elicitation` adds the server peer's `create_elicitation`, so the in-process test server can
39 +# ask the client for input and exercise the forwarding hook.
40 +rmcp = { workspace = true, features = ["server", "transport-streamable-http-server", "elicitation"] }
41 axum = "0.8"
42 tokio = { workspace = true, features = ["net", "rt-multi-thread"] }
43 # Run the feature-gated code's tests under a plain `cargo test`.
crates/ed-mcp/src/lib.rs
+108 -9
@@ -30,13 +30,13 @@ use anyhow::{Context, Result, anyhow, bail};
30 use futures::StreamExt;
31 use rmcp::handler::client::progress::ProgressDispatcher;
32 use rmcp::model::{
33 - CallToolRequestParams, ClientCapabilities, ClientRequest, ContentBlock, Implementation,
34 - ProgressNotificationParam, Request, ServerResult, Tool,
33 + CallToolRequestParams, ClientCapabilities, ClientRequest, ContentBlock, ElicitationCapability,
34 + Implementation, ProgressNotificationParam, Request, ServerResult, Tool,
35 };
36 -use rmcp::service::{NotificationContext, PeerRequestOptions, RunningService};
36 +use rmcp::service::{NotificationContext, PeerRequestOptions, RequestContext, RunningService};
37 use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
38 use rmcp::transport::{StreamableHttpClientTransport, TokioChildProcess};
39 -use rmcp::{ClientHandler, RoleClient, ServiceExt};
39 +use rmcp::{ClientHandler, ErrorData as McpError, RoleClient, ServiceExt};
40 use serde_json::{Map, Value, json};
41 use tokio::sync::Mutex;
42 use tokio_util::sync::CancellationToken;
@@ -91,6 +91,35 @@ fn sanitize(part: &str) -> String {
91 out.trim_matches('_').to_string()
92 }
93
94 +// The MCP elicitation types an [`Elicitation`] handler works with, re-exported so embedders
95 +// need not depend on `rmcp` directly: the request a server sends, the client's answer, and the
96 +// three actions it can take (accept with content, decline, cancel).
97 +pub use rmcp::model::{ElicitRequestParams, ElicitResult, ElicitationAction};
98 +
99 +/// A hook for server-initiated `elicitation/create`: an MCP server asking the end user for
100 +/// structured input mid-call (see the [MCP elicitation spec]).
101 +///
102 +/// `ed-mcp` is a client, not a UI, so it does not answer these itself. An embedder (a host that
103 +/// wires `ed-mcp` into its own UX — e.g. forwarding to an ACP editor as a form) implements this
104 +/// trait and hands it to [`ToolsetConfig::with_elicitation`]. The handler is shared by every
105 +/// server in the session, built-in or client-supplied.
106 +///
107 +/// The return maps straight to MCP semantics: [`ElicitationAction::Accept`] with `content`,
108 +/// [`ElicitationAction::Decline`], or [`ElicitationAction::Cancel`]. Returning `Err` surfaces a
109 +/// protocol error to the server. When no handler is set, `ed-mcp` declines every request, which
110 +/// is the safe default the spec allows.
111 +///
112 +/// [MCP elicitation spec]: https://modelcontextprotocol.io/specification/draft/client/elicitation
113 +pub trait Elicitation: Send + Sync {
114 + /// Answer one `elicitation/create` request. `server` is the name of the server that asked,
115 + /// so the embedder can label its prompt.
116 + fn elicit(
117 + &self,
118 + server: &str,
119 + request: ElicitRequestParams,
120 + ) -> futures::future::BoxFuture<'_, Result<ElicitResult>>;
121 +}
122 +
123 /// Who is connecting: sent to every server in `initialize` as `clientInfo`.
124 #[derive(Debug, Clone)]
125 pub struct ClientInfo {
@@ -228,6 +257,11 @@ impl ServerSpec {
257 struct Handler {
258 progress: ProgressDispatcher,
259 info: ClientInfo,
260 + /// The name of the server this handler is connected to, so an elicitation prompt can say
261 + /// which server is asking.
262 + server: String,
263 + /// Where server-initiated `elicitation/create` requests go. `None` declines them all.
264 + elicitation: Option<Arc<dyn Elicitation>>,
265 }
266
267 impl ClientHandler for Handler {
@@ -239,9 +273,32 @@ impl ClientHandler for Handler {
273 self.progress.handle_notification(params).await;
274 }
275
276 + async fn create_elicitation(
277 + &self,
278 + request: ElicitRequestParams,
279 + _context: RequestContext<RoleClient>,
280 + ) -> Result<ElicitResult, McpError> {
281 + // No embedder hook: decline, as the spec allows. We still advertised the capability so a
282 + // conforming server may ask; declining is the honest answer to "we have no UI for this".
283 + let Some(handler) = &self.elicitation else {
284 + return Ok(ElicitResult::new(ElicitationAction::Decline));
285 + };
286 + handler
287 + .elicit(&self.server, request)
288 + .await
289 + .map_err(|e| McpError::internal_error(format!("elicitation handler failed: {e}"), None))
290 + }
291 +
292 fn get_info(&self) -> rmcp::model::ClientConfig {
293 + // Advertise the `elicitation` capability only when an embedder gave us somewhere to send
294 + // the request. A conforming server will not attempt `elicitation/create` otherwise, which
295 + // keeps us from advertising a form UI we cannot actually present.
296 + let mut capabilities = ClientCapabilities::default();
297 + if self.elicitation.is_some() {
298 + capabilities.elicitation = Some(ElicitationCapability::default());
299 + }
300 rmcp::model::ClientConfig::new(
244 - ClientCapabilities::default(),
301 + capabilities,
302 Implementation::new(self.info.name.clone(), self.info.version.clone()),
303 )
304 }
@@ -257,9 +314,22 @@ pub struct Connection {
314 impl Connection {
315 /// Connect to `spec`, run the handshake and list its tools, each within `timeout`.
316 pub async fn connect(spec: &ServerSpec, info: &ClientInfo, timeout: Duration) -> Result<Self> {
317 + Self::connect_with(spec, info, None, timeout).await
318 + }
319 +
320 + /// Connect to `spec`, with an optional [`Elicitation`] hook for server-initiated
321 + /// `elicitation/create` requests. [`Connection::connect`] is this with no hook.
322 + pub async fn connect_with(
323 + spec: &ServerSpec,
324 + info: &ClientInfo,
325 + elicitation: Option<Arc<dyn Elicitation>>,
326 + timeout: Duration,
327 + ) -> Result<Self> {
328 let handler = Handler {
329 progress: ProgressDispatcher::new(),
330 info: info.clone(),
331 + server: spec.name.clone(),
332 + elicitation,
333 };
334 let handshake = match &spec.transport {
335 Transport::Stdio { command, args, env } => {
@@ -380,7 +450,12 @@ impl SharedServer {
450 self.name
451 }
452
383 - async fn get(&self, info: &ClientInfo, timeout: Duration) -> Option<Arc<Connection>> {
453 + async fn get(
454 + &self,
455 + info: &ClientInfo,
456 + elicitation: Option<Arc<dyn Elicitation>>,
457 + timeout: Duration,
458 + ) -> Option<Arc<Connection>> {
459 let mut state = self.state.lock().await;
460 match &*state {
461 SharedState::Running(conn) => return Some(conn.clone()),
@@ -392,7 +467,7 @@ impl SharedServer {
467 tracing::info!("built-in MCP server `{}` is not available", self.name);
468 return None;
469 };
395 - match Connection::connect(&spec, info, timeout).await {
470 + match Connection::connect_with(&spec, info, elicitation, timeout).await {
471 Ok(conn) => {
472 tracing::info!("built-in MCP server `{}` available", self.name);
473 let conn = Arc::new(conn);
@@ -424,6 +499,10 @@ pub struct ToolsetConfig {
499 pub client: ClientInfo,
500 pub connect_timeout: Duration,
501 pub call_timeout: Duration,
502 + /// Where server-initiated `elicitation/create` requests go. Set by
503 + /// [`ToolsetConfig::with_elicitation`]; `None` (the default) declines them all and does not
504 + /// advertise the capability. Shared by every server in the session.
505 + pub elicitation: Option<Arc<dyn Elicitation>>,
506 }
507
508 impl ToolsetConfig {
@@ -432,8 +511,16 @@ impl ToolsetConfig {
511 client,
512 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
513 call_timeout: DEFAULT_CALL_TIMEOUT,
514 + elicitation: None,
515 }
516 }
517 +
518 + /// Forward servers' `elicitation/create` requests to `handler`. This also makes `ed-mcp`
519 + /// advertise the `elicitation` client capability, so conforming servers will ask.
520 + pub fn with_elicitation(mut self, handler: Arc<dyn Elicitation>) -> Self {
521 + self.elicitation = Some(handler);
522 + self
523 + }
524 }
525
526 /// One MCP tool as the model sees it.
@@ -524,8 +611,16 @@ impl McpToolset {
611 builtins: &[&'static SharedServer],
612 ) -> Self {
613 let mut set = Self::empty(config);
614 + let elicitation = set.config.elicitation.clone();
615 for spec in servers {
528 - match Connection::connect(spec, &set.config.client, set.config.connect_timeout).await {
616 + match Connection::connect_with(
617 + spec,
618 + &set.config.client,
619 + elicitation.clone(),
620 + set.config.connect_timeout,
621 + )
622 + .await
623 + {
624 Ok(conn) => set.add(Arc::new(conn)),
625 Err(e) => tracing::warn!("MCP server `{}` unavailable: {e:#}", spec.name),
626 }
@@ -535,7 +630,11 @@ impl McpToolset {
630 continue;
631 }
632 if let Some(conn) = builtin
538 - .get(&set.config.client, set.config.connect_timeout)
633 + .get(
634 + &set.config.client,
635 + elicitation.clone(),
636 + set.config.connect_timeout,
637 + )
638 .await
639 {
640 set.add(conn);
crates/ed-mcp/tests/http.rs
+136 -4
@@ -9,7 +9,8 @@ use axum::middleware::{self, Next};
9 use axum::response::Response;
10 use ed_agent::{ToolExecutor, ToolRisk};
11 use ed_mcp::{
12 - ClientInfo, Connection, McpExecutor, McpToolset, ServerSpec, ToolPolicy, ToolsetConfig,
12 + ClientInfo, Connection, ElicitRequestParams, ElicitResult, Elicitation, ElicitationAction,
13 + McpExecutor, McpToolset, ServerSpec, ToolPolicy, ToolsetConfig,
14 };
15 use rmcp::RoleServer;
16 use rmcp::ServerHandler;
@@ -46,7 +47,9 @@ impl ServerHandler for Mailbox {
47 .annotate(ToolAnnotations::new().read_only(true)),
48 // Says nothing, like a server that predates annotations.
49 Tool::new("list_messages", "Recent messages.", schema.clone()),
49 - Tool::new("send_message", "Send mail.", schema),
50 + Tool::new("send_message", "Send mail.", schema.clone()),
51 + // Elicits structured input from the user mid-call.
52 + Tool::new("collect_feedback", "Ask the user for feedback.", schema),
53 ],
54 ..Default::default()
55 })
@@ -55,8 +58,43 @@ impl ServerHandler for Mailbox {
58 async fn call_tool(
59 &self,
60 request: CallToolRequestParams,
58 - _context: RequestContext<RoleServer>,
61 + context: RequestContext<RoleServer>,
62 ) -> Result<CallToolResponse, rmcp::ErrorData> {
63 + // `collect_feedback` asks the client (via `elicitation/create`) for input and reports
64 + // what came back, so a test can prove the request reached the client's hook and the
65 + // answer flowed back.
66 + if request.name == "collect_feedback" {
67 + let params: ElicitRequestParams = serde_json::from_value(json!({
68 + "mode": "form",
69 + "message": "How was it?",
70 + "requestedSchema": {
71 + "type": "object",
72 + "properties": {"rating": {"type": "string"}},
73 + "required": ["rating"]
74 + }
75 + }))
76 + .unwrap();
77 + let result = context
78 + .peer
79 + .create_elicitation(params)
80 + .await
81 + .map_err(|e| rmcp::ErrorData::internal_error(e.to_string(), None))?;
82 + let text = match result.action {
83 + ElicitationAction::Accept => {
84 + let rating = result
85 + .content
86 + .as_ref()
87 + .and_then(|c: &serde_json::Value| c.get("rating"))
88 + .and_then(|v: &serde_json::Value| v.as_str())
89 + .unwrap_or("");
90 + format!("accepted: {rating}")
91 + }
92 + ElicitationAction::Decline => "declined".to_string(),
93 + ElicitationAction::Cancel => "cancelled".to_string(),
94 + _ => "unknown".to_string(),
95 + };
96 + return Ok(CallToolResult::success(vec![ContentBlock::text(text)]).into());
97 + }
98 Ok(
99 CallToolResult::success(vec![ContentBlock::text(format!("ran {}", request.name))])
100 .into(),
@@ -111,6 +149,7 @@ async fn connects_lists_and_calls_with_a_bearer_token() {
149 assert_eq!(
150 names,
151 [
152 + "mcp__mail__collect_feedback",
153 "mcp__mail__list_messages",
154 "mcp__mail__send_message",
155 "mcp__mail__whoami"
@@ -140,7 +179,15 @@ async fn a_connection_lists_and_calls_tools_by_their_own_names() {
179
180 let mut names: Vec<_> = conn.tools().iter().map(|t| t.name.to_string()).collect();
181 names.sort();
143 - assert_eq!(names, ["list_messages", "send_message", "whoami"]);
182 + assert_eq!(
183 + names,
184 + [
185 + "collect_feedback",
186 + "list_messages",
187 + "send_message",
188 + "whoami"
189 + ]
190 + );
191
192 let result = conn
193 .call_tool("whoami", json!({}), Duration::from_secs(5))
@@ -231,6 +278,91 @@ async fn the_host_can_override_risk_and_drop_the_prefix() {
278 assert!(exec.execute("mcp__mail__send_message", "{}").await.is_err());
279 }
280
281 +/// Records the requests it is handed and answers each with a fixed action.
282 +struct Recorder {
283 + seen: Arc<std::sync::Mutex<Vec<(String, ElicitRequestParams)>>>,
284 + answer: ElicitResult,
285 +}
286 +
287 +impl Elicitation for Recorder {
288 + fn elicit(
289 + &self,
290 + server: &str,
291 + request: ElicitRequestParams,
292 + ) -> futures::future::BoxFuture<'_, anyhow::Result<ElicitResult>> {
293 + self.seen
294 + .lock()
295 + .unwrap()
296 + .push((server.to_string(), request));
297 + let answer = self.answer.clone();
298 + Box::pin(async move { Ok(answer) })
299 + }
300 +}
301 +
302 +#[tokio::test]
303 +async fn forwards_server_elicitation_to_the_hook() {
304 + let url = serve().await;
305 + let spec = ServerSpec::http("mail", url).with_bearer(TOKEN);
306 +
307 + let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
308 + let hook = Arc::new(Recorder {
309 + seen: seen.clone(),
310 + answer: ElicitResult::new(ElicitationAction::Accept)
311 + .with_content(json!({"rating": "great"})),
312 + });
313 + let config = ToolsetConfig::new(client()).with_elicitation(hook);
314 + let set = McpToolset::connect_specs(config, &[spec], &[]).await;
315 +
316 + let out = set
317 + .call(
318 + "mcp__mail__collect_feedback",
319 + json!({}),
320 + &CancellationToken::new(),
321 + &|_| {},
322 + )
323 + .await
324 + .unwrap();
325 +
326 + // The server's elicitation reached our hook, and the Accept + content flowed back to it.
327 + assert!(!out.failed, "{}", out.text);
328 + assert_eq!(out.text, "accepted: great");
329 +
330 + // Scoped so the guard is dropped before the `await` below.
331 + {
332 + let seen = seen.lock().unwrap();
333 + assert_eq!(seen.len(), 1, "the hook was called exactly once");
334 + assert_eq!(seen[0].0, "mail", "the hook knows which server asked");
335 + match &seen[0].1 {
336 + ElicitRequestParams::FormElicitationParams { message, .. } => {
337 + assert_eq!(message, "How was it?");
338 + }
339 + other => panic!("expected a form elicitation, got {other:?}"),
340 + }
341 + }
342 +
343 + set.shutdown().await;
344 +}
345 +
346 +#[tokio::test]
347 +async fn without_a_hook_server_elicitation_is_declined() {
348 + let url = serve().await;
349 + let spec = ServerSpec::http("mail", url).with_bearer(TOKEN);
350 +
351 + // No `with_elicitation`: the default declines, and the server sees a Decline.
352 + let set = McpToolset::connect_specs(ToolsetConfig::new(client()), &[spec], &[]).await;
353 + let out = set
354 + .call(
355 + "mcp__mail__collect_feedback",
356 + json!({}),
357 + &CancellationToken::new(),
358 + &|_| {},
359 + )
360 + .await
361 + .unwrap();
362 + assert_eq!(out.text, "declined");
363 + set.shutdown().await;
364 +}
365 +
366 #[tokio::test]
367 async fn disconnecting_removes_the_tools() {
368 let exec = executor(ToolPolicy::new()).await;