Thread MCP App UI support through app discovery

This commit is contained in:
martinauyeung-oai
2026-07-07 10:49:26 -07:00
parent cc23f67e36
commit 38f8381d17
8 changed files with 195 additions and 14 deletions
+4 -2
View File
@@ -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, .. } => {
@@ -36,8 +36,9 @@ impl AppsRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: AppsListParams,
supports_mcp_app_ui_webview: bool,
) -> Result<Option<ClientResponsePayload>, 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<Option<AppsListResponse>, 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
@@ -47,8 +47,9 @@ impl McpRequestProcessor {
&self,
request_id: &ConnectionRequestId,
params: ListMcpServerStatusParams,
supports_mcp_app_ui_webview: bool,
) -> Result<Option<ClientResponsePayload>, 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;
+117 -3
View File
@@ -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<StdMutex<Vec<Tool>>>,
tools_delay: Duration,
supports_mcp_app_ui_webview: Arc<AtomicBool>,
}
impl AppListMcpServer {
fn new(tools: Arc<StdMutex<Vec<Tool>>>, tools_delay: Duration) -> Self {
Self { tools, tools_delay }
fn new(
tools: Arc<StdMutex<Vec<Tool>>>,
tools_delay: Duration,
supports_mcp_app_ui_webview: Arc<AtomicBool>,
) -> Self {
Self {
tools,
tools_delay,
supports_mcp_app_ui_webview,
}
}
}
@@ -1483,6 +1566,7 @@ impl AppListMcpServer {
struct AppsServerControl {
response: Arc<StdMutex<serde_json::Value>>,
tools: Arc<StdMutex<Vec<Tool>>>,
supports_mcp_app_ui_webview: Arc<AtomicBool>,
}
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<RoleServer>,
) -> Result<InitializeResult, rmcp::ErrorData> {
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(),
+10 -4
View File
@@ -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)
@@ -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(),
+5 -2
View File
@@ -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.
@@ -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,
)
}