mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
Thread MCP App UI support through app discovery
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user