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
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 {
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 {
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
}
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 } => {
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()),
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);
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 {
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.
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
}
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);