Support openai/form extended form elicitations (#27500)

# Summary
Allow App Server clients to opt into `openai/form` MCP elicitations.
This commit is contained in:
Gabriel Peal
2026-06-18 11:54:49 -07:00
committed by GitHub
Unverified
parent 32a696dbac
commit 21a599fa56
67 changed files with 1486 additions and 328 deletions
@@ -774,6 +774,7 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
},
product_client_id: DEFAULT_ORIGINATOR.to_string(),
@@ -1669,6 +1670,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
},
product_client_id: DEFAULT_ORIGINATOR.to_string(),
@@ -1818,6 +1820,7 @@ async fn compaction_event_ingests_custom_fact() {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
},
product_client_id: DEFAULT_ORIGINATOR.to_string(),
@@ -1946,6 +1949,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
},
product_client_id: DEFAULT_ORIGINATOR.to_string(),
+31 -1
View File
@@ -350,6 +350,8 @@ pub struct InProcessClientStartArgs {
pub client_version: String,
/// Whether experimental APIs are requested at initialize time.
pub experimental_api: bool,
/// Whether MCP servers may send `openai/form` elicitation requests.
pub mcp_server_openai_form_elicitation: bool,
/// Notification methods this client opts out of receiving.
pub opt_out_notification_methods: Vec<String>,
/// Queue capacity for command/event channels (clamped to at least 1).
@@ -374,6 +376,7 @@ impl InProcessClientStartArgs {
} else {
Some(self.opt_out_notification_methods.clone())
},
mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation,
};
InitializeParams {
@@ -1044,6 +1047,7 @@ mod tests {
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
mcp_server_openai_form_elicitation: false,
opt_out_notification_methods: Vec::new(),
channel_capacity,
})
@@ -1237,11 +1241,25 @@ mod tests {
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
mcp_server_openai_form_elicitation: false,
opt_out_notification_methods: Vec::new(),
channel_capacity: 8,
}
}
#[test]
fn remote_initialize_params_forward_openai_form_capability() {
let mut args = test_remote_connect_args("ws://localhost/rpc".to_string());
args.mcp_server_openai_form_elicitation = true;
assert!(
args.initialize_params()
.capabilities
.expect("initialize capabilities")
.mcp_server_openai_form_elicitation
);
}
#[tokio::test]
async fn typed_request_roundtrip_works() {
let client = start_test_client(SessionSource::Exec).await;
@@ -1512,6 +1530,7 @@ mod tests {
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
mcp_server_openai_form_elicitation: false,
opt_out_notification_methods: Vec::new(),
channel_capacity: 8,
})
@@ -1600,6 +1619,7 @@ mod tests {
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
mcp_server_openai_form_elicitation: false,
opt_out_notification_methods: Vec::new(),
channel_capacity: 8,
})
@@ -1619,6 +1639,7 @@ mod tests {
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
mcp_server_openai_form_elicitation: false,
opt_out_notification_methods: Vec::new(),
channel_capacity: 8,
})
@@ -2189,7 +2210,7 @@ mod tests {
}
#[tokio::test]
async fn runtime_start_args_forward_environment_manager() {
async fn runtime_start_args_forward_environment_manager_and_openai_form_capability() {
let config = Arc::new(build_test_config().await);
let environment_manager = Arc::new(
EnvironmentManager::create_for_tests(
@@ -2222,12 +2243,20 @@ mod tests {
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
mcp_server_openai_form_elicitation: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
}
.into_runtime_start_args();
assert_eq!(runtime_args.config, config);
assert!(
runtime_args
.initialize
.capabilities
.expect("initialize capabilities")
.mcp_server_openai_form_elicitation
);
assert!(Arc::ptr_eq(
&runtime_args.environment_manager,
&environment_manager
@@ -2263,6 +2292,7 @@ mod tests {
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
mcp_server_openai_form_elicitation: false,
opt_out_notification_methods: Vec::new(),
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
}
+3 -1
View File
@@ -86,11 +86,12 @@ pub struct RemoteAppServerConnectArgs {
pub client_name: String,
pub client_version: String,
pub experimental_api: bool,
pub mcp_server_openai_form_elicitation: bool,
pub opt_out_notification_methods: Vec<String>,
pub channel_capacity: usize,
}
impl RemoteAppServerConnectArgs {
fn initialize_params(&self) -> InitializeParams {
pub(crate) fn initialize_params(&self) -> InitializeParams {
let capabilities = InitializeCapabilities {
experimental_api: self.experimental_api,
request_attestation: false,
@@ -99,6 +100,7 @@ impl RemoteAppServerConnectArgs {
} else {
Some(self.opt_out_notification_methods.clone())
},
mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation,
};
InitializeParams {
@@ -1272,6 +1272,10 @@
"description": "Opt into receiving experimental API methods and fields.",
"type": "boolean"
},
"mcpServerOpenaiFormElicitation": {
"description": "Allow downstream MCP servers to request OpenAI extended form elicitations.",
"type": "boolean"
},
"optOutNotificationMethods": {
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
"items": {
@@ -557,6 +557,27 @@
],
"type": "object"
},
{
"properties": {
"_meta": true,
"message": {
"type": "string"
},
"mode": {
"enum": [
"openai/form"
],
"type": "string"
},
"requestedSchema": true
},
"required": [
"message",
"mode",
"requestedSchema"
],
"type": "object"
},
{
"properties": {
"_meta": true,
@@ -1390,6 +1390,27 @@
],
"type": "object"
},
{
"properties": {
"_meta": true,
"message": {
"type": "string"
},
"mode": {
"enum": [
"openai/form"
],
"type": "string"
},
"requestedSchema": true
},
"required": [
"message",
"mode",
"requestedSchema"
],
"type": "object"
},
{
"properties": {
"_meta": true,
@@ -2900,6 +2900,10 @@
"description": "Opt into receiving experimental API methods and fields.",
"type": "boolean"
},
"mcpServerOpenaiFormElicitation": {
"description": "Allow downstream MCP servers to request OpenAI extended form elicitations.",
"type": "boolean"
},
"optOutNotificationMethods": {
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
"items": {
@@ -3655,6 +3659,27 @@
],
"type": "object"
},
{
"properties": {
"_meta": true,
"message": {
"type": "string"
},
"mode": {
"enum": [
"openai/form"
],
"type": "string"
},
"requestedSchema": true
},
"required": [
"message",
"mode",
"requestedSchema"
],
"type": "object"
},
{
"properties": {
"_meta": true,
@@ -7440,6 +7440,10 @@
"description": "Opt into receiving experimental API methods and fields.",
"type": "boolean"
},
"mcpServerOpenaiFormElicitation": {
"description": "Allow downstream MCP servers to request OpenAI extended form elicitations.",
"type": "boolean"
},
"optOutNotificationMethods": {
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
"items": {
@@ -30,6 +30,10 @@
"description": "Opt into receiving experimental API methods and fields.",
"type": "boolean"
},
"mcpServerOpenaiFormElicitation": {
"description": "Allow downstream MCP servers to request OpenAI extended form elicitations.",
"type": "boolean"
},
"optOutNotificationMethods": {
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
"items": {
@@ -14,6 +14,10 @@ experimentalApi: boolean,
* Opt into `attestation/generate` requests for upstream `x-oai-attestation`.
*/
requestAttestation: boolean,
/**
* Allow downstream MCP servers to request OpenAI extended form elicitations.
*/
mcpServerOpenaiFormElicitation?: boolean,
/**
* Exact notification method names that should be suppressed for this
* connection (for example `thread/started`).
@@ -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": "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": "url", _meta: JsonValue | null, message: string, url: string, elicitationId: string, });
@@ -2150,7 +2150,7 @@ mod tests {
}
#[test]
fn serialize_initialize_with_opt_out_notification_methods() -> Result<()> {
fn serialize_initialize_capabilities() -> Result<()> {
let request = ClientRequest::Initialize {
request_id: RequestId::Integer(42),
params: v1::InitializeParams {
@@ -2162,6 +2162,7 @@ mod tests {
capabilities: Some(v1::InitializeCapabilities {
experimental_api: true,
request_attestation: true,
mcp_server_openai_form_elicitation: true,
opt_out_notification_methods: Some(vec![
"thread/started".to_string(),
"item/agentMessage/delta".to_string(),
@@ -2183,6 +2184,7 @@ mod tests {
"capabilities": {
"experimentalApi": true,
"requestAttestation": true,
"mcpServerOpenaiFormElicitation": true,
"optOutNotificationMethods": [
"thread/started",
"item/agentMessage/delta"
@@ -2196,7 +2198,7 @@ mod tests {
}
#[test]
fn deserialize_initialize_with_opt_out_notification_methods() -> Result<()> {
fn deserialize_initialize_capabilities() -> Result<()> {
let request: ClientRequest = serde_json::from_value(json!({
"method": "initialize",
"id": 42,
@@ -2209,6 +2211,7 @@ mod tests {
"capabilities": {
"experimentalApi": true,
"requestAttestation": true,
"mcpServerOpenaiFormElicitation": true,
"optOutNotificationMethods": [
"thread/started",
"item/agentMessage/delta"
@@ -2230,6 +2233,7 @@ mod tests {
capabilities: Some(v1::InitializeCapabilities {
experimental_api: true,
request_attestation: true,
mcp_server_openai_form_elicitation: true,
opt_out_notification_methods: Some(vec![
"thread/started".to_string(),
"item/agentMessage/delta".to_string(),
@@ -50,6 +50,9 @@ pub struct InitializeCapabilities {
/// Opt into `attestation/generate` requests for upstream `x-oai-attestation`.
#[serde(default)]
pub request_attestation: bool,
/// Allow downstream MCP servers to request OpenAI extended form elicitations.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub mcp_server_openai_form_elicitation: bool,
/// Exact notification method names that should be suppressed for this
/// connection (for example `thread/started`).
#[ts(optional = nullable)]
@@ -632,6 +632,15 @@ pub enum McpServerElicitationRequest {
message: String,
requested_schema: McpElicitationSchema,
},
#[serde(rename = "openai/form", rename_all = "camelCase")]
#[ts(rename = "openai/form", rename_all = "camelCase")]
OpenAiForm {
#[serde(rename = "_meta")]
#[ts(rename = "_meta")]
meta: Option<JsonValue>,
message: String,
requested_schema: JsonValue,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
Url {
@@ -658,6 +667,15 @@ impl TryFrom<CoreElicitationRequest> for McpServerElicitationRequest {
message,
requested_schema: serde_json::from_value(requested_schema)?,
}),
CoreElicitationRequest::OpenAiForm {
meta,
message,
requested_schema,
} => Ok(Self::OpenAiForm {
meta,
message,
requested_schema,
}),
CoreElicitationRequest::Url {
meta,
message,
@@ -1901,6 +1901,40 @@ fn mcp_server_elicitation_request_from_core_form_request() {
);
}
#[test]
fn mcp_server_elicitation_request_from_core_openai_form_request() {
let requested_schema = json!({
"type": "object",
"properties": {
"template": {
"type": "openai/imagePicker",
"title": "Template",
"items": [{
"id": "monthly-review",
"title": "Monthly review",
"image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=",
}],
},
},
"required": ["template"],
});
let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::OpenAiForm {
meta: None,
message: "Choose a report".to_string(),
requested_schema: requested_schema.clone(),
})
.expect("OpenAI form request should convert");
assert_eq!(
request,
McpServerElicitationRequest::OpenAiForm {
meta: None,
message: "Choose a report".to_string(),
requested_schema,
}
);
}
#[test]
fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() {
let schema: McpElicitationSchema = serde_json::from_value(json!({
@@ -1668,6 +1668,7 @@ impl CodexClient {
.map(|method| (*method).to_string())
.collect(),
),
mcp_server_openai_form_elicitation: false,
}),
},
};
+12
View File
@@ -86,6 +86,13 @@ Clients must send a single `initialize` request per transport connection before
`initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored.
Clients that handle OpenAI extended MCP forms, including a fallback for
unsupported field types, set
`initialize.params.capabilities.mcpServerOpenaiFormElicitation` to `true`.
App-server then advertises the downstream `openai/form` MCP extension for
threads started, resumed, or forked by that connection. Clients that cannot
handle the request envelope omit the field or set it to `false`.
Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter.
**Important**: `clientInfo.name` is used to identify the client for the OpenAI Compliance Logs Platform. If
@@ -1470,12 +1477,17 @@ 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": { ... } }`
- 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.
For MCP tool approval elicitations, form request `meta` includes
`codex_approval_kind: "mcp_tool_call"` and may include `persist: "session"`,
`persist: "always"`, or `persist: ["session", "always"]` to advertise whether
@@ -223,6 +223,7 @@ pub(crate) struct InitializedConnectionSessionState {
pub(crate) app_server_client_name: String,
pub(crate) client_version: String,
pub(crate) request_attestation: bool,
pub(crate) supports_openai_form_elicitation: bool,
}
impl Default for ConnectionSessionState {
@@ -274,6 +275,12 @@ impl ConnectionSessionState {
.is_some_and(|session| session.request_attestation)
}
pub(crate) fn supports_openai_form_elicitation(&self) -> bool {
self.initialized
.get()
.is_some_and(|session| session.supports_openai_form_elicitation)
}
pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> {
self.initialized.set(session).map_err(|_| ())
}
@@ -884,6 +891,7 @@ impl MessageProcessor {
let serialization_scope = codex_request.serialization_scope();
let app_server_client_name = session.app_server_client_name().map(str::to_string);
let client_version = session.client_version().map(str::to_string);
let supports_openai_form_elicitation = session.supports_openai_form_elicitation();
let error_request_id = connection_request_id.clone();
let rpc_gate = Arc::clone(&session.rpc_gate);
let processor = Arc::clone(self);
@@ -899,6 +907,7 @@ impl MessageProcessor {
request_context,
app_server_client_name,
client_version,
supports_openai_form_elicitation,
)
.await;
if let Err(error) = result {
@@ -928,6 +937,7 @@ impl MessageProcessor {
request_context: RequestContext,
app_server_client_name: Option<String>,
client_version: Option<String>,
supports_openai_form_elicitation: bool,
) -> Result<(), JSONRPCErrorError> {
let connection_id = connection_request_id.connection_id;
let request_id = ConnectionRequestId {
@@ -1080,6 +1090,7 @@ impl MessageProcessor {
params,
app_server_client_name.clone(),
client_version.clone(),
supports_openai_form_elicitation,
request_context,
)
.await
@@ -1096,6 +1107,8 @@ impl MessageProcessor {
params,
app_server_client_name.clone(),
client_version.clone(),
/*supports_openai_form_elicitation*/
supports_openai_form_elicitation,
)
.await
}
@@ -1106,6 +1119,8 @@ impl MessageProcessor {
params,
app_server_client_name.clone(),
client_version.clone(),
/*supports_openai_form_elicitation*/
supports_openai_form_elicitation,
)
.await
}
@@ -1305,6 +1320,8 @@ impl MessageProcessor {
params,
app_server_client_name.clone(),
client_version.clone(),
/*supports_openai_form_elicitation*/
supports_openai_form_elicitation,
)
.await
}
@@ -67,17 +67,13 @@ impl InitializeRequestProcessor {
// experimental API). Proposed direction is instance-global first-write-wins
// with initialize-time mismatch rejection.
let analytics_initialize_params = params.clone();
let (experimental_api_enabled, request_attestation, opt_out_notification_methods) =
match params.capabilities {
Some(capabilities) => (
capabilities.experimental_api,
capabilities.request_attestation,
capabilities
.opt_out_notification_methods
.unwrap_or_default(),
),
None => (false, false, Vec::new()),
};
let capabilities = params.capabilities.unwrap_or_default();
let experimental_api_enabled = capabilities.experimental_api;
let request_attestation = capabilities.request_attestation;
let supports_openai_form_elicitation = capabilities.mcp_server_openai_form_elicitation;
let opt_out_notification_methods = capabilities
.opt_out_notification_methods
.unwrap_or_default();
let ClientInfo {
name,
title: _title,
@@ -101,6 +97,7 @@ impl InitializeRequestProcessor {
app_server_client_name: name.clone(),
client_version: version,
request_attestation,
supports_openai_form_elicitation,
})
.is_err()
{
@@ -417,6 +417,7 @@ impl ThreadRequestProcessor {
params: ThreadStartParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
request_context: RequestContext,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_start_inner(
@@ -424,6 +425,7 @@ impl ThreadRequestProcessor {
params,
app_server_client_name,
app_server_client_version,
supports_openai_form_elicitation,
request_context,
)
.await
@@ -446,12 +448,14 @@ impl ThreadRequestProcessor {
params: ThreadResumeParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_resume_inner(
request_id,
params,
app_server_client_name,
app_server_client_version,
supports_openai_form_elicitation,
)
.await
.map(|()| None)
@@ -463,12 +467,14 @@ impl ThreadRequestProcessor {
params: ThreadForkParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_fork_inner(
request_id,
params,
app_server_client_name,
app_server_client_version,
supports_openai_form_elicitation,
)
.await
.map(|()| None)
@@ -874,6 +880,7 @@ impl ThreadRequestProcessor {
params: ThreadStartParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
request_context: RequestContext,
) -> Result<(), JSONRPCErrorError> {
let ThreadStartParams {
@@ -945,6 +952,7 @@ impl ThreadRequestProcessor {
request_id,
app_server_client_name,
app_server_client_version,
supports_openai_form_elicitation,
config,
typesafe_overrides,
dynamic_tools,
@@ -1018,6 +1026,7 @@ impl ThreadRequestProcessor {
request_id: ConnectionRequestId,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
config_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
dynamic_tools: Option<Vec<DynamicToolSpec>>,
@@ -1146,6 +1155,7 @@ impl ThreadRequestProcessor {
parent_trace: request_trace,
environments,
thread_extension_init,
supports_openai_form_elicitation,
})
.instrument(tracing::info_span!(
"app_server.thread_start.create_thread",
@@ -2506,6 +2516,7 @@ impl ThreadRequestProcessor {
params: ThreadResumeParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
) -> Result<(), JSONRPCErrorError> {
if let Ok(thread_id) = ThreadId::from_string(&params.thread_id)
&& self
@@ -2650,6 +2661,7 @@ impl ThreadRequestProcessor {
thread_history,
self.auth_manager.clone(),
self.request_trace_context(&request_id).await,
supports_openai_form_elicitation,
)
.await
{
@@ -3267,6 +3279,7 @@ impl ThreadRequestProcessor {
params: ThreadForkParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
) -> Result<(), JSONRPCErrorError> {
let ThreadForkParams {
thread_id,
@@ -3376,6 +3389,7 @@ impl ThreadRequestProcessor {
}),
thread_source.map(Into::into),
self.request_trace_context(&request_id).await,
supports_openai_form_elicitation,
)
.await
.map_err(|err| match err {
@@ -101,12 +101,14 @@ impl TurnRequestProcessor {
params: TurnStartParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.turn_start_inner(
request_id,
params,
app_server_client_name,
app_server_client_version,
/*supports_openai_form_elicitation*/ supports_openai_form_elicitation,
)
.await
.map(|response| Some(response.into()))
@@ -384,6 +386,7 @@ impl TurnRequestProcessor {
params: TurnStartParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
) -> Result<TurnStartResponse, JSONRPCErrorError> {
let (thread_id, thread) =
self.load_thread(&params.thread_id)
@@ -410,6 +413,14 @@ impl TurnRequestProcessor {
.inspect_err(|error| {
self.track_error_response(&request_id, error, /*error_type*/ None);
})?;
thread
.set_openai_form_elicitation_support(supports_openai_form_elicitation)
.await
.map_err(|err| {
internal_error(format!(
"failed to update OpenAI form elicitation support: {err}"
))
})?;
let environment_selections =
resolve_turn_environment_selections(self.thread_manager.as_ref(), params.environments)?;
@@ -1164,6 +1175,7 @@ impl TurnRequestProcessor {
}),
/*thread_source*/ None,
self.request_trace_context(request_id).await,
/*supports_openai_form_elicitation*/ false,
)
.await
.map_err(|err| {
@@ -81,6 +81,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand
experimental_api: true,
request_attestation: true,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
),
)
@@ -706,7 +706,7 @@ pub(super) async fn send_request(
send_jsonrpc(stream, message).await
}
async fn send_jsonrpc(stream: &mut WsClient, message: JSONRPCMessage) -> Result<()> {
pub(super) async fn send_jsonrpc(stream: &mut WsClient, message: JSONRPCMessage) -> Result<()> {
let payload = serde_json::to_string(&message)?;
stream
.send(WebSocketMessage::Text(payload.into()))
@@ -39,6 +39,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
)
.await?;
@@ -70,6 +71,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
)
.await?;
@@ -116,6 +118,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
)
.await?;
@@ -150,6 +153,7 @@ async fn thread_settings_update_requires_experimental_api_capability() -> Result
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
)
.await?;
@@ -184,6 +188,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
)
.await?;
@@ -234,6 +239,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
)
.await?;
@@ -272,6 +278,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
)
.await?;
@@ -309,6 +316,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
)
.await?;
@@ -214,6 +214,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu
experimental_api: true,
request_attestation: false,
opt_out_notification_methods: Some(vec!["thread/started".to_string()]),
mcp_server_openai_form_elicitation: false,
}),
),
)
@@ -14,6 +14,9 @@ use axum::http::StatusCode;
use axum::http::Uri;
use axum::http::header::AUTHORIZATION;
use axum::routing::get;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::McpElicitationSchema;
@@ -24,6 +27,7 @@ use codex_app_server_protocol::McpServerElicitationRequestResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ServerRequestResolvedNotification;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnCompletedNotification;
@@ -34,6 +38,7 @@ use codex_app_server_protocol::UserInput as V2UserInput;
use codex_config::types::AuthCredentialsStoreMode;
use core_test_support::assert_regex_match;
use core_test_support::responses;
use core_test_support::responses::ResponseMock;
use pretty_assertions::assert_eq;
use rmcp::handler::server::ServerHandler;
use rmcp::model::BooleanSchema;
@@ -41,14 +46,18 @@ use rmcp::model::CallToolRequestParams;
use rmcp::model::CallToolResult;
use rmcp::model::Content;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::CustomRequest;
use rmcp::model::ElicitationAction;
use rmcp::model::ElicitationSchema;
use rmcp::model::InitializeRequestParams;
use rmcp::model::InitializeResult;
use rmcp::model::JsonObject;
use rmcp::model::ListToolsResult;
use rmcp::model::Meta;
use rmcp::model::PrimitiveSchema;
use rmcp::model::ServerCapabilities;
use rmcp::model::ServerInfo;
use rmcp::model::ServerRequest as McpServerRequest;
use rmcp::model::Tool;
use rmcp::model::ToolAnnotations;
use rmcp::service::RequestContext;
@@ -63,6 +72,15 @@ use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tokio::time::timeout;
use super::connection_handling_websocket::WsClient;
use super::connection_handling_websocket::connect_websocket;
use super::connection_handling_websocket::read_jsonrpc_message;
use super::connection_handling_websocket::read_notification_for_method;
use super::connection_handling_websocket::read_response_for_id;
use super::connection_handling_websocket::send_jsonrpc;
use super::connection_handling_websocket::send_request;
use super::connection_handling_websocket::spawn_websocket_server;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const CONNECTOR_ID: &str = "calendar";
const CONNECTOR_NAME: &str = "Calendar";
@@ -71,9 +89,278 @@ const CALLABLE_TOOL_NAME: &str = "_confirm_action";
const TOOL_NAME: &str = "calendar_confirm_action";
const TOOL_CALL_ID: &str = "call-calendar-confirm";
const ELICITATION_MESSAGE: &str = "Allow this request?";
const OPENAI_FORM_MESSAGE: &str = "Select a template";
const IMAGE_DATA_URL: &str =
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=";
#[derive(Clone, Copy)]
enum ElicitationScenario {
StandardForm,
OpenAiForm,
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn mcp_server_elicitation_round_trip() -> Result<()> {
async fn mcp_server_form_elicitation_round_trip() -> Result<()> {
let mut fixture = ElicitationRoundTripFixture::start(ElicitationScenario::StandardForm).await?;
let (request_id, params) = fixture.read_elicitation().await?;
let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value(
ElicitationSchema::builder()
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
.build()
.map_err(anyhow::Error::msg)?,
)?)?;
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::Form {
meta: None,
message: ELICITATION_MESSAGE.to_string(),
requested_schema,
},
}
);
fixture
.accept(request_id.clone(), json!({ "confirmed": true }))
.await?;
fixture.finish(request_id, "accepted").await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn mcp_server_openai_form_elicitation_round_trip() -> Result<()> {
let mut fixture = ElicitationRoundTripFixture::start(ElicitationScenario::OpenAiForm).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::OpenAiForm {
meta: None,
message: OPENAI_FORM_MESSAGE.to_string(),
requested_schema: json!({
"type": "object",
"properties": {
"template": {
"type": "openai/imagePicker",
"title": "Template",
"items": [{
"id": "monthly-review",
"title": "Monthly review",
"image": IMAGE_DATA_URL,
}],
},
},
"required": ["template"],
}),
},
}
);
fixture
.accept(request_id.clone(), json!({ "template": "monthly-review" }))
.await?;
fixture.finish(request_id, "accepted monthly-review").await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn openai_form_capability_follows_the_turn_starting_connection() -> Result<()> {
let (responses_server, response_mock, apps_server_url, apps_server_handle) =
start_elicitation_services(ElicitationScenario::OpenAiForm).await?;
let codex_home = TempDir::new()?;
write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
let mut supported_client = connect_websocket(bind_addr).await?;
initialize_websocket_client(
&mut supported_client,
/*id*/ 1,
"supported-client",
/*supports_openai_form_elicitation*/ true,
)
.await?;
send_request(
&mut supported_client,
"thread/start",
/*id*/ 2,
Some(serde_json::to_value(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})?),
)
.await?;
let ThreadStartResponse { thread, .. } =
to_response(read_response_for_id(&mut supported_client, /*id*/ 2).await?)?;
send_request(
&mut supported_client,
"turn/start",
/*id*/ 3,
Some(serde_json::to_value(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "Warm up connectors.".to_string(),
text_elements: Vec::new(),
}],
model: Some("mock-model".to_string()),
..Default::default()
})?),
)
.await?;
let _: TurnStartResponse =
to_response(read_response_for_id(&mut supported_client, /*id*/ 3).await?)?;
let _: TurnCompletedNotification = serde_json::from_value(
read_notification_for_method(&mut supported_client, "turn/completed")
.await?
.params
.expect("turn/completed params"),
)?;
let mut unsupported_client = connect_websocket(bind_addr).await?;
initialize_websocket_client(
&mut unsupported_client,
/*id*/ 4,
"unsupported-client",
/*supports_openai_form_elicitation*/ false,
)
.await?;
send_request(
&mut unsupported_client,
"thread/resume",
/*id*/ 5,
Some(serde_json::to_value(ThreadResumeParams {
thread_id: thread.id.clone(),
..Default::default()
})?),
)
.await?;
let _ = read_response_for_id(&mut unsupported_client, /*id*/ 5).await?;
send_request(
&mut supported_client,
"turn/start",
/*id*/ 6,
Some(serde_json::to_value(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(),
text_elements: Vec::new(),
}],
model: Some("mock-model".to_string()),
..Default::default()
})?),
)
.await?;
let TurnStartResponse { turn } =
to_response(read_response_for_id(&mut supported_client, /*id*/ 6).await?)?;
let (request_id, params) = loop {
let JSONRPCMessage::Request(request) = read_jsonrpc_message(&mut supported_client).await?
else {
continue;
};
let request: ServerRequest = serde_json::from_value(serde_json::to_value(request)?)?;
let ServerRequest::McpServerElicitationRequest { request_id, params } = request else {
continue;
};
break (request_id, params);
};
assert_eq!(
params.request,
McpServerElicitationRequest::OpenAiForm {
meta: None,
message: OPENAI_FORM_MESSAGE.to_string(),
requested_schema: json!({
"type": "object",
"properties": {
"template": {
"type": "openai/imagePicker",
"title": "Template",
"items": [{
"id": "monthly-review",
"title": "Monthly review",
"image": IMAGE_DATA_URL,
}],
},
},
"required": ["template"],
}),
}
);
send_jsonrpc(
&mut supported_client,
JSONRPCMessage::Response(JSONRPCResponse {
id: request_id,
result: serde_json::to_value(McpServerElicitationRequestResponse {
action: McpServerElicitationAction::Accept,
content: Some(json!({ "template": "monthly-review" })),
meta: None,
})?,
}),
)
.await?;
let completed: TurnCompletedNotification = serde_json::from_value(
read_notification_for_method(&mut supported_client, "turn/completed")
.await?
.params
.expect("turn/completed params"),
)?;
assert_eq!(completed.thread_id, thread.id);
assert_eq!(completed.turn.id, turn.id);
assert_eq!(completed.turn.status, TurnStatus::Completed);
assert_eq!(response_mock.requests().len(), 3);
process.kill().await?;
apps_server_handle.abort();
let _ = apps_server_handle.await;
Ok(())
}
async fn initialize_websocket_client(
client: &mut WsClient,
id: i64,
name: &str,
supports_openai_form_elicitation: bool,
) -> Result<()> {
send_request(
client,
"initialize",
id,
Some(serde_json::to_value(InitializeParams {
client_info: ClientInfo {
name: name.to_string(),
title: None,
version: "0.1.0".to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
mcp_server_openai_form_elicitation: supports_openai_form_elicitation,
..Default::default()
}),
})?),
)
.await?;
let _ = read_response_for_id(client, id).await?;
Ok(())
}
async fn start_elicitation_services(
scenario: ElicitationScenario,
) -> Result<(wiremock::MockServer, ResponseMock, String, JoinHandle<()>)> {
let responses_server = responses::start_mock_server().await;
let tool_call_arguments = serde_json::to_string(&json!({}))?;
let response_mock = responses::mount_sse_sequence(
@@ -102,201 +389,223 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> {
],
)
.await;
let (apps_server_url, apps_server_handle) = start_apps_server(scenario).await?;
Ok((
responses_server,
response_mock,
apps_server_url,
apps_server_handle,
))
}
let (apps_server_url, apps_server_handle) = start_apps_server().await?;
struct ElicitationRoundTripFixture {
mcp: TestAppServer,
response_mock: ResponseMock,
_responses_server: wiremock::MockServer,
thread_id: String,
turn_id: String,
apps_server_handle: JoinHandle<()>,
}
let codex_home = TempDir::new()?;
write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
impl ElicitationRoundTripFixture {
async fn start(scenario: ElicitationScenario) -> Result<Self> {
let (responses_server, response_mock, apps_server_url, apps_server_handle) =
start_elicitation_services(scenario).await?;
let codex_home = TempDir::new()?;
write_config_toml(codex_home.path(), &responses_server.uri(), &apps_server_url)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.initialize_with_capabilities(
ClientInfo {
name: "codex-app-server-tests".to_string(),
title: None,
version: "0.1.0".to_string(),
},
Some(InitializeCapabilities {
experimental_api: true,
mcp_server_openai_form_elicitation: true,
..Default::default()
}),
),
)
.await??;
let thread_start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
let thread_start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let thread_start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
let warmup_turn_start_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Warm up connectors.".to_string(),
text_elements: Vec::new(),
}],
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let warmup_turn_start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(warmup_turn_start_id)),
)
.await??;
let _: TurnStartResponse = to_response(warmup_turn_start_resp)?;
let warmup_completed = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let warmup_completed: TurnCompletedNotification = serde_json::from_value(
warmup_completed
.params
.clone()
.expect("warmup turn/completed params"),
)?;
assert_eq!(warmup_completed.thread_id, thread.id);
assert_eq!(warmup_completed.turn.status, TurnStatus::Completed);
let turn_start_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(),
text_elements: Vec::new(),
}],
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let turn_start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
)
.await??;
let TurnStartResponse { turn } = to_response(turn_start_resp)?;
Ok(Self {
mcp,
response_mock,
_responses_server: responses_server,
thread_id: thread.id,
turn_id: turn.id,
apps_server_handle,
})
.await?;
let thread_start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
let warmup_turn_start_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Warm up connectors.".to_string(),
text_elements: Vec::new(),
}],
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let warmup_turn_start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(warmup_turn_start_id)),
)
.await??;
let _: TurnStartResponse = to_response(warmup_turn_start_resp)?;
let warmup_completed = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let warmup_completed: TurnCompletedNotification = serde_json::from_value(
warmup_completed
.params
.clone()
.expect("warmup turn/completed params"),
)?;
assert_eq!(warmup_completed.thread_id, thread.id);
assert_eq!(warmup_completed.turn.status, TurnStatus::Completed);
let turn_start_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(),
text_elements: Vec::new(),
}],
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let turn_start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
)
.await??;
let TurnStartResponse { turn } = to_response(turn_start_resp)?;
let server_req = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_request_message(),
)
.await??;
let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else {
panic!("expected McpServerElicitationRequest request, got: {server_req:?}");
};
let requested_schema: McpElicitationSchema = serde_json::from_value(serde_json::to_value(
ElicitationSchema::builder()
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
.build()
.map_err(anyhow::Error::msg)?,
)?)?;
assert_eq!(
params,
McpServerElicitationRequestParams {
thread_id: thread.id.clone(),
turn_id: Some(turn.id.clone()),
server_name: "codex_apps".to_string(),
request: McpServerElicitationRequest::Form {
meta: None,
message: ELICITATION_MESSAGE.to_string(),
requested_schema,
},
}
);
let resolved_request_id = request_id.clone();
mcp.send_response(
request_id,
serde_json::to_value(McpServerElicitationRequestResponse {
action: McpServerElicitationAction::Accept,
content: Some(json!({
"confirmed": true,
})),
meta: None,
})?,
)
.await?;
let mut saw_resolved = false;
loop {
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
let JSONRPCMessage::Notification(notification) = message else {
continue;
};
match notification.method.as_str() {
"serverRequest/resolved" => {
let resolved: ServerRequestResolvedNotification = serde_json::from_value(
notification
.params
.clone()
.expect("serverRequest/resolved params"),
)?;
assert_eq!(
resolved,
ServerRequestResolvedNotification {
thread_id: thread.id.clone(),
request_id: resolved_request_id.clone(),
}
);
saw_resolved = true;
}
"turn/completed" => {
let completed: TurnCompletedNotification = serde_json::from_value(
notification.params.clone().expect("turn/completed params"),
)?;
assert!(saw_resolved, "serverRequest/resolved should arrive first");
assert_eq!(completed.thread_id, thread.id);
assert_eq!(completed.turn.id, turn.id);
assert_eq!(completed.turn.status, TurnStatus::Completed);
break;
}
_ => {}
}
}
let requests = response_mock.requests();
assert_eq!(requests.len(), 3);
let function_call_output = requests[2].function_call_output(TOOL_CALL_ID);
assert_eq!(
function_call_output.get("type"),
Some(&Value::String("function_call_output".to_string()))
);
assert_eq!(
function_call_output.get("call_id"),
Some(&Value::String(TOOL_CALL_ID.to_string()))
);
let output = function_call_output
.get("output")
.and_then(Value::as_str)
.expect("function_call_output output should be a JSON string");
let payload = assert_regex_match(
r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n(.*)$"#,
output,
)
.get(1)
.expect("wall-time wrapped output should include payload")
.as_str();
assert_eq!(
serde_json::from_str::<Value>(payload)?,
json!([{
"type": "text",
"text": "accepted"
}])
);
async fn read_elicitation(&mut self) -> Result<(RequestId, McpServerElicitationRequestParams)> {
let request = timeout(
DEFAULT_READ_TIMEOUT,
self.mcp.read_stream_until_request_message(),
)
.await??;
let ServerRequest::McpServerElicitationRequest { request_id, params } = request else {
panic!("expected McpServerElicitationRequest request, got: {request:?}");
};
Ok((request_id, params))
}
apps_server_handle.abort();
let _ = apps_server_handle.await;
Ok(())
async fn accept(&mut self, request_id: RequestId, content: Value) -> Result<()> {
self.mcp
.send_response(
request_id,
serde_json::to_value(McpServerElicitationRequestResponse {
action: McpServerElicitationAction::Accept,
content: Some(content),
meta: None,
})?,
)
.await
}
async fn finish(mut self, request_id: RequestId, expected_text: &str) -> Result<()> {
let mut resolved = false;
loop {
let message = timeout(DEFAULT_READ_TIMEOUT, self.mcp.read_next_message()).await??;
let JSONRPCMessage::Notification(notification) = message else {
continue;
};
match notification.method.as_str() {
"serverRequest/resolved" => {
let notification: ServerRequestResolvedNotification = serde_json::from_value(
notification
.params
.clone()
.expect("serverRequest/resolved params"),
)?;
assert_eq!(notification.thread_id, self.thread_id);
assert_eq!(notification.request_id, request_id);
resolved = true;
}
"turn/completed" => {
let notification: TurnCompletedNotification = serde_json::from_value(
notification.params.clone().expect("turn/completed params"),
)?;
assert!(
resolved,
"server request should resolve before turn completion"
);
assert_eq!(notification.thread_id, self.thread_id);
assert_eq!(notification.turn.id, self.turn_id);
assert_eq!(notification.turn.status, TurnStatus::Completed);
break;
}
_ => {}
}
}
let requests = self.response_mock.requests();
assert_eq!(requests.len(), 3);
let function_call_output = requests[2].function_call_output(TOOL_CALL_ID);
assert_eq!(
function_call_output.get("type"),
Some(&Value::String("function_call_output".to_string()))
);
assert_eq!(
function_call_output.get("call_id"),
Some(&Value::String(TOOL_CALL_ID.to_string()))
);
let output = function_call_output
.get("output")
.and_then(Value::as_str)
.expect("function_call_output output should be a JSON string");
let payload = assert_regex_match(
r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n(.*)$"#,
output,
)
.get(1)
.expect("wall-time wrapped output should include payload")
.as_str();
assert_eq!(
serde_json::from_str::<Value>(payload)?,
json!([{ "type": "text", "text": expected_text }])
);
self.apps_server_handle.abort();
let _ = self.apps_server_handle.await;
Ok(())
}
}
#[derive(Clone)]
@@ -305,10 +614,33 @@ struct AppsServerState {
expected_account_id: String,
}
#[derive(Clone, Default)]
struct ElicitationAppsMcpServer;
#[derive(Clone)]
struct ElicitationAppsMcpServer {
scenario: ElicitationScenario,
}
impl ServerHandler for ElicitationAppsMcpServer {
async fn initialize(
&self,
request: InitializeRequestParams,
context: RequestContext<RoleServer>,
) -> Result<InitializeResult, rmcp::ErrorData> {
if matches!(self.scenario, ElicitationScenario::OpenAiForm) {
assert_eq!(
request
.capabilities
.extensions
.as_ref()
.and_then(|extensions| extensions.get("openai/form"))
.cloned()
.map(Value::Object),
Some(json!({}))
);
}
context.peer.set_peer_info(request);
Ok(self.get_info())
}
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_protocol_version(rmcp::model::ProtocolVersion::V_2025_06_18)
@@ -351,40 +683,91 @@ impl ServerHandler for ElicitationAppsMcpServer {
_request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, rmcp::ErrorData> {
let requested_schema = ElicitationSchema::builder()
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
.build()
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
let result = context
.peer
.create_elicitation(CreateElicitationRequestParams::FormElicitationParams {
meta: None,
message: ELICITATION_MESSAGE.to_string(),
requested_schema,
})
.await
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
let output = match result.action {
ElicitationAction::Accept => {
match self.scenario {
ElicitationScenario::StandardForm => {
let requested_schema = ElicitationSchema::builder()
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
.build()
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
let result = context
.peer
.create_elicitation(CreateElicitationRequestParams::FormElicitationParams {
meta: None,
message: ELICITATION_MESSAGE.to_string(),
requested_schema,
})
.await
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
assert_eq!(
result.content,
Some(json!({
"confirmed": true,
}))
);
"accepted"
let output = match result.action {
ElicitationAction::Accept => "accepted",
ElicitationAction::Decline => "declined",
ElicitationAction::Cancel => "cancelled",
};
Ok(CallToolResult::success(vec![Content::text(output)]))
}
ElicitationAction::Decline => "declined",
ElicitationAction::Cancel => "cancelled",
};
Ok(CallToolResult::success(vec![Content::text(output)]))
ElicitationScenario::OpenAiForm => {
let result = context
.peer
.send_request(McpServerRequest::CustomRequest(CustomRequest::new(
"openai/form",
Some(json!({
"message": OPENAI_FORM_MESSAGE,
"requestedSchema": {
"type": "object",
"properties": {
"template": {
"type": "openai/imagePicker",
"title": "Template",
"items": [{
"id": "monthly-review",
"title": "Monthly review",
"image": IMAGE_DATA_URL,
}],
},
},
"required": ["template"],
},
})),
)))
.await
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?;
let result = match result {
rmcp::model::ClientResult::CustomResult(result) => result.0,
rmcp::model::ClientResult::CreateElicitationResult(result) => {
serde_json::to_value(result)
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?
}
result => {
return Err(rmcp::ErrorData::internal_error(
format!("unexpected OpenAI form response: {result:?}"),
None,
));
}
};
assert_eq!(
result,
json!({
"action": "accept",
"content": {
"template": "monthly-review",
},
})
);
Ok(CallToolResult::success(vec![Content::text(
"accepted monthly-review",
)]))
}
}
}
}
async fn start_apps_server() -> Result<(String, JoinHandle<()>)> {
async fn start_apps_server(scenario: ElicitationScenario) -> Result<(String, JoinHandle<()>)> {
let state = Arc::new(AppsServerState {
expected_bearer: "Bearer chatgpt-token".to_string(),
expected_account_id: "account-123".to_string(),
@@ -394,7 +777,7 @@ async fn start_apps_server() -> Result<(String, JoinHandle<()>)> {
let addr = listener.local_addr()?;
let mcp_service = StreamableHttpService::new(
move || Ok(ElicitationAppsMcpServer),
move || Ok(ElicitationAppsMcpServer { scenario }),
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default(),
);
@@ -148,6 +148,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> {
experimental_api: true,
request_attestation: false,
opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]),
mcp_server_openai_form_elicitation: false,
}),
),
)
@@ -133,6 +133,7 @@ impl McpConnectionManager {
host_owned_codex_apps_enabled: bool,
prefix_mcp_tool_names: bool,
client_elicitation_capability: ElicitationCapability,
supports_openai_form_elicitation: bool,
tool_plugin_provenance: ToolPluginProvenance,
auth: Option<&CodexAuth>,
elicitation_reviewer: Option<ElicitationReviewerHandle>,
@@ -209,6 +210,7 @@ impl McpConnectionManager {
runtime_context.clone(),
runtime_auth_provider,
client_elicitation_capability.clone(),
supports_openai_form_elicitation,
);
clients.insert(server_name.clone(), async_managed_client.clone());
let tx_event = tx_event.clone();
@@ -251,13 +251,15 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() {
let response = sender(
NumberOrString::Number(1),
CreateElicitationRequestParams::FormElicitationParams {
meta: None,
message: "Confirm?".to_string(),
requested_schema: rmcp::model::ElicitationSchema::builder()
.build()
.expect("schema should build"),
},
codex_rmcp_client::Elicitation::Mcp(
CreateElicitationRequestParams::FormElicitationParams {
meta: None,
message: "Confirm?".to_string(),
requested_schema: rmcp::model::ElicitationSchema::builder()
.build()
.expect("schema should build"),
},
),
)
.await
.expect("elicitation should auto accept");
@@ -284,17 +286,19 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel
let response = sender(
NumberOrString::Number(1),
CreateElicitationRequestParams::FormElicitationParams {
meta: None,
message: "What should I say?".to_string(),
requested_schema: rmcp::model::ElicitationSchema::builder()
.required_property(
"message",
rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()),
)
.build()
.expect("schema should build"),
},
codex_rmcp_client::Elicitation::Mcp(
CreateElicitationRequestParams::FormElicitationParams {
meta: None,
message: "What should I say?".to_string(),
requested_schema: rmcp::model::ElicitationSchema::builder()
.required_property(
"message",
rmcp::model::PrimitiveSchema::String(rmcp::model::StringSchema::new()),
)
.build()
.expect("schema should build"),
},
),
)
.await
.expect("elicitation should auto decline");
@@ -1265,6 +1269,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
/*host_owned_codex_apps_enabled*/ false,
/*prefix_mcp_tool_names*/ true,
ElicitationCapability::default(),
/*supports_openai_form_elicitation*/ false,
ToolPluginProvenance::default(),
/*auth*/ None,
/*elicitation_reviewer*/ None,
+35 -18
View File
@@ -22,11 +22,11 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_rmcp_client::Elicitation;
use codex_rmcp_client::ElicitationResponse;
use codex_rmcp_client::SendElicitation;
use futures::future::BoxFuture;
use futures::future::FutureExt;
use rmcp::model::CreateElicitationRequestParams;
use rmcp::model::ElicitationAction;
use rmcp::model::RequestId;
use tokio::sync::Mutex;
@@ -36,7 +36,7 @@ use tokio::sync::oneshot;
pub struct ElicitationReviewRequest {
pub server_name: String,
pub request_id: RequestId,
pub elicitation: CreateElicitationRequestParams,
pub elicitation: Elicitation,
}
pub trait ElicitationReviewer: Send + Sync {
@@ -172,11 +172,13 @@ impl ElicitationRequestManager {
}
let request = match elicitation {
CreateElicitationRequestParams::FormElicitationParams {
meta,
message,
requested_schema,
} => ElicitationRequest::Form {
Elicitation::Mcp(
rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
meta,
message,
requested_schema,
},
) => ElicitationRequest::Form {
meta: meta
.map(serde_json::to_value)
.transpose()
@@ -185,12 +187,14 @@ impl ElicitationRequestManager {
requested_schema: serde_json::to_value(requested_schema)
.context("failed to serialize MCP elicitation schema")?,
},
CreateElicitationRequestParams::UrlElicitationParams {
meta,
message,
url,
elicitation_id,
} => ElicitationRequest::Url {
Elicitation::Mcp(
rmcp::model::CreateElicitationRequestParams::UrlElicitationParams {
meta,
message,
url,
elicitation_id,
},
) => ElicitationRequest::Url {
meta: meta
.map(serde_json::to_value)
.transpose()
@@ -199,6 +203,15 @@ impl ElicitationRequestManager {
url,
elicitation_id,
},
Elicitation::OpenAiForm {
meta,
message,
requested_schema,
} => ElicitationRequest::OpenAiForm {
meta,
message,
requested_schema,
},
};
let (tx, rx) = oneshot::channel();
{
@@ -243,14 +256,18 @@ pub(crate) fn elicitation_is_rejected_by_policy(approval_policy: AskForApproval)
type ResponderMap = HashMap<(String, RequestId), oneshot::Sender<ElicitationResponse>>;
fn can_auto_accept_elicitation(elicitation: &CreateElicitationRequestParams) -> bool {
fn can_auto_accept_elicitation(elicitation: &Elicitation) -> bool {
match elicitation {
CreateElicitationRequestParams::FormElicitationParams {
requested_schema, ..
} => {
Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
requested_schema,
..
}) => {
// Auto-accept confirm/approval elicitations without schema requirements.
requested_schema.properties.is_empty()
}
CreateElicitationRequestParams::UrlElicitationParams { .. } => false,
Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams {
..
})
| Elicitation::OpenAiForm { .. } => false,
}
}
+2
View File
@@ -305,6 +305,7 @@ pub async fn read_mcp_resource(
host_owned_codex_apps_enabled,
config.prefix_mcp_tool_names,
config.client_elicitation_capability.clone(),
/*supports_openai_form_elicitation*/ false,
tool_plugin_provenance(config),
auth,
/*elicitation_reviewer*/ None,
@@ -379,6 +380,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
host_owned_codex_apps_enabled,
config.prefix_mcp_tool_names,
config.client_elicitation_capability.clone(),
/*supports_openai_form_elicitation*/ false,
tool_plugin_provenance,
auth,
/*elicitation_reviewer*/ None,
+51 -7
View File
@@ -7,6 +7,7 @@
//! [`crate::connection_manager`].
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::env;
use std::ffi::OsString;
@@ -62,6 +63,7 @@ use rmcp::model::ClientCapabilities;
use rmcp::model::ElicitationCapability;
use rmcp::model::Implementation;
use rmcp::model::InitializeRequestParams;
use rmcp::model::JsonObject;
use rmcp::model::ProtocolVersion;
use rmcp::model::Tool as RmcpTool;
use tokio_util::sync::CancellationToken;
@@ -70,6 +72,7 @@ use tracing::warn;
/// MCP server capability indicating that Codex should include [`SandboxState`]
/// in tool-call request `_meta` under this key.
pub const MCP_SANDBOX_STATE_META_CAPABILITY: &str = "codex/sandbox-state-meta";
pub const OPENAI_FORM_CAPABILITY: &str = "openai/form";
pub(crate) const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms";
pub(crate) const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str =
@@ -151,6 +154,7 @@ impl AsyncManagedClient {
runtime_context: McpRuntimeContext,
runtime_auth_provider: Option<SharedAuthProvider>,
client_elicitation_capability: ElicitationCapability,
supports_openai_form_elicitation: bool,
) -> Self {
let tool_filter = server
.configured_config()
@@ -204,6 +208,7 @@ impl AsyncManagedClient {
elicitation_requests,
codex_apps_tools_cache_context,
client_elicitation_capability,
supports_openai_form_elicitation,
},
)
.await
@@ -483,14 +488,12 @@ async fn start_server_task(
elicitation_requests,
codex_apps_tools_cache_context,
client_elicitation_capability,
supports_openai_form_elicitation,
} = params;
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = Some(client_elicitation_capability);
let params = InitializeRequestParams::new(
capabilities,
Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"),
)
.with_protocol_version(ProtocolVersion::V_2025_06_18);
let params = mcp_initialize_request_params(
client_elicitation_capability,
supports_openai_form_elicitation,
);
let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event);
@@ -550,6 +553,25 @@ async fn start_server_task(
Ok(managed)
}
fn mcp_initialize_request_params(
client_elicitation_capability: ElicitationCapability,
supports_openai_form_elicitation: bool,
) -> InitializeRequestParams {
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = Some(client_elicitation_capability);
if supports_openai_form_elicitation {
capabilities.extensions = Some(BTreeMap::from([(
OPENAI_FORM_CAPABILITY.to_string(),
JsonObject::new(),
)]));
}
InitializeRequestParams::new(
capabilities,
Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"),
)
.with_protocol_version(ProtocolVersion::V_2025_06_18)
}
fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServerInfo {
McpServerInfo {
name: server_info.name,
@@ -574,6 +596,7 @@ struct StartServerTaskParams {
elicitation_requests: ElicitationRequestManager,
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
client_elicitation_capability: ElicitationCapability,
supports_openai_form_elicitation: bool,
}
async fn make_rmcp_client(
@@ -668,6 +691,27 @@ mod tests {
use rmcp::model::JsonObject;
use rmcp::model::Meta;
#[test]
fn mcp_initialize_advertises_openai_form_only_when_supported() {
let unsupported = mcp_initialize_request_params(
ElicitationCapability::default(),
/*supports_openai_form_elicitation*/ false,
);
assert_eq!(unsupported.capabilities.extensions, None);
let supported = mcp_initialize_request_params(
ElicitationCapability::default(),
/*supports_openai_form_elicitation*/ true,
);
assert_eq!(
supported.capabilities.extensions,
Some(BTreeMap::from([(
OPENAI_FORM_CAPABILITY.to_string(),
JsonObject::new(),
)]))
);
}
fn tool_with_connector_meta() -> RmcpTool {
RmcpTool::new(
"capture_file_upload",
+4
View File
@@ -113,6 +113,10 @@ pub(crate) async fn run_codex_thread_interactive(
parent_trace: None,
environment_selections: parent_ctx.environments.to_selections(),
thread_extension_init: codex_extension_api::ExtensionDataInit::default(),
supports_openai_form_elicitation: parent_session
.services
.supports_openai_form_elicitation
.load(std::sync::atomic::Ordering::Relaxed),
analytics_events_client: Some(parent_session.services.analytics_events_client.clone()),
thread_store: Arc::clone(&parent_session.services.thread_store),
attestation_provider: parent_session.services.attestation_provider.clone(),
+7
View File
@@ -335,6 +335,13 @@ impl CodexThread {
.await
}
pub async fn set_openai_form_elicitation_support(&self, supported: bool) -> anyhow::Result<()> {
self.codex
.session
.set_openai_form_elicitation_support(supported)
.await
}
/// Preview persistent thread settings overrides without committing them.
pub async fn preview_thread_settings_overrides(
&self,
+1
View File
@@ -291,6 +291,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
host_owned_codex_apps_enabled,
mcp_config.prefix_mcp_tool_names,
mcp_config.client_elicitation_capability,
/*supports_openai_form_elicitation*/ false,
ToolPluginProvenance::default(),
auth.as_ref(),
/*elicitation_reviewer*/ None,
+1
View File
@@ -1314,6 +1314,7 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context:
/*host_owned_codex_apps_enabled*/ true,
turn_context.config.prefix_mcp_tool_names(),
rmcp::model::ElicitationCapability::default(),
/*supports_openai_form_elicitation*/ false,
codex_mcp::ToolPluginProvenance::default(),
auth.as_ref(),
/*elicitation_reviewer*/ None,
+52 -11
View File
@@ -16,7 +16,7 @@ use codex_protocol::mcp_approval_meta::TOOL_DESCRIPTION_KEY as MCP_ELICITATION_T
use codex_protocol::mcp_approval_meta::TOOL_NAME_KEY as MCP_ELICITATION_TOOL_NAME_KEY;
use codex_protocol::mcp_approval_meta::TOOL_PARAMS_KEY as MCP_ELICITATION_TOOL_PARAMS_KEY;
use codex_protocol::mcp_approval_meta::TOOL_TITLE_KEY as MCP_ELICITATION_TOOL_TITLE_KEY;
use rmcp::model::CreateElicitationRequestParams;
use codex_rmcp_client::Elicitation;
use rmcp::model::ElicitationAction;
use rmcp::model::Meta;
use serde_json::Map;
@@ -143,6 +143,15 @@ impl Session {
requested_schema,
}
}
McpServerElicitationRequest::OpenAiForm {
meta,
message,
requested_schema,
} => codex_protocol::approvals::ElicitationRequest::OpenAiForm {
meta,
message,
requested_schema,
},
McpServerElicitationRequest::Url {
meta,
message,
@@ -353,6 +362,9 @@ impl Session {
host_owned_codex_apps_enabled,
mcp_config.prefix_mcp_tool_names,
mcp_config.client_elicitation_capability,
self.services
.supports_openai_form_elicitation
.load(std::sync::atomic::Ordering::Relaxed),
tool_plugin_provenance,
auth.as_ref(),
elicitation_reviewer,
@@ -419,6 +431,34 @@ impl Session {
.await;
}
pub(crate) async fn set_openai_form_elicitation_support(
&self,
supported: bool,
) -> anyhow::Result<()> {
if self
.services
.supports_openai_form_elicitation
.load(std::sync::atomic::Ordering::Relaxed)
== supported
{
return Ok(());
}
let config = self.get_config().await;
let refresh_config = McpServerRefreshConfig {
mcp_servers: serde_json::to_value(config.mcp_servers.get())?,
mcp_oauth_credentials_store_mode: serde_json::to_value(
config.mcp_oauth_credentials_store_mode,
)?,
auth_keyring_backend_kind: serde_json::to_value(config.auth_keyring_backend_kind())?,
};
self.services
.supports_openai_form_elicitation
.store(supported, std::sync::atomic::Ordering::Relaxed);
*self.pending_mcp_server_refresh_config.lock().await = Some(refresh_config);
Ok(())
}
pub(crate) async fn refresh_mcp_servers_now(
&self,
turn_context: &TurnContext,
@@ -510,12 +550,15 @@ fn guardian_elicitation_review_request(
request: &ElicitationReviewRequest,
) -> GuardianElicitationReview {
let (meta, requested_schema) = match &request.elicitation {
CreateElicitationRequestParams::FormElicitationParams {
Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
meta,
requested_schema,
..
} => (meta, Some(requested_schema)),
CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => {
}) => (meta, Some(requested_schema)),
Elicitation::Mcp(rmcp::model::CreateElicitationRequestParams::UrlElicitationParams {
meta,
..
}) => {
return if meta_requests_approval_request(meta) {
GuardianElicitationReview::Decline(
"guardian MCP elicitation review only supports form elicitations",
@@ -524,6 +567,7 @@ fn guardian_elicitation_review_request(
GuardianElicitationReview::NotRequested
};
}
Elicitation::OpenAiForm { .. } => return GuardianElicitationReview::NotRequested,
};
let Some(meta) = meta.as_ref().map(|meta| &meta.0) else {
@@ -585,13 +629,10 @@ fn guardian_elicitation_review_request(
))
}
fn elicitation_connector_id(elicitation: &CreateElicitationRequestParams) -> Option<&str> {
match elicitation {
CreateElicitationRequestParams::FormElicitationParams { meta, .. }
| CreateElicitationRequestParams::UrlElicitationParams { meta, .. } => meta
.as_ref()
.and_then(|meta| metadata_str(&meta.0, MCP_ELICITATION_CONNECTOR_ID_KEY)),
}
fn elicitation_connector_id(elicitation: &Elicitation) -> Option<&str> {
elicitation
.meta()
.and_then(|meta| metadata_str(meta, MCP_ELICITATION_CONNECTOR_ID_KEY))
}
fn meta_requests_approval_request(meta: &Option<Meta>) -> bool {
+27 -21
View File
@@ -30,13 +30,15 @@ fn form_request(meta: Option<Meta>) -> ElicitationReviewRequest {
ElicitationReviewRequest {
server_name: "browser-use".to_string(),
request_id: rmcp::model::NumberOrString::Number(7),
elicitation: CreateElicitationRequestParams::FormElicitationParams {
meta,
message: "Allow origin?".to_string(),
requested_schema: ElicitationSchema::builder()
.build()
.expect("schema should build"),
},
elicitation: Elicitation::Mcp(
rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
meta,
message: "Allow origin?".to_string(),
requested_schema: ElicitationSchema::builder()
.build()
.expect("schema should build"),
},
),
}
}
@@ -171,12 +173,14 @@ fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() {
let url_request = ElicitationReviewRequest {
server_name: "browser-use".to_string(),
request_id: rmcp::model::NumberOrString::Number(8),
elicitation: CreateElicitationRequestParams::UrlElicitationParams {
meta: guardian_meta(Some(json!({}))),
message: "Open URL".to_string(),
url: "https://example.com".to_string(),
elicitation_id: "elicit-1".to_string(),
},
elicitation: Elicitation::Mcp(
rmcp::model::CreateElicitationRequestParams::UrlElicitationParams {
meta: guardian_meta(Some(json!({}))),
message: "Open URL".to_string(),
url: "https://example.com".to_string(),
elicitation_id: "elicit-1".to_string(),
},
),
};
assert!(matches!(
guardian_elicitation_review_request(&url_request),
@@ -186,14 +190,16 @@ fn guardian_elicitation_review_request_declines_unsupported_opt_in_shapes() {
let non_empty_schema_request = ElicitationReviewRequest {
server_name: "browser-use".to_string(),
request_id: rmcp::model::NumberOrString::Number(9),
elicitation: CreateElicitationRequestParams::FormElicitationParams {
meta: guardian_meta(Some(json!({}))),
message: "Allow origin?".to_string(),
requested_schema: ElicitationSchema::builder()
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
.build()
.expect("schema should build"),
},
elicitation: Elicitation::Mcp(
rmcp::model::CreateElicitationRequestParams::FormElicitationParams {
meta: guardian_meta(Some(json!({}))),
message: "Allow origin?".to_string(),
requested_schema: ElicitationSchema::builder()
.required_property("confirmed", PrimitiveSchema::Boolean(BooleanSchema::new()))
.build()
.expect("schema should build"),
},
),
};
assert!(matches!(
guardian_elicitation_review_request(&non_empty_schema_request),
+3
View File
@@ -435,6 +435,7 @@ pub(crate) struct CodexSpawnArgs {
pub(crate) parent_trace: Option<W3cTraceContext>,
pub(crate) environment_selections: Vec<TurnEnvironmentSelection>,
pub(crate) thread_extension_init: ExtensionDataInit,
pub(crate) supports_openai_form_elicitation: bool,
pub(crate) analytics_events_client: Option<AnalyticsEventsClient>,
pub(crate) thread_store: Arc<dyn ThreadStore>,
pub(crate) attestation_provider: Option<Arc<dyn AttestationProvider>>,
@@ -517,6 +518,7 @@ impl Codex {
parent_trace: _,
environment_selections,
thread_extension_init,
supports_openai_form_elicitation,
analytics_events_client,
thread_store,
attestation_provider,
@@ -659,6 +661,7 @@ impl Codex {
mcp_manager.clone(),
extensions,
thread_extension_init,
supports_openai_form_elicitation,
agent_control,
environment_manager,
inherited_environments,
+7
View File
@@ -484,6 +484,7 @@ impl Session {
mcp_manager: Arc<McpManager>,
extensions: Arc<codex_extension_api::ExtensionRegistry<crate::config::Config>>,
thread_extension_init: ExtensionDataInit,
supports_openai_form_elicitation: bool,
agent_control: AgentControl,
environment_manager: Arc<EnvironmentManager>,
inherited_environments: Option<TurnEnvironmentSnapshot>,
@@ -1009,6 +1010,9 @@ impl Session {
session_extension_data,
thread_extension_data,
mcp_thread_init,
supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new(
supports_openai_form_elicitation,
),
agent_control,
network_proxy: arc_swap::ArcSwapOption::from(network_proxy.map(Arc::new)),
network_proxy_audit_metadata,
@@ -1149,6 +1153,9 @@ impl Session {
host_owned_codex_apps_enabled,
config.prefix_mcp_tool_names(),
client_elicitation_capability,
sess.services
.supports_openai_form_elicitation
.load(std::sync::atomic::Ordering::Relaxed),
tool_plugin_provenance,
auth,
Some(sess.mcp_elicitation_reviewer()),
+5
View File
@@ -4856,6 +4856,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() {
mcp_manager,
Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()),
codex_extension_api::ExtensionDataInit::default(),
/*supports_openai_form_elicitation*/ false,
AgentControl::default(),
environment_manager,
/*inherited_environments*/ None,
@@ -5018,6 +5019,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
),
thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()),
mcp_thread_init: codex_extension_api::ExtensionDataInit::default(),
supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new(false),
agent_control,
network_proxy: arc_swap::ArcSwapOption::from(None),
network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(),
@@ -5203,6 +5205,7 @@ async fn make_session_with_config_and_rx(
mcp_manager,
Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()),
codex_extension_api::ExtensionDataInit::default(),
/*supports_openai_form_elicitation*/ false,
AgentControl::default(),
environment_manager,
/*inherited_environments*/ None,
@@ -5307,6 +5310,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
mcp_manager,
Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()),
codex_extension_api::ExtensionDataInit::default(),
/*supports_openai_form_elicitation*/ false,
agent_control,
environment_manager,
/*inherited_environments*/ None,
@@ -7061,6 +7065,7 @@ where
),
thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()),
mcp_thread_init: codex_extension_api::ExtensionDataInit::default(),
supports_openai_form_elicitation: std::sync::atomic::AtomicBool::new(false),
agent_control,
network_proxy: arc_swap::ArcSwapOption::from(None),
network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(),
@@ -732,6 +732,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
parent_trace: None,
environment_selections: Vec::new(),
thread_extension_init: codex_extension_api::ExtensionDataInit::default(),
supports_openai_form_elicitation: false,
analytics_events_client: None,
thread_store,
attestation_provider: None,
+2
View File
@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use crate::SkillsService;
use crate::agent::AgentControl;
@@ -67,6 +68,7 @@ pub(crate) struct SessionServices {
pub(crate) extensions: Arc<ExtensionRegistry<crate::config::Config>>,
pub(crate) session_extension_data: ExtensionData,
pub(crate) thread_extension_data: ExtensionData,
pub(crate) supports_openai_form_elicitation: AtomicBool,
pub(crate) mcp_thread_init: ExtensionDataInit,
pub(crate) agent_control: AgentControl,
pub(crate) network_proxy: ArcSwapOption<StartedNetworkProxy>,
+8 -1
View File
@@ -112,9 +112,14 @@ pub async fn start_thread_with_user_shell_override(
thread_manager: &ThreadManager,
config: Config,
user_shell_override: crate::shell::Shell,
supports_openai_form_elicitation: bool,
) -> codex_protocol::error::Result<crate::NewThread> {
thread_manager
.start_thread_with_user_shell_override_for_tests(config, user_shell_override)
.start_thread_with_user_shell_override_for_tests(
config,
user_shell_override,
supports_openai_form_elicitation,
)
.await
}
@@ -124,6 +129,7 @@ pub async fn resume_thread_from_rollout_with_user_shell_override(
rollout_path: PathBuf,
auth_manager: Arc<AuthManager>,
user_shell_override: crate::shell::Shell,
supports_openai_form_elicitation: bool,
) -> codex_protocol::error::Result<crate::NewThread> {
thread_manager
.resume_thread_from_rollout_with_user_shell_override_for_tests(
@@ -131,6 +137,7 @@ pub async fn resume_thread_from_rollout_with_user_shell_override(
rollout_path,
auth_manager,
user_shell_override,
supports_openai_form_elicitation,
)
.await
}
+31 -2
View File
@@ -186,6 +186,7 @@ pub struct StartThreadOptions {
pub parent_trace: Option<W3cTraceContext>,
pub environments: Vec<TurnEnvironmentSelection>,
pub thread_extension_init: ExtensionDataInit,
pub supports_openai_form_elicitation: bool,
}
pub(crate) struct ResumeThreadWithHistoryOptions {
@@ -604,6 +605,7 @@ impl ThreadManager {
parent_trace: None,
environments,
thread_extension_init: ExtensionDataInit::default(),
supports_openai_form_elicitation: false,
}))
.await
}
@@ -644,6 +646,7 @@ impl ThreadManager {
options.parent_trace,
options.environments,
options.thread_extension_init,
options.supports_openai_form_elicitation,
/*user_shell_override*/ None,
))
.await
@@ -692,6 +695,7 @@ impl ThreadManager {
rollout_path: PathBuf,
auth_manager: Arc<AuthManager>,
parent_trace: Option<W3cTraceContext>,
supports_openai_form_elicitation: bool,
) -> CodexResult<NewThread> {
let initial_history = self.initial_history_from_rollout_path(rollout_path).await?;
Box::pin(self.resume_thread_with_history(
@@ -699,6 +703,7 @@ impl ThreadManager {
initial_history,
auth_manager,
parent_trace,
supports_openai_form_elicitation,
))
.await
}
@@ -710,6 +715,7 @@ impl ThreadManager {
initial_history: InitialHistory,
auth_manager: Arc<AuthManager>,
parent_trace: Option<W3cTraceContext>,
supports_openai_form_elicitation: bool,
) -> CodexResult<NewThread> {
let agent_control = self.agent_control_for_config(&config);
let environments = default_thread_environment_selections(
@@ -735,6 +741,7 @@ impl ThreadManager {
parent_trace,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
supports_openai_form_elicitation,
/*user_shell_override*/ None,
))
.await
@@ -744,6 +751,7 @@ impl ThreadManager {
&self,
config: Config,
user_shell_override: crate::shell::Shell,
supports_openai_form_elicitation: bool,
) -> CodexResult<NewThread> {
let agent_control = self.agent_control_for_config(&config);
let environments = default_thread_environment_selections(
@@ -763,6 +771,7 @@ impl ThreadManager {
/*parent_trace*/ None,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
supports_openai_form_elicitation,
/*user_shell_override*/ Some(user_shell_override),
))
.await
@@ -774,6 +783,7 @@ impl ThreadManager {
rollout_path: PathBuf,
auth_manager: Arc<AuthManager>,
user_shell_override: crate::shell::Shell,
supports_openai_form_elicitation: bool,
) -> CodexResult<NewThread> {
let agent_control = self.agent_control_for_config(&config);
let initial_history = self.initial_history_from_rollout_path(rollout_path).await?;
@@ -800,6 +810,7 @@ impl ThreadManager {
/*parent_trace*/ None,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
supports_openai_form_elicitation,
/*user_shell_override*/ Some(user_shell_override),
))
.await
@@ -880,8 +891,15 @@ impl ThreadManager {
{
let snapshot = snapshot.into();
let history = self.initial_history_from_rollout_path(path).await?;
self.fork_thread_from_history(snapshot, config, history, thread_source, parent_trace)
.await
self.fork_thread_from_history(
snapshot,
config,
history,
thread_source,
parent_trace,
/*supports_openai_form_elicitation*/ false,
)
.await
}
async fn initial_history_from_rollout_path(
@@ -910,6 +928,7 @@ impl ThreadManager {
history: InitialHistory,
thread_source: Option<ThreadSource>,
parent_trace: Option<W3cTraceContext>,
supports_openai_form_elicitation: bool,
) -> CodexResult<NewThread>
where
S: Into<ForkSnapshot>,
@@ -920,6 +939,7 @@ impl ThreadManager {
history,
thread_source,
parent_trace,
supports_openai_form_elicitation,
)
.await
}
@@ -931,6 +951,7 @@ impl ThreadManager {
history: InitialHistory,
thread_source: Option<ThreadSource>,
parent_trace: Option<W3cTraceContext>,
supports_openai_form_elicitation: bool,
) -> CodexResult<NewThread> {
// `forked_from_id()` describes this history's existing lineage. When
// forking a resumed thread, the child copies the resumed thread itself.
@@ -970,6 +991,7 @@ impl ThreadManager {
parent_trace,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
supports_openai_form_elicitation,
/*user_shell_override*/ None,
))
.await
@@ -1230,6 +1252,7 @@ impl ThreadManagerState {
/*parent_trace*/ None,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
/*supports_openai_form_elicitation*/ false,
/*user_shell_override*/ None,
))
.await
@@ -1267,6 +1290,7 @@ impl ThreadManagerState {
/*parent_trace*/ None,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
/*supports_openai_form_elicitation*/ false,
/*user_shell_override*/ None,
))
.await
@@ -1305,6 +1329,7 @@ impl ThreadManagerState {
/*parent_trace*/ None,
environments,
/*thread_extension_init*/ ExtensionDataInit::default(),
/*supports_openai_form_elicitation*/ false,
/*user_shell_override*/ None,
))
.await
@@ -1326,6 +1351,7 @@ impl ThreadManagerState {
parent_trace: Option<W3cTraceContext>,
environments: Vec<TurnEnvironmentSelection>,
thread_extension_init: ExtensionDataInit,
supports_openai_form_elicitation: bool,
user_shell_override: Option<crate::shell::Shell>,
) -> CodexResult<NewThread> {
Box::pin(self.spawn_thread_with_source(
@@ -1344,6 +1370,7 @@ impl ThreadManagerState {
parent_trace,
environments,
thread_extension_init,
supports_openai_form_elicitation,
user_shell_override,
))
.await
@@ -1367,6 +1394,7 @@ impl ThreadManagerState {
parent_trace: Option<W3cTraceContext>,
environments: Vec<TurnEnvironmentSelection>,
thread_extension_init: ExtensionDataInit,
supports_openai_form_elicitation: bool,
user_shell_override: Option<crate::shell::Shell>,
) -> CodexResult<NewThread> {
let is_resumed_thread = matches!(&initial_history, InitialHistory::Resumed(_));
@@ -1434,6 +1462,7 @@ impl ThreadManagerState {
parent_trace,
environment_selections: environments,
thread_extension_init,
supports_openai_form_elicitation,
analytics_events_client: self.analytics_events_client.clone(),
thread_store: Arc::clone(&self.thread_store),
attestation_provider: self.attestation_provider.clone(),
+14
View File
@@ -325,6 +325,7 @@ async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() {
parent_trace: None,
environments: Vec::new(),
thread_extension_init: Default::default(),
supports_openai_form_elicitation: false,
})
.await
.expect("internal thread should start");
@@ -463,6 +464,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
parent_trace: None,
environments: Vec::new(),
thread_extension_init: selected_root_init("selected-a", "env-a"),
supports_openai_form_elicitation: false,
})
.await
.expect("start first thread");
@@ -477,6 +479,7 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors()
parent_trace: None,
environments: Vec::new(),
thread_extension_init: selected_root_init("selected-b", "env-b"),
supports_openai_form_elicitation: false,
})
.await
.expect("start second thread");
@@ -567,6 +570,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
parent_trace: None,
environments: environments.clone(),
thread_extension_init: Default::default(),
supports_openai_form_elicitation: false,
})
.await
.expect("start source thread");
@@ -593,6 +597,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
rollout_path.clone(),
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
)
.await
.expect("resume source thread");
@@ -729,6 +734,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() {
rollout_path,
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
)
.await
.expect("resume active source thread");
@@ -792,6 +798,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() {
rollout_path,
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
)
.await
.expect("resume stopped source thread");
@@ -842,6 +849,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
parent_trace: None,
environments: Vec::new(),
thread_extension_init: Default::default(),
supports_openai_form_elicitation: false,
})
.await
.expect("start source thread");
@@ -868,6 +876,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
rollout_path,
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
)
.await
.expect("resume source thread");
@@ -947,6 +956,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() {
}),
auth_manager.clone(),
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
)
.await
.expect("seed rollout path in store");
@@ -963,6 +973,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() {
rollout_path.clone(),
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
)
.await
.expect("resume from rollout path");
@@ -1254,6 +1265,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
]),
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
)
.await
.expect("create source thread from completed history");
@@ -1368,6 +1380,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
]),
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
)
.await
.expect("create source thread from explicit partial history");
@@ -1458,6 +1471,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
]),
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
)
.await
.expect("create source thread from partial history");
@@ -2832,6 +2832,7 @@ async fn resume_agent_restores_closed_agent_and_accepts_send_input() {
})]),
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")),
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
)
.await
.expect("start thread");
+30 -1
View File
@@ -15,6 +15,7 @@ use anyhow::Result;
use anyhow::anyhow;
use codex_config::CloudConfigBundleLoader;
use codex_core::CodexThread;
use codex_core::StartThreadOptions;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::resolve_installation_id;
@@ -39,6 +40,7 @@ use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion;
use codex_protocol::protocol::SandboxPolicy;
@@ -258,6 +260,7 @@ pub struct TestCodexBuilder {
exec_server_url: Option<String>,
extensions: Arc<ExtensionRegistry<Config>>,
user_instructions_provider: Option<Arc<dyn UserInstructionsProvider>>,
supports_openai_form_elicitation: bool,
}
impl TestCodexBuilder {
@@ -354,6 +357,11 @@ impl TestCodexBuilder {
self
}
pub fn with_openai_form_elicitation(mut self) -> Self {
self.supports_openai_form_elicitation = true;
self
}
pub fn with_windows_cmd_shell(self) -> Self {
if cfg!(windows) {
self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe")))
@@ -574,6 +582,7 @@ impl TestCodexBuilder {
path,
auth_manager,
user_shell_override,
self.supports_openai_form_elicitation,
),
)
.await?
@@ -585,6 +594,7 @@ impl TestCodexBuilder {
path,
auth_manager,
/*parent_trace*/ None,
self.supports_openai_form_elicitation,
))
.await?
}
@@ -594,11 +604,29 @@ impl TestCodexBuilder {
thread_manager.as_ref(),
config.clone(),
user_shell_override,
self.supports_openai_form_elicitation,
),
)
.await?
}
(None, None) => Box::pin(thread_manager.start_thread(config.clone())).await?,
(None, None) => {
let environments = thread_manager.default_environment_selections(&config.cwd);
Box::pin(
thread_manager.start_thread_with_options(StartThreadOptions {
config: config.clone(),
initial_history: InitialHistory::New,
session_source: None,
thread_source: None,
dynamic_tools: Vec::new(),
metrics_service_name: None,
parent_trace: None,
environments,
thread_extension_init: Default::default(),
supports_openai_form_elicitation: self.supports_openai_form_elicitation,
}),
)
.await?
}
};
Ok(TestCodex {
@@ -1143,6 +1171,7 @@ pub fn test_codex() -> TestCodexBuilder {
exec_server_url: None,
extensions: empty_extension_registry(),
user_instructions_provider: None,
supports_openai_form_elicitation: false,
}
}
+2
View File
@@ -448,6 +448,7 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> {
parent_trace: None,
environments: Vec::new(),
thread_extension_init: Default::default(),
supports_openai_form_elicitation: false,
})
.await?;
assert_eq!(provider.load_count(), 2);
@@ -664,6 +665,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho
},
],
thread_extension_init: Default::default(),
supports_openai_form_elicitation: false,
})
.await?;
assert_eq!(provider.load_count(), 2);
@@ -830,6 +830,7 @@ async fn resume_conversation(
path,
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
))
.await
.expect("resume conversation")
+1
View File
@@ -201,6 +201,7 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() {
}),
/*thread_source*/ None,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
)
.await
.expect("fork from stored history");
@@ -2267,6 +2267,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge
InitialHistory::Forked(history),
auth_manager_from_auth(CodexAuth::from_api_key("dummy")),
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
)
.await?;
let codex = resumed_thread.thread;
@@ -110,6 +110,7 @@ async fn emits_warning_when_resumed_model_differs() {
initial_history,
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
)
.await
.expect("resume conversation");
+115 -4
View File
@@ -50,6 +50,7 @@ use core_test_support::assert_regex_match;
use core_test_support::responses;
use core_test_support::responses::mount_models_once;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_wine_exec;
use core_test_support::stdio_server_bin;
@@ -339,13 +340,23 @@ async fn call_cwd_tool(
fixture: &TestCodex,
server_name: &str,
call_id: &str,
) -> anyhow::Result<Value> {
call_structured_tool(server, fixture, server_name, "cwd", call_id).await
}
async fn call_structured_tool(
server: &MockServer,
fixture: &TestCodex,
server_name: &str,
tool_name: &str,
call_id: &str,
) -> anyhow::Result<Value> {
let namespace = format!("mcp__{server_name}");
mount_sse_once(
server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(call_id, &namespace, "cwd", r#"{}"#),
responses::ev_function_call_with_namespace(call_id, &namespace, tool_name, r#"{}"#),
responses::ev_completed("resp-1"),
]),
)
@@ -353,7 +364,7 @@ async fn call_cwd_tool(
mount_sse_once(
server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp cwd tool completed successfully."),
responses::ev_assistant_message("msg-1", "rmcp tool completed successfully."),
responses::ev_completed("resp-2"),
]),
)
@@ -361,7 +372,7 @@ async fn call_cwd_tool(
fixture
.codex
.submit(read_only_user_turn(fixture, "call the rmcp cwd tool"))
.submit(read_only_user_turn(fixture, "call the requested rmcp tool"))
.await?;
wait_for_event(&fixture.codex, |ev| {
@@ -378,7 +389,7 @@ async fn call_cwd_tool(
let structured_content = end
.result
.as_ref()
.expect("rmcp cwd tool should return success")
.expect("rmcp tool should return success")
.structured_content
.as_ref()
.expect("structured content")
@@ -388,6 +399,106 @@ async fn call_cwd_tool(
Ok(structured_content)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn openai_form_capability_is_advertised_to_mcp_servers() -> anyhow::Result<()> {
assert_openai_form_capability_advertisement(/*expected*/ true).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn openai_form_capability_is_not_advertised_by_default() -> anyhow::Result<()> {
assert_openai_form_capability_advertisement(/*expected*/ false).await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn openai_form_capability_updates_for_loaded_thread() -> anyhow::Result<()> {
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
let server = start_mock_server().await;
let server_name = "capabilities";
let command = stdio_server_bin()?;
let fixture = test_codex()
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(command, /*env*/ None, Vec::new()),
TestMcpServerOptions::default(),
);
})
.build(&server)
.await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
let unsupported = call_structured_tool(
&server,
&fixture,
server_name,
"client_capabilities",
"call-client-capabilities-unsupported",
)
.await?;
assert_eq!(
unsupported,
json!({ "supportsOpenaiFormElicitation": false })
);
fixture
.codex
.set_openai_form_elicitation_support(/*supported*/ true)
.await?;
let supported = call_structured_tool(
&server,
&fixture,
server_name,
"client_capabilities",
"call-client-capabilities-supported",
)
.await?;
assert_eq!(supported, json!({ "supportsOpenaiFormElicitation": true }));
Ok(())
}
async fn assert_openai_form_capability_advertisement(expected: bool) -> anyhow::Result<()> {
skip_if_wine_exec!(
Ok(()),
"requires a Windows test_stdio_server in the Wine-exec environment"
);
let server = start_mock_server().await;
let server_name = "capabilities";
let command = stdio_server_bin()?;
let mut builder = test_codex().with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(command, /*env*/ None, Vec::new()),
TestMcpServerOptions::default(),
);
});
if expected {
builder = builder.with_openai_form_elicitation();
}
let fixture = builder.build(&server).await?;
wait_for_mcp_server(&fixture.codex, server_name).await?;
let structured = call_structured_tool(
&server,
&fixture,
server_name,
"client_capabilities",
"call-client-capabilities",
)
.await?;
assert_eq!(
structured,
json!({ "supportsOpenaiFormElicitation": expected })
);
Ok(())
}
fn assert_cwd_tool_output(structured: &Value, expected_cwd: &Path) {
let actual_cwd = structured
.get("cwd")
@@ -754,6 +754,7 @@ async fn subagent_stop_replaces_stop_and_skips_internal_subagents() -> Result<()
parent_trace: None,
environments: Vec::new(),
thread_extension_init: Default::default(),
supports_openai_form_elicitation: false,
})
.await?;
@@ -47,6 +47,7 @@ async fn emits_warning_when_unstable_features_enabled_via_config() {
InitialHistory::New,
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
)
.await
.expect("spawn conversation");
@@ -93,6 +94,7 @@ async fn suppresses_warning_when_configured() {
InitialHistory::New,
auth_manager,
/*parent_trace*/ None,
/*supports_openai_form_elicitation*/ false,
)
.await
.expect("spawn conversation");
+1
View File
@@ -556,6 +556,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
client_name: "codex_exec".to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
experimental_api: true,
mcp_server_openai_form_elicitation: false,
opt_out_notification_methods: Vec::new(),
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
};
+1
View File
@@ -313,6 +313,7 @@ impl MemoryStartupContext {
parent_trace: None,
environments,
thread_extension_init: Default::default(),
supports_openai_form_elicitation: false,
})
.await?;
+12 -1
View File
@@ -342,6 +342,15 @@ pub enum ElicitationRequest {
message: String,
requested_schema: JsonValue,
},
#[serde(rename = "openai/form")]
#[ts(rename = "openai/form")]
OpenAiForm {
#[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")]
@@ -355,7 +364,9 @@ pub enum ElicitationRequest {
impl ElicitationRequest {
pub fn message(&self) -> &str {
match self {
Self::Form { message, .. } | Self::Url { message, .. } => message,
Self::Form { message, .. }
| Self::OpenAiForm { message, .. }
| Self::Url { message, .. } => message,
}
}
}
@@ -4,6 +4,8 @@ use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;
use rmcp::ErrorData as McpError;
@@ -11,6 +13,8 @@ use rmcp::ServiceExt;
use rmcp::handler::server::ServerHandler;
use rmcp::model::CallToolRequestParams;
use rmcp::model::CallToolResult;
use rmcp::model::InitializeRequestParams;
use rmcp::model::InitializeResult;
use rmcp::model::JsonObject;
use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult;
@@ -38,6 +42,7 @@ struct TestToolServer {
tools: Arc<Vec<Tool>>,
resources: Arc<Vec<Resource>>,
resource_templates: Arc<Vec<ResourceTemplate>>,
supports_openai_form_elicitation: Arc<AtomicBool>,
}
const MEMO_URI: &str = "memo://codex/example-note";
@@ -68,6 +73,7 @@ impl TestToolServer {
let tools = vec![
Self::echo_tool(),
Self::echo_dash_tool(),
Self::client_capabilities_tool(),
Self::cwd_tool(),
Self::sync_tool(),
Self::sync_readonly_tool(),
@@ -81,6 +87,7 @@ impl TestToolServer {
tools: Arc::new(tools),
resources: Arc::new(resources),
resource_templates: Arc::new(resource_templates),
supports_openai_form_elicitation: Arc::new(AtomicBool::new(false)),
}
}
@@ -166,6 +173,24 @@ impl TestToolServer {
tool
}
fn client_capabilities_tool() -> Tool {
#[expect(clippy::expect_used)]
let schema: JsonObject = serde_json::from_value(serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
}))
.expect("client capabilities tool schema should deserialize");
let mut tool = Tool::new(
Cow::Borrowed("client_capabilities"),
Cow::Borrowed("Return capabilities advertised by the MCP client."),
Arc::new(schema),
);
tool.annotations = Some(ToolAnnotations::new().read_only(true));
tool
}
fn sync_tool() -> Tool {
#[expect(clippy::expect_used)]
let schema: JsonObject = serde_json::from_value(json!({
@@ -396,6 +421,23 @@ struct ImageScenarioArgs {
}
impl ServerHandler for TestToolServer {
async fn initialize(
&self,
request: InitializeRequestParams,
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<InitializeResult, McpError> {
self.supports_openai_form_elicitation.store(
request
.capabilities
.extensions
.as_ref()
.is_some_and(|extensions| extensions.contains_key("openai/form")),
Ordering::Relaxed,
);
context.peer.set_peer_info(request);
Ok(self.get_info())
}
fn get_info(&self) -> ServerInfo {
let mut capabilities = ServerCapabilities::builder()
.enable_tools()
@@ -481,6 +523,11 @@ impl ServerHandler for TestToolServer {
context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> Result<CallToolResult, McpError> {
match request.name.as_ref() {
"client_capabilities" => Ok(Self::structured_result(json!({
"supportsOpenaiFormElicitation": self
.supports_openai_form_elicitation
.load(Ordering::Relaxed),
}))),
"sandbox_meta" => Ok(Self::structured_result(serde_json::Value::Object(
context.meta.0,
))),
@@ -3,6 +3,7 @@ use std::sync::Arc;
use rmcp::RoleClient;
use rmcp::model::ClientInfo;
use rmcp::model::ClientResult;
use rmcp::model::CustomRequest;
use rmcp::model::CustomResult;
use rmcp::model::ElicitationAction;
use rmcp::model::Meta;
@@ -12,7 +13,9 @@ use rmcp::model::ServerRequest;
use rmcp::service::NotificationContext;
use rmcp::service::RequestContext;
use rmcp::service::Service;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Map;
use serde_json::Value;
use crate::logging_client_handler::LoggingClientHandler;
@@ -22,10 +25,21 @@ use crate::rmcp_client::ElicitationResponse;
use crate::rmcp_client::SendElicitation;
const MCP_PROGRESS_TOKEN_META_KEY: &str = "progressToken";
const OPENAI_FORM_METHOD: &str = "openai/form";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct OpenAiFormRequestParams {
#[serde(rename = "_meta")]
meta: Option<Value>,
message: String,
requested_schema: Value,
}
#[derive(Clone)]
pub(crate) struct ElicitationClientService {
handler: LoggingClientHandler,
supports_openai_form: bool,
send_elicitation: Arc<SendElicitation>,
pause_state: ElicitationPauseState,
}
@@ -36,12 +50,18 @@ impl ElicitationClientService {
send_elicitation: SendElicitation,
pause_state: ElicitationPauseState,
) -> Self {
let supports_openai_form = client_info
.capabilities
.extensions
.as_ref()
.is_some_and(|extensions| extensions.contains_key(OPENAI_FORM_METHOD));
let send_elicitation = Arc::new(send_elicitation);
Self {
handler: LoggingClientHandler::new(
client_info,
clone_send_elicitation(Arc::clone(&send_elicitation)),
),
supports_openai_form,
send_elicitation,
pause_state,
}
@@ -73,11 +93,23 @@ impl Service<RoleClient> for ElicitationClientService {
) -> Result<ClientResult, rmcp::ErrorData> {
match request {
ServerRequest::CreateElicitationRequest(request) => {
let response = self.create_elicitation(request.params, context).await?;
let response = self
.create_elicitation(Elicitation::Mcp(request.params), context)
.await?;
// RMCP's typed CreateElicitationResult does not model result-level `_meta`.
let result = elicitation_response_result(response)?;
Ok(ClientResult::CustomResult(result))
}
ServerRequest::CustomRequest(request)
if request.method == OPENAI_FORM_METHOD && self.supports_openai_form =>
{
let response = self
.create_elicitation(openai_form_elicitation(request)?, context)
.await?;
Ok(ClientResult::CustomResult(elicitation_response_result(
response,
)?))
}
request => {
<LoggingClientHandler as Service<RoleClient>>::handle_request(
&self.handler,
@@ -107,6 +139,18 @@ impl Service<RoleClient> for ElicitationClientService {
}
}
fn openai_form_elicitation(request: CustomRequest) -> Result<Elicitation, rmcp::ErrorData> {
let params = request
.params_as::<OpenAiFormRequestParams>()
.map_err(|err| rmcp::ErrorData::invalid_params(err.to_string(), None))?
.ok_or_else(|| rmcp::ErrorData::invalid_params("missing params", None))?;
Ok(Elicitation::OpenAiForm {
meta: params.meta,
message: params.message,
requested_schema: params.requested_schema,
})
}
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);
@@ -114,10 +158,20 @@ fn restore_context_meta(mut request: Elicitation, mut context_meta: Meta) -> Eli
return request;
}
request
.meta_mut()
.get_or_insert_with(Meta::new)
.extend(context_meta);
match &mut request {
Elicitation::Mcp(request) => request
.meta_mut()
.get_or_insert_with(Meta::new)
.extend(context_meta),
Elicitation::OpenAiForm { meta, .. } => {
let meta = meta
.get_or_insert_with(|| Value::Object(Map::new()))
.as_object_mut();
if let Some(meta) = meta {
meta.extend(context_meta.0);
}
}
}
request
}
@@ -165,7 +219,7 @@ mod tests {
#[test]
fn restore_context_meta_adds_elicitation_meta_and_removes_progress_token() {
let request = restore_context_meta(
form_request(/*meta*/ None),
Elicitation::Mcp(form_request(/*meta*/ None)),
meta(json!({
"progressToken": "progress-token",
"persist": ["session", "always"],
@@ -174,9 +228,54 @@ mod tests {
assert_eq!(
request,
form_request(Some(meta(json!({
Elicitation::Mcp(form_request(Some(meta(json!({
"persist": ["session", "always"],
}))))
})))))
);
}
#[test]
fn parses_openai_form_custom_requests() {
let elicitation = openai_form_elicitation(CustomRequest::new(
OPENAI_FORM_METHOD,
Some(json!({
"message": "Select a template",
"requestedSchema": {
"type": "object",
"properties": {
"template": {
"type": "openai/imagePicker",
"items": [{
"id": "monthly-review",
"title": "Monthly review",
"image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4="
}]
}
}
}
})),
))
.expect("valid openai/form request");
assert_eq!(
elicitation,
Elicitation::OpenAiForm {
meta: None,
message: "Select a template".to_string(),
requested_schema: json!({
"type": "object",
"properties": {
"template": {
"type": "openai/imagePicker",
"items": [{
"id": "monthly-review",
"title": "Monthly review",
"image": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4="
}]
}
}
}),
}
);
}
@@ -17,6 +17,7 @@ use tracing::error;
use tracing::info;
use tracing::warn;
use crate::rmcp_client::Elicitation;
use crate::rmcp_client::SendElicitation;
#[derive(Clone)]
@@ -40,7 +41,7 @@ impl ClientHandler for LoggingClientHandler {
request: CreateElicitationRequestParams,
context: RequestContext<RoleClient>,
) -> Result<CreateElicitationResult, rmcp::ErrorData> {
(self.send_elicitation)(context.id, request)
(self.send_elicitation)(context.id, Elicitation::Mcp(request))
.await
.map(Into::into)
.map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))
+19 -1
View File
@@ -40,6 +40,7 @@ use rmcp::model::PaginatedRequestParams;
use rmcp::model::ReadResourceRequestParams;
use rmcp::model::ReadResourceResult;
use rmcp::model::RequestId;
use rmcp::model::RequestParamsMeta;
use rmcp::model::ServerResult;
use rmcp::model::Tool;
use rmcp::service::RoleClient;
@@ -251,7 +252,24 @@ fn remaining_operation_timeout(
}
}
pub type Elicitation = CreateElicitationRequestParams;
#[derive(Debug, Clone, PartialEq)]
pub enum Elicitation {
Mcp(CreateElicitationRequestParams),
OpenAiForm {
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),
Self::OpenAiForm { meta, .. } => meta.as_ref().and_then(serde_json::Value::as_object),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
+4 -1
View File
@@ -292,7 +292,10 @@ impl App {
message: message.clone(),
},
)),
codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => {
codex_app_server_protocol::McpServerElicitationRequest::OpenAiForm {
..
}
| codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => {
self.app_event_tx.resolve_elicitation(
thread_id,
params.server_name.clone(),
+2 -1
View File
@@ -376,7 +376,8 @@ impl ChatWidget {
self.bottom_pane
.push_approval_request(request, &self.config.features);
}
McpServerElicitationRequest::Url { .. } => {
McpServerElicitationRequest::OpenAiForm { .. }
| McpServerElicitationRequest::Url { .. } => {
self.app_event_tx.resolve_elicitation(
thread_id,
params.server_name,
+2
View File
@@ -399,6 +399,7 @@ async fn connect_remote_app_server(
client_name: "codex-tui".to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
experimental_api: true,
mcp_server_openai_form_elicitation: false,
opt_out_notification_methods: Vec::new(),
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
@@ -562,6 +563,7 @@ where
client_name: "codex-tui".to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
experimental_api: true,
mcp_server_openai_form_elicitation: false,
opt_out_notification_methods: Vec::new(),
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
+1
View File
@@ -1055,6 +1055,7 @@ mod tests {
client_name: "test".to_string(),
client_version: "test".to_string(),
experimental_api: true,
mcp_server_openai_form_elicitation: false,
opt_out_notification_methods: Vec::new(),
channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})