[mcp] Support server-driven elicitations (#17043)

- [x] Enables MCP elicitation for custom servers, not just Codex Apps
- [x] Adds an RMCP service wrapper to preserve elicitation _meta
- [x] Round-trips response _meta for persist/approval choices
- [x] Updates TUI empty-schema elicitations into message-only approval
prompts
This commit is contained in:
Matthew Zeng
2026-04-08 10:18:58 -07:00
committed by GitHub
parent 06d88b7e81
commit 7b6486a145
8 changed files with 372 additions and 83 deletions

View File

@@ -1319,19 +1319,15 @@ impl From<anyhow::Error> for StartupOutcomeError {
}
}
fn elicitation_capability_for_server(server_name: &str) -> Option<ElicitationCapability> {
if server_name == CODEX_APPS_MCP_SERVER_NAME {
// https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities
// indicates this should be an empty object.
Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None,
}),
url: None,
})
} else {
None
}
fn elicitation_capability_for_server(_server_name: &str) -> Option<ElicitationCapability> {
// https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities
// indicates this should be an empty object.
Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None,
}),
url: None,
})
}
async fn start_server_task(

View File

@@ -508,19 +508,19 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() {
}
#[test]
fn elicitation_capability_enabled_only_for_codex_apps() {
let codex_apps_capability = elicitation_capability_for_server(CODEX_APPS_MCP_SERVER_NAME);
assert!(matches!(
codex_apps_capability,
Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None
}),
url: None,
})
));
assert!(elicitation_capability_for_server("custom_mcp").is_none());
fn elicitation_capability_enabled_for_custom_servers() {
for server_name in [CODEX_APPS_MCP_SERVER_NAME, "custom_mcp"] {
let capability = elicitation_capability_for_server(server_name);
assert!(matches!(
capability,
Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: None
}),
url: None,
})
));
}
}
#[test]

View File

@@ -0,0 +1,213 @@
use std::sync::Arc;
use rmcp::RoleClient;
use rmcp::model::ClientInfo;
use rmcp::model::ClientResult;
use rmcp::model::CustomResult;
use rmcp::model::ElicitationAction;
use rmcp::model::Meta;
use rmcp::model::RequestParamsMeta;
use rmcp::model::ServerNotification;
use rmcp::model::ServerRequest;
use rmcp::service::NotificationContext;
use rmcp::service::RequestContext;
use rmcp::service::Service;
use serde::Serialize;
use serde_json::Value;
use crate::logging_client_handler::LoggingClientHandler;
use crate::rmcp_client::Elicitation;
use crate::rmcp_client::ElicitationResponse;
use crate::rmcp_client::SendElicitation;
const MCP_PROGRESS_TOKEN_META_KEY: &str = "progressToken";
#[derive(Clone)]
pub(crate) struct ElicitationClientService {
handler: LoggingClientHandler,
send_elicitation: Arc<SendElicitation>,
}
impl ElicitationClientService {
pub(crate) fn new(client_info: ClientInfo, send_elicitation: SendElicitation) -> Self {
let send_elicitation = Arc::new(send_elicitation);
Self {
handler: LoggingClientHandler::new(
client_info,
clone_send_elicitation(Arc::clone(&send_elicitation)),
),
send_elicitation,
}
}
async fn create_elicitation(
&self,
request: Elicitation,
context: RequestContext<RoleClient>,
) -> Result<ElicitationResponse, rmcp::ErrorData> {
let RequestContext { id, meta, .. } = context;
let request = restore_context_meta(request, meta);
(self.send_elicitation)(id, request)
.await
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))
}
}
fn clone_send_elicitation(send_elicitation: Arc<SendElicitation>) -> SendElicitation {
Box::new(move |request_id, request| send_elicitation(request_id, request))
}
impl Service<RoleClient> for ElicitationClientService {
async fn handle_request(
&self,
request: ServerRequest,
context: RequestContext<RoleClient>,
) -> Result<ClientResult, rmcp::ErrorData> {
match request {
ServerRequest::CreateElicitationRequest(request) => {
let response = self.create_elicitation(request.params, context).await?;
// RMCP's typed CreateElicitationResult does not model result-level `_meta`.
let result = elicitation_response_result(response)?;
Ok(ClientResult::CustomResult(result))
}
request => {
<LoggingClientHandler as Service<RoleClient>>::handle_request(
&self.handler,
request,
context,
)
.await
}
}
}
async fn handle_notification(
&self,
notification: ServerNotification,
context: NotificationContext<RoleClient>,
) -> Result<(), rmcp::ErrorData> {
<LoggingClientHandler as Service<RoleClient>>::handle_notification(
&self.handler,
notification,
context,
)
.await
}
fn get_info(&self) -> ClientInfo {
<LoggingClientHandler as Service<RoleClient>>::get_info(&self.handler)
}
}
fn restore_context_meta(mut request: Elicitation, mut context_meta: Meta) -> Elicitation {
// RMCP lifts JSON-RPC `_meta` into RequestContext before invoking services.
context_meta.remove(MCP_PROGRESS_TOKEN_META_KEY);
if context_meta.is_empty() {
return request;
}
request
.meta_mut()
.get_or_insert_with(Meta::new)
.extend(context_meta);
request
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CreateElicitationResultWithMeta {
action: ElicitationAction,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<Value>,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
meta: Option<Value>,
}
fn elicitation_response_result(
response: ElicitationResponse,
) -> Result<CustomResult, rmcp::ErrorData> {
let ElicitationResponse {
action,
content,
meta,
} = response;
let result = CreateElicitationResultWithMeta {
action,
content,
meta,
};
serde_json::to_value(result)
.map(CustomResult)
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use rmcp::model::BooleanSchema;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::ElicitationSchema;
use rmcp::model::PrimitiveSchema;
use serde_json::Value;
use serde_json::json;
use super::*;
#[test]
fn restore_context_meta_adds_elicitation_meta_and_removes_progress_token() {
let request = restore_context_meta(
form_request(/*meta*/ None),
meta(json!({
"progressToken": "progress-token",
"persist": ["session", "always"],
})),
);
assert_eq!(
request,
form_request(Some(meta(json!({
"persist": ["session", "always"],
}))))
);
}
#[test]
fn elicitation_response_result_serializes_response_meta() {
let result = rmcp::model::ClientResult::CustomResult(
elicitation_response_result(ElicitationResponse {
action: ElicitationAction::Accept,
content: Some(json!({ "confirmed": true })),
meta: Some(json!({ "persist": "always" })),
})
.expect("elicitation response should serialize"),
);
assert_eq!(
serde_json::to_value(result).expect("client result should serialize"),
json!({
"action": "accept",
"content": { "confirmed": true },
"_meta": { "persist": "always" },
})
);
}
fn form_request(meta: Option<Meta>) -> CreateElicitationRequestParams {
CreateElicitationRequestParams::FormElicitationParams {
meta,
message: "Confirm?".to_string(),
requested_schema: ElicitationSchema::builder()
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
.build()
.expect("schema should build"),
}
}
fn meta(value: Value) -> Meta {
let Value::Object(map) = value else {
panic!("meta must be an object");
};
Meta(map)
}
}

View File

@@ -1,4 +1,5 @@
mod auth_status;
mod elicitation_client_service;
mod logging_client_handler;
mod oauth;
mod perform_oauth_login;

View File

@@ -67,8 +67,8 @@ use tokio::time;
use tracing::info;
use tracing::warn;
use crate::elicitation_client_service::ElicitationClientService;
use crate::load_oauth_tokens;
use crate::logging_client_handler::LoggingClientHandler;
use crate::oauth::OAuthPersistor;
use crate::oauth::StoredOAuthTokens;
use crate::program_resolver;
@@ -321,7 +321,7 @@ enum ClientState {
},
Ready {
_process_group_guard: Option<ProcessGroupGuard>,
service: Arc<RunningService<RoleClient, LoggingClientHandler>>,
service: Arc<RunningService<RoleClient, ElicitationClientService>>,
oauth: Option<OAuthPersistor>,
},
}
@@ -407,7 +407,7 @@ enum TransportRecipe {
#[derive(Clone)]
struct InitializeContext {
timeout: Option<Duration>,
handler: LoggingClientHandler,
client_service: ElicitationClientService,
}
#[derive(Debug, thiserror::Error)]
@@ -539,7 +539,7 @@ impl RmcpClient {
timeout: Option<Duration>,
send_elicitation: SendElicitation,
) -> Result<InitializeResult> {
let client_handler = LoggingClientHandler::new(params.clone(), send_elicitation);
let client_service = ElicitationClientService::new(params.clone(), send_elicitation);
let pending_transport = {
let mut guard = self.state.lock().await;
match &mut *guard {
@@ -552,7 +552,7 @@ impl RmcpClient {
};
let (service, oauth_persistor, process_group_guard) =
Self::connect_pending_transport(pending_transport, client_handler.clone(), timeout)
Self::connect_pending_transport(pending_transport, client_service.clone(), timeout)
.await?;
let initialize_result_rmcp = service
@@ -565,7 +565,7 @@ impl RmcpClient {
let mut initialize_context = self.initialize_context.lock().await;
*initialize_context = Some(InitializeContext {
timeout,
handler: client_handler,
client_service,
});
}
@@ -814,7 +814,7 @@ impl RmcpClient {
Ok(response)
}
async fn service(&self) -> Result<Arc<RunningService<RoleClient, LoggingClientHandler>>> {
async fn service(&self) -> Result<Arc<RunningService<RoleClient, ElicitationClientService>>> {
let guard = self.state.lock().await;
match &*guard {
ClientState::Ready { service, .. } => Ok(Arc::clone(service)),
@@ -997,10 +997,10 @@ impl RmcpClient {
async fn connect_pending_transport(
pending_transport: PendingTransport,
client_handler: LoggingClientHandler,
client_service: ElicitationClientService,
timeout: Option<Duration>,
) -> Result<(
Arc<RunningService<RoleClient, LoggingClientHandler>>,
Arc<RunningService<RoleClient, ElicitationClientService>>,
Option<OAuthPersistor>,
Option<ProcessGroupGuard>,
)> {
@@ -1009,12 +1009,12 @@ impl RmcpClient {
transport,
process_group_guard,
} => (
service::serve_client(client_handler, transport).boxed(),
service::serve_client(client_service, transport).boxed(),
None,
process_group_guard,
),
PendingTransport::StreamableHttp { transport } => (
service::serve_client(client_handler, transport).boxed(),
service::serve_client(client_service, transport).boxed(),
None,
None,
),
@@ -1022,7 +1022,7 @@ impl RmcpClient {
transport,
oauth_persistor,
} => (
service::serve_client(client_handler, transport).boxed(),
service::serve_client(client_service, transport).boxed(),
Some(oauth_persistor),
None,
),
@@ -1048,7 +1048,7 @@ impl RmcpClient {
operation: F,
) -> Result<T>
where
F: Fn(Arc<RunningService<RoleClient, LoggingClientHandler>>) -> Fut,
F: Fn(Arc<RunningService<RoleClient, ElicitationClientService>>) -> Fut,
Fut: std::future::Future<Output = std::result::Result<T, rmcp::service::ServiceError>>,
{
let service = self.service().await?;
@@ -1068,13 +1068,13 @@ impl RmcpClient {
}
async fn run_service_operation_once<T, F, Fut>(
service: Arc<RunningService<RoleClient, LoggingClientHandler>>,
service: Arc<RunningService<RoleClient, ElicitationClientService>>,
label: &str,
timeout: Option<Duration>,
operation: &F,
) -> std::result::Result<T, ClientOperationError>
where
F: Fn(Arc<RunningService<RoleClient, LoggingClientHandler>>) -> Fut,
F: Fn(Arc<RunningService<RoleClient, ElicitationClientService>>) -> Fut,
Fut: std::future::Future<Output = std::result::Result<T, rmcp::service::ServiceError>>,
{
match timeout {
@@ -1111,7 +1111,7 @@ impl RmcpClient {
async fn reinitialize_after_session_expiry(
&self,
failed_service: &Arc<RunningService<RoleClient, LoggingClientHandler>>,
failed_service: &Arc<RunningService<RoleClient, ElicitationClientService>>,
) -> Result<()> {
let _recovery_guard = self.session_recovery_lock.lock().await;
@@ -1137,7 +1137,7 @@ impl RmcpClient {
let pending_transport = Self::create_pending_transport(&self.transport_recipe).await?;
let (service, oauth_persistor, process_group_guard) = Self::connect_pending_transport(
pending_transport,
initialize_context.handler,
initialize_context.client_service,
initialize_context.timeout,
)
.await?;

View File

@@ -278,46 +278,48 @@ impl McpServerElicitationFormRequest {
.and_then(Value::as_object)
.is_some_and(serde_json::Map::is_empty)
});
let is_tool_approval_action =
is_tool_approval && (requested_schema.is_null() || is_empty_object_schema);
let is_message_only_schema = requested_schema.is_null() || is_empty_object_schema;
let is_tool_approval_action = is_tool_approval && is_message_only_schema;
let approval_display_params = if is_tool_approval_action {
parse_tool_approval_display_params(meta.as_ref())
} else {
Vec::new()
};
let (response_mode, fields) = if tool_suggestion.is_some()
&& (requested_schema.is_null() || is_empty_object_schema)
{
let (response_mode, fields) = if tool_suggestion.is_some() && is_message_only_schema {
(McpServerElicitationResponseMode::FormContent, Vec::new())
} else if requested_schema.is_null() || (is_tool_approval && is_empty_object_schema) {
} else if is_message_only_schema {
let allow_description = if is_tool_approval_action {
"Run the tool and continue."
} else {
"Allow this request and continue."
};
let mut options = vec![McpServerElicitationOption {
label: "Allow".to_string(),
description: Some("Run the tool and continue.".to_string()),
description: Some(allow_description.to_string()),
value: Value::String(APPROVAL_ACCEPT_ONCE_VALUE.to_string()),
}];
if is_tool_approval_action
&& tool_approval_supports_persist_mode(
meta.as_ref(),
APPROVAL_PERSIST_SESSION_VALUE,
)
{
if approval_supports_persist_mode(meta.as_ref(), APPROVAL_PERSIST_SESSION_VALUE) {
let description = if is_tool_approval_action {
"Run the tool and remember this choice for this session."
} else {
"Allow this request and remember this choice for this session."
};
options.push(McpServerElicitationOption {
label: "Allow for this session".to_string(),
description: Some(
"Run the tool and remember this choice for this session.".to_string(),
),
description: Some(description.to_string()),
value: Value::String(APPROVAL_ACCEPT_SESSION_VALUE.to_string()),
});
}
if is_tool_approval_action
&& tool_approval_supports_persist_mode(meta.as_ref(), APPROVAL_PERSIST_ALWAYS_VALUE)
{
if approval_supports_persist_mode(meta.as_ref(), APPROVAL_PERSIST_ALWAYS_VALUE) {
let description = if is_tool_approval_action {
"Run the tool and remember this choice for future tool calls."
} else {
"Allow this request and remember this choice for future requests."
};
options.push(McpServerElicitationOption {
label: "Always allow".to_string(),
description: Some(
"Run the tool and remember this choice for future tool calls.".to_string(),
),
description: Some(description.to_string()),
value: Value::String(APPROVAL_ACCEPT_ALWAYS_VALUE.to_string()),
});
}
@@ -331,12 +333,12 @@ impl McpServerElicitationFormRequest {
options.extend([
McpServerElicitationOption {
label: "Deny".to_string(),
description: Some("Decline this tool call and continue.".to_string()),
description: Some("Decline this request and continue.".to_string()),
value: Value::String(APPROVAL_DECLINE_VALUE.to_string()),
},
McpServerElicitationOption {
label: "Cancel".to_string(),
description: Some("Cancel this tool call".to_string()),
description: Some("Cancel this request".to_string()),
value: Value::String(APPROVAL_CANCEL_VALUE.to_string()),
},
]);
@@ -428,7 +430,7 @@ fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option<ToolSuggestionR
})
}
fn tool_approval_supports_persist_mode(meta: Option<&Value>, expected_mode: &str) -> bool {
fn approval_supports_persist_mode(meta: Option<&Value>, expected_mode: &str) -> bool {
let Some(persist) = meta
.and_then(Value::as_object)
.and_then(|meta| meta.get(APPROVAL_PERSIST_KEY))
@@ -1889,19 +1891,17 @@ mod tests {
options: vec![
McpServerElicitationOption {
label: "Allow".to_string(),
description: Some("Run the tool and continue.".to_string()),
description: Some("Allow this request and continue.".to_string()),
value: Value::String(APPROVAL_ACCEPT_ONCE_VALUE.to_string()),
},
McpServerElicitationOption {
label: "Deny".to_string(),
description: Some(
"Decline this tool call and continue.".to_string(),
),
description: Some("Decline this request and continue.".to_string()),
value: Value::String(APPROVAL_DECLINE_VALUE.to_string()),
},
McpServerElicitationOption {
label: "Cancel".to_string(),
description: Some("Cancel this tool call".to_string()),
description: Some("Cancel this request".to_string()),
value: Value::String(APPROVAL_CANCEL_VALUE.to_string()),
},
],
@@ -2030,16 +2030,6 @@ mod tests {
);
}
#[test]
fn empty_unmarked_schema_falls_back() {
let request = McpServerElicitationFormRequest::from_event(
ThreadId::default(),
form_request("Empty form", empty_object_schema(), /*meta*/ None),
);
assert_eq!(request, None);
}
#[test]
fn tool_approval_display_params_prefer_explicit_display_order() {
let request = McpServerElicitationFormRequest::from_event(
@@ -2432,6 +2422,57 @@ mod tests {
);
}
#[test]
fn message_only_form_snapshot() {
let (tx, _rx) = test_sender();
let request = McpServerElicitationFormRequest::from_event(
ThreadId::default(),
form_request(
"Boolean elicit MCP example: do you confirm?",
empty_object_schema(),
/*meta*/ None,
),
)
.expect("expected message-only form");
let overlay = McpServerElicitationOverlay::new(
request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
insta::assert_snapshot!(
"mcp_server_elicitation_message_only_form",
render_snapshot(&overlay, Rect::new(0, 0, 120, 16))
);
}
#[test]
fn message_only_form_with_persist_options_snapshot() {
let (tx, _rx) = test_sender();
let request = McpServerElicitationFormRequest::from_event(
ThreadId::default(),
form_request(
"Boolean elicit MCP example: do you confirm?",
empty_object_schema(),
Some(serde_json::json!({
APPROVAL_PERSIST_KEY: [
APPROVAL_PERSIST_SESSION_VALUE,
APPROVAL_PERSIST_ALWAYS_VALUE,
],
})),
),
)
.expect("expected message-only form");
let overlay = McpServerElicitationOverlay::new(
request, tx, /*has_input_focus*/ true, /*enhanced_keys_supported*/ false,
/*disable_paste_burst*/ false,
);
insta::assert_snapshot!(
"mcp_server_elicitation_message_only_form_with_persist_options",
render_snapshot(&overlay, Rect::new(0, 0, 120, 16))
);
}
#[test]
fn approval_form_tool_approval_with_persist_options_snapshot() {
let (tx, _rx) = test_sender();

View File

@@ -0,0 +1,19 @@
---
source: tui/src/bottom_pane/mcp_server_elicitation.rs
expression: "render_snapshot(&overlay, Rect::new(0, 0, 120, 16))"
---
Field 1/1
Boolean elicit MCP example: do you confirm?
1. Allow Allow this request and continue.
2. Deny Decline this request and continue.
3. Cancel Cancel this request
enter to submit | esc to cancel

View File

@@ -0,0 +1,19 @@
---
source: tui/src/bottom_pane/mcp_server_elicitation.rs
expression: "render_snapshot(&overlay, Rect::new(0, 0, 120, 16))"
---
Field 1/1
Boolean elicit MCP example: do you confirm?
1. Allow Allow this request and continue.
2. Allow for this session Allow this request and remember this choice for this session.
3. Always allow Allow this request and remember this choice for future requests.
4. Deny Decline this request and continue.
5. Cancel Cancel this request
enter to submit | esc to cancel