Preserve MCP app resource URIs without defaulting display mode (#48764)

## Why

Defaulting missing or unsupported display preferences to `inline` prevents clients from applying resource display defaults.

## What changed

Populate `mcpAppUi` only when a tool descriptor has a resource URI and an explicit `inline` or `fullscreen` preference. Preserve `mcpAppResourceUri` independently in tool-call events, including when `mcpAppUi` is unset, and document the behavior.

## Testing

Update integration coverage for explicit display modes, missing and unsupported preferences, and legacy resource URIs, checking tool-call events and resumed history.

GitOrigin-RevId: 4876cced068d8e464adeb265abd053d8abe667ce
This commit is contained in:
victor-openai
2026-09-27 18:20:25 +00:00
committed by copyberry
parent e75b6b1e0c
commit 32f5784851
4 changed files with 41 additions and 21 deletions
+3 -2
View File
@@ -10,8 +10,9 @@ and caching behavior remain in effect while the provider satisfies current requi
# MCP App UI
`mcpToolCall.mcpAppUi` records the invoked descriptor's `resourceUri`
and `preferredModelDisplayMode` (`inline` or `fullscreen`). Descriptors with a widget
URI default to `inline` when the preference is missing or unsupported. The
and explicit `preferredModelDisplayMode` (`inline` or `fullscreen`). Missing or
unsupported preferences leave `mcpAppUi` unset; `mcpAppResourceUri` retains the URI
so clients can apply resource display defaults. The
UI information is preserved in tool-call events and saved history so clients can
render without waiting for the full MCP catalog.
+15 -13
View File
@@ -400,6 +400,7 @@ pub(crate) struct HandledMcpToolCall {
struct McpToolCallItemMetadata {
connector_id: Option<String>,
link_id: Option<String>,
mcp_app_resource_uri: Option<String>,
mcp_app_ui: Option<McpAppUi>,
app_name: Option<String>,
action_name: Option<String>,
@@ -418,6 +419,8 @@ impl McpToolCallItemMetadata {
connector_id: trusted_mcp_app_metadata
.and_then(|metadata| metadata.connector_id.clone()),
link_id: trusted_mcp_app_metadata.and_then(|metadata| metadata.link_id.clone()),
mcp_app_resource_uri: metadata
.and_then(|metadata| metadata.mcp_app_resource_uri.clone()),
mcp_app_ui: metadata.and_then(|metadata| metadata.mcp_app_ui.clone()),
app_name: trusted_mcp_app_metadata.and_then(|metadata| metadata.connector_name.clone()),
action_name: trusted_mcp_app_metadata
@@ -1038,10 +1041,7 @@ async fn notify_mcp_tool_call_started(
tool,
arguments: arguments.unwrap_or(JsonValue::Null),
connector_id: item_metadata.connector_id,
mcp_app_resource_uri: item_metadata
.mcp_app_ui
.as_ref()
.map(|ui| ui.resource_uri.clone()),
mcp_app_resource_uri: item_metadata.mcp_app_resource_uri,
mcp_app_ui: item_metadata.mcp_app_ui,
link_id: item_metadata.link_id,
app_name: item_metadata.app_name,
@@ -1087,10 +1087,7 @@ async fn notify_mcp_tool_call_completed(
tool,
arguments: arguments.unwrap_or(JsonValue::Null),
connector_id: item_metadata.connector_id,
mcp_app_resource_uri: item_metadata
.mcp_app_ui
.as_ref()
.map(|ui| ui.resource_uri.clone()),
mcp_app_resource_uri: item_metadata.mcp_app_resource_uri,
mcp_app_ui: item_metadata.mcp_app_ui,
link_id: item_metadata.link_id,
app_name: item_metadata.app_name,
@@ -1214,6 +1211,7 @@ pub(crate) struct McpToolApprovalMetadata {
plugin_id: Option<String>,
tool_title: Option<String>,
tool_description: Option<String>,
mcp_app_resource_uri: Option<String>,
mcp_app_ui: Option<McpAppUi>,
codex_apps_meta: Option<serde_json::Map<String, serde_json::Value>>,
openai_file_input_optional_fields: Option<HashMap<String, Vec<String>>>,
@@ -1677,6 +1675,7 @@ pub(crate) async fn request_mcp_tool_user_approval(
plugin_id: None,
tool_title: tool_title.clone(),
tool_description: tool_description.clone(),
mcp_app_resource_uri: None,
mcp_app_ui: None,
codex_apps_meta: None,
openai_file_input_optional_fields: None,
@@ -1834,7 +1833,8 @@ fn mcp_tool_metadata(
None
};
let mcp_app_ui = get_mcp_app_resource_uri(tool_info.tool.meta.as_deref()).map(|resource_uri| {
let mcp_app_resource_uri = get_mcp_app_resource_uri(tool_info.tool.meta.as_deref());
let mcp_app_ui = mcp_app_resource_uri.as_ref().and_then(|resource_uri| {
let preferred_model_display_mode = match tool_info
.tool
.meta
@@ -1843,13 +1843,14 @@ fn mcp_tool_metadata(
.and_then(|ui| ui.get("preferredModelDisplayMode"))
.and_then(serde_json::Value::as_str)
{
Some("inline") => McpAppDisplayMode::Inline,
Some("fullscreen") => McpAppDisplayMode::Fullscreen,
_ => McpAppDisplayMode::Inline,
_ => return None,
};
McpAppUi {
resource_uri,
Some(McpAppUi {
resource_uri: resource_uri.clone(),
preferred_model_display_mode,
}
})
});
Ok(McpToolApprovalMetadata {
@@ -1862,6 +1863,7 @@ fn mcp_tool_metadata(
plugin_id: plugin_id.map(str::to_string),
tool_title: tool_info.tool.title,
tool_description: tool_info.tool.description.map(std::borrow::Cow::into_owned),
mcp_app_resource_uri,
mcp_app_ui,
codex_apps_meta,
// Disallow custom MCPs from uploading files via fileParams.
+15
View File
@@ -90,6 +90,7 @@ fn approval_metadata(
plugin_id: None,
tool_title: tool_title.map(str::to_string),
tool_description: tool_description.map(str::to_string),
mcp_app_resource_uri: None,
mcp_app_ui: None,
codex_apps_meta: None,
openai_file_input_optional_fields: None,
@@ -1433,6 +1434,7 @@ fn mcp_tool_call_item_metadata_only_trusts_codex_apps_identity() {
McpToolCallItemMetadata {
connector_id: Some("asdk_app_0123456789abcdef0123456789abcdef".to_string()),
link_id: Some("link_fedcba9876543210fedcba9876543210".to_string()),
mcp_app_resource_uri: None,
mcp_app_ui: None,
app_name: Some("Calendar".to_string()),
action_name: Some("create_event".to_string()),
@@ -1445,6 +1447,7 @@ fn mcp_tool_call_item_metadata_only_trusts_codex_apps_identity() {
McpToolCallItemMetadata {
connector_id: None,
link_id: None,
mcp_app_resource_uri: None,
mcp_app_ui: None,
app_name: None,
action_name: None,
@@ -1470,6 +1473,7 @@ async fn mcp_tool_call_item_includes_app_identity() {
McpToolCallItemMetadata {
connector_id: Some("asdk_app_0123456789abcdef0123456789abcdef".to_string()),
link_id: Some("link_fedcba9876543210fedcba9876543210".to_string()),
mcp_app_resource_uri: None,
mcp_app_ui: None,
app_name: Some("Calendar".to_string()),
action_name: Some("create_event".to_string()),
@@ -1520,6 +1524,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps
plugin_id: None,
tool_title: Some("Create Event".to_string()),
tool_description: Some("Create a calendar event.".to_string()),
mcp_app_resource_uri: None,
mcp_app_ui: None,
codex_apps_meta: Some(
serde_json::json!({
@@ -1979,6 +1984,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() {
plugin_id: None,
tool_title: None,
tool_description: None,
mcp_app_resource_uri: None,
mcp_app_ui: None,
codex_apps_meta: None,
openai_file_input_optional_fields: None,
@@ -2744,6 +2750,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
plugin_id: None,
tool_title: Some("Read Only Tool".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
mcp_app_ui: None,
codex_apps_meta: None,
openai_file_input_optional_fields: None,
@@ -2825,6 +2832,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
plugin_id: None,
tool_title: Some("Read Only Tool".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
mcp_app_ui: None,
codex_apps_meta: None,
openai_file_input_optional_fields: None,
@@ -2888,6 +2896,7 @@ async fn permission_request_hook_allows_mcp_tool_call() {
plugin_id: None,
tool_title: Some("Create entities".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
mcp_app_ui: None,
codex_apps_meta: None,
openai_file_input_optional_fields: None,
@@ -3039,6 +3048,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() {
plugin_id: None,
tool_title: Some("Create entities".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
mcp_app_ui: None,
codex_apps_meta: None,
openai_file_input_optional_fields: None,
@@ -3140,6 +3150,7 @@ async fn strict_auto_review_forces_guardian_for_mcp_policy_skip() {
plugin_id: None,
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Reads calendar data.".to_string()),
mcp_app_resource_uri: None,
mcp_app_ui: None,
codex_apps_meta: None,
openai_file_input_optional_fields: None,
@@ -3219,6 +3230,7 @@ async fn assert_mcp_user_approval_persistence(
plugin_id: None,
tool_title: Some("Create entities".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
mcp_app_ui: None,
codex_apps_meta: None,
openai_file_input_optional_fields: None,
@@ -3305,6 +3317,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
plugin_id: None,
tool_title: Some("Read Only Tool".to_string()),
tool_description: None,
mcp_app_resource_uri: None,
mcp_app_ui: None,
codex_apps_meta: None,
openai_file_input_optional_fields: None,
@@ -3370,6 +3383,7 @@ async fn full_access_mode_skips_mcp_tool_approval_for_all_approval_modes() {
plugin_id: None,
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Performs a risky action.".to_string()),
mcp_app_resource_uri: None,
mcp_app_ui: None,
codex_apps_meta: None,
openai_file_input_optional_fields: None,
@@ -3429,6 +3443,7 @@ async fn approve_mode_skips_guardian_in_every_permission_mode() {
plugin_id: None,
tool_title: Some("Dangerous Tool".to_string()),
tool_description: Some("Performs a risky action.".to_string()),
mcp_app_resource_uri: None,
mcp_app_ui: None,
codex_apps_meta: None,
openai_file_input_optional_fields: None,
@@ -76,14 +76,16 @@ use wiremock::matchers::method;
use wiremock::matchers::path_regex;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[test_case(json!({"ui": {"resourceUri": "ui://calendar/widget"}, "openai/ui": {"preferredModelDisplayMode": "fullscreen"}}), Some(McpAppDisplayMode::Fullscreen); "fullscreen")]
#[test_case(json!({"ui": {"resourceUri": "ui://calendar/widget"}}), Some(McpAppDisplayMode::Inline); "missing preference")]
#[test_case(json!({"ui": {"resourceUri": "ui://calendar/widget"}, "openai/ui": {"preferredModelDisplayMode": "unsupported"}}), Some(McpAppDisplayMode::Inline); "unsupported preference")]
#[test_case(json!({"openai/outputTemplate": "ui://calendar/widget"}), Some(McpAppDisplayMode::Inline); "legacy uri")]
#[test_case(json!({}), None; "result only ui")]
#[test_case(json!({"ui": {"resourceUri": "ui://calendar/widget"}, "openai/ui": {"preferredModelDisplayMode": "fullscreen"}}), Some(McpAppDisplayMode::Fullscreen), Some("ui://calendar/widget"); "fullscreen")]
#[test_case(json!({"ui": {"resourceUri": "ui://calendar/widget"}, "openai/ui": {"preferredModelDisplayMode": "inline"}}), Some(McpAppDisplayMode::Inline), Some("ui://calendar/widget"); "inline")]
#[test_case(json!({"ui": {"resourceUri": "ui://calendar/widget"}}), None, Some("ui://calendar/widget"); "missing preference")]
#[test_case(json!({"ui": {"resourceUri": "ui://calendar/widget"}, "openai/ui": {"preferredModelDisplayMode": "unsupported"}}), None, Some("ui://calendar/widget"); "unsupported preference")]
#[test_case(json!({"openai/outputTemplate": "ui://calendar/widget"}), None, Some("ui://calendar/widget"); "legacy uri")]
#[test_case(json!({}), None, None; "result only ui")]
async fn mcp_app_ui_survives_tool_events_and_resume(
mut metadata: Value,
expected_mode: Option<McpAppDisplayMode>,
expected_uri: Option<&str>,
) -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -165,7 +167,7 @@ async fn mcp_app_ui_survives_tool_events_and_resume(
resource_uri: "ui://calendar/widget".to_string(),
preferred_model_display_mode,
});
let expected_uri = expected_ui.as_ref().map(|ui| ui.resource_uri.clone());
let expected_uri = expected_uri.map(str::to_string);
let mut observed = Vec::new();
let mut completed = None;
wait_for_event(&test.codex, |event| {