Support openai/elicitation form requests (#41447)

## What changed

- Advertise `openai/elicitation` form support when the client declares an
  object-valued `form` capability, without deriving it from the legacy
  `openai/form` capability.
- Handle `openai/elicitation/create` requests in `form` mode and forward their
  metadata and opaque schema through app-server as `openaiForm`, preserving
  `x-openai-*` annotations.
- Keep legacy form handling independent and have the TUI automatically decline
  the new form requests it cannot render.

## Testing

- Add an app-server round-trip test for capability negotiation, request
  forwarding, and accepted responses.
- Extend MCP capability filtering coverage for `openai/elicitation.form`.

GitOrigin-RevId: 88f39257bffafe4ee98a9c910e38507843cd7eeb
This commit is contained in:
victor-openai
2026-08-29 02:43:10 +00:00
committed by copyberry
parent 0ae94fdd49
commit eec4a23cb1
19 changed files with 329 additions and 25 deletions
@@ -578,6 +578,27 @@
],
"type": "object"
},
{
"properties": {
"_meta": true,
"message": {
"type": "string"
},
"mode": {
"enum": [
"openaiForm"
],
"type": "string"
},
"requestedSchema": true
},
"required": [
"message",
"mode",
"requestedSchema"
],
"type": "object"
},
{
"properties": {
"_meta": true,
@@ -1436,6 +1436,27 @@
],
"type": "object"
},
{
"properties": {
"_meta": true,
"message": {
"type": "string"
},
"mode": {
"enum": [
"openaiForm"
],
"type": "string"
},
"requestedSchema": true
},
"required": [
"message",
"mode",
"requestedSchema"
],
"type": "object"
},
{
"properties": {
"_meta": true,
@@ -3999,6 +3999,27 @@
],
"type": "object"
},
{
"properties": {
"_meta": true,
"message": {
"type": "string"
},
"mode": {
"enum": [
"openaiForm"
],
"type": "string"
},
"requestedSchema": true
},
"required": [
"message",
"mode",
"requestedSchema"
],
"type": "object"
},
{
"properties": {
"_meta": true,
@@ -13,4 +13,4 @@ export type McpServerElicitationRequestParams = { threadId: string,
* context is app-server correlation rather than part of the protocol identity of the
* elicitation itself.
*/
turnId: string | null, serverName: string, } & ({ "mode": "form", _meta: JsonValue | null, message: string, requestedSchema: McpElicitationSchema, } | { "mode": "openai/form", _meta: JsonValue | null, message: string, requestedSchema: JsonValue, } | { "mode": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, });
turnId: string | null, serverName: string, } & ({ "mode": "form", _meta: JsonValue | null, message: string, requestedSchema: McpElicitationSchema, } | { "mode": "openai/form", _meta: JsonValue | null, message: string, requestedSchema: JsonValue, } | { "mode": "openaiForm", _meta: JsonValue | null, message: string, requestedSchema: JsonValue, } | { "mode": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, });
@@ -755,6 +755,7 @@ pub enum McpServerElicitationRequest {
message: String,
requested_schema: McpElicitationSchema,
},
// TODO(victor): Deprecate once migrated to `openai/elicitation/create`.
#[serde(rename = "openai/form", rename_all = "camelCase")]
#[ts(rename = "openai/form", rename_all = "camelCase")]
OpenAiForm {
@@ -764,6 +765,15 @@ pub enum McpServerElicitationRequest {
message: String,
requested_schema: JsonValue,
},
#[serde(rename = "openaiForm", rename_all = "camelCase")]
#[ts(rename = "openaiForm", rename_all = "camelCase")]
OpenAiElicitationForm {
#[serde(rename = "_meta")]
#[ts(rename = "_meta")]
meta: Option<JsonValue>,
message: String,
requested_schema: JsonValue,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Url {
@@ -799,6 +809,15 @@ impl TryFrom<CoreElicitationRequest> for McpServerElicitationRequest {
message,
requested_schema,
}),
CoreElicitationRequest::OpenAiElicitationForm {
meta,
message,
requested_schema,
} => Ok(Self::OpenAiElicitationForm {
meta,
message,
requested_schema,
}),
CoreElicitationRequest::Url {
meta,
message,
+23 -4
View File
@@ -98,6 +98,7 @@ legacy alias for declaring the `openai/form` extension.
"capabilities": {
"extensions": {
"openai/form": {},
"openai/elicitation": { "form": {} },
"io.modelcontextprotocol/ui": {
"mimeTypes": ["text/html;profile=mcp-app"]
}
@@ -106,6 +107,15 @@ legacy alias for declaring the `openai/form` extension.
}
```
`openai/elicitation.form: {}` declares support for forms received as
`mode: "openaiForm"`. It does not follow from the legacy capability.
App-server retains only the `form` key under `openai/elicitation`, preserving
its value when present. Form requests require an object-valued `form`
declaration. A bare namespace does not imply form support. User verification
requests are not implemented.
Clients must only advertise features supported by both the client and the
connected app-server.
App-server keeps the complete value under `io.modelcontextprotocol/ui`, rather
than deriving a WebView boolean, so clients can advertise additional supported
MIME types and future extension settings. The MCP extension profile is fixed
@@ -1931,16 +1941,25 @@ Order of messages:
1. `mcpServer/elicitation/request` (request) — includes `threadId`, nullable `turnId`, `serverName`, and either:
- a form request: `{ "mode": "form", "message": "...", "requestedSchema": { ... } }`
- an OpenAI extended form request: `{ "mode": "openai/form", "message": "...", "requestedSchema": { ... } }`
- an OpenAI form request: `{ "mode": "openaiForm", "message": "...", "requestedSchema": { ... } }`
- a legacy OpenAI extended form request: `{ "mode": "openai/form", "message": "...", "requestedSchema": { ... } }`
- a URL request: `{ "mode": "url", "message": "...", "url": "...", "elicitationId": "..." }`
2. Client response — `{ "action": "accept", "content": ... }`, `{ "action": "decline", "content": null }`, or `{ "action": "cancel", "content": null }`.
3. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt.
`turnId` is best-effort. When the elicitation is correlated with an active turn, the request includes that turn id; otherwise it is `null`.
For `openai/form`, app-server forwards `requestedSchema` as opaque JSON. The
client owns validation and rendering of supported field types and must return a
valid `decline` or `cancel` response when it cannot render a form.
MCP `openai/elicitation/create` requests must explicitly specify `mode: "form"`.
App-server forwards them as `mode: "openaiForm"`, preserving
`requestedSchema` as opaque JSON, including `x-openai-*` annotations. The legacy
`openai/form` route remains independent and also preserves its schema.
The client owns validation and rendering. Graphical clients show an unsupported
state for unknown semantic inputs, never a partial form or generic approval,
and wait for the user to decline or cancel instead of returning a JSON-RPC
error. The TUI automatically declines OpenAI forms, including requests replayed
from another client. Capability advertisement describes the session's initial
client, not all clients that may later attach.
For MCP tool approval elicitations, form request `meta` includes
`codex_approval_kind: "mcp_tool_call"` and may include `persist: "session"`,
@@ -112,6 +112,7 @@ enum ElicitationScenario {
StandardForm,
LegacySep1034Defaults,
OpenAiForm,
OpenAiElicitationForm,
Strict(StrictReviewScenario),
}
@@ -272,6 +273,39 @@ async fn mcp_server_openai_form_elicitation_round_trip() -> Result<()> {
fixture.finish(request_id, "accepted monthly-review").await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn mcp_server_openai_elicitation_form_round_trip() -> Result<()> {
let mut fixture =
ElicitationRoundTripFixture::start(ElicitationScenario::OpenAiElicitationForm).await?;
let (request_id, params) = fixture.read_elicitation().await?;
assert_eq!(
params,
McpServerElicitationRequestParams {
thread_id: fixture.thread_id.clone(),
turn_id: Some(fixture.turn_id.clone()),
server_name: "codex_apps".to_string(),
request: McpServerElicitationRequest::OpenAiElicitationForm {
meta: Some(json!({ "example/request": "template-picker" })),
message: OPENAI_FORM_MESSAGE.to_string(),
requested_schema: openai_elicitation_form_schema(),
},
}
);
fixture
.mcp
.send_response(
request_id.clone(),
serde_json::to_value(McpServerElicitationRequestResponse {
action: McpServerElicitationAction::Accept,
content: Some(json!({ "template": "monthly-review" })),
meta: Some(json!({ "example/response": "selected" })),
})?,
)
.await?;
fixture.finish(request_id, "accepted monthly-review").await
}
#[test_case(Review::Approved; "approved")]
#[test_case(Review::ApproveForMe; "approve_for_me")]
#[test_case(Review::Never; "never")]
@@ -666,6 +700,13 @@ impl ElicitationRoundTripFixture {
.with_codex_home(codex_home.path())
.build()
.await?;
let mut extensions = HashMap::from([(
OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID.to_string(),
json!({}),
)]);
if matches!(scenario, ElicitationScenario::OpenAiElicitationForm) {
extensions.insert("openai/elicitation".to_string(), json!({ "form": {} }));
}
timeout(
DEFAULT_READ_TIMEOUT,
mcp.initialize_with_capabilities(
@@ -676,11 +717,11 @@ impl ElicitationRoundTripFixture {
},
Some(InitializeCapabilities {
experimental_api: true,
mcp_server_openai_form_elicitation: true,
extensions: Some(HashMap::from([(
OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID.to_string(),
json!({}),
)])),
mcp_server_openai_form_elicitation: !matches!(
scenario,
ElicitationScenario::OpenAiElicitationForm
),
extensions: Some(extensions),
..Default::default()
}),
),
@@ -979,6 +1020,18 @@ impl ServerHandler for ElicitationAppsMcpServer {
Some(json!({}))
);
}
let extensions = request.capabilities.extensions.as_ref();
assert_eq!(
extensions
.and_then(|extensions| extensions.get("openai/elicitation"))
.cloned()
.map(Value::Object),
matches!(self.scenario, ElicitationScenario::OpenAiElicitationForm)
.then(|| json!({ "form": {} })),
);
if matches!(self.scenario, ElicitationScenario::OpenAiElicitationForm) {
assert!(extensions.is_some_and(|extensions| !extensions.contains_key("openai/form")));
}
context.peer.set_peer_info(request);
Ok(self.get_info())
}
@@ -1203,6 +1256,34 @@ impl ServerHandler for ElicitationAppsMcpServer {
.into(),
)
}
ElicitationScenario::OpenAiElicitationForm => {
let result = context
.peer
.send_request(McpServerRequest::CustomRequest(CustomRequest::new(
"openai/elicitation/create",
Some(json!({
"mode": "form",
"_meta": { "example/request": "template-picker" },
"message": OPENAI_FORM_MESSAGE,
"requestedSchema": openai_elicitation_form_schema(),
})),
)))
.await
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
assert_eq!(
serde_json::to_value(result)
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?,
json!({
"action": "accept",
"content": { "template": "monthly-review" },
"_meta": { "example/response": "selected" },
}),
);
Ok(
CallToolResult::success(vec![ContentBlock::text("accepted monthly-review")])
.into(),
)
}
ElicitationScenario::OpenAiForm => {
let result = context
.peer
@@ -1258,6 +1339,24 @@ impl ServerHandler for ElicitationAppsMcpServer {
}
}
fn openai_elicitation_form_schema() -> Value {
json!({
"type": "object",
"properties": {
"template": {
"type": "string",
"title": "Template",
"oneOf": [{
"const": "monthly-review",
"title": "Monthly review",
"x-openai-preview": { "src": IMAGE_DATA_URL },
}],
},
},
"required": ["template"],
})
}
fn sep1034_schema() -> Value {
json!({
"type": "object",
@@ -2,6 +2,7 @@ use std::collections::HashMap;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::mcp::MCP_APP_UI_EXTENSION_ID;
use codex_protocol::mcp::OPENAI_ELICITATION_EXTENSION_ID;
use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID;
use codex_protocol::mcp::OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID;
use serde_json::Map;
@@ -23,12 +24,19 @@ pub fn client_mcp_extensions(
matches!(
id.as_str(),
OPENAI_FORM_EXTENSION_ID
| OPENAI_ELICITATION_EXTENSION_ID
| OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID
| MCP_APP_UI_EXTENSION_ID
)
})
.map(|(id, value)| (id.clone(), value.clone()))
.collect::<HashMap<_, _>>();
if let Some(settings) = selected
.get_mut(OPENAI_ELICITATION_EXTENSION_ID)
.and_then(Value::as_object_mut)
{
settings.retain(|key, _| key == "form");
}
if legacy_openai_form_elicitation {
selected
.entry(OPENAI_FORM_EXTENSION_ID.to_string())
@@ -14,7 +14,12 @@ fn selects_only_supported_mcp_extensions() {
],
"futureField": {"preserved": true},
});
let form = json!({"futureField": {"preserved": true}});
let extensions = HashMap::from([
(
OPENAI_ELICITATION_EXTENSION_ID.to_string(),
json!({"form": form, "userVerification": {}, "unsupported": {}}),
),
(MCP_APP_UI_EXTENSION_ID.to_string(), app_ui.clone()),
(OPENAI_FORM_EXTENSION_ID.to_string(), json!({})),
(
@@ -30,6 +35,10 @@ fn selects_only_supported_mcp_extensions() {
/*legacy_openai_form_elicitation*/ false,
),
ClientMcpExtensions::new(HashMap::from([
(
OPENAI_ELICITATION_EXTENSION_ID.to_string(),
json!({"form": form}),
),
(MCP_APP_UI_EXTENSION_ID.to_string(), app_ui),
(OPENAI_FORM_EXTENSION_ID.to_string(), json!({})),
(
+26 -10
View File
@@ -320,19 +320,24 @@ impl ElicitationRequestManager {
let should_surface_form_in_full_access = router.full_access_form_input_enabled()
&& permission_prompt_is_auto_approved
&& matches!(
&elicitation,
&& !elicitation
.meta()
.is_some_and(|meta| meta.contains_key(APPROVAL_KIND_KEY))
&& match &elicitation {
Elicitation::Mcp(
rmcp::model::ElicitRequestParams::FormElicitationParams {
meta,
requested_schema,
..
}
) if !requested_schema.properties.is_empty()
&& !meta
.as_ref()
.is_some_and(|meta| meta.contains_key(APPROVAL_KIND_KEY))
);
},
) => !requested_schema.properties.is_empty(),
Elicitation::OpenAiElicitationForm {
requested_schema, ..
} => requested_schema
.get("properties")
.and_then(Value::as_object)
.is_some_and(|properties| !properties.is_empty()),
Elicitation::Mcp(_) | Elicitation::OpenAiForm { .. } => false,
};
if !should_surface_form_in_full_access {
if elicitation_is_rejected_by_policy(approval_policy) {
@@ -412,6 +417,15 @@ impl ElicitationRequestManager {
message,
requested_schema,
},
Elicitation::OpenAiElicitationForm {
meta,
message,
requested_schema,
} => ElicitationRequest::OpenAiElicitationForm {
meta,
message,
requested_schema,
},
};
let (tx, rx) = oneshot::channel();
let _active_elicitation = lifecycle.as_ref().map(ElicitationLifecycle::start);
@@ -475,7 +489,9 @@ fn can_auto_accept_elicitation(elicitation: &Elicitation) -> bool {
// Auto-accept confirm/approval elicitations without schema requirements.
requested_schema.properties.is_empty()
}
Elicitation::Mcp(_) | Elicitation::OpenAiForm { .. } => false,
Elicitation::Mcp(_)
| Elicitation::OpenAiForm { .. }
| Elicitation::OpenAiElicitationForm { .. } => false,
}
}
+3 -1
View File
@@ -942,7 +942,9 @@ fn guardian_elicitation_review_request(
"guardian MCP elicitation review does not support this elicitation mode",
);
}
Elicitation::OpenAiForm { .. } => return GuardianElicitationReview::NotRequested,
Elicitation::OpenAiForm { .. } | Elicitation::OpenAiElicitationForm { .. } => {
return GuardianElicitationReview::NotRequested;
}
};
let Some(meta) = meta.as_ref().map(|meta| &meta.0.0) else {
+9
View File
@@ -389,6 +389,15 @@ pub enum ElicitationRequest {
message: String,
requested_schema: JsonValue,
},
#[serde(rename = "openaiForm")]
#[ts(rename = "openaiForm")]
OpenAiElicitationForm {
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
#[ts(optional, rename = "_meta")]
meta: Option<JsonValue>,
message: String,
requested_schema: JsonValue,
},
Url {
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
#[ts(optional, rename = "_meta")]
+3 -1
View File
@@ -23,7 +23,9 @@ pub enum McpServerConnectionStatus {
Disabled,
}
/// Extension ID for OpenAI form elicitation.
/// Extension ID for OpenAI elicitation modes.
pub const OPENAI_ELICITATION_EXTENSION_ID: &str = "openai/elicitation";
/// Extension ID for legacy OpenAI form elicitation.
pub const OPENAI_FORM_EXTENSION_ID: &str = "openai/form";
/// Extension ID for standard MCP form elicitations that require user-entered input.
pub const OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID: &str = "openai/standard-form-input";
@@ -1,5 +1,7 @@
use std::sync::Arc;
use codex_protocol::mcp::OPENAI_ELICITATION_EXTENSION_ID;
use rmcp::RoleClient;
use rmcp::model::ClientInfo;
use rmcp::model::ClientResult;
@@ -30,6 +32,14 @@ use crate::rmcp_client::SendElicitation;
const MCP_PROGRESS_TOKEN_META_KEY: &str = "progressToken";
const MCP_ELICITATION_CREATE_METHOD: &str = "elicitation/create";
const OPENAI_FORM_METHOD: &str = "openai/form";
const OPENAI_ELICITATION_METHOD: &str = "openai/elicitation/create";
#[derive(Deserialize)]
#[serde(tag = "mode")]
enum OpenAiElicitationRequestParams {
#[serde(rename = "form")]
Form(OpenAiFormRequestParams),
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -44,6 +54,7 @@ struct OpenAiFormRequestParams {
pub(crate) struct ElicitationClientService {
handler: LoggingClientHandler,
supports_openai_form: bool,
supports_openai_elicitation_form: bool,
send_elicitation: Arc<SendElicitation>,
pause_state: ElicitationPauseState,
}
@@ -59,6 +70,13 @@ impl ElicitationClientService {
.extensions
.as_ref()
.is_some_and(|extensions| extensions.contains_key(OPENAI_FORM_METHOD));
let supports_openai_elicitation_form = client_info
.capabilities
.extensions
.as_ref()
.and_then(|extensions| extensions.get(OPENAI_ELICITATION_EXTENSION_ID))
.and_then(|settings| settings.get("form"))
.is_some_and(Value::is_object);
let send_elicitation = Arc::new(send_elicitation);
Self {
handler: LoggingClientHandler::new(
@@ -66,6 +84,7 @@ impl ElicitationClientService {
clone_send_elicitation(Arc::clone(&send_elicitation)),
),
supports_openai_form,
supports_openai_elicitation_form,
send_elicitation,
pause_state,
}
@@ -144,6 +163,33 @@ impl Service<RoleClient> for ElicitationClientService {
response,
)?))
}
ServerRequest::CustomRequest(request)
if request.method == OPENAI_ELICITATION_METHOD
&& self.supports_openai_elicitation_form =>
{
let params = request
.params_as::<OpenAiElicitationRequestParams>()
.map_err(|err| {
rmcp::ErrorData::invalid_params(err.to_string(), /*data*/ None)
})?
.ok_or_else(|| {
rmcp::ErrorData::invalid_params("missing params", /*data*/ None)
})?;
let OpenAiElicitationRequestParams::Form(params) = params;
let response = self
.create_elicitation(
Elicitation::OpenAiElicitationForm {
meta: params.meta,
message: params.message,
requested_schema: params.requested_schema,
},
context,
)
.await?;
Ok(ClientResult::CustomResult(elicitation_response_result(
response,
)?))
}
request => {
<LoggingClientHandler as Service<RoleClient>>::handle_request(
&self.handler,
@@ -209,7 +255,7 @@ fn restore_context_meta(
.meta_mut()
.get_or_insert_with(RequestMetaObject::new)
.extend(context_meta),
Elicitation::OpenAiForm { meta, .. } => {
Elicitation::OpenAiForm { meta, .. } | Elicitation::OpenAiElicitationForm { meta, .. } => {
let meta = meta
.get_or_insert_with(|| Value::Object(Map::new()))
.as_object_mut();
+8 -1
View File
@@ -302,13 +302,20 @@ pub enum Elicitation {
message: String,
requested_schema: serde_json::Value,
},
OpenAiElicitationForm {
meta: Option<serde_json::Value>,
message: String,
requested_schema: serde_json::Value,
},
}
impl Elicitation {
pub fn meta(&self) -> Option<&serde_json::Map<String, serde_json::Value>> {
match self {
Self::Mcp(request) => request.meta().map(|meta| &meta.0.0),
Self::OpenAiForm { meta, .. } => meta.as_ref().and_then(serde_json::Value::as_object),
Self::OpenAiForm { meta, .. } | Self::OpenAiElicitationForm { meta, .. } => {
meta.as_ref().and_then(serde_json::Value::as_object)
}
}
}
}
+5 -1
View File
@@ -301,6 +301,7 @@ impl App {
codex_app_server_protocol::McpServerElicitationRequest::OpenAiForm {
..
}
| codex_app_server_protocol::McpServerElicitationRequest::OpenAiElicitationForm { .. }
| codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => {
self.app_event_tx.resolve_elicitation(
thread_id,
@@ -1673,7 +1674,10 @@ impl App {
match &params.request {
codex_app_server_protocol::McpServerElicitationRequest::Form { .. } => true,
codex_app_server_protocol::McpServerElicitationRequest::OpenAiForm { .. } => false,
codex_app_server_protocol::McpServerElicitationRequest::OpenAiForm { .. }
| codex_app_server_protocol::McpServerElicitationRequest::OpenAiElicitationForm {
..
} => false,
request @ codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => {
let thread_id = ThreadId::from_string(&params.thread_id)
.unwrap_or_else(|_| self.chat_widget.thread_id().unwrap_or_default());
@@ -377,6 +377,7 @@ impl ChatWidget {
.push_approval_request(request, &self.config.features);
}
McpServerElicitationRequest::OpenAiForm { .. }
| McpServerElicitationRequest::OpenAiElicitationForm { .. }
| McpServerElicitationRequest::Url { .. } => {
self.app_event_tx.resolve_elicitation(
thread_id,