From 38f8381d17ecc5c76fd1dc55a1ae4159e80a8711 Mon Sep 17 00:00:00 2001 From: martinauyeung-oai <280153141+martinauyeung-oai@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:49:26 -0700 Subject: [PATCH] Thread MCP App UI support through app discovery --- codex-rs/app-server/src/message_processor.rs | 6 +- .../src/request_processors/apps_processor.rs | 5 +- .../src/request_processors/mcp_processor.rs | 7 +- .../app-server/tests/suite/v2/app_list.rs | 120 +++++++++++++++++- codex-rs/codex-mcp/src/codex_apps_cache.rs | 14 +- .../codex-mcp/src/codex_apps_cache_tests.rs | 49 +++++++ codex-rs/codex-mcp/src/connection_manager.rs | 7 +- .../codex-mcp/src/connection_manager_tests.rs | 1 + 8 files changed, 195 insertions(+), 14 deletions(-) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index b777e7cf2f..949a809a02 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1305,7 +1305,9 @@ impl MessageProcessor { self.plugin_processor.plugin_share_delete(params).await } ClientRequest::AppsList { params, .. } => { - self.apps_processor.apps_list(&request_id, params).await + self.apps_processor + .apps_list(&request_id, params, supports_mcp_app_ui_webview) + .await } ClientRequest::SkillsConfigWrite { params, .. } => { self.catalog_processor.skills_config_write(params).await @@ -1400,7 +1402,7 @@ impl MessageProcessor { } ClientRequest::McpServerStatusList { params, .. } => { self.mcp_processor - .mcp_server_status_list(&request_id, params) + .mcp_server_status_list(&request_id, params, supports_mcp_app_ui_webview) .await } ClientRequest::McpResourceRead { params, .. } => { diff --git a/codex-rs/app-server/src/request_processors/apps_processor.rs b/codex-rs/app-server/src/request_processors/apps_processor.rs index 67d768fa57..7ef69ba0b7 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -36,8 +36,9 @@ impl AppsRequestProcessor { &self, request_id: &ConnectionRequestId, params: AppsListParams, + supports_mcp_app_ui_webview: bool, ) -> Result, JSONRPCErrorError> { - self.apps_list_inner(request_id, params) + self.apps_list_inner(request_id, params, supports_mcp_app_ui_webview) .await .map(|response| response.map(Into::into)) } @@ -46,6 +47,7 @@ impl AppsRequestProcessor { &self, request_id: &ConnectionRequestId, params: AppsListParams, + supports_mcp_app_ui_webview: bool, ) -> Result, JSONRPCErrorError> { let thread = if let Some(thread_id) = params.thread_id.as_deref() { let (_, loaded_thread) = self.load_thread(thread_id).await?; @@ -58,6 +60,7 @@ impl AppsRequestProcessor { None => None, }; let mut config = self.load_latest_config(fallback_cwd).await?; + config.supports_mcp_app_ui_webview = supports_mcp_app_ui_webview; if let Some(thread) = thread { let _ = config diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index 133619e9a0..5fd4e5a0e4 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -47,8 +47,9 @@ impl McpRequestProcessor { &self, request_id: &ConnectionRequestId, params: ListMcpServerStatusParams, + supports_mcp_app_ui_webview: bool, ) -> Result, JSONRPCErrorError> { - self.list_mcp_server_status(request_id, params) + self.list_mcp_server_status(request_id, params, supports_mcp_app_ui_webview) .await .map(|()| None) } @@ -227,11 +228,12 @@ impl McpRequestProcessor { &self, request_id: &ConnectionRequestId, params: ListMcpServerStatusParams, + supports_mcp_app_ui_webview: bool, ) -> Result<(), JSONRPCErrorError> { let request = request_id.clone(); let outgoing = Arc::clone(&self.outgoing); - let (config, thread) = match params.thread_id.as_deref() { + let (mut config, thread) = match params.thread_id.as_deref() { Some(thread_id) => { let (_, thread) = self.load_thread(thread_id).await?; let thread_config = thread.config().await; @@ -244,6 +246,7 @@ impl McpRequestProcessor { } None => (self.load_latest_config(/*fallback_cwd*/ None).await?, None), }; + config.supports_mcp_app_ui_webview = supports_mcp_app_ui_webview; let mcp_manager = self.thread_manager.mcp_manager(); let codex_apps_tools_cache = mcp_manager.codex_apps_tools_cache(); let auth = self.auth_manager.auth().await; diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 5fad04929f..18cbc3634f 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -3,6 +3,8 @@ use std::collections::HashMap; use std::path::Path; use std::sync::Arc; use std::sync::Mutex as StdMutex; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use std::time::Duration; use anyhow::Result; @@ -27,6 +29,8 @@ use codex_app_server_protocol::AppReview; use codex_app_server_protocol::AppScreenshot; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -40,6 +44,8 @@ use codex_login::save_auth; use codex_protocol::auth::AuthMode; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; +use rmcp::model::InitializeRequestParams; +use rmcp::model::InitializeResult; use rmcp::model::JsonObject; use rmcp::model::ListToolsResult; use rmcp::model::Meta; @@ -47,6 +53,8 @@ use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; use rmcp::model::ToolAnnotations; +use rmcp::service::RequestContext; +use rmcp::service::RoleServer; use rmcp::transport::StreamableHttpServerConfig; use rmcp::transport::StreamableHttpService; use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; @@ -156,6 +164,72 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> { Ok(()) } +#[tokio::test] +async fn list_apps_forwards_mcp_app_ui_capability_to_codex_apps() -> Result<()> { + let tools = vec![connector_tool("beta", "Beta App")?]; + let (server_url, server_handle, server_control) = start_apps_server_with_delays_and_control( + Vec::new(), + tools, + Duration::ZERO, + Duration::ZERO, + ) + .await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &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_without_managed_config(codex_home.path()).await?; + timeout( + DEFAULT_TIMEOUT, + mcp.initialize_with_capabilities( + ClientInfo { + name: "widget-capable-client".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + Some(InitializeCapabilities { + experimental_api: true, + extensions: Some(HashMap::from([( + "io.modelcontextprotocol/ui".to_string(), + json!({ + "mimeTypes": ["text/html;profile=mcp-app"], + }), + )])), + ..Default::default() + }), + ), + ) + .await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: Some(50), + cursor: None, + thread_id: None, + force_refetch: true, + }) + .await?; + let _: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + assert!(server_control.supports_mcp_app_ui_webview()); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + #[tokio::test] async fn list_apps_returns_empty_when_workspace_codex_plugins_disabled() -> Result<()> { let connectors = vec![AppInfo { @@ -1471,11 +1545,20 @@ struct AppsServerState { struct AppListMcpServer { tools: Arc>>, tools_delay: Duration, + supports_mcp_app_ui_webview: Arc, } impl AppListMcpServer { - fn new(tools: Arc>>, tools_delay: Duration) -> Self { - Self { tools, tools_delay } + fn new( + tools: Arc>>, + tools_delay: Duration, + supports_mcp_app_ui_webview: Arc, + ) -> Self { + Self { + tools, + tools_delay, + supports_mcp_app_ui_webview, + } } } @@ -1483,6 +1566,7 @@ impl AppListMcpServer { struct AppsServerControl { response: Arc>, tools: Arc>>, + supports_mcp_app_ui_webview: Arc, } impl AppsServerControl { @@ -1501,9 +1585,30 @@ impl AppsServerControl { .unwrap_or_else(std::sync::PoisonError::into_inner); *tools_guard = tools; } + + fn supports_mcp_app_ui_webview(&self) -> bool { + self.supports_mcp_app_ui_webview.load(Ordering::Relaxed) + } } impl ServerHandler for AppListMcpServer { + async fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext, + ) -> Result { + self.supports_mcp_app_ui_webview.store( + request + .capabilities + .extensions + .as_ref() + .is_some_and(|extensions| extensions.contains_key("io.modelcontextprotocol/ui")), + Ordering::Relaxed, + ); + context.peer.set_peer_info(request); + Ok(self.get_info()) + } + fn get_info(&self) -> ServerInfo { ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } @@ -1589,6 +1694,7 @@ async fn start_apps_server_with_delays_and_control_inner( json!({ "apps": connectors, "next_token": null }), )); let tools = Arc::new(StdMutex::new(tools)); + let supports_mcp_app_ui_webview = Arc::new(AtomicBool::new(false)); let state = AppsServerState { expected_bearer: "Bearer chatgpt-token".to_string(), expected_account_id: "account-123".to_string(), @@ -1600,6 +1706,7 @@ async fn start_apps_server_with_delays_and_control_inner( let server_control = AppsServerControl { response, tools: tools.clone(), + supports_mcp_app_ui_webview: Arc::clone(&supports_mcp_app_ui_webview), }; let listener = TcpListener::bind("127.0.0.1:0").await?; @@ -1608,7 +1715,14 @@ async fn start_apps_server_with_delays_and_control_inner( let mcp_service = StreamableHttpService::new( { let tools = tools.clone(); - move || Ok(AppListMcpServer::new(tools.clone(), tools_delay)) + let supports_mcp_app_ui_webview = Arc::clone(&supports_mcp_app_ui_webview); + move || { + Ok(AppListMcpServer::new( + tools.clone(), + tools_delay, + Arc::clone(&supports_mcp_app_ui_webview), + )) + } }, Arc::new(LocalSessionManager::default()), StreamableHttpServerConfig::default(), diff --git a/codex-rs/codex-mcp/src/codex_apps_cache.rs b/codex-rs/codex-mcp/src/codex_apps_cache.rs index ea39444628..cca984426e 100644 --- a/codex-rs/codex-mcp/src/codex_apps_cache.rs +++ b/codex-rs/codex-mcp/src/codex_apps_cache.rs @@ -1,8 +1,8 @@ //! Shared raw tool cache for the host-owned Codex Apps MCP server. //! //! Cache entries are process-local live state scoped by the active Codex auth -//! key. Disk is best-effort cold-start persistence; entries do not reread disk -//! after creation. +//! key and host UI capability. Disk is best-effort cold-start persistence; +//! entries do not reread disk after creation. use std::collections::HashMap; use std::path::Path; @@ -146,10 +146,12 @@ impl CodexAppsToolsCache { &self, codex_home: PathBuf, auth_key: CodexAppsToolsCacheKey, + supports_mcp_app_ui_webview: bool, ) -> CodexAppsToolsCacheContext { let identity = CodexAppsToolsCacheIdentity { codex_home, auth_key, + supports_mcp_app_ui_webview, }; let mut entries = lock_unpoisoned(&self.entries); let entry = entries @@ -201,12 +203,14 @@ impl CodexAppsToolsCacheEntry { /// Everything that decides whether two Codex Apps clients can share tools. /// -/// The auth key says whose catalog we are reading. `codex_home` keeps the +/// The auth key says whose catalog we are reading. The UI capability keeps +/// widget-aware and text-only catalogs isolated. `codex_home` keeps the /// persisted cache under the right home directory. #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct CodexAppsToolsCacheIdentity { codex_home: PathBuf, auth_key: CodexAppsToolsCacheKey, + supports_mcp_app_ui_webview: bool, } impl CodexAppsToolsCacheIdentity { @@ -214,7 +218,9 @@ impl CodexAppsToolsCacheIdentity { // `codex_home` is already the parent directory. Keep it out of the // filename hash so non-UTF-8 Unix paths cannot collapse distinct auth // keys onto the same disk cache file. - let identity_json = serde_json::to_string(&self.auth_key).unwrap_or_default(); + let identity_json = + serde_json::to_string(&(&self.auth_key, self.supports_mcp_app_ui_webview)) + .unwrap_or_default(); let identity_hash = sha1_hex(&identity_json); self.codex_home .join(cache_dir) diff --git a/codex-rs/codex-mcp/src/codex_apps_cache_tests.rs b/codex-rs/codex-mcp/src/codex_apps_cache_tests.rs index 46ad311a1b..85d4869505 100644 --- a/codex-rs/codex-mcp/src/codex_apps_cache_tests.rs +++ b/codex-rs/codex-mcp/src/codex_apps_cache_tests.rs @@ -56,6 +56,7 @@ fn create_codex_apps_tools_cache_context( chatgpt_user_id: chatgpt_user_id.map(ToOwned::to_owned), is_workspace_account: false, }, + /*supports_mcp_app_ui_webview*/ false, ) } @@ -134,6 +135,49 @@ fn codex_apps_tools_cache_is_scoped_per_user() { ); } +#[test] +fn codex_apps_tools_cache_is_scoped_per_host_ui_capability() { + let codex_home = tempdir().expect("tempdir"); + let cache = CodexAppsToolsCache::default(); + let cache_key = CodexAppsToolsCacheKey { + account_id: Some("account-one".to_string()), + chatgpt_user_id: Some("user-one".to_string()), + is_workspace_account: false, + }; + let text_only_context = cache.context( + codex_home.path().to_path_buf(), + cache_key.clone(), + /*supports_mcp_app_ui_webview*/ false, + ); + let widget_context = cache.context( + codex_home.path().to_path_buf(), + cache_key, + /*supports_mcp_app_ui_webview*/ true, + ); + + text_only_context + .store_current_tools_for_test(vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "text")]); + widget_context + .store_current_tools_for_test(vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "widget")]); + + assert_eq!( + text_only_context + .current_tools() + .expect("text-only cache entry")[0] + .callable_name, + "text" + ); + assert_eq!( + widget_context.current_tools().expect("widget cache entry")[0].callable_name, + "widget" + ); + assert_ne!( + text_only_context.tools_cache_path(), + widget_context.tools_cache_path(), + "each host UI capability should get an isolated cache file" + ); +} + #[test] fn codex_apps_tools_cache_preserves_formerly_disallowed_connectors() { let codex_home = tempdir().expect("tempdir"); @@ -362,6 +406,7 @@ fn codex_apps_tools_cache_publishes_newest_shared_snapshot() { chatgpt_user_id: Some("user-one".to_string()), is_workspace_account: false, }, + /*supports_mcp_app_ui_webview*/ false, ); let cache_context_2 = cache.context( codex_home.path().to_path_buf(), @@ -370,6 +415,7 @@ fn codex_apps_tools_cache_publishes_newest_shared_snapshot() { chatgpt_user_id: Some("user-one".to_string()), is_workspace_account: false, }, + /*supports_mcp_app_ui_webview*/ false, ); let older_ticket = cache_context_1.begin_fetch(CodexAppsToolsFetchSource::Startup); let newer_ticket = cache_context_2.begin_fetch(CodexAppsToolsFetchSource::HardRefresh); @@ -414,6 +460,7 @@ fn codex_apps_tools_cache_keeps_live_publish_when_disk_persistence_fails() { chatgpt_user_id: Some("user-one".to_string()), is_workspace_account: false, }, + /*supports_mcp_app_ui_webview*/ false, ); let tools = vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "live")]; let published_tools = cache_context.publish_if_newest_accepted( @@ -443,6 +490,7 @@ fn codex_apps_tools_cache_scopes_non_utf8_home_disk_paths() { chatgpt_user_id: Some("user-one".to_string()), is_workspace_account: false, }, + /*supports_mcp_app_ui_webview*/ false, ); let user_two_context = cache.context( codex_home, @@ -451,6 +499,7 @@ fn codex_apps_tools_cache_scopes_non_utf8_home_disk_paths() { chatgpt_user_id: Some("user-two".to_string()), is_workspace_account: false, }, + /*supports_mcp_app_ui_webview*/ false, ); let cache_paths = [ user_one_context.tools_cache_path(), diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 351e4c4c94..6b017615ed 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -200,8 +200,11 @@ impl McpConnectionManager { let shares_codex_apps_tools_cache = should_share_codex_apps_tools_cache(&server_name, uses_env_bearer_token); let codex_apps_tools_cache_context = shares_codex_apps_tools_cache.then(|| { - codex_apps_tools_cache - .context(codex_home.clone(), codex_apps_tools_cache_key.clone()) + codex_apps_tools_cache.context( + codex_home.clone(), + codex_apps_tools_cache_key.clone(), + supports_mcp_app_ui_webview, + ) }); // If Codex Apps has an env bearer token, that is its auth path. Do // not also attach the ambient CodexAuth provider. diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 5794a4759f..57aff76f95 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -81,6 +81,7 @@ fn create_codex_apps_tools_cache_context( chatgpt_user_id: chatgpt_user_id.map(ToOwned::to_owned), is_workspace_account: false, }, + /*supports_mcp_app_ui_webview*/ false, ) }