[apps] Add apps MCP path override (#20231)

Summary

- Add `[features.apps_mcp_path_override]` config with a `path` field for
overriding only the built-in apps MCP path.
- Keep existing host/base URL derivation unchanged and append the
configured path after that base.
- Regenerate the config schema with the custom feature-config case.

Test Plan

- Not run for latest revision; only `just fmt` and `just
write-config-schema` were run.
- Earlier revision: `cargo test -p codex-features`
- Earlier revision: `cargo test -p codex-mcp`
This commit is contained in:
Alex Daley
2026-04-29 18:08:06 -04:00
committed by GitHub
Unverified
parent 8d5da3ffe5
commit f63b19bedd
9 changed files with 183 additions and 11 deletions
+16 -7
View File
@@ -93,6 +93,8 @@ pub fn mcp_permission_prompt_is_auto_approved(
pub struct McpConfig {
/// Base URL for ChatGPT-hosted app MCP servers, copied from the root config.
pub chatgpt_base_url: String,
/// Optional path override for the built-in apps MCP server.
pub apps_mcp_path_override: Option<String>,
/// Codex home directory used for MCP OAuth state and app-tool cache files.
pub codex_home: PathBuf,
/// Preferred credential store for MCP OAuth tokens.
@@ -333,7 +335,10 @@ pub async fn collect_mcp_snapshot_from_manager(
}
pub(crate) fn codex_apps_mcp_url(config: &McpConfig) -> String {
codex_apps_mcp_url_for_base_url(&config.chatgpt_base_url)
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_-]+$`.
@@ -376,15 +381,19 @@ fn normalize_codex_apps_base_url(base_url: &str) -> String {
base_url
}
fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String {
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);
if base_url.contains("/backend-api") {
format!("{base_url}/wham/apps")
let (base_url, default_path) = if base_url.contains("/backend-api") {
(base_url, "wham/apps")
} else if base_url.contains("/api/codex") {
format!("{base_url}/apps")
(base_url, "apps")
} else {
format!("{base_url}/api/codex/apps")
}
(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 {
+36 -4
View File
@@ -14,6 +14,7 @@ use std::path::PathBuf;
fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
McpConfig {
chatgpt_base_url: "https://chatgpt.com".to_string(),
apps_mcp_path_override: None,
codex_home,
mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::default(),
mcp_oauth_callback_port: None,
@@ -109,19 +110,31 @@ 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"),
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"),
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"),
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"),
codex_apps_mcp_url_for_base_url(
"http://localhost:8080",
/*apps_mcp_path_override*/ None,
),
"http://localhost:8080/api/codex/apps"
);
}
@@ -158,6 +171,25 @@ 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_uses_configured_apps_mcp_path_override() {
let mut config = test_mcp_config(PathBuf::from("/tmp"));
config.apps_mcp_path_override = Some("/custom/mcp".to_string());
config.apps_enabled = true;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let servers = with_codex_apps_mcp(HashMap::new(), Some(&auth), &config);
let server = servers
.get(CODEX_APPS_MCP_SERVER_NAME)
.expect("codex apps should be present when apps is enabled");
let url = match &server.transport {
McpServerTransportConfig::StreamableHttp { url, .. } => url,
_ => panic!("expected streamable http transport for codex apps"),
};
assert_eq!(url, "https://chatgpt.com/backend-api/custom/mcp");
}
#[tokio::test]
async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
let codex_home = tempfile::tempdir().expect("tempdir");
+9
View File
@@ -34,6 +34,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema {
);
continue;
}
if feature.id == codex_features::Feature::AppsMcpPathOverride {
validation.properties.insert(
feature.key.to_string(),
schema_gen.subschema_for::<codex_features::FeatureToml<
codex_features::AppsMcpPathOverrideConfigToml,
>>(),
);
continue;
}
validation
.properties
.insert(feature.key.to_string(), schema_gen.subschema_for::<bool>());
+28
View File
@@ -218,6 +218,18 @@
},
"type": "object"
},
"AppsMcpPathOverrideConfigToml": {
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"path": {
"type": "string"
}
},
"type": "object"
},
"AskForApproval": {
"description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.",
"oneOf": [
@@ -355,6 +367,9 @@
"apps": {
"type": "boolean"
},
"apps_mcp_path_override": {
"$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml"
},
"browser_use": {
"type": "boolean"
},
@@ -705,6 +720,16 @@
},
"type": "object"
},
"FeatureToml_for_AppsMcpPathOverrideConfigToml": {
"anyOf": [
{
"type": "boolean"
},
{
"$ref": "#/definitions/AppsMcpPathOverrideConfigToml"
}
]
},
"FeatureToml_for_MultiAgentV2ConfigToml": {
"anyOf": [
{
@@ -3297,6 +3322,9 @@
"apps": {
"type": "boolean"
},
"apps_mcp_path_override": {
"$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml"
},
"browser_use": {
"type": "boolean"
},
+35
View File
@@ -3241,8 +3241,13 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<(
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
config.apps_mcp_path_override = Some("/custom/mcp".to_string());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert!(mcp_config.apps_enabled);
assert_eq!(
mcp_config.apps_mcp_path_override.as_deref(),
Some("/custom/mcp")
);
let _ = config.features.disable(Feature::Apps);
let mcp_config = config.to_mcp_config(&plugins_manager).await;
@@ -5945,6 +5950,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
model_verbosity: None,
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
apps_mcp_path_override: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
@@ -6139,6 +6145,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
model_verbosity: None,
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
apps_mcp_path_override: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
@@ -6287,6 +6294,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
model_verbosity: None,
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
apps_mcp_path_override: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
@@ -6420,6 +6428,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
model_verbosity: Some(Verbosity::High),
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
apps_mcp_path_override: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_start_instructions: None,
experimental_realtime_ws_base_url: None,
@@ -7058,6 +7067,32 @@ allow_login_shell = false
Ok(())
}
#[tokio::test]
async fn config_loads_apps_mcp_path_override_from_feature_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let toml = r#"
model = "gpt-5.4"
[features.apps_mcp_path_override]
path = "/custom/mcp"
"#;
let cfg: ConfigToml =
toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert_eq!(
config.apps_mcp_path_override.as_deref(),
Some("/custom/mcp")
);
Ok(())
}
#[tokio::test]
async fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+25
View File
@@ -55,6 +55,7 @@ use codex_config::types::UriBasedFileOpener;
use codex_config::types::WindowsSandboxModeToml;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::LOCAL_FS;
use codex_features::AppsMcpPathOverrideConfigToml;
use codex_features::Feature;
use codex_features::FeatureConfigSource;
use codex_features::FeatureOverrides;
@@ -647,6 +648,9 @@ pub struct Config {
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
pub chatgpt_base_url: String,
/// Optional path override for the built-in apps MCP server.
pub apps_mcp_path_override: Option<String>,
/// Machine-local realtime audio device preferences used by realtime voice.
pub realtime_audio: RealtimeAudioConfig,
@@ -977,6 +981,7 @@ impl Config {
McpConfig {
chatgpt_base_url: self.chatgpt_base_url.clone(),
apps_mcp_path_override: self.apps_mcp_path_override.clone(),
codex_home: self.codex_home.to_path_buf(),
mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode,
mcp_oauth_callback_port: self.mcp_oauth_callback_port,
@@ -1786,6 +1791,15 @@ fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiA
}
}
fn apps_mcp_path_override_toml_config(
features: Option<&FeaturesToml>,
) -> Option<&AppsMcpPathOverrideConfigToml> {
match features?.apps_mcp_path_override.as_ref()? {
FeatureToml::Enabled(_) => None,
FeatureToml::Config(config) => Some(config),
}
}
pub(crate) fn resolve_web_search_mode_for_turn(
web_search_mode: &Constrained<WebSearchMode>,
permission_profile: &PermissionProfile,
@@ -2237,6 +2251,16 @@ impl Config {
.unwrap_or(WebSearchMode::Cached);
let web_search_config = resolve_web_search_config(&cfg, &config_profile);
let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg, &config_profile);
let apps_mcp_path_override = if features.enabled(Feature::AppsMcpPathOverride) {
let base = apps_mcp_path_override_toml_config(cfg.features.as_ref());
let profile = apps_mcp_path_override_toml_config(config_profile.features.as_ref());
profile
.and_then(|config| config.path.as_ref())
.or_else(|| base.and_then(|config| config.path.as_ref()))
.cloned()
} else {
None
};
let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg);
let agent_roles =
@@ -2738,6 +2762,7 @@ impl Config {
.chatgpt_base_url
.or(cfg.chatgpt_base_url)
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
apps_mcp_path_override,
realtime_audio: cfg
.audio
.map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig {
+15
View File
@@ -31,3 +31,18 @@ impl FeatureConfig for MultiAgentV2ConfigToml {
self.enabled
}
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AppsMcpPathOverrideConfigToml {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
impl FeatureConfig for AppsMcpPathOverrideConfigToml {
fn enabled(&self) -> Option<bool> {
self.enabled.or(self.path.as_ref().map(|_| true))
}
}
+18
View File
@@ -16,6 +16,7 @@ use toml::Table;
mod feature_configs;
mod legacy;
pub use feature_configs::AppsMcpPathOverrideConfigToml;
pub use feature_configs::MultiAgentV2ConfigToml;
use legacy::LegacyFeatureToggles;
pub use legacy::legacy_feature_keys;
@@ -149,6 +150,8 @@ pub enum Feature {
Apps,
/// Enable MCP apps.
EnableMcpApps,
/// Use the new path for the built-in apps MCP server.
AppsMcpPathOverride,
/// Enable the tool_search tool for apps.
ToolSearch,
/// Always defer MCP tools behind tool_search instead of exposing small sets directly.
@@ -557,6 +560,8 @@ pub fn is_known_feature_key(key: &str) -> bool {
pub struct FeaturesToml {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub multi_agent_v2: Option<FeatureToml<MultiAgentV2ConfigToml>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apps_mcp_path_override: Option<FeatureToml<AppsMcpPathOverrideConfigToml>>,
/// Boolean feature toggles keyed by canonical or legacy feature name.
#[serde(flatten)]
entries: BTreeMap<String, bool>,
@@ -575,6 +580,13 @@ impl FeaturesToml {
if let Some(enabled) = self.multi_agent_v2.as_ref().and_then(FeatureToml::enabled) {
entries.insert(Feature::MultiAgentV2.key().to_string(), enabled);
}
if let Some(enabled) = self
.apps_mcp_path_override
.as_ref()
.and_then(FeatureToml::enabled)
{
entries.insert(Feature::AppsMcpPathOverride.key().to_string(), enabled);
}
entries
}
}
@@ -848,6 +860,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::AppsMcpPathOverride,
key: "apps_mcp_path_override",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ToolSearch,
key: "tool_search",
@@ -237,6 +237,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
model_catalog: None,
model_verbosity: None,
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
apps_mcp_path_override: None,
realtime_audio: RealtimeAudioConfig::default(),
experimental_realtime_ws_base_url: None,
experimental_realtime_ws_model: None,