Compare commits

...

12 Commits

Author SHA1 Message Date
Matthew Zeng
d1498d5e8d update 2026-05-07 11:25:44 -07:00
Matthew Zeng
aba93e61f4 Merge branch 'main' of github.com:openai/codex into dev/mzeng/codex_apps_mcp_declare 2026-05-05 11:11:56 -07:00
Matthew Zeng
3c0c775143 Reuse shared ChatGPT host allowlist 2026-05-05 11:09:31 -07:00
Matthew Zeng
0fcebd8d8f Fix search tool MCP config provenance 2026-05-04 16:01:10 -07:00
Matthew Zeng
0bb1719363 Merge remote-tracking branch 'origin/main' into dev/mzeng/codex_apps_mcp_declare 2026-05-04 15:55:50 -07:00
Matthew Zeng
b22bcab083 Validate host-owned Codex Apps MCP endpoint 2026-05-04 15:42:05 -07:00
Matthew Zeng
5a9d9fdf86 Merge branch 'main' of github.com:openai/codex into dev/mzeng/codex_apps_mcp_declare
# Conflicts:
#	codex-rs/core/src/mcp_tool_call.rs
2026-05-04 10:33:11 -07:00
Matthew Zeng
24c953304d Restamp cached Codex Apps tool provenance 2026-05-03 22:22:36 -07:00
Matthew Zeng
a67d0dc430 update 2026-05-01 16:21:56 -07:00
Matthew Zeng
d2723793b2 Merge branch 'main' of github.com:openai/codex into dev/mzeng/codex_apps_mcp_declare 2026-05-01 16:04:58 -07:00
Matthew Zeng
2c7611434b Merge branch 'main' of github.com:openai/codex into dev/mzeng/codex_apps_mcp_declare 2026-05-01 15:49:01 -07:00
Matthew Zeng
6b3013938c update 2026-05-01 11:01:38 -07:00
38 changed files with 724 additions and 183 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -2986,6 +2986,7 @@ dependencies = [
"async-channel",
"codex-api",
"codex-async-utils",
"codex-client",
"codex-config",
"codex-exec-server",
"codex-login",

View File

@@ -303,6 +303,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,

View File

@@ -16,6 +16,7 @@ anyhow = { workspace = true }
async-channel = { workspace = true }
codex-async-utils = { workspace = true }
codex-api = { workspace = true }
codex-client = { workspace = true }
codex-config = { workspace = true }
codex-exec-server = { workspace = true }
codex-login = { workspace = true }

View File

@@ -9,10 +9,10 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Instant;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::runtime::emit_duration;
use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC;
use crate::tools::ToolInfo;
use codex_config::McpServerProvenance;
use codex_login::CodexAuth;
use codex_utils_plugins::mcp_connector::is_connector_id_allowed;
use codex_utils_plugins::mcp_connector::sanitize_name;
@@ -43,7 +43,7 @@ pub fn filter_non_codex_apps_mcp_tools_only(
) -> HashMap<String, ToolInfo> {
mcp_tools
.iter()
.filter(|(_, tool)| tool.server_name != CODEX_APPS_MCP_SERVER_NAME)
.filter(|(_, tool)| !tool.is_host_owned_codex_apps())
.map(|(name, tool)| (name.clone(), tool.clone()))
.collect()
}
@@ -71,11 +71,11 @@ pub(crate) enum CachedCodexAppsToolsLoad {
}
pub(crate) fn normalize_codex_apps_tool_title(
server_name: &str,
is_host_owned_codex_apps_server: bool,
connector_name: Option<&str>,
value: &str,
) -> String {
if server_name != CODEX_APPS_MCP_SERVER_NAME {
if !is_host_owned_codex_apps_server {
return value.to_string();
}
@@ -97,12 +97,12 @@ pub(crate) fn normalize_codex_apps_tool_title(
}
pub(crate) fn normalize_codex_apps_callable_name(
server_name: &str,
is_host_owned_codex_apps_server: bool,
tool_name: &str,
connector_id: Option<&str>,
connector_name: Option<&str>,
) -> String {
if server_name != CODEX_APPS_MCP_SERVER_NAME {
if !is_host_owned_codex_apps_server {
return tool_name.to_string();
}
@@ -133,11 +133,10 @@ pub(crate) fn normalize_codex_apps_callable_name(
pub(crate) fn normalize_codex_apps_callable_namespace(
server_name: &str,
is_host_owned_codex_apps_server: bool,
connector_name: Option<&str>,
) -> String {
if server_name == CODEX_APPS_MCP_SERVER_NAME
&& let Some(connector_name) = connector_name
{
if is_host_owned_codex_apps_server && let Some(connector_name) = connector_name {
format!("mcp__{}__{}", server_name, sanitize_name(connector_name))
} else {
format!("mcp__{server_name}__")
@@ -145,11 +144,11 @@ pub(crate) fn normalize_codex_apps_callable_namespace(
}
pub(crate) fn write_cached_codex_apps_tools_if_needed(
server_name: &str,
is_host_owned_codex_apps_server: bool,
cache_context: Option<&CodexAppsToolsCacheContext>,
tools: &[ToolInfo],
) {
if server_name != CODEX_APPS_MCP_SERVER_NAME {
if !is_host_owned_codex_apps_server {
return;
}
@@ -165,10 +164,10 @@ pub(crate) fn write_cached_codex_apps_tools_if_needed(
}
pub(crate) fn load_startup_cached_codex_apps_tools_snapshot(
server_name: &str,
is_host_owned_codex_apps_server: bool,
cache_context: Option<&CodexAppsToolsCacheContext>,
) -> Option<Vec<ToolInfo>> {
if server_name != CODEX_APPS_MCP_SERVER_NAME {
if !is_host_owned_codex_apps_server {
return None;
}
@@ -208,7 +207,11 @@ pub(crate) fn load_cached_codex_apps_tools(
if cache.schema_version != CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION {
return CachedCodexAppsToolsLoad::Invalid;
}
CachedCodexAppsToolsLoad::Hit(filter_disallowed_codex_apps_tools(cache.tools))
let mut tools = filter_disallowed_codex_apps_tools(cache.tools);
for tool in &mut tools {
tool.server_provenance = McpServerProvenance::HostOwnedCodexApps;
}
CachedCodexAppsToolsLoad::Hit(tools)
}
pub(crate) fn write_cached_codex_apps_tools(

View File

@@ -17,7 +17,6 @@ use crate::codex_apps::CodexAppsToolsCacheContext;
use crate::codex_apps::CodexAppsToolsCacheKey;
use crate::codex_apps::write_cached_codex_apps_tools_if_needed;
use crate::elicitation::ElicitationRequestManager;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::ToolPluginProvenance;
use crate::rmcp_client::AsyncManagedClient;
use crate::rmcp_client::DEFAULT_STARTUP_TIMEOUT;
@@ -38,6 +37,7 @@ use anyhow::anyhow;
use async_channel::Sender;
use codex_config::Constrained;
use codex_config::McpServerConfig;
use codex_config::McpServerProvenance;
use codex_config::McpServerTransportConfig;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_login::CodexAuth;
@@ -116,6 +116,12 @@ impl McpConnectionManager {
self.server_origins.get(server_name).map(String::as_str)
}
pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool {
self.clients
.get(server_name)
.is_some_and(|client| client.provenance == McpServerProvenance::HostOwnedCodexApps)
}
pub fn set_approval_policy(&self, approval_policy: &Constrained<AskForApproval>) {
if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() {
*policy = approval_policy.value();
@@ -156,6 +162,9 @@ impl McpConnectionManager {
.map(codex_model_provider::auth_provider_from_auth);
let mcp_servers = mcp_servers.clone();
for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) {
let provenance = cfg.provenance;
let is_host_owned_codex_apps_server =
provenance == McpServerProvenance::HostOwnedCodexApps;
if let Some(origin) = transport_origin(&cfg.transport) {
server_origins.insert(server_name.clone(), origin);
}
@@ -169,7 +178,7 @@ impl McpConnectionManager {
},
)
.await;
let codex_apps_tools_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME {
let codex_apps_tools_cache_context = if is_host_owned_codex_apps_server {
Some(CodexAppsToolsCacheContext {
codex_home: codex_home.clone(),
user_key: codex_apps_tools_cache_key.clone(),
@@ -184,12 +193,12 @@ impl McpConnectionManager {
} => bearer_token_env_var.is_some(),
McpServerTransportConfig::Stdio { .. } => false,
};
let runtime_auth_provider =
if server_name == CODEX_APPS_MCP_SERVER_NAME && !uses_env_bearer_token {
codex_apps_auth_provider.clone()
} else {
None
};
let runtime_auth_provider = if is_host_owned_codex_apps_server && !uses_env_bearer_token
{
codex_apps_auth_provider.clone()
} else {
None
};
let async_managed_client = AsyncManagedClient::new(
server_name.clone(),
cfg,
@@ -201,6 +210,7 @@ impl McpConnectionManager {
Arc::clone(&tool_plugin_provenance),
runtime_environment.clone(),
runtime_auth_provider,
provenance,
);
clients.insert(server_name.clone(), async_managed_client.clone());
let tx_event = tx_event.clone();
@@ -335,10 +345,12 @@ impl McpConnectionManager {
/// 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
let (server_name, async_managed_client) = self
.clients
.get(CODEX_APPS_MCP_SERVER_NAME)
.ok_or_else(|| anyhow!("unknown MCP server '{CODEX_APPS_MCP_SERVER_NAME}'"))?
.iter()
.find(|(_, client)| client.provenance == McpServerProvenance::HostOwnedCodexApps)
.ok_or_else(|| anyhow!("host-owned Codex Apps MCP server is not available"))?;
let managed_client = async_managed_client
.client()
.await
.context("failed to get client")?;
@@ -346,15 +358,14 @@ impl McpConnectionManager {
let list_start = Instant::now();
let fetch_start = Instant::now();
let tools = list_tools_for_client_uncached(
CODEX_APPS_MCP_SERVER_NAME,
server_name,
async_managed_client.provenance,
&managed_client.client,
managed_client.tool_timeout,
managed_client.server_instructions.as_deref(),
)
.await
.with_context(|| {
format!("failed to refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}'")
})?;
.with_context(|| format!("failed to refresh tools for MCP server '{server_name}'"))?;
emit_duration(
MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC,
fetch_start.elapsed(),
@@ -362,7 +373,7 @@ impl McpConnectionManager {
);
write_cached_codex_apps_tools_if_needed(
CODEX_APPS_MCP_SERVER_NAME,
async_managed_client.provenance == McpServerProvenance::HostOwnedCodexApps,
managed_client.codex_apps_tools_cache_context.as_ref(),
&tools,
);

View File

@@ -1,4 +1,5 @@
use super::*;
use crate::CODEX_APPS_MCP_SERVER_NAME;
use crate::codex_apps::CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION;
use crate::codex_apps::CodexAppsToolsCacheContext;
use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot;
@@ -17,6 +18,7 @@ use crate::tools::filter_tools;
use crate::tools::qualify_tools;
use crate::tools::tool_with_model_visible_input_schema;
use codex_config::Constrained;
use codex_config::McpServerProvenance;
use codex_protocol::ToolName;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::GranularApprovalConfig;
@@ -38,6 +40,7 @@ fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo {
let tool_namespace = format!("mcp__{server_name}__");
ToolInfo {
server_name: server_name.to_string(),
server_provenance: Default::default(),
callable_name: tool_name.to_string(),
callable_namespace: tool_namespace,
namespace_description: None,
@@ -630,13 +633,17 @@ fn startup_cached_codex_apps_tools_loads_from_disk_cache() {
write_cached_codex_apps_tools(&cache_context, &cached_tools);
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
CODEX_APPS_MCP_SERVER_NAME,
/*is_host_owned_codex_apps_server*/ true,
Some(&cache_context),
);
let startup_tools = startup_snapshot.expect("expected startup snapshot to load from cache");
assert_eq!(startup_tools.len(), 1);
assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME);
assert_eq!(
startup_tools[0].server_provenance,
McpServerProvenance::HostOwnedCodexApps
);
assert_eq!(startup_tools[0].callable_name, "calendar_search");
}
@@ -661,6 +668,7 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() {
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
cancel_token: CancellationToken::new(),
provenance: McpServerProvenance::HostOwnedCodexApps,
},
);
@@ -690,6 +698,7 @@ async fn resolve_tool_info_accepts_canonical_namespaced_tool_names() {
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
cancel_token: CancellationToken::new(),
provenance: Default::default(),
},
);
@@ -727,6 +736,7 @@ async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot(
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
cancel_token: CancellationToken::new(),
provenance: McpServerProvenance::HostOwnedCodexApps,
},
);
@@ -752,6 +762,7 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty(
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
cancel_token: CancellationToken::new(),
provenance: McpServerProvenance::HostOwnedCodexApps,
},
);
@@ -787,6 +798,7 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() {
startup_complete,
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
cancel_token: CancellationToken::new(),
provenance: McpServerProvenance::HostOwnedCodexApps,
},
);
@@ -798,6 +810,42 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() {
assert_eq!(tool.callable_name, "calendar_create_event");
}
#[tokio::test]
async fn host_owned_codex_apps_server_is_identified_by_client_provenance() {
let pending_client = futures::future::pending::<Result<ManagedClient, StartupOutcomeError>>()
.boxed()
.shared();
let approval_policy = Constrained::allow_any(AskForApproval::OnFailure);
let permission_profile = Constrained::allow_any(PermissionProfile::default());
let mut manager =
McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile);
manager.clients.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
AsyncManagedClient {
client: pending_client.clone(),
startup_snapshot: None,
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
cancel_token: CancellationToken::new(),
provenance: McpServerProvenance::UserConfigured,
},
);
manager.clients.insert(
"host_apps".to_string(),
AsyncManagedClient {
client: pending_client,
startup_snapshot: None,
startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)),
tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()),
cancel_token: CancellationToken::new(),
provenance: McpServerProvenance::HostOwnedCodexApps,
},
);
assert!(!manager.is_host_owned_codex_apps_server(CODEX_APPS_MCP_SERVER_NAME));
assert!(manager.is_host_owned_codex_apps_server("host_apps"));
}
#[test]
fn elicitation_capability_uses_2025_06_18_shape_for_all_servers() {
for server_name in [CODEX_APPS_MCP_SERVER_NAME, "custom_mcp"] {
@@ -826,6 +874,7 @@ fn mcp_init_error_display_prompts_for_github_pat() {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -878,6 +927,7 @@ fn mcp_init_error_display_reports_generic_errors() {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,

View File

@@ -2,6 +2,7 @@ use std::collections::HashMap;
use anyhow::Result;
use codex_config::McpServerConfig;
use codex_config::McpServerProvenance;
use codex_config::McpServerTransportConfig;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_login::CodexAuth;
@@ -12,8 +13,6 @@ use codex_rmcp_client::discover_streamable_http_oauth;
use futures::future::join_all;
use tracing::warn;
use super::CODEX_APPS_MCP_SERVER_NAME;
#[derive(Debug, Clone)]
pub struct McpOAuthLoginConfig {
pub url: String,
@@ -136,7 +135,7 @@ where
let futures = servers.into_iter().map(|(name, config)| {
let name = name.clone();
let config = config.clone();
let has_runtime_auth = name == CODEX_APPS_MCP_SERVER_NAME
let has_runtime_auth = config.provenance == McpServerProvenance::HostOwnedCodexApps
&& auth.is_some_and(CodexAuth::uses_codex_backend)
&& matches!(
&config.transport,

View File

@@ -0,0 +1,149 @@
use codex_client::is_allowed_chatgpt_host;
use codex_config::McpServerProvenance;
use url::Host;
use url::Url;
use super::McpConfig;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct HostOwnedCodexAppsMcpEndpoint {
url: trusted_codex_apps_mcp_url::TrustedCodexAppsMcpUrl,
provenance: McpServerProvenance,
}
impl HostOwnedCodexAppsMcpEndpoint {
fn new(url: trusted_codex_apps_mcp_url::TrustedCodexAppsMcpUrl) -> Self {
Self {
url,
provenance: McpServerProvenance::HostOwnedCodexApps,
}
}
pub(super) fn into_parts(self) -> (String, McpServerProvenance) {
(self.url.into_string(), self.provenance)
}
#[cfg(test)]
pub(super) fn url(&self) -> &str {
self.url.as_str()
}
#[cfg(test)]
pub(super) fn provenance(&self) -> McpServerProvenance {
self.provenance
}
}
/// Builds the only endpoint allowed to receive host-owned Codex Apps provenance.
pub(super) fn host_owned_codex_apps_mcp_endpoint(
config: &McpConfig,
) -> Result<HostOwnedCodexAppsMcpEndpoint, String> {
// HostOwnedCodexApps gates first-party connector behavior, including
// privileged file upload handling. Keep the trusted URL check and the
// provenance grant together so a config-derived URL cannot receive the
// host-owned marker without passing this audit point.
let url = trusted_codex_apps_mcp_url::from_base_url(
&config.chatgpt_base_url,
config.apps_mcp_path_override.as_deref(),
)?;
Ok(HostOwnedCodexAppsMcpEndpoint::new(url))
}
mod trusted_codex_apps_mcp_url {
use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct TrustedCodexAppsMcpUrl(String);
impl TrustedCodexAppsMcpUrl {
fn new(url: String) -> Result<Self, String> {
let parsed_url = Url::parse(&url)
.map_err(|err| format!("invalid Codex Apps MCP URL `{url}`: {err}"))?;
validate_url(&parsed_url, &url, "URL")?;
Ok(Self(url))
}
pub(super) fn into_string(self) -> String {
self.0
}
#[cfg(test)]
pub(super) fn as_str(&self) -> &str {
&self.0
}
}
pub(super) fn from_base_url(
base_url: &str,
apps_mcp_path_override: Option<&str>,
) -> Result<TrustedCodexAppsMcpUrl, String> {
let base_url = base_url.trim_end_matches('/');
let parsed_base_url = Url::parse(base_url)
.map_err(|err| format!("invalid Codex Apps MCP base URL `{base_url}`: {err}"))?;
validate_url(&parsed_base_url, base_url, "base URL")?;
let mut base_url = base_url.to_string();
if is_allowed_chatgpt_host_url(&parsed_base_url.host())
&& !parsed_base_url.path().contains("/backend-api")
{
base_url = format!("{base_url}/backend-api");
}
let (base_url, default_path) = if base_url.contains("/backend-api") {
(base_url, "wham/apps")
} else if base_url.contains("/api/codex") {
(base_url, "apps")
} else {
(format!("{base_url}/api/codex"), "apps")
};
let path = apps_mcp_path_override
.unwrap_or(default_path)
.trim_start_matches('/');
TrustedCodexAppsMcpUrl::new(format!("{base_url}/{path}"))
}
fn validate_url(url: &Url, original_url: &str, label: &str) -> Result<(), String> {
if !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(format!(
"invalid Codex Apps MCP {label} `{original_url}`; expected a URL without credentials, query, or fragment"
));
}
let scheme = url.scheme();
let host = url.host();
let valid_first_party_url =
scheme == "https" && url.port().is_none() && is_allowed_chatgpt_host_url(&host);
let valid_local_url =
cfg!(debug_assertions) && matches!(scheme, "http" | "https") && is_localhost(&host);
if valid_first_party_url || valid_local_url {
Ok(())
} else {
Err(format!(
"invalid Codex Apps MCP {label} `{original_url}`; expected an HTTPS URL for chatgpt.com, chat.openai.com, or chatgpt-staging.com"
))
}
}
fn is_allowed_chatgpt_host_url(host: &Option<Host<&str>>) -> bool {
let Some(Host::Domain(host)) = host else {
return false;
};
is_allowed_chatgpt_host(host)
}
fn is_localhost(host: &Option<Host<&str>>) -> bool {
match host {
Some(Host::Domain(host)) => *host == "localhost",
Some(Host::Ipv4(ip)) => ip.is_loopback(),
Some(Host::Ipv6(ip)) => ip.is_loopback(),
_ => false,
}
}
}
#[cfg(test)]
#[path = "codex_apps_endpoint_tests.rs"]
mod tests;

View File

@@ -0,0 +1,96 @@
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn trusted_url_keeps_existing_paths() {
assert_eq!(
trusted_codex_apps_mcp_url::from_base_url(
"https://chatgpt.com/backend-api",
/*apps_mcp_path_override*/ None,
)
.expect("trusted ChatGPT URL should build")
.as_str(),
"https://chatgpt.com/backend-api/wham/apps"
);
assert_eq!(
trusted_codex_apps_mcp_url::from_base_url(
"https://chat.openai.com",
/*apps_mcp_path_override*/ None,
)
.expect("trusted legacy ChatGPT URL should build")
.as_str(),
"https://chat.openai.com/backend-api/wham/apps"
);
assert_eq!(
trusted_codex_apps_mcp_url::from_base_url(
"http://localhost:8080/api/codex",
/*apps_mcp_path_override*/ None,
)
.expect("local debug URL should build")
.as_str(),
"http://localhost:8080/api/codex/apps"
);
assert_eq!(
trusted_codex_apps_mcp_url::from_base_url(
"http://localhost:8080",
/*apps_mcp_path_override*/ None,
)
.expect("local debug URL should build")
.as_str(),
"http://localhost:8080/api/codex/apps"
);
}
#[test]
fn trusted_url_rejects_untrusted_base_urls() {
for base_url in [
"http://chatgpt.com/backend-api",
"https://example.com/backend-api",
"https://chatgpt.com.evil.example/backend-api",
"https://evilchatgpt.com/backend-api",
"https://foo.chat.openai.com/backend-api",
"https://chatgpt.com:4443/backend-api",
"https://user:pass@chatgpt.com/backend-api",
"https://chatgpt.com/backend-api?token=secret",
] {
let err = trusted_codex_apps_mcp_url::from_base_url(
base_url, /*apps_mcp_path_override*/ None,
)
.expect_err("untrusted URL should be rejected");
assert!(
err.starts_with("invalid Codex Apps MCP base URL"),
"unexpected error: {err}"
);
}
}
#[test]
fn trusted_url_rejects_override_that_adds_query_or_fragment() {
for path_override in ["custom/mcp?token=secret", "custom/mcp#fragment"] {
let err =
trusted_codex_apps_mcp_url::from_base_url("https://chatgpt.com", Some(path_override))
.expect_err("untrusted final URL should be rejected");
assert!(
err.starts_with("invalid Codex Apps MCP URL"),
"unexpected error: {err}"
);
}
}
#[test]
fn endpoint_pairs_trusted_url_with_provenance() {
let codex_home = tempfile::tempdir().expect("tempdir");
let config = crate::mcp::tests::test_mcp_config(codex_home.path().to_path_buf());
let endpoint =
host_owned_codex_apps_mcp_endpoint(&config).expect("trusted ChatGPT URL should build");
assert_eq!(
(endpoint.url(), endpoint.provenance()),
(
"https://chatgpt.com/backend-api/wham/apps",
McpServerProvenance::HostOwnedCodexApps,
)
);
}

View File

@@ -10,6 +10,7 @@ pub use auth::resolve_oauth_scopes;
pub use auth::should_retry_without_scopes;
pub(crate) mod auth;
mod codex_apps_endpoint;
use std::collections::HashMap;
use std::env;
@@ -39,6 +40,7 @@ use serde_json::Value;
use crate::codex_apps::codex_apps_tools_cache_key;
use crate::connection_manager::McpConnectionManager;
use crate::runtime::McpRuntimeEnvironment;
use codex_apps_endpoint::host_owned_codex_apps_mcp_endpoint;
pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
const MCP_TOOL_NAME_PREFIX: &str = "mcp";
@@ -198,19 +200,32 @@ pub fn with_codex_apps_mcp(
auth: Option<&CodexAuth>,
config: &McpConfig,
) -> HashMap<String, McpServerConfig> {
remove_reserved_codex_apps_server(&mut servers);
if config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend) {
servers.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
codex_apps_mcp_server_config(config),
);
} else {
servers.remove(CODEX_APPS_MCP_SERVER_NAME);
match codex_apps_mcp_server_config(config) {
Ok(server_config) => {
servers.insert(CODEX_APPS_MCP_SERVER_NAME.to_string(), server_config);
}
Err(err) => {
tracing::warn!("Skipping host-owned Codex Apps MCP server: {err}");
}
}
}
servers
}
pub fn configured_mcp_servers(config: &McpConfig) -> HashMap<String, McpServerConfig> {
config.configured_mcp_servers.clone()
let mut servers = config.configured_mcp_servers.clone();
remove_reserved_codex_apps_server(&mut servers);
servers
}
fn remove_reserved_codex_apps_server(servers: &mut HashMap<String, McpServerConfig>) {
if servers.remove(CODEX_APPS_MCP_SERVER_NAME).is_some() {
tracing::warn!(
"Ignoring configured MCP server with reserved name `{CODEX_APPS_MCP_SERVER_NAME}`"
);
}
}
pub fn effective_mcp_servers(
@@ -347,13 +362,6 @@ pub async fn collect_mcp_snapshot_from_manager(
.await
}
pub(crate) fn codex_apps_mcp_url(config: &McpConfig) -> String {
codex_apps_mcp_url_for_base_url(
&config.chatgpt_base_url,
config.apps_mcp_path_override.as_deref(),
)
}
/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`.
/// MCP server/tool names are user-controlled, so sanitize the fully-qualified
/// name we expose to the model by replacing any disallowed character with `_`.
@@ -383,36 +391,11 @@ fn codex_apps_mcp_bearer_token_env_var() -> Option<String> {
}
}
fn normalize_codex_apps_base_url(base_url: &str) -> String {
let mut base_url = base_url.trim_end_matches('/').to_string();
if (base_url.starts_with("https://chatgpt.com")
|| base_url.starts_with("https://chat.openai.com"))
&& !base_url.contains("/backend-api")
{
base_url = format!("{base_url}/backend-api");
}
base_url
}
fn codex_apps_mcp_server_config(config: &McpConfig) -> Result<McpServerConfig, String> {
let endpoint = host_owned_codex_apps_mcp_endpoint(config)?;
let (url, provenance) = endpoint.into_parts();
fn codex_apps_mcp_url_for_base_url(base_url: &str, apps_mcp_path_override: Option<&str>) -> String {
let base_url = normalize_codex_apps_base_url(base_url);
let (base_url, default_path) = if base_url.contains("/backend-api") {
(base_url, "wham/apps")
} else if base_url.contains("/api/codex") {
(base_url, "apps")
} else {
(format!("{base_url}/api/codex"), "apps")
};
let path = apps_mcp_path_override
.unwrap_or(default_path)
.trim_start_matches('/');
format!("{base_url}/{path}")
}
fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig {
let url = codex_apps_mcp_url(config);
McpServerConfig {
Ok(McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url,
bearer_token_env_var: codex_apps_mcp_bearer_token_env_var(),
@@ -424,6 +407,7 @@ fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance,
startup_timeout_sec: Some(Duration::from_secs(30)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -432,7 +416,7 @@ fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig {
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
}
})
}
fn protocol_tool_from_rmcp_tool(name: &str, tool: &rmcp::model::Tool) -> Option<Tool> {

View File

@@ -1,5 +1,6 @@
use super::*;
use codex_config::Constrained;
use codex_config::McpServerProvenance;
use codex_config::types::AppToolApproval;
use codex_config::types::ApprovalsReviewer;
use codex_login::CodexAuth;
@@ -14,7 +15,7 @@ use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::PathBuf;
fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
pub(super) fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
McpConfig {
chatgpt_base_url: "https://chatgpt.com".to_string(),
apps_mcp_path_override: None,
@@ -161,51 +162,21 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() {
);
}
#[test]
fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() {
assert_eq!(
codex_apps_mcp_url_for_base_url(
"https://chatgpt.com/backend-api",
/*apps_mcp_path_override*/ None,
),
"https://chatgpt.com/backend-api/wham/apps"
);
assert_eq!(
codex_apps_mcp_url_for_base_url(
"https://chat.openai.com",
/*apps_mcp_path_override*/ None,
),
"https://chat.openai.com/backend-api/wham/apps"
);
assert_eq!(
codex_apps_mcp_url_for_base_url(
"http://localhost:8080/api/codex",
/*apps_mcp_path_override*/ None,
),
"http://localhost:8080/api/codex/apps"
);
assert_eq!(
codex_apps_mcp_url_for_base_url(
"http://localhost:8080",
/*apps_mcp_path_override*/ None,
),
"http://localhost:8080/api/codex/apps"
);
}
#[test]
fn codex_apps_mcp_url_uses_legacy_codex_apps_path() {
let config = test_mcp_config(PathBuf::from("/tmp"));
let codex_home = tempfile::tempdir().expect("tempdir");
let config = test_mcp_config(codex_home.path().to_path_buf());
assert_eq!(
codex_apps_mcp_url(&config),
"https://chatgpt.com/backend-api/wham/apps"
);
let endpoint =
host_owned_codex_apps_mcp_endpoint(&config).expect("trusted ChatGPT URL should build");
assert_eq!(endpoint.url(), "https://chatgpt.com/backend-api/wham/apps");
}
#[test]
fn codex_apps_server_config_uses_legacy_codex_apps_path() {
let mut config = test_mcp_config(PathBuf::from("/tmp"));
let codex_home = tempfile::tempdir().expect("tempdir");
let mut config = test_mcp_config(codex_home.path().to_path_buf());
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let mut servers = with_codex_apps_mcp(HashMap::new(), /*auth*/ None, &config);
@@ -225,9 +196,23 @@ fn codex_apps_server_config_uses_legacy_codex_apps_path() {
assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps");
}
#[test]
fn codex_apps_server_config_is_marked_host_owned() {
let codex_home = tempfile::tempdir().expect("tempdir");
let config = test_mcp_config(codex_home.path().to_path_buf());
assert_eq!(
codex_apps_mcp_server_config(&config)
.expect("trusted ChatGPT URL should build")
.provenance,
McpServerProvenance::HostOwnedCodexApps
);
}
#[test]
fn codex_apps_server_config_uses_configured_apps_mcp_path_override() {
let mut config = test_mcp_config(PathBuf::from("/tmp"));
let codex_home = tempfile::tempdir().expect("tempdir");
let mut config = test_mcp_config(codex_home.path().to_path_buf());
config.apps_mcp_path_override = Some("/custom/mcp".to_string());
config.apps_enabled = true;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
@@ -244,6 +229,52 @@ fn codex_apps_server_config_uses_configured_apps_mcp_path_override() {
assert_eq!(url, "https://chatgpt.com/backend-api/custom/mcp");
}
#[test]
fn with_codex_apps_mcp_does_not_trust_untrusted_base_url() {
let codex_home = tempfile::tempdir().expect("tempdir");
let mut config = test_mcp_config(codex_home.path().to_path_buf());
config.apps_enabled = true;
config.chatgpt_base_url = "https://chatgpt.com.evil.example/backend-api".to_string();
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let servers = with_codex_apps_mcp(HashMap::new(), Some(&auth), &config);
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
}
#[test]
fn configured_mcp_servers_ignore_reserved_codex_apps_name() {
let codex_home = tempfile::tempdir().expect("tempdir");
let mut config = test_mcp_config(codex_home.path().to_path_buf());
config.configured_mcp_servers.insert(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "https://user.example/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
},
);
assert!(!configured_mcp_servers(&config).contains_key(CODEX_APPS_MCP_SERVER_NAME));
}
#[tokio::test]
async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
let codex_home = tempfile::tempdir().expect("tempdir");
@@ -265,6 +296,7 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -289,6 +321,7 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,

View File

@@ -26,7 +26,6 @@ use crate::codex_apps::normalize_codex_apps_callable_namespace;
use crate::codex_apps::normalize_codex_apps_tool_title;
use crate::codex_apps::write_cached_codex_apps_tools_if_needed;
use crate::elicitation::ElicitationRequestManager;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::ToolPluginProvenance;
use crate::runtime::McpRuntimeEnvironment;
use crate::runtime::emit_duration;
@@ -41,6 +40,7 @@ use codex_api::SharedAuthProvider;
use codex_async_utils::CancelErr;
use codex_async_utils::OrCancelExt;
use codex_config::McpServerConfig;
use codex_config::McpServerProvenance;
use codex_config::McpServerTransportConfig;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_exec_server::HttpClient;
@@ -125,6 +125,7 @@ pub(crate) struct AsyncManagedClient {
pub(crate) startup_complete: Arc<AtomicBool>,
pub(crate) tool_plugin_provenance: Arc<ToolPluginProvenance>,
pub(crate) cancel_token: CancellationToken,
pub(crate) provenance: McpServerProvenance,
}
impl AsyncManagedClient {
@@ -142,10 +143,12 @@ impl AsyncManagedClient {
tool_plugin_provenance: Arc<ToolPluginProvenance>,
runtime_environment: McpRuntimeEnvironment,
runtime_auth_provider: Option<SharedAuthProvider>,
provenance: McpServerProvenance,
) -> Self {
let tool_filter = ToolFilter::from_config(&config);
let is_host_owned_codex_apps_server = provenance == McpServerProvenance::HostOwnedCodexApps;
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
&server_name,
is_host_owned_codex_apps_server,
codex_apps_tools_cache_context.as_ref(),
)
.map(|tools| filter_tools(tools, &tool_filter));
@@ -181,6 +184,7 @@ impl AsyncManagedClient {
tx_event,
elicitation_requests,
codex_apps_tools_cache_context,
provenance,
},
)
.await
@@ -209,6 +213,7 @@ impl AsyncManagedClient {
startup_complete,
tool_plugin_provenance,
cancel_token,
provenance,
}
}
@@ -238,7 +243,8 @@ impl AsyncManagedClient {
let annotate_tools = |tools: Vec<ToolInfo>| {
let mut tools = tools;
for tool in &mut tools {
if tool.server_name == CODEX_APPS_MCP_SERVER_NAME {
tool.server_provenance = self.provenance;
if tool.is_host_owned_codex_apps() {
tool.tool = tool_with_model_visible_input_schema(&tool.tool);
}
@@ -327,10 +333,13 @@ pub(crate) fn elicitation_capability_for_server(
pub(crate) async fn list_tools_for_client_uncached(
server_name: &str,
server_provenance: McpServerProvenance,
client: &Arc<RmcpClient>,
timeout: Option<Duration>,
server_instructions: Option<&str>,
) -> Result<Vec<ToolInfo>> {
let is_host_owned_codex_apps_server =
server_provenance == McpServerProvenance::HostOwnedCodexApps;
let resp = client
.list_tools_with_connector_ids(/*params*/ None, timeout)
.await?;
@@ -341,23 +350,29 @@ pub(crate) async fn list_tools_for_client_uncached(
let mut tool_def = tool.tool;
let (connector_id, connector_name, connector_description) =
sanitize_tool_connector_metadata(
server_name,
is_host_owned_codex_apps_server,
&mut tool_def,
tool.connector_id,
tool.connector_name,
tool.connector_description,
);
let callable_name = normalize_codex_apps_callable_name(
server_name,
is_host_owned_codex_apps_server,
&tool_def.name,
connector_id.as_deref(),
connector_name.as_deref(),
);
let callable_namespace =
normalize_codex_apps_callable_namespace(server_name, connector_name.as_deref());
let callable_namespace = normalize_codex_apps_callable_namespace(
server_name,
is_host_owned_codex_apps_server,
connector_name.as_deref(),
);
if let Some(title) = tool_def.title.as_deref() {
let normalized_title =
normalize_codex_apps_tool_title(server_name, connector_name.as_deref(), title);
let normalized_title = normalize_codex_apps_tool_title(
is_host_owned_codex_apps_server,
connector_name.as_deref(),
title,
);
if tool_def.title.as_deref() != Some(normalized_title.as_str()) {
tool_def.title = Some(normalized_title);
}
@@ -372,6 +387,7 @@ pub(crate) async fn list_tools_for_client_uncached(
};
ToolInfo {
server_name: server_name.to_owned(),
server_provenance,
callable_name,
callable_namespace,
namespace_description,
@@ -382,20 +398,20 @@ pub(crate) async fn list_tools_for_client_uncached(
}
})
.collect();
if server_name == CODEX_APPS_MCP_SERVER_NAME {
if is_host_owned_codex_apps_server {
return Ok(filter_disallowed_codex_apps_tools(tools));
}
Ok(tools)
}
fn sanitize_tool_connector_metadata(
server_name: &str,
is_host_owned_codex_apps_server: bool,
tool: &mut RmcpTool,
connector_id: Option<String>,
connector_name: Option<String>,
connector_description: Option<String>,
) -> (Option<String>, Option<String>, Option<String>) {
if server_name == CODEX_APPS_MCP_SERVER_NAME {
if is_host_owned_codex_apps_server {
return (connector_id, connector_name, connector_description);
}
@@ -463,9 +479,10 @@ async fn start_server_task(
tx_event,
elicitation_requests,
codex_apps_tools_cache_context,
provenance,
} = params;
let elicitation = elicitation_capability_for_server(&server_name);
let params = InitializeRequestParams {
let initialize_params = InitializeRequestParams {
meta: None,
capabilities: ClientCapabilities {
experimental: None,
@@ -489,7 +506,7 @@ async fn start_server_task(
let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event);
let initialize_result = client
.initialize(params, startup_timeout, send_elicitation)
.initialize(initialize_params, startup_timeout, send_elicitation)
.await
.map_err(StartupOutcomeError::from)?;
@@ -503,6 +520,7 @@ async fn start_server_task(
let fetch_start = Instant::now();
let tools = list_tools_for_client_uncached(
&server_name,
provenance,
&client,
startup_timeout,
initialize_result.instructions.as_deref(),
@@ -515,11 +533,11 @@ async fn start_server_task(
&[],
);
write_cached_codex_apps_tools_if_needed(
&server_name,
provenance == McpServerProvenance::HostOwnedCodexApps,
codex_apps_tools_cache_context.as_ref(),
&tools,
);
if server_name == CODEX_APPS_MCP_SERVER_NAME {
if provenance == McpServerProvenance::HostOwnedCodexApps {
emit_duration(
MCP_TOOLS_LIST_DURATION_METRIC,
list_start.elapsed(),
@@ -548,6 +566,7 @@ struct StartServerTaskParams {
tx_event: Sender<Event>,
elicitation_requests: ElicitationRequestManager,
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
provenance: McpServerProvenance,
}
async fn make_rmcp_client(
@@ -685,7 +704,7 @@ mod tests {
let (connector_id, connector_name, connector_description) =
sanitize_tool_connector_metadata(
"minimaltest",
/*is_host_owned_codex_apps_server*/ false,
&mut tool,
Some("connector_gmail".to_string()),
Some("Gmail".to_string()),
@@ -721,7 +740,7 @@ mod tests {
let (connector_id, connector_name, connector_description) =
sanitize_tool_connector_metadata(
CODEX_APPS_MCP_SERVER_NAME,
/*is_host_owned_codex_apps_server*/ true,
&mut tool,
Some("connector_gmail".to_string()),
Some("Gmail".to_string()),

View File

@@ -10,6 +10,7 @@ use std::collections::HashSet;
use std::sync::Arc;
use codex_config::McpServerConfig;
use codex_config::McpServerProvenance;
use codex_protocol::ToolName;
use rmcp::model::Tool;
use serde::Deserialize;
@@ -29,6 +30,9 @@ pub(crate) const MCP_TOOLS_CACHE_WRITE_DURATION_METRIC: &str =
pub struct ToolInfo {
/// Raw MCP server name used for routing the tool call.
pub server_name: String,
/// Runtime-only provenance for the server that exposed this tool.
#[serde(skip)]
pub server_provenance: McpServerProvenance,
/// Model-visible tool name used in Responses API tool declarations.
#[serde(rename = "tool_name", alias = "callable_name")]
pub callable_name: String,
@@ -51,6 +55,10 @@ impl ToolInfo {
pub fn canonical_tool_name(&self) -> ToolName {
ToolName::namespaced(self.callable_namespace.clone(), self.callable_name.clone())
}
pub fn is_host_owned_codex_apps(&self) -> bool {
self.server_provenance == McpServerProvenance::HostOwnedCodexApps
}
}
pub fn declared_openai_file_input_param_names(

View File

@@ -91,6 +91,7 @@ pub use mcp_types::AppToolApproval;
pub use mcp_types::McpServerConfig;
pub use mcp_types::McpServerDisabledReason;
pub use mcp_types::McpServerEnvVar;
pub use mcp_types::McpServerProvenance;
pub use mcp_types::McpServerToolConfig;
pub use mcp_types::McpServerTransportConfig;
pub use mcp_types::RawMcpServerConfig;

View File

@@ -27,6 +27,7 @@ async fn replace_mcp_servers_serializes_per_tool_approval_overrides() -> anyhow:
required: false,
supports_parallel_tool_calls: true,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: Some(AppToolApproval::Auto),

View File

@@ -36,6 +36,14 @@ pub enum McpServerDisabledReason {
Requirements { source: RequirementSource },
}
/// Runtime-only origin marker for an MCP server instance.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum McpServerProvenance {
#[default]
UserConfigured,
HostOwnedCodexApps,
}
impl fmt::Display for McpServerDisabledReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -139,6 +147,10 @@ pub struct McpServerConfig {
#[serde(skip)]
pub disabled_reason: Option<McpServerDisabledReason>,
/// Internal provenance marker for server configs created by Codex itself.
#[serde(skip)]
pub provenance: McpServerProvenance,
/// Startup timeout in seconds for initializing MCP server & initially listing tools.
#[serde(
default,
@@ -334,6 +346,7 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
required: required.unwrap_or_default(),
supports_parallel_tool_calls: supports_parallel_tool_calls.unwrap_or_default(),
disabled_reason: None,
provenance: McpServerProvenance::UserConfigured,
default_tools_approval_mode,
enabled_tools,
disabled_tools,

View File

@@ -387,6 +387,7 @@ fn deserialize_ignores_unknown_server_fields() {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,

View File

@@ -7,6 +7,7 @@ pub use crate::mcp_types::AppToolApproval;
pub use crate::mcp_types::McpServerConfig;
pub use crate::mcp_types::McpServerDisabledReason;
pub use crate::mcp_types::McpServerEnvVar;
pub use crate::mcp_types::McpServerProvenance;
pub use crate::mcp_types::McpServerToolConfig;
pub use crate::mcp_types::McpServerTransportConfig;
pub use crate::mcp_types::RawMcpServerConfig;

View File

@@ -223,6 +223,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -688,6 +689,7 @@ async fn load_plugins_uses_manifest_configured_component_paths() {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -799,6 +801,7 @@ async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -960,6 +963,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,

View File

@@ -104,6 +104,7 @@ fn stdio_mcp(command: &str) -> McpServerConfig {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -128,6 +129,7 @@ fn http_mcp(url: &str) -> McpServerConfig {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -3499,6 +3501,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(Duration::from_secs(3)),
tool_timeout_sec: Some(Duration::from_secs(5)),
default_tools_approval_mode: None,
@@ -3762,6 +3765,7 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -3838,6 +3842,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -3899,6 +3904,7 @@ async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()>
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -3950,6 +3956,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -4004,6 +4011,7 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -4074,6 +4082,7 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -4156,6 +4165,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -4191,6 +4201,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -4261,6 +4272,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -4286,6 +4298,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -4374,6 +4387,7 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -4424,6 +4438,7 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> {
required: true,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -4474,6 +4489,7 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -4528,6 +4544,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,

View File

@@ -869,6 +869,7 @@ fn blocking_replace_mcp_servers_round_trips() {
required: false,
supports_parallel_tool_calls: true,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -898,6 +899,7 @@ fn blocking_replace_mcp_servers_round_trips() {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(std::time::Duration::from_secs(5)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -964,6 +966,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: Some(AppToolApproval::Prompt),
@@ -1028,6 +1031,7 @@ foo = { command = "cmd" }
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -1082,6 +1086,7 @@ foo = { command = "cmd" } # keep me
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -1135,6 +1140,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -1189,6 +1195,7 @@ foo = { command = "cmd" }
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,

View File

@@ -518,7 +518,7 @@ pub(crate) fn accessible_connectors_from_mcp_tools(
// ToolInfo already carries plugin provenance, so app-level plugin sources
// can be derived here instead of requiring a separate enrichment pass.
let tools = mcp_tools.values().filter_map(|tool| {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
if !tool.is_host_owned_codex_apps() {
return None;
}
let connector_id = tool.connector_id.as_deref()?;
@@ -588,7 +588,7 @@ pub(crate) fn app_tool_policy(
}
pub(crate) fn codex_app_tool_is_enabled(config: &Config, tool_info: &ToolInfo) -> bool {
if tool_info.server_name != CODEX_APPS_MCP_SERVER_NAME {
if !tool_info.is_host_owned_codex_apps() {
return true;
}

View File

@@ -7,6 +7,7 @@ use codex_config::CloudRequirementsLoader;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::McpServerProvenance;
use codex_config::types::AppConfig;
use codex_config::types::AppToolConfig;
use codex_config::types::AppToolsConfig;
@@ -117,6 +118,7 @@ fn codex_app_tool(
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
server_provenance: McpServerProvenance::HostOwnedCodexApps,
callable_name: tool_name.to_string(),
callable_namespace: tool_namespace,
namespace_description: None,
@@ -195,6 +197,7 @@ fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() {
"mcp__sample__echo".to_string(),
ToolInfo {
server_name: "sample".to_string(),
server_provenance: Default::default(),
callable_name: "echo".to_string(),
callable_namespace: "sample".to_string(),
namespace_description: None,
@@ -319,6 +322,7 @@ fn accessible_connectors_from_mcp_tools_preserves_description() {
"mcp__codex_apps__calendar_create_event".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
server_provenance: McpServerProvenance::HostOwnedCodexApps,
callable_name: "calendar_create_event".to_string(),
callable_namespace: "mcp__codex_apps__calendar".to_string(),
namespace_description: Some("Plan events".to_string()),

View File

@@ -355,6 +355,7 @@ fn mcp_dependency_to_server_config(
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -384,6 +385,7 @@ fn mcp_dependency_to_server_config(
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,

View File

@@ -39,7 +39,6 @@ use codex_analytics::build_track_events_context;
use codex_config::types::AppToolApproval;
use codex_features::Feature;
use codex_hooks::PermissionRequestDecision;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::McpPermissionPromptAutoApproveContext;
use codex_mcp::SandboxState;
use codex_mcp::declared_openai_file_input_param_names;
@@ -121,13 +120,19 @@ pub(crate) async fn handle_mcp_tool_call(
tool: tool_name.clone(),
arguments: arguments_value.clone(),
};
let is_host_owned_codex_apps_server = sess
.services
.mcp_connection_manager
.read()
.await
.is_host_owned_codex_apps_server(&server);
let metadata =
lookup_mcp_tool_metadata(sess.as_ref(), turn_context.as_ref(), &server, &tool_name).await;
let mcp_app_resource_uri = metadata
.as_ref()
.and_then(|metadata| metadata.mcp_app_resource_uri.clone());
let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME {
let app_tool_policy = if is_host_owned_codex_apps_server {
connectors::app_tool_policy(
&turn_context.config,
metadata
@@ -144,14 +149,14 @@ pub(crate) async fn handle_mcp_tool_call(
} else {
connectors::AppToolPolicy::default()
};
let approval_mode = if server == CODEX_APPS_MCP_SERVER_NAME {
let approval_mode = if is_host_owned_codex_apps_server {
app_tool_policy.approval
} else {
custom_mcp_tool_approval_mode(sess.as_ref(), turn_context.as_ref(), &server, &tool_name)
.await
};
if server == CODEX_APPS_MCP_SERVER_NAME && !app_tool_policy.enabled {
if is_host_owned_codex_apps_server && !app_tool_policy.enabled {
let result = notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context.as_ref(),
@@ -176,7 +181,7 @@ pub(crate) async fn handle_mcp_tool_call(
}
let request_meta = build_mcp_tool_call_request_meta(
turn_context.as_ref(),
&server,
is_host_owned_codex_apps_server,
&call_id,
metadata.as_ref(),
);
@@ -308,6 +313,12 @@ async fn handle_approved_mcp_tool_call(
maybe_mark_thread_memory_mode_polluted(sess, turn_context).await;
let server = invocation.server.clone();
let is_host_owned_codex_apps_server = sess
.services
.mcp_connection_manager
.read()
.await
.is_host_owned_codex_apps_server(&server);
let tool_name = invocation.tool.clone();
let arguments_value = invocation.arguments.clone();
let connector_id = metadata.and_then(|metadata| metadata.connector_id.as_deref());
@@ -375,7 +386,14 @@ async fn handle_approved_mcp_tool_call(
truncate_mcp_tool_result_for_event(&result),
)
.await;
maybe_track_codex_app_used(sess, turn_context, &server, &tool_name).await;
maybe_track_codex_app_used(
sess,
turn_context,
&server,
&tool_name,
is_host_owned_codex_apps_server,
)
.await;
let status = if result.is_ok() { "ok" } else { "error" };
emit_mcp_call_metrics(
@@ -777,8 +795,9 @@ async fn maybe_track_codex_app_used(
turn_context: &TurnContext,
server: &str,
tool_name: &str,
is_host_owned_codex_apps_server: bool,
) {
if server != CODEX_APPS_MCP_SERVER_NAME {
if !is_host_owned_codex_apps_server {
return;
}
let metadata = lookup_mcp_app_usage_metadata(sess, server, tool_name).await;
@@ -889,7 +908,7 @@ async fn custom_mcp_tool_approval_mode(
fn build_mcp_tool_call_request_meta(
turn_context: &TurnContext,
server: &str,
is_host_owned_codex_apps_server: bool,
call_id: &str,
metadata: Option<&McpToolApprovalMetadata>,
) -> Option<serde_json::Value> {
@@ -902,7 +921,7 @@ fn build_mcp_tool_call_request_meta(
);
}
if server == CODEX_APPS_MCP_SERVER_NAME {
if is_host_owned_codex_apps_server {
let mut codex_apps_meta = metadata
.and_then(|metadata| metadata.codex_apps_meta.clone())
.unwrap_or_default();
@@ -951,6 +970,7 @@ struct McpToolApprovalPromptOptions {
struct McpToolApprovalElicitationRequest<'a> {
server: &'a str,
is_host_owned_codex_apps_server: bool,
metadata: Option<&'a McpToolApprovalMetadata>,
tool_params: Option<&'a serde_json::Value>,
tool_params_display: Option<&'a [RenderedMcpToolApprovalParam]>,
@@ -995,6 +1015,7 @@ pub(crate) fn is_mcp_tool_approval_question_id(question_id: &str) -> bool {
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
struct McpToolApprovalKey {
server: String,
is_host_owned_codex_apps_server: bool,
connector_id: Option<String>,
tool_name: String,
}
@@ -1020,6 +1041,12 @@ async fn maybe_request_mcp_tool_approval(
metadata: Option<&McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
) -> Option<McpToolApprovalDecision> {
let is_host_owned_codex_apps_server = sess
.services
.mcp_connection_manager
.read()
.await
.is_host_owned_codex_apps_server(&invocation.server);
if mcp_permission_prompt_is_auto_approved(
turn_context.approval_policy.value(),
&turn_context.permission_profile(),
@@ -1062,9 +1089,18 @@ async fn maybe_request_mcp_tool_approval(
}
}
let session_approval_key = session_mcp_tool_approval_key(invocation, metadata, approval_mode);
let persistent_approval_key =
persistent_mcp_tool_approval_key(invocation, metadata, approval_mode);
let session_approval_key = session_mcp_tool_approval_key(
invocation,
metadata,
approval_mode,
is_host_owned_codex_apps_server,
);
let persistent_approval_key = persistent_mcp_tool_approval_key(
invocation,
metadata,
approval_mode,
is_host_owned_codex_apps_server,
);
if let Some(key) = session_approval_key.as_ref()
&& mcp_tool_approval_is_remembered(sess, key).await
{
@@ -1143,6 +1179,7 @@ async fn maybe_request_mcp_tool_approval(
let mut question = build_mcp_tool_approval_question(
question_id.clone(),
&invocation.server,
is_host_owned_codex_apps_server,
&invocation.tool,
metadata.and_then(|metadata| metadata.connector_name.as_deref()),
prompt_options,
@@ -1161,6 +1198,7 @@ async fn maybe_request_mcp_tool_approval(
turn_context.as_ref(),
McpToolApprovalElicitationRequest {
server: &invocation.server,
is_host_owned_codex_apps_server,
metadata,
tool_params: rendered_template
.as_ref()
@@ -1249,18 +1287,20 @@ fn session_mcp_tool_approval_key(
invocation: &McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
is_host_owned_codex_apps_server: bool,
) -> Option<McpToolApprovalKey> {
if approval_mode != AppToolApproval::Auto {
return None;
}
let connector_id = metadata.and_then(|metadata| metadata.connector_id.clone());
if invocation.server == CODEX_APPS_MCP_SERVER_NAME && connector_id.is_none() {
if is_host_owned_codex_apps_server && connector_id.is_none() {
return None;
}
Some(McpToolApprovalKey {
server: invocation.server.clone(),
is_host_owned_codex_apps_server,
connector_id,
tool_name: invocation.tool.clone(),
})
@@ -1270,8 +1310,14 @@ fn persistent_mcp_tool_approval_key(
invocation: &McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
is_host_owned_codex_apps_server: bool,
) -> Option<McpToolApprovalKey> {
session_mcp_tool_approval_key(invocation, metadata, approval_mode)
session_mcp_tool_approval_key(
invocation,
metadata,
approval_mode,
is_host_owned_codex_apps_server,
)
}
pub(crate) fn build_guardian_mcp_tool_review_request(
@@ -1341,6 +1387,12 @@ pub(crate) async fn lookup_mcp_tool_metadata(
server: &str,
tool_name: &str,
) -> Option<McpToolApprovalMetadata> {
let is_host_owned_codex_apps_server = sess
.services
.mcp_connection_manager
.read()
.await
.is_host_owned_codex_apps_server(server);
let tools = sess
.services
.mcp_connection_manager
@@ -1351,7 +1403,7 @@ pub(crate) async fn lookup_mcp_tool_metadata(
let tool_info = tools
.into_values()
.find(|tool_info| tool_info.server_name == server && tool_info.tool.name == tool_name)?;
let connector_description = if server == CODEX_APPS_MCP_SERVER_NAME {
let connector_description = if is_host_owned_codex_apps_server {
let connectors = match connectors::list_cached_accessible_connectors_from_mcp_tools(
turn_context.config.as_ref(),
)
@@ -1392,17 +1444,17 @@ pub(crate) async fn lookup_mcp_tool_metadata(
.cloned(),
// Disallow custom MCPs from uploading files via fileParams.
openai_file_input_params: openai_file_input_params_for_server(
server,
is_host_owned_codex_apps_server,
tool_info.tool.meta.as_deref(),
),
})
}
fn openai_file_input_params_for_server(
server: &str,
is_host_owned_codex_apps_server: bool,
meta: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Option<Vec<String>> {
(server == CODEX_APPS_MCP_SERVER_NAME)
is_host_owned_codex_apps_server
.then_some(declared_openai_file_input_param_names(meta))
.filter(|params| !params.is_empty())
}
@@ -1459,6 +1511,7 @@ async fn lookup_mcp_app_usage_metadata(
fn build_mcp_tool_approval_question(
question_id: String,
server: &str,
is_host_owned_codex_apps_server: bool,
tool_name: &str,
connector_name: Option<&str>,
prompt_options: McpToolApprovalPromptOptions,
@@ -1467,7 +1520,12 @@ fn build_mcp_tool_approval_question(
let question = question_override
.map(ToString::to_string)
.unwrap_or_else(|| {
build_mcp_tool_approval_fallback_message(server, tool_name, connector_name)
build_mcp_tool_approval_fallback_message(
server,
is_host_owned_codex_apps_server,
tool_name,
connector_name,
)
});
let question = format!("{}?", question.trim_end_matches('?'));
@@ -1504,6 +1562,7 @@ fn build_mcp_tool_approval_question(
fn build_mcp_tool_approval_fallback_message(
server: &str,
is_host_owned_codex_apps_server: bool,
tool_name: &str,
connector_name: Option<&str>,
) -> String {
@@ -1512,7 +1571,7 @@ fn build_mcp_tool_approval_fallback_message(
.filter(|name| !name.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| {
if server == CODEX_APPS_MCP_SERVER_NAME {
if is_host_owned_codex_apps_server {
"this app".to_string()
} else {
format!("the {server} MCP server")
@@ -1555,7 +1614,7 @@ fn build_mcp_tool_approval_elicitation_request(
server_name: request.server.to_string(),
request: McpServerElicitationRequest::Form {
meta: build_mcp_tool_approval_elicitation_meta(
request.server,
request.is_host_owned_codex_apps_server,
request.metadata,
request.tool_params,
request.tool_params_display,
@@ -1573,7 +1632,7 @@ fn build_mcp_tool_approval_elicitation_request(
}
fn build_mcp_tool_approval_elicitation_meta(
server: &str,
is_host_owned_codex_apps_server: bool,
metadata: Option<&McpToolApprovalMetadata>,
tool_params: Option<&serde_json::Value>,
tool_params_display: Option<&[RenderedMcpToolApprovalParam]>,
@@ -1624,7 +1683,7 @@ fn build_mcp_tool_approval_elicitation_meta(
serde_json::Value::String(tool_description.clone()),
);
}
if server == CODEX_APPS_MCP_SERVER_NAME
if is_host_owned_codex_apps_server
&& (metadata.connector_id.is_some()
|| metadata.connector_name.is_some()
|| metadata.connector_description.is_some())
@@ -1852,7 +1911,7 @@ async fn maybe_persist_mcp_tool_approval(
) {
let tool_name = key.tool_name.clone();
let persist_result = if key.server == CODEX_APPS_MCP_SERVER_NAME {
let persist_result = if key.is_host_owned_codex_apps_server {
let Some(connector_id) = key.connector_id.clone() else {
remember_mcp_tool_approval(sess, key).await;
return;

View File

@@ -15,6 +15,7 @@ use codex_config::types::McpServerConfig;
use codex_config::types::McpServerToolConfig;
use codex_hooks::Hooks;
use codex_hooks::HooksConfig;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_model_provider::create_model_provider;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
@@ -219,11 +220,11 @@ fn openai_file_params_are_only_honored_for_codex_apps() {
let meta = meta.as_object();
assert_eq!(
openai_file_input_params_for_server(CODEX_APPS_MCP_SERVER_NAME, meta),
openai_file_input_params_for_server(/*is_host_owned_codex_apps_server*/ true, meta),
Some(vec!["file".to_string()])
);
assert_eq!(
openai_file_input_params_for_server("minimaltest", meta),
openai_file_input_params_for_server(/*is_host_owned_codex_apps_server*/ false, meta),
None
);
}
@@ -483,6 +484,7 @@ async fn approval_elicitation_request_uses_message_override_and_preserves_tool_p
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
/*is_host_owned_codex_apps_server*/ true,
"create_event",
Some("Calendar"),
prompt_options(
@@ -496,6 +498,7 @@ async fn approval_elicitation_request_uses_message_override_and_preserves_tool_p
&turn_context,
McpToolApprovalElicitationRequest {
server: CODEX_APPS_MCP_SERVER_NAME,
is_host_owned_codex_apps_server: true,
metadata: Some(&approval_metadata(
Some("calendar"),
Some("Calendar"),
@@ -580,6 +583,7 @@ fn custom_mcp_tool_question_mentions_server_name() {
let question = build_mcp_tool_approval_question(
"q".to_string(),
"custom_server",
/*is_host_owned_codex_apps_server*/ false,
"run_action",
/*connector_name*/ None,
prompt_options(
@@ -608,6 +612,7 @@ fn codex_apps_tool_question_uses_fallback_app_label() {
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
/*is_host_owned_codex_apps_server*/ true,
"run_action",
/*connector_name*/ None,
prompt_options(
@@ -622,11 +627,32 @@ fn codex_apps_tool_question_uses_fallback_app_label() {
);
}
#[test]
fn spoofed_codex_apps_server_name_uses_custom_server_fallback() {
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
/*is_host_owned_codex_apps_server*/ false,
"run_action",
/*connector_name*/ None,
prompt_options(
/*allow_session_remember*/ true, /*allow_persistent_approval*/ true,
),
/*question_override*/ None,
);
assert_eq!(
question.question,
"Allow the codex_apps MCP server to run tool \"run_action\"?"
);
}
#[test]
fn trusted_codex_apps_tool_question_offers_always_allow() {
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
/*is_host_owned_codex_apps_server*/ true,
"run_action",
Some("Calendar"),
prompt_options(
@@ -662,6 +688,7 @@ fn trusted_codex_apps_tool_question_offers_always_allow() {
fn codex_apps_tool_question_without_elicitation_omits_always_allow() {
let session_key = McpToolApprovalKey {
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
is_host_owned_codex_apps_server: true,
connector_id: Some("calendar".to_string()),
tool_name: "run_action".to_string(),
};
@@ -669,6 +696,7 @@ fn codex_apps_tool_question_without_elicitation_omits_always_allow() {
let question = build_mcp_tool_approval_question(
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
/*is_host_owned_codex_apps_server*/ true,
"run_action",
Some("Calendar"),
mcp_tool_approval_prompt_options(
@@ -699,6 +727,7 @@ fn custom_mcp_tool_question_offers_session_remember_and_always_allow() {
let question = build_mcp_tool_approval_question(
"q".to_string(),
"custom_server",
/*is_host_owned_codex_apps_server*/ false,
"run_action",
/*connector_name*/ None,
prompt_options(
@@ -732,19 +761,26 @@ fn custom_servers_support_session_and_persistent_approval() {
};
let expected = McpToolApprovalKey {
server: "custom_server".to_string(),
is_host_owned_codex_apps_server: false,
connector_id: None,
tool_name: "run_action".to_string(),
};
assert_eq!(
session_mcp_tool_approval_key(&invocation, /*metadata*/ None, AppToolApproval::Auto),
session_mcp_tool_approval_key(
&invocation,
/*metadata*/ None,
AppToolApproval::Auto,
/*is_host_owned_codex_apps_server*/ false,
),
Some(expected.clone())
);
assert_eq!(
persistent_mcp_tool_approval_key(
&invocation,
/*metadata*/ None,
AppToolApproval::Auto
AppToolApproval::Auto,
/*is_host_owned_codex_apps_server*/ false,
),
Some(expected)
);
@@ -766,16 +802,27 @@ fn codex_apps_connectors_support_persistent_approval() {
);
let expected = McpToolApprovalKey {
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
is_host_owned_codex_apps_server: true,
connector_id: Some("calendar".to_string()),
tool_name: "calendar/list_events".to_string(),
};
assert_eq!(
session_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto),
session_mcp_tool_approval_key(
&invocation,
Some(&metadata),
AppToolApproval::Auto,
/*is_host_owned_codex_apps_server*/ true,
),
Some(expected.clone())
);
assert_eq!(
persistent_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto),
persistent_mcp_tool_approval_key(
&invocation,
Some(&metadata),
AppToolApproval::Auto,
/*is_host_owned_codex_apps_server*/ true,
),
Some(expected)
);
}
@@ -918,7 +965,7 @@ async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() {
let meta = build_mcp_tool_call_request_meta(
&turn_context,
"custom_server",
/*is_host_owned_codex_apps_server*/ false,
"call-custom",
/*metadata*/ None,
)
@@ -941,7 +988,7 @@ async fn mcp_tool_call_request_meta_includes_turn_started_at_unix_ms() {
let meta = build_mcp_tool_call_request_meta(
&turn_context,
"custom_server",
/*is_host_owned_codex_apps_server*/ false,
"call-custom",
/*metadata*/ None,
)
@@ -992,7 +1039,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps
assert_eq!(
build_mcp_tool_call_request_meta(
&turn_context,
CODEX_APPS_MCP_SERVER_NAME,
/*is_host_owned_codex_apps_server*/ true,
"call_abc123xyz789",
Some(&metadata),
),
@@ -1022,7 +1069,7 @@ async fn codex_apps_tool_call_request_meta_includes_call_id_without_existing_cod
assert_eq!(
build_mcp_tool_call_request_meta(
&turn_context,
CODEX_APPS_MCP_SERVER_NAME,
/*is_host_owned_codex_apps_server*/ true,
"call_abc123xyz789",
/*metadata*/ None,
),
@@ -1089,7 +1136,7 @@ fn accepted_elicitation_content_converts_to_request_user_input_response() {
fn approval_elicitation_meta_marks_tool_approvals() {
assert_eq!(
build_mcp_tool_approval_elicitation_meta(
"custom_server",
/*is_host_owned_codex_apps_server*/ false,
/*metadata*/ None,
/*tool_params*/ None,
/*tool_params_display*/ None,
@@ -1107,7 +1154,7 @@ fn approval_elicitation_meta_marks_tool_approvals() {
fn approval_elicitation_meta_merges_session_and_always_persist_for_custom_servers() {
assert_eq!(
build_mcp_tool_approval_elicitation_meta(
"custom_server",
/*is_host_owned_codex_apps_server*/ false,
Some(&approval_metadata(
/*connector_id*/ None,
/*connector_name*/ None,
@@ -1319,7 +1366,7 @@ async fn guardian_review_decision_maps_to_mcp_tool_decision() {
fn approval_elicitation_meta_includes_connector_source_for_codex_apps() {
assert_eq!(
build_mcp_tool_approval_elicitation_meta(
CODEX_APPS_MCP_SERVER_NAME,
/*is_host_owned_codex_apps_server*/ true,
Some(&approval_metadata(
Some("calendar"),
Some("Calendar"),
@@ -1354,7 +1401,7 @@ fn approval_elicitation_meta_includes_connector_source_for_codex_apps() {
fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_source() {
assert_eq!(
build_mcp_tool_approval_elicitation_meta(
CODEX_APPS_MCP_SERVER_NAME,
/*is_host_owned_codex_apps_server*/ true,
Some(&approval_metadata(
Some("calendar"),
Some("Calendar"),
@@ -1697,6 +1744,7 @@ async fn maybe_persist_mcp_tool_approval_reloads_session_config() {
std::fs::create_dir_all(&codex_home).expect("create codex home");
let key = McpToolApprovalKey {
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
is_host_owned_codex_apps_server: true,
connector_id: Some("calendar".to_string()),
tool_name: "calendar/list_events".to_string(),
};
@@ -1741,6 +1789,7 @@ async fn maybe_persist_mcp_tool_approval_reloads_session_config_for_custom_serve
.expect("seed config");
let key = McpToolApprovalKey {
server: "docs".to_string(),
is_host_owned_codex_apps_server: false,
connector_id: None,
tool_name: "search".to_string(),
};
@@ -1796,6 +1845,7 @@ enabled = true
session.services.plugins_manager.clear_cache();
let key = McpToolApprovalKey {
server: "sample".to_string(),
is_host_owned_codex_apps_server: false,
connector_id: None,
tool_name: "search".to_string(),
};
@@ -1852,6 +1902,7 @@ async fn maybe_persist_mcp_tool_approval_writes_project_config_for_project_serve
turn_context.config = Arc::new(config);
let key = McpToolApprovalKey {
server: "docs".to_string(),
is_host_owned_codex_apps_server: false,
connector_id: None,
tool_name: "search".to_string(),
};
@@ -2162,9 +2213,13 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() {
codex_apps_meta: None,
openai_file_input_params: None,
};
let remembered_key =
session_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto)
.expect("memory MCP tool should support session approval");
let remembered_key = session_mcp_tool_approval_key(
&invocation,
Some(&metadata),
AppToolApproval::Auto,
/*is_host_owned_codex_apps_server*/ false,
)
.expect("memory MCP tool should support session approval");
remember_mcp_tool_approval(&session, remembered_key).await;
let session = Arc::new(session);

View File

@@ -2,7 +2,6 @@ use std::collections::HashMap;
use std::collections::HashSet;
use codex_features::Feature;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::ToolInfo as McpToolInfo;
use codex_mcp::filter_non_codex_apps_mcp_tools_only;
use codex_tools::ToolsConfig;
@@ -71,7 +70,7 @@ fn filter_codex_apps_mcp_tools(
mcp_tools
.iter()
.filter(|(_, tool)| {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
if !tool.is_host_owned_codex_apps() {
return false;
}
let Some(connector_id) = tool.connector_id.as_deref() else {

View File

@@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use codex_config::McpServerProvenance;
use codex_connectors::metadata::sanitize_name;
use codex_features::Feature;
use codex_features::Features;
@@ -56,6 +57,11 @@ fn make_mcp_tool(
ToolInfo {
server_name: server_name.to_string(),
server_provenance: if server_name == CODEX_APPS_MCP_SERVER_NAME {
McpServerProvenance::HostOwnedCodexApps
} else {
McpServerProvenance::UserConfigured
},
callable_name: tool_name.to_string(),
callable_namespace: tool_namespace,
namespace_description: None,

View File

@@ -9,7 +9,6 @@ use crate::context::ContextualUserFragment;
use crate::context::PluginInstructions;
use crate::plugins::PluginCapabilitySummary;
use crate::plugins::render_explicit_plugin_instructions;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::ToolInfo;
pub(crate) fn build_plugin_injections(
@@ -29,7 +28,7 @@ pub(crate) fn build_plugin_injections(
let available_mcp_servers = mcp_tools
.values()
.filter(|tool| {
tool.server_name != CODEX_APPS_MCP_SERVER_NAME
!tool.is_host_owned_codex_apps()
&& tool
.plugin_display_names
.iter()

View File

@@ -390,6 +390,7 @@ mod tests {
fn tool_info(server_name: &str, tool_name: &str, description_prefix: &str) -> ToolInfo {
ToolInfo {
server_name: server_name.to_string(),
server_provenance: Default::default(),
callable_name: tool_name.to_string(),
callable_namespace: format!("mcp__{server_name}__"),
namespace_description: None,

View File

@@ -60,6 +60,7 @@ fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> r
fn mcp_tool_info(tool: rmcp::model::Tool) -> ToolInfo {
ToolInfo {
server_name: "test_server".to_string(),
server_provenance: Default::default(),
callable_name: tool.name.to_string(),
callable_namespace: "mcp__test_server__".to_string(),
namespace_description: None,
@@ -78,6 +79,7 @@ fn mcp_tool_info_with_display_name(display_name: &str, tool: rmcp::model::Tool)
ToolInfo {
server_name: "test_server".to_string(),
server_provenance: Default::default(),
callable_name,
callable_namespace,
namespace_description: None,
@@ -894,6 +896,7 @@ async fn search_tool_description_falls_back_to_connector_name_without_descriptio
"mcp__codex_apps__calendar_create_event".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
server_provenance: codex_config::McpServerProvenance::HostOwnedCodexApps,
callable_name: "_create_event".to_string(),
callable_namespace: "mcp__codex_apps__calendar".to_string(),
namespace_description: None,
@@ -945,6 +948,7 @@ async fn search_tool_registers_namespaced_mcp_tool_aliases() {
"mcp__codex_apps__calendar_create_event".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
server_provenance: codex_config::McpServerProvenance::HostOwnedCodexApps,
callable_name: "_create_event".to_string(),
callable_namespace: "mcp__codex_apps__calendar".to_string(),
namespace_description: None,
@@ -962,6 +966,7 @@ async fn search_tool_registers_namespaced_mcp_tool_aliases() {
"mcp__codex_apps__calendar_list_events".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
server_provenance: codex_config::McpServerProvenance::HostOwnedCodexApps,
callable_name: "_list_events".to_string(),
callable_namespace: "mcp__codex_apps__calendar".to_string(),
namespace_description: None,
@@ -979,6 +984,7 @@ async fn search_tool_registers_namespaced_mcp_tool_aliases() {
"mcp__rmcp__echo".to_string(),
ToolInfo {
server_name: "rmcp".to_string(),
server_provenance: Default::default(),
callable_name: "echo".to_string(),
callable_namespace: "mcp__rmcp__".to_string(),
namespace_description: None,

View File

@@ -238,6 +238,7 @@ async fn run_code_mode_turn_with_rmcp_config(
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
default_tools_approval_mode: None,

View File

@@ -143,6 +143,7 @@ fn insert_rmcp_test_server(config: &mut Config, command: String, approval_mode:
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
default_tools_approval_mode: Some(approval_mode),

View File

@@ -315,6 +315,7 @@ fn insert_mcp_server(
required: false,
supports_parallel_tool_calls: options.supports_parallel_tool_calls,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: options.tool_timeout_sec,
default_tools_approval_mode: None,

View File

@@ -960,6 +960,7 @@ async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> {
enabled: true,
required: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -1078,6 +1079,7 @@ async fn tool_search_uses_non_app_mcp_server_instructions_as_namespace_descripti
enabled: true,
required: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
default_tools_approval_mode: None,

View File

@@ -379,6 +379,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result<
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
default_tools_approval_mode: None,

View File

@@ -250,6 +250,7 @@ async fn historical_unavailable_mcp_call_is_exposed_as_placeholder_tool() -> Res
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
default_tools_approval_mode: None,

View File

@@ -392,6 +392,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(std::time::Duration::from_secs(10)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -491,6 +492,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
default_tools_approval_mode: None,
@@ -773,6 +775,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
provenance: Default::default(),
startup_timeout_sec: Some(std::time::Duration::from_secs(10)),
tool_timeout_sec: None,
default_tools_approval_mode: None,