Advertise MCP App UI support to Codex Apps

This commit is contained in:
martinauyeung-oai
2026-07-07 03:32:57 -07:00
parent cca16a1087
commit 16f525d099
46 changed files with 779 additions and 115 deletions
@@ -793,12 +793,7 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact {
title: None,
version: "1.0.0".to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
capabilities: Some(InitializeCapabilities::default()),
},
product_client_id: DEFAULT_ORIGINATOR.to_string(),
runtime: CodexRuntimeMetadata {
@@ -1674,12 +1669,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize
title: None,
version: "1.0.0".to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
capabilities: Some(InitializeCapabilities::default()),
},
product_client_id: DEFAULT_ORIGINATOR.to_string(),
runtime: CodexRuntimeMetadata {
@@ -1978,12 +1968,7 @@ async fn compaction_event_ingests_custom_fact() {
title: None,
version: "1.0.0".to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
capabilities: Some(InitializeCapabilities::default()),
},
product_client_id: DEFAULT_ORIGINATOR.to_string(),
runtime: sample_runtime_metadata(),
@@ -2108,12 +2093,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() {
title: None,
version: "1.0.0".to_string(),
},
capabilities: Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
capabilities: Some(InitializeCapabilities::default()),
},
product_client_id: DEFAULT_ORIGINATOR.to_string(),
runtime: sample_runtime_metadata(),
+1
View File
@@ -374,6 +374,7 @@ impl InProcessClientStartArgs {
Some(self.opt_out_notification_methods.clone())
},
mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation,
extensions: None,
};
InitializeParams {
+1
View File
@@ -101,6 +101,7 @@ impl RemoteAppServerConnectArgs {
Some(self.opt_out_notification_methods.clone())
},
mcp_server_openai_form_elicitation: self.mcp_server_openai_form_elicitation,
extensions: None,
};
InitializeParams {
@@ -1279,6 +1279,14 @@
"description": "Opt into receiving experimental API methods and fields.",
"type": "boolean"
},
"extensions": {
"additionalProperties": true,
"description": "MCP extension settings declared by the app-server client.\n\nCodex currently consumes `io.modelcontextprotocol/ui` so widget-capable hosts can advertise the same UI support to downstream MCP servers.",
"type": [
"object",
"null"
]
},
"mcpServerOpenaiFormElicitation": {
"description": "Allow downstream MCP servers to request OpenAI extended form elicitations.",
"type": "boolean"
@@ -2923,6 +2923,14 @@
"description": "Opt into receiving experimental API methods and fields.",
"type": "boolean"
},
"extensions": {
"additionalProperties": true,
"description": "MCP extension settings declared by the app-server client.\n\nCodex currently consumes `io.modelcontextprotocol/ui` so widget-capable hosts can advertise the same UI support to downstream MCP servers.",
"type": [
"object",
"null"
]
},
"mcpServerOpenaiFormElicitation": {
"description": "Allow downstream MCP servers to request OpenAI extended form elicitations.",
"type": "boolean"
@@ -7524,6 +7524,14 @@
"description": "Opt into receiving experimental API methods and fields.",
"type": "boolean"
},
"extensions": {
"additionalProperties": true,
"description": "MCP extension settings declared by the app-server client.\n\nCodex currently consumes `io.modelcontextprotocol/ui` so widget-capable hosts can advertise the same UI support to downstream MCP servers.",
"type": [
"object",
"null"
]
},
"mcpServerOpenaiFormElicitation": {
"description": "Allow downstream MCP servers to request OpenAI extended form elicitations.",
"type": "boolean"
@@ -30,6 +30,14 @@
"description": "Opt into receiving experimental API methods and fields.",
"type": "boolean"
},
"extensions": {
"additionalProperties": true,
"description": "MCP extension settings declared by the app-server client.\n\nCodex currently consumes `io.modelcontextprotocol/ui` so widget-capable hosts can advertise the same UI support to downstream MCP servers.",
"type": [
"object",
"null"
]
},
"mcpServerOpenaiFormElicitation": {
"description": "Allow downstream MCP servers to request OpenAI extended form elicitations.",
"type": "boolean"
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JsonValue } from "./serde_json/JsonValue";
/**
* Client-declared capabilities negotiated during initialize.
@@ -22,4 +23,11 @@ mcpServerOpenaiFormElicitation?: boolean,
* Exact notification method names that should be suppressed for this
* connection (for example `thread/started`).
*/
optOutNotificationMethods?: Array<string> | null, };
optOutNotificationMethods?: Array<string> | null,
/**
* MCP extension settings declared by the app-server client.
*
* Codex currently consumes `io.modelcontextprotocol/ui` so widget-capable
* hosts can advertise the same UI support to downstream MCP servers.
*/
extensions?: { [key in string]?: JsonValue } | null, };
@@ -2207,6 +2207,12 @@ mod tests {
"thread/started".to_string(),
"item/agentMessage/delta".to_string(),
]),
extensions: Some(std::collections::HashMap::from([(
"io.modelcontextprotocol/ui".to_string(),
json!({
"mimeTypes": ["text/html;profile=mcp-app"],
}),
)])),
}),
},
};
@@ -2228,7 +2234,12 @@ mod tests {
"optOutNotificationMethods": [
"thread/started",
"item/agentMessage/delta"
]
],
"extensions": {
"io.modelcontextprotocol/ui": {
"mimeTypes": ["text/html;profile=mcp-app"]
}
}
}
}
}),
@@ -2255,7 +2266,12 @@ mod tests {
"optOutNotificationMethods": [
"thread/started",
"item/agentMessage/delta"
]
],
"extensions": {
"io.modelcontextprotocol/ui": {
"mimeTypes": ["text/html;profile=mcp-app"]
}
}
}
}
}))?;
@@ -2278,6 +2294,12 @@ mod tests {
"thread/started".to_string(),
"item/agentMessage/delta".to_string(),
]),
extensions: Some(std::collections::HashMap::from([(
"io.modelcontextprotocol/ui".to_string(),
json!({
"mimeTypes": ["text/html;profile=mcp-app"],
}),
)])),
}),
},
}
@@ -57,6 +57,13 @@ pub struct InitializeCapabilities {
/// connection (for example `thread/started`).
#[ts(optional = nullable)]
pub opt_out_notification_methods: Option<Vec<String>>,
/// MCP extension settings declared by the app-server client.
///
/// Codex currently consumes `io.modelcontextprotocol/ui` so widget-capable
/// hosts can advertise the same UI support to downstream MCP servers.
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub extensions: Option<HashMap<String, serde_json::Value>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1671,6 +1671,7 @@ impl CodexClient {
.collect(),
),
mcp_server_openai_form_elicitation: false,
extensions: None,
}),
},
};
+20 -3
View File
@@ -225,6 +225,7 @@ pub(crate) struct InitializedConnectionSessionState {
pub(crate) client_version: String,
pub(crate) request_attestation: bool,
pub(crate) supports_openai_form_elicitation: bool,
pub(crate) supports_mcp_app_ui_webview: bool,
}
impl Default for ConnectionSessionState {
@@ -281,6 +282,12 @@ impl ConnectionSessionState {
.get()
.is_some_and(|session| session.supports_openai_form_elicitation)
}
pub(crate) fn supports_mcp_app_ui_webview(&self) -> bool {
self.initialized
.get()
.is_some_and(|session| session.supports_mcp_app_ui_webview)
}
pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> {
self.initialized.set(session).map_err(|_| ())
}
@@ -897,6 +904,7 @@ impl MessageProcessor {
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 supports_mcp_app_ui_webview = session.supports_mcp_app_ui_webview();
let error_request_id = connection_request_id.clone();
let rpc_gate = Arc::clone(&session.rpc_gate);
let processor = Arc::clone(self);
@@ -913,6 +921,7 @@ impl MessageProcessor {
app_server_client_name,
client_version,
supports_openai_form_elicitation,
supports_mcp_app_ui_webview,
)
.await;
if let Err(error) = result {
@@ -935,6 +944,7 @@ impl MessageProcessor {
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn handle_initialized_client_request(
self: Arc<Self>,
connection_request_id: ConnectionRequestId,
@@ -943,6 +953,7 @@ impl MessageProcessor {
app_server_client_name: Option<String>,
client_version: Option<String>,
supports_openai_form_elicitation: bool,
supports_mcp_app_ui_webview: bool,
) -> Result<(), JSONRPCErrorError> {
let connection_id = connection_request_id.connection_id;
let request_id = ConnectionRequestId {
@@ -1099,6 +1110,7 @@ impl MessageProcessor {
app_server_client_name.clone(),
client_version.clone(),
supports_openai_form_elicitation,
supports_mcp_app_ui_webview,
request_context,
)
.await
@@ -1117,6 +1129,7 @@ impl MessageProcessor {
client_version.clone(),
/*supports_openai_form_elicitation*/
supports_openai_form_elicitation,
supports_mcp_app_ui_webview,
)
.await
}
@@ -1129,6 +1142,7 @@ impl MessageProcessor {
client_version.clone(),
/*supports_openai_form_elicitation*/
supports_openai_form_elicitation,
supports_mcp_app_ui_webview,
)
.await
}
@@ -1330,6 +1344,7 @@ impl MessageProcessor {
client_version.clone(),
/*supports_openai_form_elicitation*/
supports_openai_form_elicitation,
supports_mcp_app_ui_webview,
)
.await
}
@@ -1346,7 +1361,7 @@ impl MessageProcessor {
}
ClientRequest::ThreadRealtimeStart { params, .. } => {
self.turn_processor
.thread_realtime_start(&request_id, params)
.thread_realtime_start(&request_id, params, supports_mcp_app_ui_webview)
.await
}
ClientRequest::ThreadRealtimeAppendAudio { params, .. } => {
@@ -1373,7 +1388,9 @@ impl MessageProcessor {
self.turn_processor.thread_realtime_list_voices().await
}
ClientRequest::ReviewStart { params, .. } => {
self.turn_processor.review_start(&request_id, params).await
self.turn_processor
.review_start(&request_id, params, supports_mcp_app_ui_webview)
.await
}
ClientRequest::McpServerOauthLogin { params, .. } => {
self.mcp_processor.mcp_server_oauth_login(params).await
@@ -1393,7 +1410,7 @@ impl MessageProcessor {
}
ClientRequest::McpServerToolCall { params, .. } => {
self.mcp_processor
.mcp_server_tool_call(&request_id, params)
.mcp_server_tool_call(&request_id, params, supports_mcp_app_ui_webview)
.await
}
ClientRequest::WindowsSandboxSetupStart { params, .. } => {
@@ -71,6 +71,8 @@ impl InitializeRequestProcessor {
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 supports_mcp_app_ui_webview =
codex_mcp::supports_mcp_app_ui_webview(capabilities.extensions.as_ref());
let opt_out_notification_methods = capabilities
.opt_out_notification_methods
.unwrap_or_default();
@@ -98,6 +100,7 @@ impl InitializeRequestProcessor {
client_version: version,
request_attestation,
supports_openai_form_elicitation,
supports_mcp_app_ui_webview,
})
.is_err()
{
@@ -67,8 +67,9 @@ impl McpRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: McpServerToolCallParams,
supports_mcp_app_ui_webview: bool,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.call_mcp_server_tool(request_id, params)
self.call_mcp_server_tool(request_id, params, supports_mcp_app_ui_webview)
.await
.map(|()| None)
}
@@ -456,16 +457,22 @@ impl McpRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: McpServerToolCallParams,
supports_mcp_app_ui_webview: bool,
) -> Result<(), JSONRPCErrorError> {
let outgoing = Arc::clone(&self.outgoing);
let thread_id = params.thread_id.clone();
let (_, thread) = self.load_thread(&thread_id).await?;
let mcp_runtime = thread
.current_mcp_runtime_with_mcp_app_ui_webview_support(supports_mcp_app_ui_webview)
.await
.map_err(|err| internal_error(format!("failed to resolve MCP runtime: {err}")))?;
let meta = with_mcp_tool_call_thread_id_meta(params.meta, &thread_id);
let request_id = request_id.clone();
tokio::spawn(async move {
let result = thread
.call_mcp_tool(&params.server, &params.tool, params.arguments, meta)
let result = mcp_runtime
.manager()
.call_tool(&params.server, &params.tool, params.arguments, meta)
.await
.map(McpServerToolCallResponse::from)
.map_err(|error| internal_error(format!("{error:#}")));
@@ -419,6 +419,7 @@ impl ThreadRequestProcessor {
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn thread_start(
&self,
request_id: ConnectionRequestId,
@@ -426,6 +427,7 @@ impl ThreadRequestProcessor {
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
supports_mcp_app_ui_webview: bool,
request_context: RequestContext,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_start_inner(
@@ -434,6 +436,7 @@ impl ThreadRequestProcessor {
app_server_client_name,
app_server_client_version,
supports_openai_form_elicitation,
supports_mcp_app_ui_webview,
request_context,
)
.await
@@ -457,6 +460,7 @@ impl ThreadRequestProcessor {
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
supports_mcp_app_ui_webview: bool,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_resume_inner(
request_id,
@@ -464,6 +468,7 @@ impl ThreadRequestProcessor {
app_server_client_name,
app_server_client_version,
supports_openai_form_elicitation,
supports_mcp_app_ui_webview,
)
.await
.map(|()| None)
@@ -476,6 +481,7 @@ impl ThreadRequestProcessor {
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
supports_mcp_app_ui_webview: bool,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_fork_inner(
request_id,
@@ -483,6 +489,7 @@ impl ThreadRequestProcessor {
app_server_client_name,
app_server_client_version,
supports_openai_form_elicitation,
supports_mcp_app_ui_webview,
)
.await
.map(|()| None)
@@ -776,6 +783,7 @@ impl ThreadRequestProcessor {
thread: &CodexThread,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_mcp_app_ui_webview: bool,
) -> Result<(), JSONRPCErrorError> {
let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny(
app_server_client_name.as_deref(),
@@ -785,6 +793,7 @@ impl ThreadRequestProcessor {
.set_app_server_client_info(
app_server_client_name,
app_server_client_version,
Some(supports_mcp_app_ui_webview),
mcp_elicitations_auto_deny,
)
.await
@@ -899,6 +908,7 @@ impl ThreadRequestProcessor {
.await
}
#[allow(clippy::too_many_arguments)]
async fn thread_start_inner(
&self,
request_id: ConnectionRequestId,
@@ -906,6 +916,7 @@ impl ThreadRequestProcessor {
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
supports_mcp_app_ui_webview: bool,
request_context: RequestContext,
) -> Result<(), JSONRPCErrorError> {
let ThreadStartParams {
@@ -982,6 +993,7 @@ impl ThreadRequestProcessor {
app_server_client_name,
app_server_client_version,
supports_openai_form_elicitation,
supports_mcp_app_ui_webview,
config,
typesafe_overrides,
dynamic_tools,
@@ -1059,6 +1071,7 @@ impl ThreadRequestProcessor {
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
supports_mcp_app_ui_webview: bool,
config_overrides: Option<HashMap<String, serde_json::Value>>,
typesafe_overrides: ConfigOverrides,
dynamic_tools: Option<Vec<DynamicToolSpec>>,
@@ -1145,6 +1158,8 @@ impl ThreadRequestProcessor {
.map_err(|err| config_load_error(&err))?;
}
config.supports_mcp_app_ui_webview = supports_mcp_app_ui_webview;
if let Ok(Some(err)) =
codex_core::check_execpolicy_for_warnings(&config.config_layer_stack).await
{
@@ -1230,6 +1245,7 @@ impl ThreadRequestProcessor {
thread.as_ref(),
app_server_client_name,
app_server_client_version,
supports_mcp_app_ui_webview,
)
.await?;
@@ -2640,6 +2656,7 @@ impl ThreadRequestProcessor {
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
supports_mcp_app_ui_webview: bool,
) -> Result<(), JSONRPCErrorError> {
if let Ok(thread_id) = ThreadId::from_string(&params.thread_id)
&& self
@@ -2684,6 +2701,7 @@ impl ThreadRequestProcessor {
&params,
app_server_client_name.clone(),
app_server_client_version.clone(),
supports_mcp_app_ui_webview,
)
.await
{
@@ -2762,7 +2780,7 @@ impl ThreadRequestProcessor {
.await;
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = match self
let mut config = match self
.config_manager
.load_for_cwd(request_overrides, typesafe_overrides, history_cwd)
.await
@@ -2774,6 +2792,7 @@ impl ThreadRequestProcessor {
return Ok(());
}
};
config.supports_mcp_app_ui_webview = supports_mcp_app_ui_webview;
let response_history = thread_history.clone();
@@ -2798,6 +2817,7 @@ impl ThreadRequestProcessor {
codex_thread.as_ref(),
app_server_client_name,
app_server_client_version,
supports_mcp_app_ui_webview,
)
.await
{
@@ -2977,6 +2997,7 @@ impl ThreadRequestProcessor {
params: &ThreadResumeParams,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_mcp_app_ui_webview: bool,
) -> Result<RunningThreadResumeResult, JSONRPCErrorError> {
let running_thread = if params.history.is_some() {
if let Ok(existing_thread_id) = ThreadId::from_string(&params.thread_id)
@@ -3105,6 +3126,7 @@ impl ThreadRequestProcessor {
existing_thread.as_ref(),
app_server_client_name,
app_server_client_version,
supports_mcp_app_ui_webview,
)
.await?;
@@ -3405,6 +3427,7 @@ impl ThreadRequestProcessor {
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
supports_mcp_app_ui_webview: bool,
) -> Result<(), JSONRPCErrorError> {
let ThreadForkParams {
thread_id,
@@ -3498,11 +3521,12 @@ impl ThreadRequestProcessor {
);
typesafe_overrides.ephemeral = ephemeral.then_some(true);
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = self
let mut config = self
.config_manager
.load_for_cwd(request_overrides, typesafe_overrides, history_cwd)
.await
.map_err(|err| config_load_error(&err))?;
config.supports_mcp_app_ui_webview = supports_mcp_app_ui_webview;
let fallback_model_provider = config.model_provider_id.clone();
@@ -3538,6 +3562,7 @@ impl ThreadRequestProcessor {
forked_thread.as_ref(),
app_server_client_name,
app_server_client_version,
supports_mcp_app_ui_webview,
)
.await?;
if session_configured.rollout_path.is_some()
@@ -159,6 +159,7 @@ impl TurnRequestProcessor {
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
supports_mcp_app_ui_webview: bool,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
validate_user_input_image_urls(&params.input)?;
self.turn_start_inner(
@@ -167,6 +168,7 @@ impl TurnRequestProcessor {
app_server_client_name,
app_server_client_version,
/*supports_openai_form_elicitation*/ supports_openai_form_elicitation,
supports_mcp_app_ui_webview,
)
.await
.map(|response| Some(response.into()))
@@ -216,8 +218,9 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ThreadRealtimeStartParams,
supports_mcp_app_ui_webview: bool,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_realtime_start_inner(request_id, params)
self.thread_realtime_start_inner(request_id, params, supports_mcp_app_ui_webview)
.await
.map(|response| response.map(Into::into))
}
@@ -277,8 +280,9 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ReviewStartParams,
supports_mcp_app_ui_webview: bool,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.review_start_inner(request_id, params)
self.review_start_inner(request_id, params, supports_mcp_app_ui_webview)
.await
.map(|()| None)
}
@@ -419,6 +423,22 @@ impl TurnRequestProcessor {
.await
}
async fn submit_core_op_with_mcp_app_ui_webview_support(
&self,
request_id: &ConnectionRequestId,
thread: &CodexThread,
op: Op,
supports_mcp_app_ui_webview: bool,
) -> CodexResult<String> {
thread
.submit_with_trace_and_mcp_app_ui_webview_support(
op,
self.request_trace_context(request_id).await,
supports_mcp_app_ui_webview,
)
.await
}
fn input_too_large_error(actual_chars: usize) -> JSONRPCErrorError {
let mut error = invalid_params(format!(
"Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters."
@@ -446,6 +466,7 @@ impl TurnRequestProcessor {
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_openai_form_elicitation: bool,
supports_mcp_app_ui_webview: bool,
) -> Result<TurnStartResponse, JSONRPCErrorError> {
let (thread_id, thread) =
self.load_thread(&params.thread_id)
@@ -531,6 +552,7 @@ impl TurnRequestProcessor {
turn_op,
self.request_trace_context(&request_id).await,
client_user_message_id,
supports_mcp_app_ui_webview,
)
.await
.map_err(|err| {
@@ -840,6 +862,7 @@ impl TurnRequestProcessor {
.set_app_server_client_info(
app_server_client_name,
app_server_client_version,
None,
mcp_elicitations_auto_deny,
)
.await
@@ -990,6 +1013,7 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ThreadRealtimeStartParams,
supports_mcp_app_ui_webview: bool,
) -> Result<Option<ThreadRealtimeStartResponse>, JSONRPCErrorError> {
let Some((_, thread)) = self
.prepare_realtime_conversation_thread(request_id, &params.thread_id)
@@ -997,7 +1021,7 @@ impl TurnRequestProcessor {
else {
return Ok(None);
};
self.submit_core_op(
self.submit_core_op_with_mcp_app_ui_webview_support(
request_id,
thread.as_ref(),
Op::RealtimeConversationStart(ConversationStartParams {
@@ -1024,6 +1048,7 @@ impl TurnRequestProcessor {
version: params.version,
voice: params.voice,
}),
supports_mcp_app_ui_webview,
)
.await
.map_err(|err| internal_error(format!("failed to start realtime conversation: {err}")))?;
@@ -1178,12 +1203,14 @@ impl TurnRequestProcessor {
review_request: ReviewRequest,
display_text: &str,
parent_thread_id: String,
supports_mcp_app_ui_webview: bool,
) -> std::result::Result<(), JSONRPCErrorError> {
let turn_id = self
.submit_core_op(
.submit_core_op_with_mcp_app_ui_webview_support(
request_id,
parent_thread.as_ref(),
Op::Review { review_request },
supports_mcp_app_ui_webview,
)
.await
.map_err(|err| internal_error(format!("failed to start review: {err}")))?;
@@ -1200,6 +1227,7 @@ impl TurnRequestProcessor {
parent_thread: Arc<CodexThread>,
review_request: ReviewRequest,
display_text: &str,
supports_mcp_app_ui_webview: bool,
) -> std::result::Result<(), JSONRPCErrorError> {
parent_thread.ensure_rollout_materialized().await;
parent_thread.flush_rollout().await.map_err(|err| {
@@ -1217,6 +1245,7 @@ impl TurnRequestProcessor {
})?;
let mut config = self.config.as_ref().clone();
config.supports_mcp_app_ui_webview = supports_mcp_app_ui_webview;
if let Some(review_model) = &config.review_model {
config.model = Some(review_model.clone());
}
@@ -1287,10 +1316,11 @@ impl TurnRequestProcessor {
}
let turn_id = self
.submit_core_op(
.submit_core_op_with_mcp_app_ui_webview_support(
request_id,
review_thread.as_ref(),
Op::Review { review_request },
supports_mcp_app_ui_webview,
)
.await
.map_err(|err| {
@@ -1309,6 +1339,7 @@ impl TurnRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ReviewStartParams,
supports_mcp_app_ui_webview: bool,
) -> Result<(), JSONRPCErrorError> {
let ReviewStartParams {
thread_id,
@@ -1326,6 +1357,7 @@ impl TurnRequestProcessor {
review_request,
&display_text,
thread_id,
supports_mcp_app_ui_webview,
)
.await?;
}
@@ -1336,6 +1368,7 @@ impl TurnRequestProcessor {
parent_thread,
review_request,
&display_text,
supports_mcp_app_ui_webview,
)
.await?;
}
@@ -80,8 +80,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand
Some(InitializeCapabilities {
experimental_api: true,
request_attestation: true,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
..Default::default()
}),
),
)
@@ -35,12 +35,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu
let init = mcp
.initialize_with_capabilities(
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
Some(InitializeCapabilities::default()),
)
.await?;
let JSONRPCMessage::Response(_) = init else {
@@ -67,12 +62,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
let init = mcp
.initialize_with_capabilities(
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
Some(InitializeCapabilities::default()),
)
.await?;
let JSONRPCMessage::Response(_) = init else {
@@ -114,12 +104,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result
let init = mcp
.initialize_with_capabilities(
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
Some(InitializeCapabilities::default()),
)
.await?;
let JSONRPCMessage::Response(_) = init else {
@@ -149,12 +134,7 @@ async fn thread_settings_update_requires_experimental_api_capability() -> Result
let init = mcp
.initialize_with_capabilities(
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
Some(InitializeCapabilities::default()),
)
.await?;
let JSONRPCMessage::Response(_) = init else {
@@ -184,12 +164,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
let init = mcp
.initialize_with_capabilities(
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
Some(InitializeCapabilities::default()),
)
.await?;
let JSONRPCMessage::Response(_) = init else {
@@ -235,12 +210,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul
let init = mcp
.initialize_with_capabilities(
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
Some(InitializeCapabilities::default()),
)
.await?;
let JSONRPCMessage::Response(_) = init else {
@@ -274,12 +244,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa
let init = mcp
.initialize_with_capabilities(
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
Some(InitializeCapabilities::default()),
)
.await?;
let JSONRPCMessage::Response(_) = init else {
@@ -312,12 +277,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil
let init = mcp
.initialize_with_capabilities(
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
mcp_server_openai_form_elicitation: false,
}),
Some(InitializeCapabilities::default()),
)
.await?;
let JSONRPCMessage::Response(_) = init else {
@@ -212,9 +212,8 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu
},
Some(InitializeCapabilities {
experimental_api: true,
request_attestation: false,
opt_out_notification_methods: Some(vec!["thread/started".to_string()]),
mcp_server_openai_form_elicitation: false,
..Default::default()
}),
),
)
@@ -146,9 +146,8 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> {
},
Some(InitializeCapabilities {
experimental_api: true,
request_attestation: false,
opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]),
mcp_server_openai_form_elicitation: false,
..Default::default()
}),
),
)
@@ -0,0 +1,155 @@
use std::collections::HashMap;
use rmcp::model::ExtensionCapabilities;
use serde_json::Map;
use serde_json::Value;
const MCP_APP_UI_EXTENSION_ID: &str = "io.modelcontextprotocol/ui";
const MCP_APP_UI_WEBVIEW_MIME_TYPE: &str = "text/html;profile=mcp-app";
const MCP_CLIENT_CAPABILITIES_META_KEY: &str = "io.modelcontextprotocol/clientCapabilities";
/// Returns whether the app-server host can render MCP App WebViews.
///
/// App-server clients may declare unrelated extensions or additional UI MIME
/// types. Codex only forwards this one trusted capability because it is the
/// rendering contract downstream MCP servers need for MCP App widgets.
pub fn supports_mcp_app_ui_webview(extensions: Option<&HashMap<String, Value>>) -> bool {
extensions
.and_then(|extensions| extensions.get(MCP_APP_UI_EXTENSION_ID))
.and_then(Value::as_object)
.and_then(|settings| settings.get("mimeTypes"))
.and_then(Value::as_array)
.is_some_and(|mime_types| {
mime_types
.iter()
.any(|mime_type| mime_type.as_str() == Some(MCP_APP_UI_WEBVIEW_MIME_TYPE))
})
}
pub(crate) fn mcp_app_ui_extensions() -> ExtensionCapabilities {
let mut settings = Map::new();
settings.insert(
"mimeTypes".to_string(),
serde_json::json!([MCP_APP_UI_WEBVIEW_MIME_TYPE]),
);
[(MCP_APP_UI_EXTENSION_ID.to_string(), settings)]
.into_iter()
.collect()
}
/// Adds trusted host capabilities to a Codex Apps request and removes any
/// caller-supplied copy of the reserved metadata key.
pub(crate) fn with_client_capabilities_meta(
meta: Option<Value>,
supports_mcp_app_ui_webview: bool,
) -> Option<Value> {
let meta = match meta {
Some(Value::Object(mut object)) => {
object.remove(MCP_CLIENT_CAPABILITIES_META_KEY);
Some(Value::Object(object))
}
other => other,
};
if !supports_mcp_app_ui_webview {
return meta;
}
let extensions = mcp_app_ui_extensions();
let capabilities = serde_json::json!({ "extensions": extensions });
let mut object = match meta {
Some(Value::Object(object)) => object,
_ => Map::new(),
};
object.insert(MCP_CLIENT_CAPABILITIES_META_KEY.to_string(), capabilities);
Some(Value::Object(object))
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use serde_json::json;
use super::*;
#[test]
fn recognizes_only_the_webview_mime_type() {
let extensions = HashMap::from([
(
MCP_APP_UI_EXTENSION_ID.to_string(),
json!({
"mimeTypes": [
MCP_APP_UI_WEBVIEW_MIME_TYPE,
"text/x-dil;profile=mcp-app",
],
}),
),
("example/other".to_string(), json!({"enabled": true})),
]);
assert!(supports_mcp_app_ui_webview(Some(&extensions)));
assert!(!supports_mcp_app_ui_webview(Some(&HashMap::from([(
MCP_APP_UI_EXTENSION_ID.to_string(),
json!({"mimeTypes": ["text/x-dil;profile=mcp-app"]}),
),]))));
assert!(!supports_mcp_app_ui_webview(Some(&HashMap::from([(
MCP_APP_UI_EXTENSION_ID.to_string(),
json!("invalid")
),]))));
}
#[test]
fn trusted_capabilities_replace_spoofed_request_metadata() {
let meta = with_client_capabilities_meta(
Some(json!({
MCP_CLIENT_CAPABILITIES_META_KEY: {"spoofed": true},
"caller": "preserved",
})),
true,
);
assert_eq!(
meta,
Some(json!({
"caller": "preserved",
MCP_CLIENT_CAPABILITIES_META_KEY: {
"extensions": {
MCP_APP_UI_EXTENSION_ID: {
"mimeTypes": [
MCP_APP_UI_WEBVIEW_MIME_TYPE,
]
}
}
}
}))
);
}
#[test]
fn absent_host_capabilities_remove_spoofed_request_metadata() {
assert_eq!(
with_client_capabilities_meta(
Some(json!({
MCP_CLIENT_CAPABILITIES_META_KEY: {"spoofed": true},
"caller": "preserved",
})),
false,
),
Some(json!({"caller": "preserved"}))
);
}
#[test]
fn invalid_caller_metadata_does_not_suppress_trusted_capabilities() {
assert_eq!(
with_client_capabilities_meta(Some(json!("invalid")), true),
Some(json!({
MCP_CLIENT_CAPABILITIES_META_KEY: {
"extensions": {
MCP_APP_UI_EXTENSION_ID: {
"mimeTypes": [MCP_APP_UI_WEBVIEW_MIME_TYPE],
}
}
}
}))
);
}
}
@@ -14,6 +14,7 @@ use std::time::Duration;
use std::time::Instant;
use crate::McpAuthStatusEntry;
use crate::client_capabilities::with_client_capabilities_meta;
use crate::codex_apps_cache::CodexAppsToolsCache;
use crate::codex_apps_cache::CodexAppsToolsCacheKey;
use crate::codex_apps_cache::CodexAppsToolsFetchSource;
@@ -116,6 +117,7 @@ pub struct McpConnectionManager {
required_servers: Vec<String>,
tool_plugin_provenance: Arc<ToolPluginProvenance>,
prefix_mcp_tool_names: bool,
supports_mcp_app_ui_webview: bool,
elicitation_requests: ElicitationRequestManager,
startup_cancellation_token: CancellationToken,
}
@@ -138,6 +140,7 @@ impl McpConnectionManager {
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
prefix_mcp_tool_names: bool,
client_elicitation_capability: ElicitationCapability,
supports_mcp_app_ui_webview: bool,
supports_openai_form_elicitation: bool,
tool_plugin_provenance: ToolPluginProvenance,
auth: Option<&CodexAuth>,
@@ -222,6 +225,7 @@ impl McpConnectionManager {
runtime_context.clone(),
runtime_auth_provider,
client_elicitation_capability.clone(),
server_name == CODEX_APPS_MCP_SERVER_NAME && supports_mcp_app_ui_webview,
supports_openai_form_elicitation,
);
clients.insert(server_name.clone(), async_managed_client.clone());
@@ -273,6 +277,7 @@ impl McpConnectionManager {
required_servers,
tool_plugin_provenance,
prefix_mcp_tool_names,
supports_mcp_app_ui_webview,
elicitation_requests: elicitation_requests.clone(),
startup_cancellation_token: startup_cancellation_token.clone(),
};
@@ -358,6 +363,7 @@ impl McpConnectionManager {
required_servers: Vec::new(),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
prefix_mcp_tool_names,
supports_mcp_app_ui_webview: false,
elicitation_requests: ElicitationRequestManager::new(
approval_policy.value(),
permission_profile.clone(),
@@ -749,6 +755,11 @@ impl McpConnectionManager {
));
}
let meta = if server == CODEX_APPS_MCP_SERVER_NAME {
with_client_capabilities_meta(meta, self.supports_mcp_app_ui_webview)
} else {
meta
};
let result: rmcp::model::CallToolResult = client
.client
.call_tool(tool.to_string(), arguments, meta, client.tool_timeout)
@@ -1558,6 +1558,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
},
/*prefix_mcp_tool_names*/ true,
ElicitationCapability::default(),
/*supports_mcp_app_ui_webview*/ false,
/*supports_openai_form_elicitation*/ false,
ToolPluginProvenance::default(),
/*auth*/ None,
+2
View File
@@ -1,3 +1,4 @@
pub use client_capabilities::supports_mcp_app_ui_webview;
pub use connection_manager::McpConnectionManager;
pub use connection_manager::tool_is_model_visible;
pub use elicitation::ElicitationLifecycle;
@@ -77,6 +78,7 @@ pub use tools::declared_openai_file_input_param_names;
pub(crate) mod auth_elicitation;
mod catalog;
pub(crate) mod client_capabilities;
pub(crate) mod codex_apps;
pub(crate) mod codex_apps_cache;
pub(crate) mod connection_manager;
+7
View File
@@ -145,6 +145,11 @@ pub struct McpConfig {
pub prefix_mcp_tool_names: bool,
/// Client-side elicitation capabilities advertised during MCP initialization.
pub client_elicitation_capability: ElicitationCapability,
/// Whether the active host can render MCP App WebViews.
///
/// This is runtime-only and is forwarded only to the host-owned Codex Apps
/// server, never to arbitrary configured MCP servers.
pub supports_mcp_app_ui_webview: bool,
/// Resolved MCP registrations keyed by logical server name.
pub mcp_server_catalog: ResolvedMcpCatalog,
/// Plugin declarations used to attribute connector tools to plugin display names.
@@ -335,6 +340,7 @@ pub async fn read_mcp_resource(
codex_apps_tools_cache_key(auth),
config.prefix_mcp_tool_names,
config.client_elicitation_capability.clone(),
config.supports_mcp_app_ui_webview,
/*supports_openai_form_elicitation*/ false,
tool_plugin_provenance(config),
auth,
@@ -413,6 +419,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
codex_apps_tools_cache_key(auth),
config.prefix_mcp_tool_names,
config.client_elicitation_capability.clone(),
config.supports_mcp_app_ui_webview,
/*supports_openai_form_elicitation*/ false,
tool_plugin_provenance,
auth,
+1
View File
@@ -33,6 +33,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
apps_enabled: false,
prefix_mcp_tool_names: true,
client_elicitation_capability: ElicitationCapability::default(),
supports_mcp_app_ui_webview: false,
mcp_server_catalog: ResolvedMcpCatalog::default(),
connector_snapshot: codex_connectors::ConnectorSnapshot::default(),
}
+41 -7
View File
@@ -7,6 +7,7 @@
//! [`crate::connection_manager`].
use std::borrow::Cow;
#[cfg(test)]
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::env;
@@ -275,6 +276,7 @@ struct ManagedClientStartup {
runtime_context: McpRuntimeContext,
runtime_auth_provider: Option<SharedAuthProvider>,
client_elicitation_capability: ElicitationCapability,
supports_mcp_app_ui_webview: bool,
supports_openai_form_elicitation: bool,
cancel_token: CancellationToken,
startup_complete: Arc<AtomicBool>,
@@ -293,6 +295,7 @@ impl ManagedClientStartup {
runtime_context,
runtime_auth_provider,
client_elicitation_capability,
supports_mcp_app_ui_webview,
supports_openai_form_elicitation,
cancel_token,
startup_complete,
@@ -338,6 +341,7 @@ impl ManagedClientStartup {
elicitation_requests,
codex_apps_tools_cache_context,
client_elicitation_capability,
supports_mcp_app_ui_webview,
supports_openai_form_elicitation,
},
)
@@ -391,6 +395,7 @@ impl AsyncManagedClient {
runtime_context: McpRuntimeContext,
runtime_auth_provider: Option<SharedAuthProvider>,
client_elicitation_capability: ElicitationCapability,
supports_mcp_app_ui_webview: bool,
supports_openai_form_elicitation: bool,
) -> Self {
let is_codex_apps_mcp_server = server_name == CODEX_APPS_MCP_SERVER_NAME;
@@ -419,6 +424,7 @@ impl AsyncManagedClient {
runtime_context,
runtime_auth_provider,
client_elicitation_capability,
supports_mcp_app_ui_webview,
supports_openai_form_elicitation,
cancel_token: cancel_token.clone(),
startup_complete: Arc::clone(&startup_complete),
@@ -799,10 +805,12 @@ async fn start_server_task(
elicitation_requests,
codex_apps_tools_cache_context,
client_elicitation_capability,
supports_mcp_app_ui_webview,
supports_openai_form_elicitation,
} = params;
let params = mcp_initialize_request_params(
client_elicitation_capability,
supports_mcp_app_ui_webview,
supports_openai_form_elicitation,
);
@@ -871,16 +879,20 @@ async fn start_server_task(
fn mcp_initialize_request_params(
client_elicitation_capability: ElicitationCapability,
supports_mcp_app_ui_webview: bool,
supports_openai_form_elicitation: bool,
) -> InitializeRequestParams {
let mut capabilities = ClientCapabilities::default();
capabilities.elicitation = Some(client_elicitation_capability);
let mut extensions = if supports_mcp_app_ui_webview {
crate::client_capabilities::mcp_app_ui_extensions()
} else {
Default::default()
};
if supports_openai_form_elicitation {
capabilities.extensions = Some(BTreeMap::from([(
OPENAI_FORM_CAPABILITY.to_string(),
JsonObject::new(),
)]));
extensions.insert(OPENAI_FORM_CAPABILITY.to_string(), JsonObject::new());
}
capabilities.extensions = (!extensions.is_empty()).then_some(extensions);
InitializeRequestParams::new(
capabilities,
Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"),
@@ -913,6 +925,7 @@ struct StartServerTaskParams {
elicitation_requests: ElicitationRequestManager,
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
client_elicitation_capability: ElicitationCapability,
supports_mcp_app_ui_webview: bool,
supports_openai_form_elicitation: bool,
}
@@ -1023,24 +1036,45 @@ mod tests {
}
#[test]
fn mcp_initialize_advertises_openai_form_only_when_supported() {
fn mcp_initialize_advertises_extensions_only_when_supported() {
let unsupported = mcp_initialize_request_params(
ElicitationCapability::default(),
/*supports_mcp_app_ui_webview*/ false,
/*supports_openai_form_elicitation*/ false,
);
assert_eq!(unsupported.capabilities.extensions, None);
let supported = mcp_initialize_request_params(
let form_supported = mcp_initialize_request_params(
ElicitationCapability::default(),
/*supports_mcp_app_ui_webview*/ false,
/*supports_openai_form_elicitation*/ true,
);
assert_eq!(
supported.capabilities.extensions,
form_supported.capabilities.extensions,
Some(BTreeMap::from([(
OPENAI_FORM_CAPABILITY.to_string(),
JsonObject::new(),
)]))
);
let ui_supported = mcp_initialize_request_params(
ElicitationCapability::default(),
/*supports_mcp_app_ui_webview*/ true,
/*supports_openai_form_elicitation*/ false,
);
assert_eq!(
ui_supported.capabilities.extensions,
Some(crate::client_capabilities::mcp_app_ui_extensions())
);
let both_supported = mcp_initialize_request_params(
ElicitationCapability::default(),
/*supports_mcp_app_ui_webview*/ true,
/*supports_openai_form_elicitation*/ true,
);
let mut expected = crate::client_capabilities::mcp_app_ui_extensions();
expected.insert(OPENAI_FORM_CAPABILITY.to_string(), JsonObject::new());
assert_eq!(both_supported.capabilities.extensions, Some(expected));
}
fn tool_with_connector_meta() -> RmcpTool {
+1
View File
@@ -250,6 +250,7 @@ pub(crate) async fn run_codex_thread_one_shot(
id: "shutdown".to_string(),
op: Op::Shutdown {},
client_user_message_id: None,
supports_mcp_app_ui_webview: None,
trace: None,
})
.await;
@@ -156,6 +156,7 @@ async fn forward_ops_preserves_submission_trace_context() {
id: "sub-1".to_string(),
op: Op::Interrupt,
client_user_message_id: None,
supports_mcp_app_ui_webview: None,
trace: Some(codex_protocol::protocol::W3cTraceContext {
traceparent: Some(
"00-1234567890abcdef1234567890abcdef-1234567890abcdef-01".to_string(),
+43 -1
View File
@@ -258,11 +258,27 @@ impl CodexThread {
self.codex.submit_with_trace(op, trace).await
}
pub async fn submit_with_trace_and_mcp_app_ui_webview_support(
&self,
op: Op,
trace: Option<W3cTraceContext>,
supports_mcp_app_ui_webview: bool,
) -> CodexResult<String> {
self.codex
.submit_with_trace_and_mcp_app_ui_webview_support(
op,
trace,
supports_mcp_app_ui_webview,
)
.await
}
pub async fn submit_user_input_with_client_user_message_id(
&self,
op: Op,
trace: Option<W3cTraceContext>,
client_user_message_id: Option<String>,
supports_mcp_app_ui_webview: bool,
) -> CodexResult<String> {
self.codex
.session
@@ -271,7 +287,12 @@ impl CodexThread {
.ensure_execution_capacity_for_op(self.session_configured.thread_id, &op)
.await?;
self.codex
.submit_user_input_with_client_user_message_id(op, trace, client_user_message_id)
.submit_user_input_with_client_user_message_id(
op,
trace,
client_user_message_id,
supports_mcp_app_ui_webview,
)
.await
}
@@ -335,12 +356,14 @@ impl CodexThread {
&self,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_mcp_app_ui_webview: Option<bool>,
mcp_elicitations_auto_deny: bool,
) -> ConstraintResult<()> {
self.codex
.set_app_server_client_info(
app_server_client_name,
app_server_client_version,
supports_mcp_app_ui_webview,
mcp_elicitations_auto_deny,
)
.await
@@ -614,6 +637,25 @@ impl CodexThread {
.clone()
}
/// Captures the MCP runtime for one app-server request before detached work begins.
pub async fn current_mcp_runtime_with_mcp_app_ui_webview_support(
&self,
supports_mcp_app_ui_webview: bool,
) -> CodexResult<Arc<crate::session::McpRuntimeSnapshot>> {
let turn_context = self
.codex
.session
.new_default_turn_with_mcp_app_ui_webview_support(supports_mcp_app_ui_webview)
.await?;
Ok(self
.codex
.session
.capture_step_context(turn_context)
.await
.mcp
.clone())
}
pub fn multi_agent_version(&self) -> Option<MultiAgentVersion> {
self.codex.session.multi_agent_version()
}
+6
View File
@@ -967,6 +967,10 @@ pub struct Config {
/// Optional product SKU forwarded to the host-owned apps MCP server.
pub apps_mcp_product_sku: Option<String>,
/// Whether the active app-server host can render MCP App WebViews.
///
/// This is runtime-only state, not a config.toml setting.
pub supports_mcp_app_ui_webview: bool,
/// Machine-local realtime audio device preferences used by realtime voice.
pub realtime_audio: RealtimeAudioConfig,
@@ -1584,6 +1588,7 @@ impl Config {
McpConfig {
chatgpt_base_url: self.chatgpt_base_url.clone(),
apps_mcp_product_sku: self.apps_mcp_product_sku.clone(),
supports_mcp_app_ui_webview: self.supports_mcp_app_ui_webview,
codex_home: self.codex_home.to_path_buf(),
mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode,
auth_keyring_backend_kind: self.auth_keyring_backend_kind(),
@@ -3908,6 +3913,7 @@ impl Config {
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
respect_system_proxy,
apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(),
supports_mcp_app_ui_webview: false,
realtime_audio: cfg
.audio
.map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig {
+1
View File
@@ -266,6 +266,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
codex_apps_tools_cache_key(auth.as_ref()),
mcp_config.prefix_mcp_tool_names,
mcp_config.client_elicitation_capability,
mcp_config.supports_mcp_app_ui_webview,
/*supports_openai_form_elicitation*/ false,
ToolPluginProvenance::default(),
auth.as_ref(),
+1
View File
@@ -1435,6 +1435,7 @@ async fn host_owned_codex_apps_manager(
codex_mcp::codex_apps_tools_cache_key(auth.as_ref()),
turn_context.config.prefix_mcp_tool_names(),
rmcp::model::ElicitationCapability::default(),
turn_context.config.supports_mcp_app_ui_webview,
/*supports_openai_form_elicitation*/ false,
codex_mcp::ToolPluginProvenance::default(),
auth.as_ref(),
+11 -3
View File
@@ -700,6 +700,7 @@ pub(crate) async fn handle_start(
sess: &Arc<Session>,
sub_id: String,
params: ConversationStartParams,
supports_mcp_app_ui_webview: Option<bool>,
) -> CodexResult<()> {
let prepared_start = match prepare_realtime_start(sess, params).await {
Ok(prepared_start) => prepared_start,
@@ -717,7 +718,9 @@ pub(crate) async fn handle_start(
}
};
if let Err(err) = handle_start_inner(sess, &sub_id, prepared_start).await {
if let Err(err) =
handle_start_inner(sess, &sub_id, prepared_start, supports_mcp_app_ui_webview).await
{
error!("failed to start realtime conversation: {err}");
let message = err.to_string();
sess.send_event_raw(Event {
@@ -982,6 +985,7 @@ async fn handle_start_inner(
sess: &Arc<Session>,
sub_id: &str,
prepared_start: PreparedRealtimeConversationStart,
supports_mcp_app_ui_webview: Option<bool>,
) -> CodexResult<()> {
let PreparedRealtimeConversationStart {
api_provider,
@@ -1074,7 +1078,9 @@ async fn handle_start_inner(
if let Some(text) = maybe_routed_text {
debug!(text = %text, "[realtime-text] realtime conversation text output");
let sess_for_routed_text = Arc::clone(&sess_clone);
sess_for_routed_text.route_realtime_text_input(text).await;
sess_for_routed_text
.route_realtime_text_input(text, supports_mcp_app_ui_webview)
.await;
}
sess_clone
.send_event_raw(ev(EventMsg::RealtimeConversationRealtime(
@@ -1085,7 +1091,9 @@ async fn handle_start_inner(
.await;
}
if let Ok(text) = transcript_tail_rx.recv().await {
sess_clone.route_realtime_text_input(text).await;
sess_clone
.route_realtime_text_input(text, supports_mcp_app_ui_webview)
.await;
}
if fanout_realtime_active.swap(false, Ordering::Relaxed) {
match end {
+53 -7
View File
@@ -85,8 +85,16 @@ pub async fn user_input_or_turn(
sub_id: String,
op: Op,
client_user_message_id: Option<String>,
supports_mcp_app_ui_webview: Option<bool>,
) {
user_input_or_turn_inner(sess, sub_id, op, client_user_message_id).await;
user_input_or_turn_inner(
sess,
sub_id,
op,
client_user_message_id,
supports_mcp_app_ui_webview,
)
.await;
}
pub async fn update_thread_settings(
@@ -185,6 +193,7 @@ pub(super) async fn user_input_or_turn_inner(
sub_id: String,
op: Op,
client_user_message_id: Option<String>,
supports_mcp_app_ui_webview: Option<bool>,
) {
let Op::UserInput {
items,
@@ -203,6 +212,7 @@ pub(super) async fn user_input_or_turn_inner(
SessionSettingsUpdate::default()
};
updates.final_output_json_schema = Some(final_output_json_schema);
updates.supports_mcp_app_ui_webview = supports_mcp_app_ui_webview;
let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else {
// new_turn_with_sub_id already emits the error event.
@@ -669,8 +679,26 @@ pub async fn review(
config: &Arc<Config>,
sub_id: String,
review_request: ReviewRequest,
supports_mcp_app_ui_webview: Option<bool>,
) {
let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await;
let turn_context = match supports_mcp_app_ui_webview {
Some(supports_mcp_app_ui_webview) => {
let Ok(turn_context) = sess
.new_turn_with_sub_id(
sub_id.clone(),
SessionSettingsUpdate {
supports_mcp_app_ui_webview: Some(supports_mcp_app_ui_webview),
..Default::default()
},
)
.await
else {
return;
};
turn_context
}
None => sess.new_default_turn_with_sub_id(sub_id.clone()).await,
};
sess.maybe_emit_model_warnings_for_turn(turn_context.as_ref())
.await;
sess.refresh_mcp_servers_if_requested(&turn_context, Some(sess.mcp_elicitation_reviewer()))
@@ -721,8 +749,13 @@ pub(super) async fn submission_loop(
false
}
Op::RealtimeConversationStart(params) => {
if let Err(err) =
handle_realtime_conversation_start(&sess, sub.id.clone(), params).await
if let Err(err) = handle_realtime_conversation_start(
&sess,
sub.id.clone(),
params,
sub.supports_mcp_app_ui_webview,
)
.await
{
sess.send_event_raw(Event {
id: sub.id.clone(),
@@ -756,8 +789,14 @@ pub(super) async fn submission_loop(
false
}
Op::UserInput { .. } => {
user_input_or_turn(&sess, sub.id.clone(), sub.op, sub.client_user_message_id)
.await;
user_input_or_turn(
&sess,
sub.id.clone(),
sub.op,
sub.client_user_message_id,
sub.supports_mcp_app_ui_webview,
)
.await;
false
}
Op::ThreadSettings { thread_settings } => {
@@ -829,7 +868,14 @@ pub(super) async fn submission_loop(
}
Op::Shutdown => shutdown(&sess, sub.id.clone()).await,
Op::Review { review_request } => {
review(&sess, &config, sub.id.clone(), review_request).await;
review(
&sess,
&config,
sub.id.clone(),
review_request,
sub.supports_mcp_app_ui_webview,
)
.await;
false
}
Op::ApproveGuardianDeniedAction { event } => {
+13 -2
View File
@@ -116,13 +116,21 @@ impl Session {
let available_environment_ids =
Self::available_selected_environment_ids(selected_capability_roots);
let current = self.services.latest_mcp_runtime();
if current.available_environment_ids() == available_environment_ids {
let host_capability_matches = current.config().supports_mcp_app_ui_webview
== turn_context.config.supports_mcp_app_ui_webview;
if current.available_environment_ids() == available_environment_ids
&& host_capability_matches
{
return current;
}
let _guard = self.services.mcp_projection_lock.lock().await;
let current = self.services.latest_mcp_runtime();
if current.available_environment_ids() == available_environment_ids {
let host_capability_matches = current.config().supports_mcp_app_ui_webview
== turn_context.config.supports_mcp_app_ui_webview;
if current.available_environment_ids() == available_environment_ids
&& host_capability_matches
{
return current;
}
let mcp_projection = self
@@ -153,6 +161,8 @@ impl Session {
.mcp_server_catalog
.has_same_servers(&mcp_config.mcp_server_catalog)
&& current.config().connector_snapshot == mcp_config.connector_snapshot
&& current.config().supports_mcp_app_ui_webview
== mcp_config.supports_mcp_app_ui_webview
{
// Availability is only an input to the MCP projection. When that input changes but
// the projected servers and connectors do not, advance the input key without
@@ -377,6 +387,7 @@ impl Session {
codex_apps_tools_cache_key(auth.as_ref()),
mcp_config.prefix_mcp_tool_names,
mcp_config.client_elicitation_capability.clone(),
mcp_config.supports_mcp_app_ui_webview,
self.services
.supports_openai_form_elicitation
.load(std::sync::atomic::Ordering::Relaxed),
+1
View File
@@ -79,6 +79,7 @@ impl McpRuntimeSnapshot {
apps_enabled: config.features.enabled(Feature::Apps),
prefix_mcp_tool_names: config.prefix_mcp_tool_names(),
client_elicitation_capability: ElicitationCapability::default(),
supports_mcp_app_ui_webview: config.supports_mcp_app_ui_webview,
mcp_server_catalog: ResolvedMcpCatalog::default(),
connector_snapshot: codex_connectors::ConnectorSnapshot::default(),
};
+29 -1
View File
@@ -752,6 +752,25 @@ impl Codex {
id: id.clone(),
op,
client_user_message_id: None,
supports_mcp_app_ui_webview: None,
trace,
};
self.submit_with_id(sub).await?;
Ok(id)
}
pub async fn submit_with_trace_and_mcp_app_ui_webview_support(
&self,
op: Op,
trace: Option<W3cTraceContext>,
supports_mcp_app_ui_webview: bool,
) -> CodexResult<String> {
let id = new_submission_id();
let sub = Submission {
id: id.clone(),
op,
client_user_message_id: None,
supports_mcp_app_ui_webview: Some(supports_mcp_app_ui_webview),
trace,
};
self.submit_with_id(sub).await?;
@@ -763,6 +782,7 @@ impl Codex {
op: Op,
trace: Option<W3cTraceContext>,
client_user_message_id: Option<String>,
supports_mcp_app_ui_webview: bool,
) -> CodexResult<String> {
debug_assert!(matches!(op, Op::UserInput { .. }));
let id = new_submission_id();
@@ -770,6 +790,7 @@ impl Codex {
id: id.clone(),
op,
client_user_message_id,
supports_mcp_app_ui_webview: Some(supports_mcp_app_ui_webview),
trace,
};
self.submit_with_id(sub).await?;
@@ -843,12 +864,14 @@ impl Codex {
&self,
app_server_client_name: Option<String>,
app_server_client_version: Option<String>,
supports_mcp_app_ui_webview: Option<bool>,
mcp_elicitations_auto_deny: bool,
) -> ConstraintResult<()> {
self.session
.update_settings(SessionSettingsUpdate {
app_server_client_name,
app_server_client_version,
supports_mcp_app_ui_webview,
..Default::default()
})
.await?;
@@ -1220,7 +1243,11 @@ impl Session {
format!("auto-compact-{id}")
}
pub(crate) async fn route_realtime_text_input(self: &Arc<Self>, text: String) {
pub(crate) async fn route_realtime_text_input(
self: &Arc<Self>,
text: String,
supports_mcp_app_ui_webview: Option<bool>,
) {
handlers::user_input_or_turn_inner(
self,
Uuid::now_v7().to_string(),
@@ -1235,6 +1262,7 @@ impl Session {
thread_settings: Default::default(),
},
/*client_user_message_id*/ None,
supports_mcp_app_ui_webview,
)
.await;
}
+7
View File
@@ -373,6 +373,11 @@ impl SessionConfiguration {
if let Some(app_server_client_version) = updates.app_server_client_version.clone() {
next_configuration.app_server_client_version = Some(app_server_client_version);
}
if let Some(supports_mcp_app_ui_webview) = updates.supports_mcp_app_ui_webview {
let mut config = (*next_configuration.original_config_do_not_use).clone();
config.supports_mcp_app_ui_webview = supports_mcp_app_ui_webview;
next_configuration.original_config_do_not_use = Arc::new(config);
}
Ok(next_configuration)
}
@@ -431,6 +436,7 @@ pub(crate) struct SessionSettingsUpdate {
pub(crate) personality: Option<Personality>,
pub(crate) app_server_client_name: Option<String>,
pub(crate) app_server_client_version: Option<String>,
pub(crate) supports_mcp_app_ui_webview: Option<bool>,
}
pub(crate) struct AppServerClientMetadata {
@@ -1213,6 +1219,7 @@ impl Session {
.config
.client_elicitation_capability
.clone(),
mcp_projection.config.supports_mcp_app_ui_webview,
sess.services
.supports_openai_form_elicitation
.load(std::sync::atomic::Ordering::Relaxed),
+119
View File
@@ -6479,6 +6479,7 @@ async fn submit_with_id_captures_current_span_trace_context() {
id: "sub-1".into(),
op: Op::Interrupt,
client_user_message_id: None,
supports_mcp_app_ui_webview: None,
trace: None,
})
.await
@@ -6492,6 +6493,40 @@ async fn submit_with_id_captures_current_span_trace_context() {
assert_eq!(submitted.trace, Some(expected_trace));
}
#[tokio::test]
async fn user_input_submission_carries_request_scoped_mcp_app_ui_capability() {
let (session, _turn_context) = make_session_and_context().await;
let (tx_sub, rx_sub) = async_channel::bounded(1);
let (_tx_event, rx_event) = async_channel::unbounded();
let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit);
let codex = Codex {
tx_sub,
rx_event,
agent_status,
session: Arc::new(session),
session_loop_termination: completed_session_loop_termination(),
};
codex
.submit_user_input_with_client_user_message_id(
Op::UserInput {
items: Vec::new(),
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
},
/*trace*/ None,
/*client_user_message_id*/ None,
/*supports_mcp_app_ui_webview*/ true,
)
.await
.expect("submit user input");
let submitted = rx_sub.recv().await.expect("submission");
assert_eq!(submitted.supports_mcp_app_ui_webview, Some(true));
}
#[tokio::test]
async fn new_default_turn_captures_current_span_trace_id() {
let (session, _turn_context) = make_session_and_context().await;
@@ -6551,6 +6586,7 @@ fn submission_dispatch_span_prefers_submission_trace_context() {
id: "sub-1".into(),
op: Op::Interrupt,
client_user_message_id: None,
supports_mcp_app_ui_webview: None,
trace: Some(submission_trace),
})
});
@@ -6578,6 +6614,7 @@ fn submission_dispatch_span_uses_debug_for_realtime_audio() {
},
}),
client_user_message_id: None,
supports_mcp_app_ui_webview: None,
trace: None,
});
@@ -6644,6 +6681,7 @@ async fn user_turn_updates_approvals_reviewer() {
},
},
/*client_user_message_id*/ None,
/*supports_mcp_app_ui_webview*/ None,
)
.await;
@@ -6654,6 +6692,27 @@ async fn user_turn_updates_approvals_reviewer() {
);
}
#[tokio::test]
async fn routed_realtime_text_uses_the_starting_client_mcp_app_ui_capability() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
session
.update_settings(SessionSettingsUpdate {
supports_mcp_app_ui_webview: Some(true),
..Default::default()
})
.await
.expect("simulate a later widget-capable client update");
session
.route_realtime_text_input(
"hello from realtime".to_string(),
/*supports_mcp_app_ui_webview*/ Some(false),
)
.await;
assert!(!session.get_config().await.supports_mcp_app_ui_webview);
}
#[tokio::test]
async fn turn_environments_set_primary_environment() {
let (session, _turn_context, _rx) = make_session_and_context_with_rx().await;
@@ -6891,6 +6950,7 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() {
id: "sub-1".into(),
op: Op::Interrupt,
client_user_message_id: None,
supports_mcp_app_ui_webview: None,
trace: Some(submission_trace.clone()),
});
let dispatch_span_id = dispatch_span.context().span().span_context().span_id();
@@ -7759,6 +7819,65 @@ async fn refresh_mcp_servers_keeps_the_previous_runtime_alive() {
);
}
#[tokio::test]
async fn host_mcp_app_ui_capability_change_rebuilds_the_mcp_manager() {
let (session, _) = make_session_and_context().await;
let session = Arc::new(session);
let old_runtime = session.services.latest_mcp_runtime();
let old_manager = old_runtime.manager_arc();
assert!(!old_runtime.config().supports_mcp_app_ui_webview);
session
.update_settings(SessionSettingsUpdate {
supports_mcp_app_ui_webview: Some(true),
..Default::default()
})
.await
.expect("update host MCP App UI capability");
let turn_context = session.new_default_turn().await;
let new_runtime = session
.mcp_runtime_for_step(
turn_context.as_ref(),
&turn_context.environments,
/*selected_capability_roots*/ &[],
)
.await;
assert!(new_runtime.config().supports_mcp_app_ui_webview);
assert!(!Arc::ptr_eq(&old_runtime, &new_runtime));
assert!(!Arc::ptr_eq(&old_manager, &new_runtime.manager_arc()));
}
#[tokio::test]
async fn request_scoped_host_mcp_app_ui_capability_survives_later_session_update() {
let (session, _) = make_session_and_context().await;
let session = Arc::new(session);
let widget_turn_context = session
.new_default_turn_with_mcp_app_ui_webview_support(true)
.await
.expect("capture widget client turn context");
session
.update_settings(SessionSettingsUpdate {
supports_mcp_app_ui_webview: Some(false),
..Default::default()
})
.await
.expect("simulate a later text-only client update");
let widget_runtime = session
.mcp_runtime_for_step(
widget_turn_context.as_ref(),
&widget_turn_context.environments,
/*selected_capability_roots*/ &[],
)
.await;
assert!(widget_turn_context.config.supports_mcp_app_ui_webview);
assert!(widget_runtime.config().supports_mcp_app_ui_webview);
}
#[tokio::test]
async fn plugin_availability_change_reuses_the_mcp_manager() {
struct ReadyPluginContributor;
+14
View File
@@ -816,6 +816,20 @@ impl Session {
.await
}
pub(crate) async fn new_default_turn_with_mcp_app_ui_webview_support(
&self,
supports_mcp_app_ui_webview: bool,
) -> CodexResult<Arc<TurnContext>> {
self.new_turn_with_sub_id(
self.next_internal_sub_id(),
SessionSettingsUpdate {
supports_mcp_app_ui_webview: Some(supports_mcp_app_ui_webview),
..Default::default()
},
)
.await
}
pub(crate) async fn new_startup_prewarm_turn_with_sub_id(
&self,
sub_id: String,
@@ -124,6 +124,71 @@ async fn submit_user_turn(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn codex_apps_tool_call_forwards_mcp_app_ui_webview_capability() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
let call_id = "calendar-call-with-ui-capability";
let calendar_args = serde_json::to_string(&json!({
"title": "Lunch",
"starts_at": "2026-03-10T12:00:00Z"
}))?;
let mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call_with_namespace(
call_id,
SEARCH_CALENDAR_NAMESPACE,
SEARCH_CALENDAR_CREATE_TOOL,
&calendar_args,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone())
.with_config(|config| {
config.supports_mcp_app_ui_webview = true;
set_calendar_approval_mode(config, AppToolApproval::Auto);
});
let test = builder.build(&server).await?;
submit_user_turn(
&test,
"Use [$calendar](app://calendar) to create a calendar event.",
AskForApproval::Never,
/*collaboration_mode*/ None,
)
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
assert_eq!(mock.requests().len(), 2);
let apps_tool_call = recorded_apps_tool_call_by_call_id(&server, call_id).await;
assert_eq!(
apps_tool_call.pointer(
"/params/_meta/io.modelcontextprotocol~1clientCapabilities/extensions/io.modelcontextprotocol~1ui/mimeTypes"
),
Some(&json!(["text/html;profile=mcp-app"]))
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn approved_mcp_tool_call_metadata_records_prior_user_input_request() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -114,6 +114,7 @@ pub async fn run_codex_tool_session(
thread_settings: Default::default(),
},
client_user_message_id: None,
supports_mcp_app_ui_webview: None,
trace: None,
};
@@ -546,6 +546,7 @@ impl MessageProcessor {
id: request_id_string,
op: codex_protocol::protocol::Op::Interrupt,
client_user_message_id: None,
supports_mcp_app_ui_webview: None,
trace: None,
})
.await
+5
View File
@@ -166,6 +166,11 @@ pub struct Submission {
pub op: Op,
/// Client-provided id for the user message represented by `Op::UserInput`.
pub client_user_message_id: Option<String>,
/// Request-scoped host capability for turns submitted by app-server clients.
///
/// This is carried with the queued operation so a later request on the same
/// thread cannot change which MCP runtime the turn initializes.
pub supports_mcp_app_ui_webview: Option<bool>,
/// Optional W3C trace carrier propagated across async submission handoffs.
pub trace: Option<W3cTraceContext>,
}
@@ -261,6 +261,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
respect_system_proxy: false,
apps_mcp_product_sku: None,
supports_mcp_app_ui_webview: false,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_ws_base_url: None,
experimental_realtime_webrtc_call_base_url: None,