mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
[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:
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
213
codex-rs/rmcp-client/src/elicitation_client_service.rs
Normal file
213
codex-rs/rmcp-client/src/elicitation_client_service.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod auth_status;
|
||||
mod elicitation_client_service;
|
||||
mod logging_client_handler;
|
||||
mod oauth;
|
||||
mod perform_oauth_login;
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user