[apps] Add tool_suggest tool. (#14287)

- [x] Add tool_suggest tool.
- [x] Move chatgpt/src/connectors.rs and core/src/connectors.rs into a
dedicated mod so that we have all the logic and global cache in one
place.
- [x] Update TUI app link view to support rendering the installation
view for mcp elicitation.

---------

Co-authored-by: Shaqayeq <shaqayeq@openai.com>
Co-authored-by: Eric Traut <etraut@openai.com>
Co-authored-by: pakrym-oai <pakrym@openai.com>
Co-authored-by: Ahmed Ibrahim <aibrahim@openai.com>
Co-authored-by: guinness-oai <guinness@openai.com>
Co-authored-by: Eugene Brevdo <ebrevdo@users.noreply.github.com>
Co-authored-by: Charlie Guo <cguo@openai.com>
Co-authored-by: Fouad Matin <fouad@openai.com>
Co-authored-by: Fouad Matin <169186268+fouad-openai@users.noreply.github.com>
Co-authored-by: xl-openai <xl@openai.com>
Co-authored-by: alexsong-oai <alexsong@openai.com>
Co-authored-by: Owen Lin <owenlin0@gmail.com>
Co-authored-by: sdcoffey <stevendcoffey@gmail.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Won Park <won@openai.com>
Co-authored-by: Dylan Hurd <dylan.hurd@openai.com>
Co-authored-by: celia-oai <celia@openai.com>
Co-authored-by: gabec-openai <gabec@openai.com>
Co-authored-by: joeytrasatti-openai <joey.trasatti@openai.com>
Co-authored-by: Leo Shimonaka <leoshimo@openai.com>
Co-authored-by: Rasmus Rygaard <rasmus@openai.com>
Co-authored-by: maja-openai <163171781+maja-openai@users.noreply.github.com>
Co-authored-by: pash-openai <pash@openai.com>
Co-authored-by: Josh McKinney <joshka@openai.com>
This commit is contained in:
Matthew Zeng 2026-03-11 22:06:59 -07:00 committed by GitHub
parent 917c2df201
commit ba5b94287e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 2594 additions and 437 deletions

15
codex-rs/Cargo.lock generated
View file

@ -1619,6 +1619,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"codex-connectors",
"codex-core",
"codex-git",
"codex-utils-cargo-bin",
@ -1628,7 +1629,6 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
"urlencoding",
]
[[package]]
@ -1790,6 +1790,18 @@ dependencies = [
"tracing",
]
[[package]]
name = "codex-connectors"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-app-server-protocol",
"pretty_assertions",
"serde",
"tokio",
"urlencoding",
]
[[package]]
name = "codex-core"
version = "0.0.0"
@ -1814,6 +1826,7 @@ dependencies = [
"codex-async-utils",
"codex-client",
"codex-config",
"codex-connectors",
"codex-execpolicy",
"codex-file-search",
"codex-git",

View file

@ -16,6 +16,7 @@ members = [
"cloud-tasks",
"cloud-tasks-client",
"cli",
"connectors",
"config",
"shell-command",
"shell-escalation",
@ -98,6 +99,7 @@ codex-chatgpt = { path = "chatgpt" }
codex-cli = { path = "cli" }
codex-client = { path = "codex-client" }
codex-cloud-requirements = { path = "cloud-requirements" }
codex-connectors = { path = "connectors" }
codex-config = { path = "config" }
codex-core = { path = "core" }
codex-exec = { path = "exec" }

View file

@ -10,6 +10,7 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-connectors = { workspace = true }
codex-core = { workspace = true }
codex-utils-cli = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
@ -17,7 +18,6 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
codex-git = { workspace = true }
urlencoding = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }

View file

@ -1,25 +1,18 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::LazyLock;
use std::sync::Mutex as StdMutex;
use codex_core::AuthManager;
use codex_core::config::Config;
use codex_core::token_data::TokenData;
use serde::Deserialize;
use std::collections::HashSet;
use std::time::Duration;
use std::time::Instant;
use crate::chatgpt_client::chatgpt_get_request_with_timeout;
use crate::chatgpt_token::get_chatgpt_token_data;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
use codex_core::connectors::AppBranding;
use codex_connectors::AllConnectorsCacheKey;
use codex_connectors::DirectoryListResponse;
pub use codex_core::connectors::AppInfo;
use codex_core::connectors::AppMetadata;
use codex_core::connectors::CONNECTORS_CACHE_TTL;
pub use codex_core::connectors::connector_display_label;
use codex_core::connectors::connector_install_url;
use codex_core::connectors::filter_disallowed_connectors;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
@ -30,51 +23,8 @@ use codex_core::connectors::merge_plugin_apps;
pub use codex_core::connectors::with_app_enabled_state;
use codex_core::plugins::PluginsManager;
#[derive(Debug, Deserialize)]
struct DirectoryListResponse {
apps: Vec<DirectoryApp>,
#[serde(alias = "nextToken")]
next_token: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
struct DirectoryApp {
id: String,
name: String,
description: Option<String>,
#[serde(alias = "appMetadata")]
app_metadata: Option<AppMetadata>,
branding: Option<AppBranding>,
labels: Option<HashMap<String, String>>,
#[serde(alias = "logoUrl")]
logo_url: Option<String>,
#[serde(alias = "logoUrlDark")]
logo_url_dark: Option<String>,
#[serde(alias = "distributionChannel")]
distribution_channel: Option<String>,
visibility: Option<String>,
}
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Clone, PartialEq, Eq)]
struct AllConnectorsCacheKey {
chatgpt_base_url: String,
account_id: Option<String>,
chatgpt_user_id: Option<String>,
is_workspace_account: bool,
}
#[derive(Clone)]
struct CachedAllConnectors {
key: AllConnectorsCacheKey,
expires_at: Instant,
connectors: Vec<AppInfo>,
}
static ALL_CONNECTORS_CACHE: LazyLock<StdMutex<Option<CachedAllConnectors>>> =
LazyLock::new(|| StdMutex::new(None));
async fn apps_enabled(config: &Config) -> bool {
let auth_manager = AuthManager::shared(
config.codex_home.clone(),
@ -83,7 +33,6 @@ async fn apps_enabled(config: &Config) -> bool {
);
config.features.apps_enabled(Some(&auth_manager)).await
}
pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
if !apps_enabled(config).await {
return Ok(Vec::new());
@ -117,7 +66,7 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option<Vec<AppInfo>>
}
let token_data = get_chatgpt_token_data()?;
let cache_key = all_connectors_cache_key(config, &token_data);
read_cached_all_connectors(&cache_key).map(|connectors| {
codex_connectors::cached_all_connectors(&cache_key).map(|connectors| {
let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config));
filter_disallowed_connectors(connectors)
})
@ -136,76 +85,31 @@ pub async fn list_all_connectors_with_options(
let token_data =
get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?;
let cache_key = all_connectors_cache_key(config, &token_data);
if !force_refetch && let Some(cached_connectors) = read_cached_all_connectors(&cache_key) {
let connectors = merge_plugin_apps(cached_connectors, plugin_apps_for_config(config));
return Ok(filter_disallowed_connectors(connectors));
}
let mut apps = list_directory_connectors(config).await?;
if token_data.id_token.is_workspace_account() {
apps.extend(list_workspace_connectors(config).await?);
}
let mut connectors = merge_directory_apps(apps)
.into_iter()
.map(directory_app_to_app_info)
.collect::<Vec<_>>();
for connector in &mut connectors {
let install_url = match connector.install_url.take() {
Some(install_url) => install_url,
None => connector_install_url(&connector.name, &connector.id),
};
connector.name = normalize_connector_name(&connector.name, &connector.id);
connector.description = normalize_connector_value(connector.description.as_deref());
connector.install_url = Some(install_url);
connector.is_accessible = false;
}
connectors.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
let connectors = filter_disallowed_connectors(connectors);
write_cached_all_connectors(cache_key, &connectors);
let connectors = codex_connectors::list_all_connectors_with_options(
cache_key,
token_data.id_token.is_workspace_account(),
force_refetch,
|path| async move {
chatgpt_get_request_with_timeout::<DirectoryListResponse>(
config,
path,
Some(DIRECTORY_CONNECTORS_TIMEOUT),
)
.await
},
)
.await?;
let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config));
Ok(filter_disallowed_connectors(connectors))
}
fn all_connectors_cache_key(config: &Config, token_data: &TokenData) -> AllConnectorsCacheKey {
AllConnectorsCacheKey {
chatgpt_base_url: config.chatgpt_base_url.clone(),
account_id: token_data.account_id.clone(),
chatgpt_user_id: token_data.id_token.chatgpt_user_id.clone(),
is_workspace_account: token_data.id_token.is_workspace_account(),
}
}
fn read_cached_all_connectors(cache_key: &AllConnectorsCacheKey) -> Option<Vec<AppInfo>> {
let mut cache_guard = ALL_CONNECTORS_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let now = Instant::now();
if let Some(cached) = cache_guard.as_ref() {
if now < cached.expires_at && cached.key == *cache_key {
return Some(cached.connectors.clone());
}
if now >= cached.expires_at {
*cache_guard = None;
}
}
None
}
fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[AppInfo]) {
let mut cache_guard = ALL_CONNECTORS_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*cache_guard = Some(CachedAllConnectors {
key: cache_key,
expires_at: Instant::now() + CONNECTORS_CACHE_TTL,
connectors: connectors.to_vec(),
});
AllConnectorsCacheKey::new(
config.chatgpt_base_url.clone(),
token_data.account_id.clone(),
token_data.id_token.chatgpt_user_id.clone(),
token_data.id_token.is_workspace_account(),
)
}
fn plugin_apps_for_config(config: &Config) -> Vec<codex_core::plugins::AppConnectorId> {
@ -235,248 +139,10 @@ pub fn merge_connectors_with_accessible(
filter_disallowed_connectors(merged)
}
async fn list_directory_connectors(config: &Config) -> anyhow::Result<Vec<DirectoryApp>> {
let mut apps = Vec::new();
let mut next_token: Option<String> = None;
loop {
let path = match next_token.as_deref() {
Some(token) => {
let encoded_token = urlencoding::encode(token);
format!(
"/connectors/directory/list?tier=categorized&token={encoded_token}&external_logos=true"
)
}
None => "/connectors/directory/list?tier=categorized&external_logos=true".to_string(),
};
let response: DirectoryListResponse =
chatgpt_get_request_with_timeout(config, path, Some(DIRECTORY_CONNECTORS_TIMEOUT))
.await?;
apps.extend(
response
.apps
.into_iter()
.filter(|app| !is_hidden_directory_app(app)),
);
next_token = response
.next_token
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty());
if next_token.is_none() {
break;
}
}
Ok(apps)
}
async fn list_workspace_connectors(config: &Config) -> anyhow::Result<Vec<DirectoryApp>> {
let response: anyhow::Result<DirectoryListResponse> = chatgpt_get_request_with_timeout(
config,
"/connectors/directory/list_workspace?external_logos=true".to_string(),
Some(DIRECTORY_CONNECTORS_TIMEOUT),
)
.await;
match response {
Ok(response) => Ok(response
.apps
.into_iter()
.filter(|app| !is_hidden_directory_app(app))
.collect()),
Err(_) => Ok(Vec::new()),
}
}
fn merge_directory_apps(apps: Vec<DirectoryApp>) -> Vec<DirectoryApp> {
let mut merged: HashMap<String, DirectoryApp> = HashMap::new();
for app in apps {
if let Some(existing) = merged.get_mut(&app.id) {
merge_directory_app(existing, app);
} else {
merged.insert(app.id.clone(), app);
}
}
merged.into_values().collect()
}
fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
let DirectoryApp {
id: _,
name,
description,
app_metadata,
branding,
labels,
logo_url,
logo_url_dark,
distribution_channel,
visibility: _,
} = incoming;
let incoming_name_is_empty = name.trim().is_empty();
if existing.name.trim().is_empty() && !incoming_name_is_empty {
existing.name = name;
}
let incoming_description_present = description
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
if incoming_description_present {
existing.description = description;
}
if existing.logo_url.is_none() && logo_url.is_some() {
existing.logo_url = logo_url;
}
if existing.logo_url_dark.is_none() && logo_url_dark.is_some() {
existing.logo_url_dark = logo_url_dark;
}
if existing.distribution_channel.is_none() && distribution_channel.is_some() {
existing.distribution_channel = distribution_channel;
}
if let Some(incoming_branding) = branding {
if let Some(existing_branding) = existing.branding.as_mut() {
if existing_branding.category.is_none() && incoming_branding.category.is_some() {
existing_branding.category = incoming_branding.category;
}
if existing_branding.developer.is_none() && incoming_branding.developer.is_some() {
existing_branding.developer = incoming_branding.developer;
}
if existing_branding.website.is_none() && incoming_branding.website.is_some() {
existing_branding.website = incoming_branding.website;
}
if existing_branding.privacy_policy.is_none()
&& incoming_branding.privacy_policy.is_some()
{
existing_branding.privacy_policy = incoming_branding.privacy_policy;
}
if existing_branding.terms_of_service.is_none()
&& incoming_branding.terms_of_service.is_some()
{
existing_branding.terms_of_service = incoming_branding.terms_of_service;
}
if !existing_branding.is_discoverable_app && incoming_branding.is_discoverable_app {
existing_branding.is_discoverable_app = true;
}
} else {
existing.branding = Some(incoming_branding);
}
}
if let Some(incoming_app_metadata) = app_metadata {
if let Some(existing_app_metadata) = existing.app_metadata.as_mut() {
if existing_app_metadata.review.is_none() && incoming_app_metadata.review.is_some() {
existing_app_metadata.review = incoming_app_metadata.review;
}
if existing_app_metadata.categories.is_none()
&& incoming_app_metadata.categories.is_some()
{
existing_app_metadata.categories = incoming_app_metadata.categories;
}
if existing_app_metadata.sub_categories.is_none()
&& incoming_app_metadata.sub_categories.is_some()
{
existing_app_metadata.sub_categories = incoming_app_metadata.sub_categories;
}
if existing_app_metadata.seo_description.is_none()
&& incoming_app_metadata.seo_description.is_some()
{
existing_app_metadata.seo_description = incoming_app_metadata.seo_description;
}
if existing_app_metadata.screenshots.is_none()
&& incoming_app_metadata.screenshots.is_some()
{
existing_app_metadata.screenshots = incoming_app_metadata.screenshots;
}
if existing_app_metadata.developer.is_none()
&& incoming_app_metadata.developer.is_some()
{
existing_app_metadata.developer = incoming_app_metadata.developer;
}
if existing_app_metadata.version.is_none() && incoming_app_metadata.version.is_some() {
existing_app_metadata.version = incoming_app_metadata.version;
}
if existing_app_metadata.version_id.is_none()
&& incoming_app_metadata.version_id.is_some()
{
existing_app_metadata.version_id = incoming_app_metadata.version_id;
}
if existing_app_metadata.version_notes.is_none()
&& incoming_app_metadata.version_notes.is_some()
{
existing_app_metadata.version_notes = incoming_app_metadata.version_notes;
}
if existing_app_metadata.first_party_type.is_none()
&& incoming_app_metadata.first_party_type.is_some()
{
existing_app_metadata.first_party_type = incoming_app_metadata.first_party_type;
}
if existing_app_metadata.first_party_requires_install.is_none()
&& incoming_app_metadata.first_party_requires_install.is_some()
{
existing_app_metadata.first_party_requires_install =
incoming_app_metadata.first_party_requires_install;
}
if existing_app_metadata
.show_in_composer_when_unlinked
.is_none()
&& incoming_app_metadata
.show_in_composer_when_unlinked
.is_some()
{
existing_app_metadata.show_in_composer_when_unlinked =
incoming_app_metadata.show_in_composer_when_unlinked;
}
} else {
existing.app_metadata = Some(incoming_app_metadata);
}
}
if existing.labels.is_none() && labels.is_some() {
existing.labels = labels;
}
}
fn is_hidden_directory_app(app: &DirectoryApp) -> bool {
matches!(app.visibility.as_deref(), Some("HIDDEN"))
}
fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
AppInfo {
id: app.id,
name: app.name,
description: app.description,
logo_url: app.logo_url,
logo_url_dark: app.logo_url_dark,
distribution_channel: app.distribution_channel,
branding: app.branding,
app_metadata: app.app_metadata,
labels: app.labels,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}
}
fn normalize_connector_name(name: &str, connector_id: &str) -> String {
let trimmed = name.trim();
if trimmed.is_empty() {
connector_id.to_string()
} else {
trimmed.to_string()
}
}
fn normalize_connector_value(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::connectors::connector_install_url;
use pretty_assertions::assert_eq;
fn app(id: &str) -> AppInfo {

View file

@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "connectors",
crate_name = "codex_connectors",
)

View file

@ -0,0 +1,18 @@
[package]
name = "codex-connectors"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
codex-app-server-protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] }
urlencoding = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }

View file

@ -0,0 +1,534 @@
use std::collections::HashMap;
use std::future::Future;
use std::sync::LazyLock;
use std::sync::Mutex as StdMutex;
use std::time::Duration;
use std::time::Instant;
use codex_app_server_protocol::AppBranding;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppMetadata;
use serde::Deserialize;
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AllConnectorsCacheKey {
chatgpt_base_url: String,
account_id: Option<String>,
chatgpt_user_id: Option<String>,
is_workspace_account: bool,
}
impl AllConnectorsCacheKey {
pub fn new(
chatgpt_base_url: String,
account_id: Option<String>,
chatgpt_user_id: Option<String>,
is_workspace_account: bool,
) -> Self {
Self {
chatgpt_base_url,
account_id,
chatgpt_user_id,
is_workspace_account,
}
}
}
#[derive(Clone)]
struct CachedAllConnectors {
key: AllConnectorsCacheKey,
expires_at: Instant,
connectors: Vec<AppInfo>,
}
static ALL_CONNECTORS_CACHE: LazyLock<StdMutex<Option<CachedAllConnectors>>> =
LazyLock::new(|| StdMutex::new(None));
#[derive(Debug, Deserialize)]
pub struct DirectoryListResponse {
apps: Vec<DirectoryApp>,
#[serde(alias = "nextToken")]
next_token: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DirectoryApp {
id: String,
name: String,
description: Option<String>,
#[serde(alias = "appMetadata")]
app_metadata: Option<AppMetadata>,
branding: Option<AppBranding>,
labels: Option<HashMap<String, String>>,
#[serde(alias = "logoUrl")]
logo_url: Option<String>,
#[serde(alias = "logoUrlDark")]
logo_url_dark: Option<String>,
#[serde(alias = "distributionChannel")]
distribution_channel: Option<String>,
visibility: Option<String>,
}
pub fn cached_all_connectors(cache_key: &AllConnectorsCacheKey) -> Option<Vec<AppInfo>> {
let mut cache_guard = ALL_CONNECTORS_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let now = Instant::now();
if let Some(cached) = cache_guard.as_ref() {
if now < cached.expires_at && cached.key == *cache_key {
return Some(cached.connectors.clone());
}
if now >= cached.expires_at {
*cache_guard = None;
}
}
None
}
pub async fn list_all_connectors_with_options<F, Fut>(
cache_key: AllConnectorsCacheKey,
is_workspace_account: bool,
force_refetch: bool,
mut fetch_page: F,
) -> anyhow::Result<Vec<AppInfo>>
where
F: FnMut(String) -> Fut,
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
{
if !force_refetch && let Some(cached_connectors) = cached_all_connectors(&cache_key) {
return Ok(cached_connectors);
}
let mut apps = list_directory_connectors(&mut fetch_page).await?;
if is_workspace_account {
apps.extend(list_workspace_connectors(&mut fetch_page).await?);
}
let mut connectors = merge_directory_apps(apps)
.into_iter()
.map(directory_app_to_app_info)
.collect::<Vec<_>>();
for connector in &mut connectors {
let install_url = match connector.install_url.take() {
Some(install_url) => install_url,
None => connector_install_url(&connector.name, &connector.id),
};
connector.name = normalize_connector_name(&connector.name, &connector.id);
connector.description = normalize_connector_value(connector.description.as_deref());
connector.install_url = Some(install_url);
connector.is_accessible = false;
}
connectors.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
write_cached_all_connectors(cache_key, &connectors);
Ok(connectors)
}
fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[AppInfo]) {
let mut cache_guard = ALL_CONNECTORS_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*cache_guard = Some(CachedAllConnectors {
key: cache_key,
expires_at: Instant::now() + CONNECTORS_CACHE_TTL,
connectors: connectors.to_vec(),
});
}
async fn list_directory_connectors<F, Fut>(fetch_page: &mut F) -> anyhow::Result<Vec<DirectoryApp>>
where
F: FnMut(String) -> Fut,
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
{
let mut apps = Vec::new();
let mut next_token: Option<String> = None;
loop {
let path = match next_token.as_deref() {
Some(token) => {
let encoded_token = urlencoding::encode(token);
format!(
"/connectors/directory/list?tier=categorized&token={encoded_token}&external_logos=true"
)
}
None => "/connectors/directory/list?tier=categorized&external_logos=true".to_string(),
};
let response = fetch_page(path).await?;
apps.extend(
response
.apps
.into_iter()
.filter(|app| !is_hidden_directory_app(app)),
);
next_token = response
.next_token
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty());
if next_token.is_none() {
break;
}
}
Ok(apps)
}
async fn list_workspace_connectors<F, Fut>(fetch_page: &mut F) -> anyhow::Result<Vec<DirectoryApp>>
where
F: FnMut(String) -> Fut,
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
{
let response =
fetch_page("/connectors/directory/list_workspace?external_logos=true".to_string()).await;
match response {
Ok(response) => Ok(response
.apps
.into_iter()
.filter(|app| !is_hidden_directory_app(app))
.collect()),
Err(_) => Ok(Vec::new()),
}
}
fn merge_directory_apps(apps: Vec<DirectoryApp>) -> Vec<DirectoryApp> {
let mut merged: HashMap<String, DirectoryApp> = HashMap::new();
for app in apps {
if let Some(existing) = merged.get_mut(&app.id) {
merge_directory_app(existing, app);
} else {
merged.insert(app.id.clone(), app);
}
}
merged.into_values().collect()
}
fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
let DirectoryApp {
id: _,
name,
description,
app_metadata,
branding,
labels,
logo_url,
logo_url_dark,
distribution_channel,
visibility: _,
} = incoming;
let incoming_name_is_empty = name.trim().is_empty();
if existing.name.trim().is_empty() && !incoming_name_is_empty {
existing.name = name;
}
let incoming_description_present = description
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
if incoming_description_present {
existing.description = description;
}
if existing.logo_url.is_none() && logo_url.is_some() {
existing.logo_url = logo_url;
}
if existing.logo_url_dark.is_none() && logo_url_dark.is_some() {
existing.logo_url_dark = logo_url_dark;
}
if existing.distribution_channel.is_none() && distribution_channel.is_some() {
existing.distribution_channel = distribution_channel;
}
if let Some(incoming_branding) = branding {
if let Some(existing_branding) = existing.branding.as_mut() {
if existing_branding.category.is_none() && incoming_branding.category.is_some() {
existing_branding.category = incoming_branding.category;
}
if existing_branding.developer.is_none() && incoming_branding.developer.is_some() {
existing_branding.developer = incoming_branding.developer;
}
if existing_branding.website.is_none() && incoming_branding.website.is_some() {
existing_branding.website = incoming_branding.website;
}
if existing_branding.privacy_policy.is_none()
&& incoming_branding.privacy_policy.is_some()
{
existing_branding.privacy_policy = incoming_branding.privacy_policy;
}
if existing_branding.terms_of_service.is_none()
&& incoming_branding.terms_of_service.is_some()
{
existing_branding.terms_of_service = incoming_branding.terms_of_service;
}
if !existing_branding.is_discoverable_app && incoming_branding.is_discoverable_app {
existing_branding.is_discoverable_app = true;
}
} else {
existing.branding = Some(incoming_branding);
}
}
if let Some(incoming_app_metadata) = app_metadata {
if let Some(existing_app_metadata) = existing.app_metadata.as_mut() {
if existing_app_metadata.review.is_none() && incoming_app_metadata.review.is_some() {
existing_app_metadata.review = incoming_app_metadata.review;
}
if existing_app_metadata.categories.is_none()
&& incoming_app_metadata.categories.is_some()
{
existing_app_metadata.categories = incoming_app_metadata.categories;
}
if existing_app_metadata.sub_categories.is_none()
&& incoming_app_metadata.sub_categories.is_some()
{
existing_app_metadata.sub_categories = incoming_app_metadata.sub_categories;
}
if existing_app_metadata.seo_description.is_none()
&& incoming_app_metadata.seo_description.is_some()
{
existing_app_metadata.seo_description = incoming_app_metadata.seo_description;
}
if existing_app_metadata.screenshots.is_none()
&& incoming_app_metadata.screenshots.is_some()
{
existing_app_metadata.screenshots = incoming_app_metadata.screenshots;
}
if existing_app_metadata.developer.is_none()
&& incoming_app_metadata.developer.is_some()
{
existing_app_metadata.developer = incoming_app_metadata.developer;
}
if existing_app_metadata.version.is_none() && incoming_app_metadata.version.is_some() {
existing_app_metadata.version = incoming_app_metadata.version;
}
if existing_app_metadata.version_id.is_none()
&& incoming_app_metadata.version_id.is_some()
{
existing_app_metadata.version_id = incoming_app_metadata.version_id;
}
if existing_app_metadata.version_notes.is_none()
&& incoming_app_metadata.version_notes.is_some()
{
existing_app_metadata.version_notes = incoming_app_metadata.version_notes;
}
if existing_app_metadata.first_party_type.is_none()
&& incoming_app_metadata.first_party_type.is_some()
{
existing_app_metadata.first_party_type = incoming_app_metadata.first_party_type;
}
if existing_app_metadata.first_party_requires_install.is_none()
&& incoming_app_metadata.first_party_requires_install.is_some()
{
existing_app_metadata.first_party_requires_install =
incoming_app_metadata.first_party_requires_install;
}
if existing_app_metadata
.show_in_composer_when_unlinked
.is_none()
&& incoming_app_metadata
.show_in_composer_when_unlinked
.is_some()
{
existing_app_metadata.show_in_composer_when_unlinked =
incoming_app_metadata.show_in_composer_when_unlinked;
}
} else {
existing.app_metadata = Some(incoming_app_metadata);
}
}
if existing.labels.is_none() && labels.is_some() {
existing.labels = labels;
}
}
fn is_hidden_directory_app(app: &DirectoryApp) -> bool {
matches!(app.visibility.as_deref(), Some("HIDDEN"))
}
fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
AppInfo {
id: app.id,
name: app.name,
description: app.description,
logo_url: app.logo_url,
logo_url_dark: app.logo_url_dark,
distribution_channel: app.distribution_channel,
branding: app.branding,
app_metadata: app.app_metadata,
labels: app.labels,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}
}
fn connector_install_url(name: &str, connector_id: &str) -> String {
let slug = connector_name_slug(name);
format!("https://chatgpt.com/apps/{slug}/{connector_id}")
}
fn connector_name_slug(name: &str) -> String {
let mut normalized = String::with_capacity(name.len());
for character in name.chars() {
if character.is_ascii_alphanumeric() {
normalized.push(character.to_ascii_lowercase());
} else {
normalized.push('-');
}
}
let normalized = normalized.trim_matches('-');
if normalized.is_empty() {
"app".to_string()
} else {
normalized.to_string()
}
}
fn normalize_connector_name(name: &str, connector_id: &str) -> String {
let trimmed = name.trim();
if trimmed.is_empty() {
connector_id.to_string()
} else {
trimmed.to_string()
}
}
fn normalize_connector_value(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
fn cache_key(id: &str) -> AllConnectorsCacheKey {
AllConnectorsCacheKey::new(
"https://chatgpt.example".to_string(),
Some(format!("account-{id}")),
Some(format!("user-{id}")),
true,
)
}
fn app(id: &str, name: &str) -> DirectoryApp {
DirectoryApp {
id: id.to_string(),
name: name.to_string(),
description: None,
app_metadata: None,
branding: None,
labels: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
visibility: None,
}
}
#[tokio::test]
async fn list_all_connectors_uses_shared_cache() -> anyhow::Result<()> {
let calls = Arc::new(AtomicUsize::new(0));
let call_counter = Arc::clone(&calls);
let key = cache_key("shared");
let first = list_all_connectors_with_options(key.clone(), false, false, move |_path| {
let call_counter = Arc::clone(&call_counter);
async move {
call_counter.fetch_add(1, Ordering::SeqCst);
Ok(DirectoryListResponse {
apps: vec![app("alpha", "Alpha")],
next_token: None,
})
}
})
.await?;
let second = list_all_connectors_with_options(key, false, false, move |_path| async move {
anyhow::bail!("cache should have been used");
})
.await?;
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert_eq!(first, second);
Ok(())
}
#[tokio::test]
async fn list_all_connectors_merges_and_normalizes_directory_apps() -> anyhow::Result<()> {
let key = cache_key("merged");
let calls = Arc::new(AtomicUsize::new(0));
let call_counter = Arc::clone(&calls);
let connectors = list_all_connectors_with_options(key, true, true, move |path| {
let call_counter = Arc::clone(&call_counter);
async move {
call_counter.fetch_add(1, Ordering::SeqCst);
if path.starts_with("/connectors/directory/list_workspace") {
Ok(DirectoryListResponse {
apps: vec![
DirectoryApp {
description: Some("Merged description".to_string()),
branding: Some(AppBranding {
category: Some("calendar".to_string()),
developer: None,
website: None,
privacy_policy: None,
terms_of_service: None,
is_discoverable_app: true,
}),
..app("alpha", "")
},
DirectoryApp {
visibility: Some("HIDDEN".to_string()),
..app("hidden", "Hidden")
},
],
next_token: None,
})
} else {
Ok(DirectoryListResponse {
apps: vec![app("alpha", " Alpha "), app("beta", "Beta")],
next_token: None,
})
}
}
})
.await?;
assert_eq!(calls.load(Ordering::SeqCst), 2);
assert_eq!(connectors.len(), 2);
assert_eq!(connectors[0].id, "alpha");
assert_eq!(connectors[0].name, "Alpha");
assert_eq!(
connectors[0].description.as_deref(),
Some("Merged description")
);
assert_eq!(
connectors[0].install_url.as_deref(),
Some("https://chatgpt.com/apps/alpha/alpha")
);
assert_eq!(
connectors[0]
.branding
.as_ref()
.and_then(|branding| branding.category.as_deref()),
Some("calendar")
);
assert_eq!(connectors[1].id, "beta");
assert_eq!(connectors[1].name, "Beta");
Ok(())
}
}

View file

@ -32,6 +32,7 @@ codex-app-server-protocol = { workspace = true }
codex-apply-patch = { workspace = true }
codex-async-utils = { workspace = true }
codex-client = { workspace = true }
codex-connectors = { workspace = true }
codex-config = { workspace = true }
codex-shell-command = { workspace = true }
codex-skills = { workspace = true }

View file

@ -471,6 +471,9 @@
"tool_call_mcp_elicitation": {
"type": "boolean"
},
"tool_suggest": {
"type": "boolean"
},
"undo": {
"type": "boolean"
},
@ -1973,6 +1976,9 @@
"tool_call_mcp_elicitation": {
"type": "boolean"
},
"tool_suggest": {
"type": "boolean"
},
"undo": {
"type": "boolean"
},

View file

@ -287,12 +287,14 @@ use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::ToolRouter;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::discoverable::DiscoverableTool;
use crate::tools::js_repl::JsReplHandle;
use crate::tools::js_repl::resolve_compatible_node;
use crate::tools::network_approval::NetworkApprovalService;
use crate::tools::network_approval::build_blocked_request_observer;
use crate::tools::network_approval::build_network_policy_decider;
use crate::tools::parallel::ToolCallRuntime;
use crate::tools::router::ToolRouterParams;
use crate::tools::sandboxing::ApprovalStore;
use crate::tools::spec::ToolsConfig;
use crate::tools::spec::ToolsConfigParams;
@ -6246,7 +6248,7 @@ async fn run_sampling_request(
}
}
async fn built_tools(
pub(crate) async fn built_tools(
sess: &Session,
turn_context: &TurnContext,
input: &[ResponseItem],
@ -6269,10 +6271,17 @@ async fn built_tools(
let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone();
effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await);
let connectors = if turn_context.apps_enabled() {
let apps_enabled = turn_context.apps_enabled();
let accessible_connectors =
apps_enabled.then(|| connectors::accessible_connectors_from_mcp_tools(&mcp_tools));
let accessible_connectors_with_enabled_state =
accessible_connectors.as_ref().map(|connectors| {
connectors::with_app_enabled_state(connectors.clone(), &turn_context.config)
});
let connectors = if apps_enabled {
let connectors = connectors::merge_plugin_apps_with_accessible(
loaded_plugins.effective_apps(),
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
accessible_connectors.clone().unwrap_or_default(),
);
Some(connectors::with_app_enabled_state(
connectors,
@ -6281,6 +6290,34 @@ async fn built_tools(
} else {
None
};
let auth = sess.services.auth_manager.auth().await;
let discoverable_tools = if apps_enabled
&& turn_context.tools_config.search_tool
&& turn_context.tools_config.tool_suggest
{
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
match connectors::list_tool_suggest_discoverable_tools_with_auth(
&turn_context.config,
auth.as_ref(),
accessible_connectors.as_slice(),
)
.await
{
Ok(connectors) if connectors.is_empty() => None,
Ok(connectors) => {
Some(connectors.into_iter().map(DiscoverableTool::from).collect())
}
Err(err) => {
warn!("failed to load discoverable tool suggestions: {err:#}");
None
}
}
} else {
None
}
} else {
None
};
// Keep the connector-grouped app view around for the router even though
// app tools only become prompt-visible after explicit selection/discovery.
@ -6312,14 +6349,17 @@ async fn built_tools(
Ok(Arc::new(ToolRouter::from_config(
&turn_context.tools_config,
has_mcp_servers.then(|| {
mcp_tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect()
}),
app_tools,
turn_context.dynamic_tools.as_slice(),
ToolRouterParams {
mcp_tools: has_mcp_servers.then(|| {
mcp_tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect()
}),
app_tools,
discoverable_tools,
dynamic_tools: turn_context.dynamic_tools.as_slice(),
},
)))
}

View file

@ -165,9 +165,12 @@ fn default_image_save_developer_message_text() -> String {
fn test_tool_runtime(session: Arc<Session>, turn_context: Arc<TurnContext>) -> ToolCallRuntime {
let router = Arc::new(ToolRouter::from_config(
&turn_context.tools_config,
None,
None,
turn_context.dynamic_tools.as_slice(),
crate::tools::router::ToolRouterParams {
mcp_tools: None,
app_tools: None,
discoverable_tools: None,
dynamic_tools: turn_context.dynamic_tools.as_slice(),
},
));
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
ToolCallRuntime::new(router, session, turn_context, tracker)
@ -3954,14 +3957,17 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
let app_tools = Some(tools.clone());
let router = ToolRouter::from_config(
&turn_context.tools_config,
Some(
tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect(),
),
app_tools,
turn_context.dynamic_tools.as_slice(),
crate::tools::router::ToolRouterParams {
mcp_tools: Some(
tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect(),
),
app_tools,
discoverable_tools: None,
dynamic_tools: turn_context.dynamic_tools.as_slice(),
},
);
let item = ResponseItem::CustomToolCall {
id: None,

View file

@ -9,13 +9,17 @@ use std::sync::Mutex as StdMutex;
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use async_channel::unbounded;
pub use codex_app_server_protocol::AppBranding;
pub use codex_app_server_protocol::AppInfo;
pub use codex_app_server_protocol::AppMetadata;
use codex_connectors::AllConnectorsCacheKey;
use codex_connectors::DirectoryListResponse;
use codex_protocol::protocol::SandboxPolicy;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use tracing::warn;
use crate::AuthManager;
@ -24,6 +28,7 @@ use crate::SandboxState;
use crate::config::Config;
use crate::config::types::AppToolApproval;
use crate::config::types::AppsConfigToml;
use crate::default_client::create_client;
use crate::default_client::is_first_party_chat_originator;
use crate::default_client::originator;
use crate::features::Feature;
@ -38,8 +43,22 @@ use crate::plugins::AppConnectorId;
use crate::plugins::PluginsManager;
use crate::token_data::TokenData;
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
pub use codex_connectors::CONNECTORS_CACHE_TTL;
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
const TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS: &[&str] = &[
"connector_2128aebfecb84f64a069897515042a44",
"connector_68df038e0ba48191908c8434991bbac2",
"asdk_app_69a1d78e929881919bba0dbda1f6436d",
"connector_4964e3b22e3e427e9b4ae1acf2c1fa34",
"connector_9d7cfa34e6654a5f98d3387af34b2e1c",
"connector_6f1ec045b8fa4ced8738e32c7f74514b",
"connector_947e0d954944416db111db556030eea6",
"connector_5f3c8c41a1e54ad7a76272c89e2554fa",
"connector_686fad9b54914a35b75be6d06a0f6f31",
"connector_76869538009648d5b282a4bb21c3d157",
"connector_37316be7febe4224b3d31465bae4dbd7",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AppToolPolicy {
@ -90,6 +109,19 @@ pub async fn list_accessible_connectors_from_mcp_tools(
)
}
pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
config: &Config,
auth: Option<&CodexAuth>,
accessible_connectors: &[AppInfo],
) -> anyhow::Result<Vec<AppInfo>> {
let directory_connectors =
list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?;
Ok(filter_tool_suggest_discoverable_tools(
directory_connectors,
accessible_connectors,
))
}
pub async fn list_cached_accessible_connectors_from_mcp_tools(
config: &Config,
) -> Option<Vec<AppInfo>> {
@ -102,6 +134,21 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools(
read_cached_accessible_connectors(&cache_key).map(filter_disallowed_connectors)
}
pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools(
config: &Config,
auth: Option<&CodexAuth>,
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
) {
if !config.features.enabled(Feature::Apps) {
return;
}
let cache_key = accessible_connectors_cache_key(config, auth);
let accessible_connectors =
filter_disallowed_connectors(accessible_connectors_from_mcp_tools(mcp_tools));
write_cached_accessible_connectors(cache_key, &accessible_connectors);
}
pub async fn list_accessible_connectors_from_mcp_tools_with_options(
config: &Config,
force_refetch: bool,
@ -172,19 +219,33 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
)
.await;
if force_refetch
&& let Err(err) = mcp_connection_manager
let refreshed_tools = if force_refetch {
match mcp_connection_manager
.hard_refresh_codex_apps_tools_cache()
.await
{
warn!(
"failed to force-refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}', using cached/startup tools: {err:#}"
);
}
{
Ok(tools) => Some(tools),
Err(err) => {
warn!(
"failed to force-refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}', using cached/startup tools: {err:#}"
);
None
}
}
} else {
None
};
let refreshed_tools_succeeded = refreshed_tools.is_some();
let mut tools = mcp_connection_manager.list_all_tools().await;
let mut tools = if let Some(tools) = refreshed_tools {
tools
} else {
mcp_connection_manager.list_all_tools().await
};
let mut should_reload_tools = false;
let codex_apps_ready = if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) {
let codex_apps_ready = if refreshed_tools_succeeded {
true
} else if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) {
let immediate_ready = mcp_connection_manager
.wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, Duration::ZERO)
.await;
@ -281,6 +342,119 @@ fn write_cached_accessible_connectors(
});
}
fn filter_tool_suggest_discoverable_tools(
directory_connectors: Vec<AppInfo>,
accessible_connectors: &[AppInfo],
) -> Vec<AppInfo> {
let accessible_connector_ids: HashSet<&str> = accessible_connectors
.iter()
.filter(|connector| connector.is_accessible && connector.is_enabled)
.map(|connector| connector.id.as_str())
.collect();
let allowed_connector_ids: HashSet<&str> = TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS
.iter()
.copied()
.collect();
let mut connectors = filter_disallowed_connectors(directory_connectors)
.into_iter()
.filter(|connector| !accessible_connector_ids.contains(connector.id.as_str()))
.filter(|connector| allowed_connector_ids.contains(connector.id.as_str()))
.collect::<Vec<_>>();
connectors.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
connectors
}
async fn list_directory_connectors_for_tool_suggest_with_auth(
config: &Config,
auth: Option<&CodexAuth>,
) -> anyhow::Result<Vec<AppInfo>> {
if !config.features.enabled(Feature::Apps) {
return Ok(Vec::new());
}
let token_data = if let Some(auth) = auth {
auth.get_token_data().ok()
} else {
let auth_manager = auth_manager_from_config(config);
auth_manager
.auth()
.await
.and_then(|auth| auth.get_token_data().ok())
};
let Some(token_data) = token_data else {
return Ok(Vec::new());
};
let account_id = match token_data.account_id.as_deref() {
Some(account_id) if !account_id.is_empty() => account_id,
_ => return Ok(Vec::new()),
};
let access_token = token_data.access_token.clone();
let account_id = account_id.to_string();
let is_workspace_account = token_data.id_token.is_workspace_account();
let cache_key = AllConnectorsCacheKey::new(
config.chatgpt_base_url.clone(),
Some(account_id.clone()),
token_data.id_token.chatgpt_user_id.clone(),
is_workspace_account,
);
codex_connectors::list_all_connectors_with_options(
cache_key,
is_workspace_account,
false,
|path| {
let access_token = access_token.clone();
let account_id = account_id.clone();
async move {
chatgpt_get_request_with_token::<DirectoryListResponse>(
config,
path,
access_token.as_str(),
account_id.as_str(),
)
.await
}
},
)
.await
}
async fn chatgpt_get_request_with_token<T: DeserializeOwned>(
config: &Config,
path: String,
access_token: &str,
account_id: &str,
) -> anyhow::Result<T> {
let client = create_client();
let url = format!("{}{}", config.chatgpt_base_url, path);
let response = client
.get(&url)
.bearer_auth(access_token)
.header("chatgpt-account-id", account_id)
.header("Content-Type", "application/json")
.timeout(DIRECTORY_CONNECTORS_TIMEOUT)
.send()
.await
.context("failed to send request")?;
if response.status().is_success() {
response
.json()
.await
.context("failed to parse JSON response")
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!("request failed with status {status}: {body}");
}
}
fn auth_manager_from_config(config: &Config) -> std::sync::Arc<AuthManager> {
AuthManager::shared(
config.codex_home.clone(),
@ -719,10 +893,12 @@ fn format_connector_label(name: &str, _id: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ConfigBuilder;
use crate::config::types::AppConfig;
use crate::config::types::AppToolConfig;
use crate::config::types::AppToolsConfig;
use crate::config::types::AppsDefaultConfig;
use crate::features::Feature;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp_connection_manager::ToolInfo;
use pretty_assertions::assert_eq;
@ -730,6 +906,7 @@ mod tests {
use rmcp::model::Tool;
use std::collections::HashMap;
use std::sync::Arc;
use tempfile::tempdir;
fn annotations(
destructive_hint: Option<bool>,
@ -762,6 +939,15 @@ mod tests {
}
}
fn named_app(id: &str, name: &str) -> AppInfo {
AppInfo {
id: id.to_string(),
name: name.to_string(),
install_url: Some(connector_install_url(name, id)),
..app(id)
}
}
fn plugin_names(names: &[&str]) -> Vec<String> {
names.iter().map(ToString::to_string).collect()
}
@ -821,6 +1007,21 @@ mod tests {
}
}
fn with_accessible_connectors_cache_cleared<R>(f: impl FnOnce() -> R) -> R {
let previous = {
let mut cache_guard = ACCESSIBLE_CONNECTORS_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
cache_guard.take()
};
let result = f();
let mut cache_guard = ACCESSIBLE_CONNECTORS_CACHE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*cache_guard = previous;
result
}
#[test]
fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() {
let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string()));
@ -907,6 +1108,62 @@ mod tests {
);
}
#[tokio::test]
async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_installed_apps() {
let codex_home = tempdir().expect("tempdir should succeed");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config should load");
let _ = config.features.set_enabled(Feature::Apps, true);
let cache_key = accessible_connectors_cache_key(&config, None);
let tools = HashMap::from([
(
"mcp__codex_apps__calendar_list_events".to_string(),
codex_app_tool(
"calendar_list_events",
"calendar",
Some("Google Calendar"),
&["calendar-plugin"],
),
),
(
"mcp__codex_apps__openai_hidden".to_string(),
codex_app_tool(
"openai_hidden",
"connector_openai_hidden",
Some("Hidden"),
&[],
),
),
]);
let cached = with_accessible_connectors_cache_cleared(|| {
refresh_accessible_connectors_cache_from_mcp_tools(&config, None, &tools);
read_cached_accessible_connectors(&cache_key).expect("cache should be populated")
});
assert_eq!(
cached,
vec![AppInfo {
id: "calendar".to_string(),
name: "Google Calendar".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some(connector_install_url("Google Calendar", "calendar")),
branding: None,
app_metadata: None,
labels: None,
is_accessible: true,
is_enabled: true,
plugin_display_names: plugin_names(&["calendar-plugin"]),
}]
);
}
#[test]
fn merge_connectors_unions_and_dedupes_plugin_display_names() {
let mut plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string()));
@ -1344,4 +1601,68 @@ mod tests {
vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c")]
);
}
#[test]
fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_apps() {
let filtered = filter_tool_suggest_discoverable_tools(
vec![
named_app(
"connector_2128aebfecb84f64a069897515042a44",
"Google Calendar",
),
named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"),
named_app("connector_other", "Other"),
],
&[AppInfo {
is_accessible: true,
..named_app(
"connector_2128aebfecb84f64a069897515042a44",
"Google Calendar",
)
}],
);
assert_eq!(
filtered,
vec![named_app(
"connector_68df038e0ba48191908c8434991bbac2",
"Gmail",
)]
);
}
#[test]
fn filter_tool_suggest_discoverable_tools_keeps_disabled_accessible_apps() {
let filtered = filter_tool_suggest_discoverable_tools(
vec![
named_app(
"connector_2128aebfecb84f64a069897515042a44",
"Google Calendar",
),
named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"),
],
&[
AppInfo {
is_accessible: true,
..named_app(
"connector_2128aebfecb84f64a069897515042a44",
"Google Calendar",
)
},
AppInfo {
is_accessible: true,
is_enabled: false,
..named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail")
},
],
);
assert_eq!(
filtered,
vec![named_app(
"connector_68df038e0ba48191908c8434991bbac2",
"Gmail"
)]
);
}
}

View file

@ -142,6 +142,8 @@ pub enum Feature {
SpawnCsv,
/// Enable apps.
Apps,
/// Enable discoverable tool suggestions for apps.
ToolSuggest,
/// Enable plugins.
Plugins,
/// Allow the model to invoke the built-in image generation tool.
@ -714,6 +716,12 @@ pub const FEATURES: &[FeatureSpec] = &[
},
default_enabled: false,
},
FeatureSpec {
id: Feature::ToolSuggest,
key: "tool_suggest",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::Plugins,
key: "plugins",
@ -996,6 +1004,12 @@ mod tests {
assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false);
}
#[test]
fn tool_suggest_is_under_development() {
assert_eq!(Feature::ToolSuggest.stage(), Stage::UnderDevelopment);
assert_eq!(Feature::ToolSuggest.default_enabled(), false);
}
#[test]
fn image_generation_is_under_development() {
assert_eq!(Feature::ImageGeneration.stage(), Stage::UnderDevelopment);

View file

@ -827,9 +827,10 @@ impl McpConnectionManager {
/// Force-refresh codex apps tools by bypassing the in-process cache.
///
/// On success, the refreshed tools replace the cache contents. On failure,
/// the existing cache remains unchanged.
pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<()> {
/// On success, the refreshed tools replace the cache contents and the
/// latest filtered tool map is returned directly to the caller. On
/// failure, the existing cache remains unchanged.
pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<HashMap<String, ToolInfo>> {
let managed_client = self
.clients
.get(CODEX_APPS_MCP_SERVER_NAME)
@ -865,7 +866,10 @@ impl McpConnectionManager {
list_start.elapsed(),
&[("cache", "miss")],
);
Ok(())
Ok(qualify_tools(filter_tools(
tools,
&managed_client.tool_filter,
)))
}
/// Returns a single map that contains all resources. Each key is the

View file

@ -18,6 +18,7 @@ use crate::tools::context::ToolPayload;
use crate::tools::js_repl::resolve_compatible_node;
use crate::tools::router::ToolCall;
use crate::tools::router::ToolCallSource;
use crate::tools::router::ToolRouterParams;
use crate::truncate::TruncationPolicy;
use crate::truncate::formatted_truncate_text_content_items_with_policy;
use crate::truncate::truncate_function_output_items_with_policy;
@ -408,9 +409,12 @@ async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
ToolRouter::from_config(
&nested_tools_config,
Some(mcp_tools),
None,
exec.turn.dynamic_tools.as_slice(),
ToolRouterParams {
mcp_tools: Some(mcp_tools),
app_tools: None,
discoverable_tools: None,
dynamic_tools: exec.turn.dynamic_tools.as_slice(),
},
)
}

View file

@ -0,0 +1,111 @@
use crate::plugins::PluginCapabilitySummary;
use codex_app_server_protocol::AppInfo;
use serde::Deserialize;
use serde::Serialize;
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum DiscoverableToolType {
Connector,
Plugin,
}
impl DiscoverableToolType {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Connector => "connector",
Self::Plugin => "plugin",
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum DiscoverableToolAction {
Install,
Enable,
}
impl DiscoverableToolAction {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Install => "install",
Self::Enable => "enable",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum DiscoverableTool {
Connector(Box<AppInfo>),
Plugin(Box<DiscoverablePluginInfo>),
}
impl DiscoverableTool {
pub(crate) fn tool_type(&self) -> DiscoverableToolType {
match self {
Self::Connector(_) => DiscoverableToolType::Connector,
Self::Plugin(_) => DiscoverableToolType::Plugin,
}
}
pub(crate) fn id(&self) -> &str {
match self {
Self::Connector(connector) => connector.id.as_str(),
Self::Plugin(plugin) => plugin.id.as_str(),
}
}
pub(crate) fn name(&self) -> &str {
match self {
Self::Connector(connector) => connector.name.as_str(),
Self::Plugin(plugin) => plugin.name.as_str(),
}
}
pub(crate) fn description(&self) -> Option<&str> {
match self {
Self::Connector(connector) => connector.description.as_deref(),
Self::Plugin(plugin) => plugin.description.as_deref(),
}
}
}
impl From<AppInfo> for DiscoverableTool {
fn from(value: AppInfo) -> Self {
Self::Connector(Box::new(value))
}
}
impl From<DiscoverablePluginInfo> for DiscoverableTool {
fn from(value: DiscoverablePluginInfo) -> Self {
Self::Plugin(Box::new(value))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DiscoverablePluginInfo {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) description: Option<String>,
pub(crate) has_skills: bool,
pub(crate) mcp_server_names: Vec<String>,
pub(crate) app_connector_ids: Vec<String>,
}
impl From<PluginCapabilitySummary> for DiscoverablePluginInfo {
fn from(value: PluginCapabilitySummary) -> Self {
Self {
id: value.config_name,
name: value.display_name,
description: value.description,
has_skills: value.has_skills,
mcp_server_names: value.mcp_server_names,
app_connector_ids: value
.app_connector_ids
.into_iter()
.map(|connector_id| connector_id.0)
.collect(),
}
}
}

View file

@ -16,6 +16,7 @@ mod request_user_input;
mod shell;
mod test_sync;
mod tool_search;
mod tool_suggest;
pub(crate) mod unified_exec;
mod view_image;
@ -56,6 +57,8 @@ pub use test_sync::TestSyncHandler;
pub(crate) use tool_search::DEFAULT_LIMIT as TOOL_SEARCH_DEFAULT_LIMIT;
pub(crate) use tool_search::TOOL_SEARCH_TOOL_NAME;
pub use tool_search::ToolSearchHandler;
pub(crate) use tool_suggest::TOOL_SUGGEST_TOOL_NAME;
pub use tool_suggest::ToolSuggestHandler;
pub use unified_exec::UnifiedExecHandler;
pub use view_image::ViewImageHandler;

View file

@ -0,0 +1,465 @@
use std::collections::BTreeMap;
use std::collections::HashSet;
use async_trait::async_trait;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::McpElicitationObjectType;
use codex_app_server_protocol::McpElicitationSchema;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_rmcp_client::ElicitationAction;
use rmcp::model::RequestId;
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use tracing::warn;
use crate::connectors;
use crate::function_tool::FunctionCallError;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::discoverable::DiscoverableTool;
use crate::tools::discoverable::DiscoverableToolAction;
use crate::tools::discoverable::DiscoverableToolType;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
pub struct ToolSuggestHandler;
pub(crate) const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest";
const TOOL_SUGGEST_APPROVAL_KIND_VALUE: &str = "tool_suggestion";
#[derive(Debug, Deserialize)]
struct ToolSuggestArgs {
tool_type: DiscoverableToolType,
action_type: DiscoverableToolAction,
tool_id: String,
suggest_reason: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
struct ToolSuggestResult {
completed: bool,
user_confirmed: bool,
tool_type: DiscoverableToolType,
action_type: DiscoverableToolAction,
tool_id: String,
tool_name: String,
suggest_reason: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
struct ToolSuggestMeta<'a> {
codex_approval_kind: &'static str,
tool_type: DiscoverableToolType,
suggest_type: DiscoverableToolAction,
suggest_reason: &'a str,
tool_id: &'a str,
tool_name: &'a str,
install_url: &'a str,
}
#[async_trait]
impl ToolHandler for ToolSuggestHandler {
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
payload,
session,
turn,
call_id,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::Fatal(format!(
"{TOOL_SUGGEST_TOOL_NAME} handler received unsupported payload"
)));
}
};
let args: ToolSuggestArgs = parse_arguments(&arguments)?;
let suggest_reason = args.suggest_reason.trim();
if suggest_reason.is_empty() {
return Err(FunctionCallError::RespondToModel(
"suggest_reason must not be empty".to_string(),
));
}
if args.tool_type == DiscoverableToolType::Plugin {
return Err(FunctionCallError::RespondToModel(
"plugin tool suggestions are not currently available".to_string(),
));
}
if args.action_type != DiscoverableToolAction::Install {
return Err(FunctionCallError::RespondToModel(
"connector tool suggestions currently support only action_type=\"install\""
.to_string(),
));
}
let auth = session.services.auth_manager.auth().await;
let manager = session.services.mcp_connection_manager.read().await;
let mcp_tools = manager.list_all_tools().await;
drop(manager);
let accessible_connectors = connectors::with_app_enabled_state(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
&turn.config,
);
let discoverable_tools = connectors::list_tool_suggest_discoverable_tools_with_auth(
&turn.config,
auth.as_ref(),
&accessible_connectors,
)
.await
.map(|connectors| {
connectors
.into_iter()
.map(DiscoverableTool::from)
.collect::<Vec<_>>()
})
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
"tool suggestions are unavailable right now: {err}"
))
})?;
let connector = discoverable_tools
.into_iter()
.find_map(|tool| match tool {
DiscoverableTool::Connector(connector) if connector.id == args.tool_id => {
Some(*connector)
}
DiscoverableTool::Connector(_) | DiscoverableTool::Plugin(_) => None,
})
.ok_or_else(|| {
FunctionCallError::RespondToModel(format!(
"tool_id must match one of the discoverable tools exposed by {TOOL_SUGGEST_TOOL_NAME}"
))
})?;
let request_id = RequestId::String(format!("tool_suggestion_{call_id}").into());
let params = build_tool_suggestion_elicitation_request(
session.conversation_id.to_string(),
turn.sub_id.clone(),
&args,
suggest_reason,
&connector,
);
let response = session
.request_mcp_server_elicitation(turn.as_ref(), request_id, params)
.await;
let user_confirmed = response
.as_ref()
.is_some_and(|response| response.action == ElicitationAction::Accept);
let completed = if user_confirmed {
let manager = session.services.mcp_connection_manager.read().await;
match manager.hard_refresh_codex_apps_tools_cache().await {
Ok(mcp_tools) => {
let accessible_connectors = connectors::with_app_enabled_state(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
&turn.config,
);
connectors::refresh_accessible_connectors_cache_from_mcp_tools(
&turn.config,
auth.as_ref(),
&mcp_tools,
);
verified_connector_suggestion_completed(
args.action_type,
connector.id.as_str(),
&accessible_connectors,
)
}
Err(err) => {
warn!(
"failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}",
connector.id
);
false
}
}
} else {
false
};
if completed {
session
.merge_connector_selection(HashSet::from([connector.id.clone()]))
.await;
}
let content = serde_json::to_string(&ToolSuggestResult {
completed,
user_confirmed,
tool_type: args.tool_type,
action_type: args.action_type,
tool_id: connector.id,
tool_name: connector.name,
suggest_reason: suggest_reason.to_string(),
})
.map_err(|err| {
FunctionCallError::Fatal(format!(
"failed to serialize {TOOL_SUGGEST_TOOL_NAME} response: {err}"
))
})?;
Ok(FunctionToolOutput::from_text(content, Some(true)))
}
}
fn build_tool_suggestion_elicitation_request(
thread_id: String,
turn_id: String,
args: &ToolSuggestArgs,
suggest_reason: &str,
connector: &AppInfo,
) -> McpServerElicitationRequestParams {
let tool_name = connector.name.clone();
let install_url = connector
.install_url
.clone()
.unwrap_or_else(|| connectors::connector_install_url(&tool_name, &connector.id));
let message = format!(
"{tool_name} could help with this request.\n\n{suggest_reason}\n\nOpen ChatGPT to {} it, then confirm here if you finish.",
args.action_type.as_str()
);
McpServerElicitationRequestParams {
thread_id,
turn_id: Some(turn_id),
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(json!(build_tool_suggestion_meta(
args.tool_type,
args.action_type,
suggest_reason,
connector.id.as_str(),
tool_name.as_str(),
install_url.as_str(),
))),
message,
requested_schema: McpElicitationSchema {
schema_uri: None,
type_: McpElicitationObjectType::Object,
properties: BTreeMap::new(),
required: None,
},
},
}
}
fn build_tool_suggestion_meta<'a>(
tool_type: DiscoverableToolType,
action_type: DiscoverableToolAction,
suggest_reason: &'a str,
tool_id: &'a str,
tool_name: &'a str,
install_url: &'a str,
) -> ToolSuggestMeta<'a> {
ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
tool_type,
suggest_type: action_type,
suggest_reason,
tool_id,
tool_name,
install_url,
}
}
fn verified_connector_suggestion_completed(
action_type: DiscoverableToolAction,
tool_id: &str,
accessible_connectors: &[AppInfo],
) -> bool {
accessible_connectors
.iter()
.find(|connector| connector.id == tool_id)
.is_some_and(|connector| match action_type {
DiscoverableToolAction::Install => connector.is_accessible,
DiscoverableToolAction::Enable => connector.is_accessible && connector.is_enabled,
})
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
let args = ToolSuggestArgs {
tool_type: DiscoverableToolType::Connector,
action_type: DiscoverableToolAction::Install,
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
suggest_reason: "Plan and reference events from your calendar".to_string(),
};
let connector = AppInfo {
id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
name: "Google Calendar".to_string(),
description: Some("Plan events and schedules.".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some(
"https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44"
.to_string(),
),
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
};
let request = build_tool_suggestion_elicitation_request(
"thread-1".to_string(),
"turn-1".to_string(),
&args,
"Plan and reference events from your calendar",
&connector,
);
assert_eq!(
request,
McpServerElicitationRequestParams {
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(json!(ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
tool_type: DiscoverableToolType::Connector,
suggest_type: DiscoverableToolAction::Install,
suggest_reason: "Plan and reference events from your calendar",
tool_id: "connector_2128aebfecb84f64a069897515042a44",
tool_name: "Google Calendar",
install_url: "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44",
})),
message: "Google Calendar could help with this request.\n\nPlan and reference events from your calendar\n\nOpen ChatGPT to install it, then confirm here if you finish.".to_string(),
requested_schema: McpElicitationSchema {
schema_uri: None,
type_: McpElicitationObjectType::Object,
properties: BTreeMap::new(),
required: None,
},
},
}
);
}
#[test]
fn build_tool_suggestion_meta_uses_expected_shape() {
let meta = build_tool_suggestion_meta(
DiscoverableToolType::Connector,
DiscoverableToolAction::Install,
"Find and reference emails from your inbox",
"connector_68df038e0ba48191908c8434991bbac2",
"Gmail",
"https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2",
);
assert_eq!(
meta,
ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
tool_type: DiscoverableToolType::Connector,
suggest_type: DiscoverableToolAction::Install,
suggest_reason: "Find and reference emails from your inbox",
tool_id: "connector_68df038e0ba48191908c8434991bbac2",
tool_name: "Gmail",
install_url: "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2",
}
);
}
#[test]
fn verified_connector_suggestion_completed_requires_installed_connector() {
let accessible_connectors = vec![AppInfo {
id: "calendar".to_string(),
name: "Google Calendar".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
assert!(verified_connector_suggestion_completed(
DiscoverableToolAction::Install,
"calendar",
&accessible_connectors,
));
assert!(!verified_connector_suggestion_completed(
DiscoverableToolAction::Install,
"gmail",
&accessible_connectors,
));
}
#[test]
fn verified_connector_suggestion_completed_requires_enabled_connector_for_enable() {
let accessible_connectors = vec![
AppInfo {
id: "calendar".to_string(),
name: "Google Calendar".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: true,
is_enabled: false,
plugin_display_names: Vec::new(),
},
AppInfo {
id: "gmail".to_string(),
name: "Gmail".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
},
];
assert!(!verified_connector_suggestion_completed(
DiscoverableToolAction::Enable,
"calendar",
&accessible_connectors,
));
assert!(verified_connector_suggestion_completed(
DiscoverableToolAction::Enable,
"gmail",
&accessible_connectors,
));
}
}

View file

@ -1360,14 +1360,17 @@ impl JsReplManager {
let router = ToolRouter::from_config(
&exec.turn.tools_config,
Some(
mcp_tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect(),
),
None,
exec.turn.dynamic_tools.as_slice(),
crate::tools::router::ToolRouterParams {
mcp_tools: Some(
mcp_tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect(),
),
app_tools: None,
discoverable_tools: None,
dynamic_tools: exec.turn.dynamic_tools.as_slice(),
},
);
let payload = if let Some((server, tool)) = exec

View file

@ -1,6 +1,7 @@
pub mod code_mode;
pub(crate) mod code_mode_description;
pub mod context;
pub(crate) mod discoverable;
pub mod events;
pub(crate) mod handlers;
pub mod js_repl;

View file

@ -9,11 +9,12 @@ use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::ToolSearchOutput;
use crate::tools::discoverable::DiscoverableTool;
use crate::tools::registry::AnyToolResult;
use crate::tools::registry::ConfiguredToolSpec;
use crate::tools::registry::ToolRegistry;
use crate::tools::spec::ToolsConfig;
use crate::tools::spec::build_specs;
use crate::tools::spec::build_specs_with_discoverable_tools;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::LocalShellAction;
use codex_protocol::models::ResponseInputItem;
@ -40,14 +41,28 @@ pub struct ToolRouter {
specs: Vec<ConfiguredToolSpec>,
}
pub(crate) struct ToolRouterParams<'a> {
pub(crate) mcp_tools: Option<HashMap<String, Tool>>,
pub(crate) app_tools: Option<HashMap<String, ToolInfo>>,
pub(crate) discoverable_tools: Option<Vec<DiscoverableTool>>,
pub(crate) dynamic_tools: &'a [DynamicToolSpec],
}
impl ToolRouter {
pub fn from_config(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, Tool>>,
app_tools: Option<HashMap<String, ToolInfo>>,
dynamic_tools: &[DynamicToolSpec],
) -> Self {
let builder = build_specs(config, mcp_tools, app_tools, dynamic_tools);
pub fn from_config(config: &ToolsConfig, params: ToolRouterParams<'_>) -> Self {
let ToolRouterParams {
mcp_tools,
app_tools,
discoverable_tools,
dynamic_tools,
} = params;
let builder = build_specs_with_discoverable_tools(
config,
mcp_tools,
app_tools,
discoverable_tools,
dynamic_tools,
);
let (specs, registry) = builder.build();
Self { registry, specs }
@ -287,6 +302,7 @@ mod tests {
use super::ToolCall;
use super::ToolCallSource;
use super::ToolRouter;
use super::ToolRouterParams;
#[tokio::test]
async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> {
@ -305,14 +321,17 @@ mod tests {
let app_tools = Some(mcp_tools.clone());
let router = ToolRouter::from_config(
&turn.tools_config,
Some(
mcp_tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect(),
),
app_tools,
turn.dynamic_tools.as_slice(),
ToolRouterParams {
mcp_tools: Some(
mcp_tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect(),
),
app_tools,
discoverable_tools: None,
dynamic_tools: turn.dynamic_tools.as_slice(),
},
);
let call = ToolCall {
@ -359,14 +378,17 @@ mod tests {
let app_tools = Some(mcp_tools.clone());
let router = ToolRouter::from_config(
&turn.tools_config,
Some(
mcp_tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect(),
),
app_tools,
turn.dynamic_tools.as_slice(),
ToolRouterParams {
mcp_tools: Some(
mcp_tools
.into_iter()
.map(|(name, tool)| (name, tool.tool))
.collect(),
),
app_tools,
discoverable_tools: None,
dynamic_tools: turn.dynamic_tools.as_slice(),
},
);
let call = ToolCall {

View file

@ -10,9 +10,14 @@ use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use crate::original_image_detail::can_request_original_image_detail;
use crate::tools::code_mode::PUBLIC_TOOL_NAME;
use crate::tools::code_mode_description::augment_tool_spec_for_code_mode;
use crate::tools::discoverable::DiscoverablePluginInfo;
use crate::tools::discoverable::DiscoverableTool;
use crate::tools::discoverable::DiscoverableToolAction;
use crate::tools::discoverable::DiscoverableToolType;
use crate::tools::handlers::PLAN_TOOL;
use crate::tools::handlers::TOOL_SEARCH_DEFAULT_LIMIT;
use crate::tools::handlers::TOOL_SEARCH_TOOL_NAME;
use crate::tools::handlers::TOOL_SUGGEST_TOOL_NAME;
use crate::tools::handlers::agent_jobs::BatchJobHandler;
use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool;
use crate::tools::handlers::apply_patch::create_apply_patch_json_tool;
@ -44,6 +49,8 @@ use std::collections::HashMap;
const TOOL_SEARCH_DESCRIPTION_TEMPLATE: &str =
include_str!("../../templates/search_tool/tool_description.md");
const TOOL_SUGGEST_DESCRIPTION_TEMPLATE: &str =
include_str!("../../templates/search_tool/tool_suggest_description.md");
const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"];
fn unified_exec_output_schema() -> JsonValue {
@ -105,6 +112,7 @@ pub(crate) struct ToolsConfig {
pub image_gen_tool: bool,
pub agent_roles: BTreeMap<String, AgentRoleConfig>,
pub search_tool: bool,
pub tool_suggest: bool,
pub request_permission_enabled: bool,
pub request_permissions_tool_enabled: bool,
pub code_mode_enabled: bool,
@ -148,6 +156,7 @@ impl ToolsConfig {
let include_default_mode_request_user_input =
include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput);
let include_search_tool = features.enabled(Feature::Apps);
let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest);
let include_original_image_detail = can_request_original_image_detail(features, model_info);
let include_artifact_tools =
features.enabled(Feature::Artifact) && codex_artifacts::can_manage_artifact_runtime();
@ -215,6 +224,7 @@ impl ToolsConfig {
image_gen_tool: include_image_gen_tool,
agent_roles: BTreeMap::new(),
search_tool: include_search_tool,
tool_suggest: include_tool_suggest,
request_permission_enabled,
request_permissions_tool_enabled,
code_mode_enabled: include_code_mode,
@ -1451,6 +1461,133 @@ fn create_tool_search_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSpec {
}
}
fn create_tool_suggest_tool(discoverable_tools: &[DiscoverableTool]) -> ToolSpec {
let discoverable_tool_ids = discoverable_tools
.iter()
.map(DiscoverableTool::id)
.collect::<Vec<_>>()
.join(", ");
let properties = BTreeMap::from([
(
"tool_type".to_string(),
JsonSchema::String {
description: Some(
"Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"."
.to_string(),
),
},
),
(
"action_type".to_string(),
JsonSchema::String {
description: Some(
"Suggested action for the tool. Use \"install\" or \"enable\".".to_string(),
),
},
),
(
"tool_id".to_string(),
JsonSchema::String {
description: Some(format!(
"Connector or plugin id to suggest. Must be one of: {discoverable_tool_ids}."
)),
},
),
(
"suggest_reason".to_string(),
JsonSchema::String {
description: Some(
"Concise one-line user-facing reason why this tool can help with the current request."
.to_string(),
),
},
),
]);
let description = TOOL_SUGGEST_DESCRIPTION_TEMPLATE.replace(
"{{discoverable_tools}}",
format_discoverable_tools(discoverable_tools).as_str(),
);
ToolSpec::Function(ResponsesApiTool {
name: TOOL_SUGGEST_TOOL_NAME.to_string(),
description,
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec![
"tool_type".to_string(),
"action_type".to_string(),
"tool_id".to_string(),
"suggest_reason".to_string(),
]),
additional_properties: Some(false.into()),
},
output_schema: None,
})
}
fn format_discoverable_tools(discoverable_tools: &[DiscoverableTool]) -> String {
let mut discoverable_tools = discoverable_tools.to_vec();
discoverable_tools.sort_by(|left, right| {
left.name()
.cmp(right.name())
.then_with(|| left.id().cmp(right.id()))
});
discoverable_tools
.into_iter()
.map(|tool| {
let description = tool
.description()
.filter(|description| !description.trim().is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| match &tool {
DiscoverableTool::Connector(_) => "No description provided.".to_string(),
DiscoverableTool::Plugin(plugin) => format_plugin_summary(plugin.as_ref()),
});
let default_action = match tool.tool_type() {
DiscoverableToolType::Connector => DiscoverableToolAction::Install,
DiscoverableToolType::Plugin => DiscoverableToolAction::Enable,
};
format!(
"- {} (id: `{}`, type: {}, action: {}): {}",
tool.name(),
tool.id(),
tool.tool_type().as_str(),
default_action.as_str(),
description
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn format_plugin_summary(plugin: &DiscoverablePluginInfo) -> String {
let mut details = Vec::new();
if plugin.has_skills {
details.push("skills".to_string());
}
if !plugin.mcp_server_names.is_empty() {
details.push(format!(
"MCP servers: {}",
plugin.mcp_server_names.join(", ")
));
}
if !plugin.app_connector_ids.is_empty() {
details.push(format!(
"app connectors: {}",
plugin.app_connector_ids.join(", ")
));
}
if details.is_empty() {
"No description provided.".to_string()
} else {
details.join("; ")
}
}
fn create_read_file_tool() -> ToolSpec {
let indentation_properties = BTreeMap::from([
(
@ -2083,11 +2220,22 @@ fn sanitize_json_schema(value: &mut JsonValue) {
}
/// Builds the tool registry builder while collecting tool specs for later serialization.
#[cfg(test)]
pub(crate) fn build_specs(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, rmcp::model::Tool>>,
app_tools: Option<HashMap<String, ToolInfo>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
build_specs_with_discoverable_tools(config, mcp_tools, app_tools, None, dynamic_tools)
}
pub(crate) fn build_specs_with_discoverable_tools(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, rmcp::model::Tool>>,
app_tools: Option<HashMap<String, ToolInfo>>,
discoverable_tools: Option<Vec<DiscoverableTool>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
use crate::tools::handlers::ApplyPatchHandler;
use crate::tools::handlers::ArtifactsHandler;
@ -2108,6 +2256,7 @@ pub(crate) fn build_specs(
use crate::tools::handlers::ShellHandler;
use crate::tools::handlers::TestSyncHandler;
use crate::tools::handlers::ToolSearchHandler;
use crate::tools::handlers::ToolSuggestHandler;
use crate::tools::handlers::UnifiedExecHandler;
use crate::tools::handlers::ViewImageHandler;
use std::sync::Arc;
@ -2127,6 +2276,7 @@ pub(crate) fn build_specs(
let request_user_input_handler = Arc::new(RequestUserInputHandler {
default_mode_request_user_input: config.default_mode_request_user_input,
});
let tool_suggest_handler = Arc::new(ToolSuggestHandler);
let code_mode_handler = Arc::new(CodeModeHandler);
let js_repl_handler = Arc::new(JsReplHandler);
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
@ -2135,10 +2285,11 @@ pub(crate) fn build_specs(
if config.code_mode_enabled {
let nested_config = config.for_code_mode_nested_tools();
let (nested_specs, _) = build_specs(
let (nested_specs, _) = build_specs_with_discoverable_tools(
&nested_config,
mcp_tools.clone(),
app_tools.clone(),
None,
dynamic_tools,
)
.build();
@ -2304,6 +2455,15 @@ pub(crate) fn build_specs(
}
}
if config.tool_suggest
&& let Some(discoverable_tools) = discoverable_tools
.as_ref()
.filter(|tools| !tools.is_empty())
{
builder.push_spec_with_parallel_support(create_tool_suggest_tool(discoverable_tools), true);
builder.register_handler(TOOL_SUGGEST_TOOL_NAME, tool_suggest_handler);
}
if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type {
match apply_patch_tool_type {
ApplyPatchToolType::Freeform => {
@ -2565,6 +2725,7 @@ mod tests {
use crate::models_manager::manager::ModelsManager;
use crate::models_manager::model_info::with_config_overrides;
use crate::tools::registry::ConfiguredToolSpec;
use codex_app_server_protocol::AppInfo;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelsResponse;
@ -2590,6 +2751,25 @@ mod tests {
}
}
fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool {
let slug = name.replace(' ', "-").to_lowercase();
DiscoverableTool::Connector(Box::new(AppInfo {
id: id.to_string(),
name: name.to_string(),
description: Some(description.to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some(format!("https://chatgpt.com/apps/{slug}/{id}")),
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}))
}
#[test]
fn mcp_tool_to_openai_tool_inserts_empty_properties() {
let mut schema = rmcp::model::JsonObject::new();
@ -4147,7 +4327,6 @@ mod tests {
});
let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build();
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
let available_models = Vec::new();
@ -4162,6 +4341,41 @@ mod tests {
assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]);
}
#[test]
fn tool_suggest_is_not_registered_without_feature_flag() {
let config = test_config();
let model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs_with_discoverable_tools(
&tools_config,
None,
None,
Some(vec![discoverable_connector(
"connector_2128aebfecb84f64a069897515042a44",
"Google Calendar",
"Plan events and schedules.",
)]),
&[],
)
.build();
assert!(
!tools
.iter()
.any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME)
);
}
#[test]
fn search_tool_description_handles_no_enabled_apps() {
let config = test_config();
@ -4253,6 +4467,89 @@ mod tests {
assert!(registry.has_handler(alias.as_str(), None));
}
#[test]
fn tool_suggest_description_lists_discoverable_tools() {
let config = test_config();
let model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::Apps);
features.enable(Feature::ToolSuggest);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let discoverable_tools = vec![
discoverable_connector(
"connector_2128aebfecb84f64a069897515042a44",
"Google Calendar",
"Plan events and schedules.",
),
discoverable_connector(
"connector_68df038e0ba48191908c8434991bbac2",
"Gmail",
"Find and summarize email threads.",
),
DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
id: "sample@test".to_string(),
name: "Sample Plugin".to_string(),
description: None,
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_sample".to_string()],
})),
];
let (tools, _) = build_specs_with_discoverable_tools(
&tools_config,
None,
None,
Some(discoverable_tools),
&[],
)
.build();
let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME);
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
..
}) = &tool_suggest.spec
else {
panic!("expected function tool");
};
assert!(description.contains("Google Calendar"));
assert!(description.contains("Gmail"));
assert!(description.contains("Sample Plugin"));
assert!(description.contains("Plan events and schedules."));
assert!(description.contains("Find and summarize email threads."));
assert!(description.contains("id: `sample@test`, type: plugin, action: enable"));
assert!(
description
.contains("skills; MCP servers: sample-docs; app connectors: connector_sample")
);
assert!(
description.contains("DO NOT explore or recommend tools that are not on this list.")
);
let JsonSchema::Object { required, .. } = parameters else {
panic!("expected object parameters");
};
assert_eq!(
required.as_ref(),
Some(&vec![
"tool_type".to_string(),
"action_type".to_string(),
"tool_id".to_string(),
"suggest_reason".to_string(),
])
);
}
#[test]
fn test_mcp_tool_property_missing_type_defaults_to_string() {
let config = test_config();

View file

@ -0,0 +1,25 @@
# Tool suggestion discovery
Suggests a discoverable connector or plugin when the user clearly wants a capability that is not currently available in the active `tools` list.
Use this ONLY when:
- There's no available tool to handle the user's request
- And tool_search fails to find a good match
- AND the user's request strongly matches one of the discoverable tools listed below.
Tool suggestions should only use the discoverable tools listed here. DO NOT explore or recommend tools that are not on this list.
Discoverable tools:
{{discoverable_tools}}
Workflow:
1. Match the user's request against the discoverable tools list above.
2. If one tool clearly fits, call `tool_suggest` with:
- `tool_type`: `connector` or `plugin`
- `action_type`: `install` or `enable`
- `tool_id`: exact id from the discoverable tools list above
- `suggest_reason`: concise one-line user-facing reason this tool can help with the current request
3. After the suggestion flow completes:
- if the user finished the install or enable flow, continue by searching again or using the newly available tool
- if the user did not finish, continue without that tool, and don't suggest that tool again unless the user explicitly asks you to.

View file

@ -12,6 +12,8 @@ use wiremock::matchers::path_regex;
const CONNECTOR_ID: &str = "calendar";
const CONNECTOR_NAME: &str = "Calendar";
const DISCOVERABLE_CALENDAR_ID: &str = "connector_2128aebfecb84f64a069897515042a44";
const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2";
const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar.";
const PROTOCOL_VERSION: &str = "2025-11-25";
const SERVER_NAME: &str = "codex-apps-test";
@ -32,6 +34,7 @@ impl AppsTestServer {
connector_name: &str,
) -> Result<Self> {
mount_oauth_metadata(server).await;
mount_connectors_directory(server).await;
mount_streamable_http_json_rpc(
server,
connector_name.to_string(),
@ -56,6 +59,37 @@ async fn mount_oauth_metadata(server: &MockServer) {
.await;
}
async fn mount_connectors_directory(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/connectors/directory/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"apps": [
{
"id": DISCOVERABLE_CALENDAR_ID,
"name": "Google Calendar",
"description": "Plan events and schedules.",
},
{
"id": DISCOVERABLE_GMAIL_ID,
"name": "Gmail",
"description": "Find and summarize email threads.",
}
],
"nextToken": null
})))
.mount(server)
.await;
Mock::given(method("GET"))
.and(path("/connectors/directory/list_workspace"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"apps": [],
"nextToken": null
})))
.mount(server)
.await;
}
async fn mount_streamable_http_json_rpc(
server: &MockServer,
connector_name: String,

View file

@ -2422,6 +2422,9 @@ impl App {
url,
is_installed,
is_enabled,
suggest_reason: None,
suggestion_type: None,
elicitation_target: None,
});
}
AppEvent::OpenUrlInBrowser { url } => {

View file

@ -1,3 +1,7 @@
use codex_protocol::ThreadId;
use codex_protocol::approvals::ElicitationAction;
use codex_protocol::mcp::RequestId as McpRequestId;
use codex_protocol::protocol::Op;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
@ -34,6 +38,19 @@ enum AppLinkScreen {
InstallConfirmation,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AppLinkSuggestionType {
Install,
Enable,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct AppLinkElicitationTarget {
pub(crate) thread_id: ThreadId,
pub(crate) server_name: String,
pub(crate) request_id: McpRequestId,
}
pub(crate) struct AppLinkViewParams {
pub(crate) app_id: String,
pub(crate) title: String,
@ -42,6 +59,9 @@ pub(crate) struct AppLinkViewParams {
pub(crate) url: String,
pub(crate) is_installed: bool,
pub(crate) is_enabled: bool,
pub(crate) suggest_reason: Option<String>,
pub(crate) suggestion_type: Option<AppLinkSuggestionType>,
pub(crate) elicitation_target: Option<AppLinkElicitationTarget>,
}
pub(crate) struct AppLinkView {
@ -52,6 +72,9 @@ pub(crate) struct AppLinkView {
url: String,
is_installed: bool,
is_enabled: bool,
suggest_reason: Option<String>,
suggestion_type: Option<AppLinkSuggestionType>,
elicitation_target: Option<AppLinkElicitationTarget>,
app_event_tx: AppEventSender,
screen: AppLinkScreen,
selected_action: usize,
@ -68,6 +91,9 @@ impl AppLinkView {
url,
is_installed,
is_enabled,
suggest_reason,
suggestion_type,
elicitation_target,
} = params;
Self {
app_id,
@ -77,6 +103,9 @@ impl AppLinkView {
url,
is_installed,
is_enabled,
suggest_reason,
suggestion_type,
elicitation_target,
app_event_tx,
screen: AppLinkScreen::Link,
selected_action: 0,
@ -113,6 +142,31 @@ impl AppLinkView {
self.selected_action = (self.selected_action + 1).min(self.action_labels().len() - 1);
}
fn is_tool_suggestion(&self) -> bool {
self.elicitation_target.is_some()
}
fn resolve_elicitation(&self, decision: ElicitationAction) {
let Some(target) = self.elicitation_target.as_ref() else {
return;
};
self.app_event_tx.send(AppEvent::SubmitThreadOp {
thread_id: target.thread_id,
op: Op::ResolveElicitation {
server_name: target.server_name.clone(),
request_id: target.request_id.clone(),
decision,
content: None,
meta: None,
},
});
}
fn decline_tool_suggestion(&mut self) {
self.resolve_elicitation(ElicitationAction::Decline);
self.complete = true;
}
fn open_chatgpt_link(&mut self) {
self.app_event_tx.send(AppEvent::OpenUrlInBrowser {
url: self.url.clone(),
@ -127,6 +181,9 @@ impl AppLinkView {
self.app_event_tx.send(AppEvent::RefreshConnectors {
force_refetch: true,
});
if self.is_tool_suggestion() {
self.resolve_elicitation(ElicitationAction::Accept);
}
self.complete = true;
}
@ -141,9 +198,40 @@ impl AppLinkView {
id: self.app_id.clone(),
enabled: self.is_enabled,
});
if self.is_tool_suggestion() {
self.resolve_elicitation(ElicitationAction::Accept);
self.complete = true;
}
}
fn activate_selected_action(&mut self) {
if self.is_tool_suggestion() {
match self.suggestion_type {
Some(AppLinkSuggestionType::Enable) => match self.screen {
AppLinkScreen::Link => match self.selected_action {
0 => self.open_chatgpt_link(),
1 if self.is_installed => self.toggle_enabled(),
_ => self.decline_tool_suggestion(),
},
AppLinkScreen::InstallConfirmation => match self.selected_action {
0 => self.refresh_connectors_and_close(),
_ => self.decline_tool_suggestion(),
},
},
Some(AppLinkSuggestionType::Install) | None => match self.screen {
AppLinkScreen::Link => match self.selected_action {
0 => self.open_chatgpt_link(),
_ => self.decline_tool_suggestion(),
},
AppLinkScreen::InstallConfirmation => match self.selected_action {
0 => self.refresh_connectors_and_close(),
_ => self.decline_tool_suggestion(),
},
},
}
return;
}
match self.screen {
AppLinkScreen::Link => match self.selected_action {
0 => self.open_chatgpt_link(),
@ -181,6 +269,17 @@ impl AppLinkView {
}
lines.push(Line::from(""));
if let Some(suggest_reason) = self
.suggest_reason
.as_deref()
.map(str::trim)
.filter(|suggest_reason| !suggest_reason.is_empty())
{
for line in wrap(suggest_reason, usable_width) {
lines.push(Line::from(line.into_owned().italic()));
}
lines.push(Line::from(""));
}
if self.is_installed {
for line in wrap("Use $ to insert this app into the prompt.", usable_width) {
lines.push(Line::from(line.into_owned()));
@ -366,6 +465,9 @@ impl BottomPaneView for AppLinkView {
}
fn on_ctrl_c(&mut self) -> CancellationEvent {
if self.is_tool_suggestion() {
self.resolve_elicitation(ElicitationAction::Decline);
}
self.complete = true;
CancellationEvent::Handled
}
@ -447,8 +549,40 @@ mod tests {
use super::*;
use crate::app_event::AppEvent;
use crate::render::renderable::Renderable;
use insta::assert_snapshot;
use tokio::sync::mpsc::unbounded_channel;
fn suggestion_target() -> AppLinkElicitationTarget {
AppLinkElicitationTarget {
thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001")
.expect("valid thread id"),
server_name: "codex_apps".to_string(),
request_id: McpRequestId::String("request-1".to_string()),
}
}
fn render_snapshot(view: &AppLinkView, area: Rect) -> String {
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
(0..area.height)
.map(|y| {
(0..area.width)
.map(|x| {
let symbol = buf[(x, y)].symbol();
if symbol.is_empty() {
' '
} else {
symbol.chars().next().unwrap_or(' ')
}
})
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn installed_app_has_toggle_action() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
@ -462,6 +596,9 @@ mod tests {
url: "https://example.test/notion".to_string(),
is_installed: true,
is_enabled: true,
suggest_reason: None,
suggestion_type: None,
elicitation_target: None,
},
tx,
);
@ -485,6 +622,9 @@ mod tests {
url: "https://example.test/notion".to_string(),
is_installed: true,
is_enabled: true,
suggest_reason: None,
suggestion_type: None,
elicitation_target: None,
},
tx,
);
@ -521,6 +661,9 @@ mod tests {
url: url_like.to_string(),
is_installed: true,
is_enabled: true,
suggest_reason: None,
suggestion_type: None,
elicitation_target: None,
},
tx,
);
@ -561,6 +704,9 @@ mod tests {
url: url.to_string(),
is_installed: true,
is_enabled: true,
suggest_reason: None,
suggestion_type: None,
elicitation_target: None,
},
tx,
);
@ -593,4 +739,206 @@ mod tests {
"expected wrapped setup URL tail to remain visible in narrow pane, got:\n{rendered_blob}"
);
}
#[test]
fn install_tool_suggestion_resolves_elicitation_after_confirmation() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_google_calendar".to_string(),
title: "Google Calendar".to_string(),
description: Some("Plan events and schedules.".to_string()),
instructions: "Install this app in your browser, then return here.".to_string(),
url: "https://example.test/google-calendar".to_string(),
is_installed: false,
is_enabled: false,
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
suggestion_type: Some(AppLinkSuggestionType::Install),
elicitation_target: Some(suggestion_target()),
},
tx,
);
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match rx.try_recv() {
Ok(AppEvent::OpenUrlInBrowser { url }) => {
assert_eq!(url, "https://example.test/google-calendar".to_string());
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
assert_eq!(view.screen, AppLinkScreen::InstallConfirmation);
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match rx.try_recv() {
Ok(AppEvent::RefreshConnectors { force_refetch }) => {
assert!(force_refetch);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
match rx.try_recv() {
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
assert_eq!(thread_id, suggestion_target().thread_id);
assert_eq!(
op,
Op::ResolveElicitation {
server_name: "codex_apps".to_string(),
request_id: McpRequestId::String("request-1".to_string()),
decision: ElicitationAction::Accept,
content: None,
meta: None,
}
);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
assert!(view.is_complete());
}
#[test]
fn declined_tool_suggestion_resolves_elicitation_decline() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_google_calendar".to_string(),
title: "Google Calendar".to_string(),
description: None,
instructions: "Install this app in your browser, then return here.".to_string(),
url: "https://example.test/google-calendar".to_string(),
is_installed: false,
is_enabled: false,
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
suggestion_type: Some(AppLinkSuggestionType::Install),
elicitation_target: Some(suggestion_target()),
},
tx,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
match rx.try_recv() {
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
assert_eq!(thread_id, suggestion_target().thread_id);
assert_eq!(
op,
Op::ResolveElicitation {
server_name: "codex_apps".to_string(),
request_id: McpRequestId::String("request-1".to_string()),
decision: ElicitationAction::Decline,
content: None,
meta: None,
}
);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
assert!(view.is_complete());
}
#[test]
fn enable_tool_suggestion_resolves_elicitation_after_enable() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_google_calendar".to_string(),
title: "Google Calendar".to_string(),
description: Some("Plan events and schedules.".to_string()),
instructions: "Enable this app to use it for the current request.".to_string(),
url: "https://example.test/google-calendar".to_string(),
is_installed: true,
is_enabled: false,
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
suggestion_type: Some(AppLinkSuggestionType::Enable),
elicitation_target: Some(suggestion_target()),
},
tx,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
match rx.try_recv() {
Ok(AppEvent::SetAppEnabled { id, enabled }) => {
assert_eq!(id, "connector_google_calendar");
assert!(enabled);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
match rx.try_recv() {
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
assert_eq!(thread_id, suggestion_target().thread_id);
assert_eq!(
op,
Op::ResolveElicitation {
server_name: "codex_apps".to_string(),
request_id: McpRequestId::String("request-1".to_string()),
decision: ElicitationAction::Accept,
content: None,
meta: None,
}
);
}
Ok(other) => panic!("unexpected app event: {other:?}"),
Err(err) => panic!("missing app event: {err}"),
}
assert!(view.is_complete());
}
#[test]
fn install_suggestion_with_reason_snapshot() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_google_calendar".to_string(),
title: "Google Calendar".to_string(),
description: Some("Plan events and schedules.".to_string()),
instructions: "Install this app in your browser, then return here.".to_string(),
url: "https://example.test/google-calendar".to_string(),
is_installed: false,
is_enabled: false,
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
suggestion_type: Some(AppLinkSuggestionType::Install),
elicitation_target: Some(suggestion_target()),
},
tx,
);
assert_snapshot!(
"app_link_view_install_suggestion_with_reason",
render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))
);
}
#[test]
fn enable_suggestion_with_reason_snapshot() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let view = AppLinkView::new(
AppLinkViewParams {
app_id: "connector_google_calendar".to_string(),
title: "Google Calendar".to_string(),
description: Some("Plan events and schedules.".to_string()),
instructions: "Enable this app to use it for the current request.".to_string(),
url: "https://example.test/google-calendar".to_string(),
is_installed: true,
is_enabled: false,
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
suggestion_type: Some(AppLinkSuggestionType::Enable),
elicitation_target: Some(suggestion_target()),
},
tx,
);
assert_snapshot!(
"app_link_view_enable_suggestion_with_reason",
render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))
);
}
}

View file

@ -54,9 +54,16 @@ const APPROVAL_DECLINE_VALUE: &str = "decline";
const APPROVAL_CANCEL_VALUE: &str = "cancel";
const APPROVAL_META_KIND_KEY: &str = "codex_approval_kind";
const APPROVAL_META_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call";
const APPROVAL_META_KIND_TOOL_SUGGESTION: &str = "tool_suggestion";
const APPROVAL_PERSIST_KEY: &str = "persist";
const APPROVAL_PERSIST_SESSION_VALUE: &str = "session";
const APPROVAL_PERSIST_ALWAYS_VALUE: &str = "always";
const TOOL_TYPE_KEY: &str = "tool_type";
const TOOL_ID_KEY: &str = "tool_id";
const TOOL_NAME_KEY: &str = "tool_name";
const TOOL_SUGGEST_SUGGEST_TYPE_KEY: &str = "suggest_type";
const TOOL_SUGGEST_REASON_KEY: &str = "suggest_reason";
const TOOL_SUGGEST_INSTALL_URL_KEY: &str = "install_url";
#[derive(Clone, PartialEq, Default)]
struct ComposerDraft {
@ -117,6 +124,28 @@ enum McpServerElicitationResponseMode {
ApprovalAction,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ToolSuggestionToolType {
Connector,
Plugin,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ToolSuggestionType {
Install,
Enable,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ToolSuggestionRequest {
pub(crate) tool_type: ToolSuggestionToolType,
pub(crate) suggest_type: ToolSuggestionType,
pub(crate) suggest_reason: String,
pub(crate) tool_id: String,
pub(crate) tool_name: String,
pub(crate) install_url: String,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct McpServerElicitationFormRequest {
thread_id: ThreadId,
@ -125,6 +154,7 @@ pub(crate) struct McpServerElicitationFormRequest {
message: String,
response_mode: McpServerElicitationResponseMode,
fields: Vec<McpServerElicitationField>,
tool_suggestion: Option<ToolSuggestionRequest>,
}
#[derive(Default)]
@ -170,6 +200,7 @@ impl McpServerElicitationFormRequest {
return None;
};
let tool_suggestion = parse_tool_suggestion_request(meta.as_ref());
let is_tool_approval = meta
.as_ref()
.and_then(Value::as_object)
@ -186,9 +217,11 @@ impl McpServerElicitationFormRequest {
let is_tool_approval_action =
is_tool_approval && (requested_schema.is_null() || is_empty_object_schema);
let (response_mode, fields) = if requested_schema.is_null()
|| (is_tool_approval && is_empty_object_schema)
let (response_mode, fields) = if tool_suggestion.is_some()
&& (requested_schema.is_null() || is_empty_object_schema)
{
(McpServerElicitationResponseMode::FormContent, Vec::new())
} else if requested_schema.is_null() || (is_tool_approval && is_empty_object_schema) {
let mut options = vec![McpServerElicitationOption {
label: "Allow".to_string(),
description: Some("Run the tool and continue.".to_string()),
@ -266,8 +299,63 @@ impl McpServerElicitationFormRequest {
message,
response_mode,
fields,
tool_suggestion,
})
}
pub(crate) fn tool_suggestion(&self) -> Option<&ToolSuggestionRequest> {
self.tool_suggestion.as_ref()
}
pub(crate) fn thread_id(&self) -> ThreadId {
self.thread_id
}
pub(crate) fn server_name(&self) -> &str {
self.server_name.as_str()
}
pub(crate) fn request_id(&self) -> &McpRequestId {
&self.request_id
}
}
fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option<ToolSuggestionRequest> {
let meta = meta?.as_object()?;
if meta.get(APPROVAL_META_KIND_KEY).and_then(Value::as_str)
!= Some(APPROVAL_META_KIND_TOOL_SUGGESTION)
{
return None;
}
let tool_type = match meta.get(TOOL_TYPE_KEY).and_then(Value::as_str) {
Some("connector") => ToolSuggestionToolType::Connector,
Some("plugin") => ToolSuggestionToolType::Plugin,
_ => return None,
};
let suggest_type = match meta
.get(TOOL_SUGGEST_SUGGEST_TYPE_KEY)
.and_then(Value::as_str)
{
Some("install") => ToolSuggestionType::Install,
Some("enable") => ToolSuggestionType::Enable,
_ => return None,
};
Some(ToolSuggestionRequest {
tool_type,
suggest_type,
suggest_reason: meta
.get(TOOL_SUGGEST_REASON_KEY)
.and_then(Value::as_str)?
.to_string(),
tool_id: meta.get(TOOL_ID_KEY).and_then(Value::as_str)?.to_string(),
tool_name: meta.get(TOOL_NAME_KEY).and_then(Value::as_str)?.to_string(),
install_url: meta
.get(TOOL_SUGGEST_INSTALL_URL_KEY)
.and_then(Value::as_str)?
.to_string(),
})
}
fn tool_approval_supports_persist_mode(meta: Option<&Value>, expected_mode: &str) -> bool {
@ -1550,6 +1638,7 @@ mod tests {
default_idx: None,
},
}],
tool_suggestion: None,
}
);
}
@ -1621,6 +1710,7 @@ mod tests {
default_idx: Some(0),
},
}],
tool_suggestion: None,
}
);
}
@ -1667,10 +1757,44 @@ mod tests {
default_idx: Some(0),
},
}],
tool_suggestion: None,
}
);
}
#[test]
fn tool_suggestion_meta_is_parsed_into_request_payload() {
let request = McpServerElicitationFormRequest::from_event(
ThreadId::default(),
form_request(
"Suggest Google Calendar",
empty_object_schema(),
Some(serde_json::json!({
"codex_approval_kind": "tool_suggestion",
"tool_type": "connector",
"suggest_type": "install",
"suggest_reason": "Plan and reference events from your calendar",
"tool_id": "connector_2128aebfecb84f64a069897515042a44",
"tool_name": "Google Calendar",
"install_url": "https://example.test/google-calendar",
})),
),
)
.expect("expected tool suggestion form");
assert_eq!(
request.tool_suggestion(),
Some(&ToolSuggestionRequest {
tool_type: ToolSuggestionToolType::Connector,
suggest_type: ToolSuggestionType::Install,
suggest_reason: "Plan and reference events from your calendar".to_string(),
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
tool_name: "Google Calendar".to_string(),
install_url: "https://example.test/google-calendar".to_string(),
})
);
}
#[test]
fn empty_unmarked_schema_falls_back() {
let request = McpServerElicitationFormRequest::from_event(

View file

@ -47,6 +47,8 @@ mod mcp_server_elicitation;
mod multi_select_picker;
mod request_user_input;
mod status_line_setup;
pub(crate) use app_link_view::AppLinkElicitationTarget;
pub(crate) use app_link_view::AppLinkSuggestionType;
pub(crate) use app_link_view::AppLinkView;
pub(crate) use app_link_view::AppLinkViewParams;
pub(crate) use approval_overlay::ApprovalOverlay;
@ -963,6 +965,52 @@ impl BottomPane {
request
};
if let Some(tool_suggestion) = request.tool_suggestion() {
let suggestion_type = match tool_suggestion.suggest_type {
mcp_server_elicitation::ToolSuggestionType::Install => {
AppLinkSuggestionType::Install
}
mcp_server_elicitation::ToolSuggestionType::Enable => AppLinkSuggestionType::Enable,
};
let is_installed = matches!(
tool_suggestion.suggest_type,
mcp_server_elicitation::ToolSuggestionType::Enable
);
let view = AppLinkView::new(
AppLinkViewParams {
app_id: tool_suggestion.tool_id.clone(),
title: tool_suggestion.tool_name.clone(),
description: None,
instructions: match suggestion_type {
AppLinkSuggestionType::Install => {
"Install this app in your browser, then return here.".to_string()
}
AppLinkSuggestionType::Enable => {
"Enable this app to use it for the current request.".to_string()
}
},
url: tool_suggestion.install_url.clone(),
is_installed,
is_enabled: false,
suggest_reason: Some(tool_suggestion.suggest_reason.clone()),
suggestion_type: Some(suggestion_type),
elicitation_target: Some(AppLinkElicitationTarget {
thread_id: request.thread_id(),
server_name: request.server_name().to_string(),
request_id: request.request_id().clone(),
}),
},
self.app_event_tx.clone(),
);
self.pause_status_timer_for_modal();
self.set_composer_input_enabled(
false,
Some("Respond to the tool suggestion to continue.".to_string()),
);
self.push_view(Box::new(view));
return;
}
let modal = McpServerElicitationOverlay::new(
request,
self.app_event_tx.clone(),

View file

@ -0,0 +1,20 @@
---
source: tui/src/bottom_pane/app_link_view.rs
expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))"
---
Google Calendar
Plan events and schedules.
Plan and reference events from your calendar
Use $ to insert this app into the prompt.
Enable this app to use it for the current request.
Newly installed apps can take a few minutes to appear in /apps.
1. Manage on ChatGPT
2. Enable app
3. Back
Use tab / ↑ ↓ to move, enter to select, esc to close

View file

@ -0,0 +1,18 @@
---
source: tui/src/bottom_pane/app_link_view.rs
expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))"
---
Google Calendar
Plan events and schedules.
Plan and reference events from your calendar
Install this app in your browser, then return here.
Newly installed apps can take a few minutes to appear in /apps.
After installed, use $ to insert this app into the prompt.
1. Install on ChatGPT
2. Back
Use tab / ↑ ↓ to move, enter to select, esc to close