Files
codex/codex-rs/ext/mcp/tests/hosted_apps_mcp.rs
T
Matthew Zeng cb2581dae8 Preserve MCP disable reasons across runtime overlays (#56266)
## Why

A requirements denial on a custom `codex_apps` registration could block the trusted hosted Apps replacement. Requirements filtering also lost whether a server had been explicitly disabled, which must remain effective across runtime overlays.

## What changed

- Track explicit disables separately from requirements denials, retaining the requirement source across catalog rebuilds and server materialization.
- Allow trusted host-owned Apps to bypass a previous registration's requirements denial while preserving explicit disables and blocking later custom replacements.
- Preserve combined explicit and requirements disables during repeated filtering, and reject those servers when preparing standalone MCP login HTTP clients.

## Testing

Extend regression coverage for hosted Apps replacements, later custom replacements, cloud catalog refreshes, repeated requirements filtering, and standalone login rejection.

GitOrigin-RevId: 736a13c40fbf7436b4d0682f70c335f33f45962f
2026-10-22 21:11:21 +00:00

234 lines
8.3 KiB
Rust

use std::sync::Arc;
use codex_config::McpServerTransportConfig;
use codex_config::test_support::CloudConfigBundleFixture;
use codex_core::McpManager;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::plugins_manager_for_config;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::McpServerContribution;
use codex_extension_api::McpServerContributionContext;
use codex_extension_api::McpServerContributor;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::test_support::auth_manager_from_optional_auth;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use pretty_assertions::assert_eq;
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[tokio::test]
async fn contributes_hosted_plugin_runtime_without_an_executor() -> TestResult {
let codex_home = tempfile::tempdir()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![
("features.apps".to_string(), true.into()),
("chatgpt_base_url".to_string(), "https://chatgpt.com".into()),
])
.build()
.await?;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let manager = installed_manager(&config, Some(auth.clone()));
let servers = manager.effective_servers(&config, Some(&auth)).await;
let server = servers
.get(CODEX_APPS_MCP_SERVER_NAME)
.ok_or("hosted plugin runtime should be contributed as a configured server")?
.config();
let McpServerTransportConfig::StreamableHttp { url, .. } = &server.transport else {
panic!("hosted plugin runtime should use streamable HTTP");
};
assert_eq!(url, "https://chatgpt.com/backend-api/ps/mcp");
Ok(())
}
#[tokio::test]
async fn runtime_overlay_preserves_disabled_server() -> TestResult {
let codex_home = tempfile::tempdir()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![
("features.apps".to_string(), true.into()),
(
"mcp_servers.codex_apps.url".to_string(),
"https://example.com/mcp".into(),
),
("mcp_servers.codex_apps.enabled".to_string(), false.into()),
])
.cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement("[mcp_servers]"),
)
.build()
.await?;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let manager = installed_manager(&config, Some(auth.clone()));
let configured = manager.configured_servers(&config).await;
// Standalone login must still reject requirements-denied, explicitly disabled entries.
let login_client = Arc::new(codex_exec_server::RouteAwareHttpClient::new(
config.http_client_factory(),
));
assert_eq!(
codex_mcp::apply_http_headers_helper(
login_client,
&configured[CODEX_APPS_MCP_SERVER_NAME],
codex_home.path().to_path_buf(),
)
.err(),
Some("the MCP server is disabled by managed requirements".to_string())
);
let servers = manager.effective_servers(&config, Some(&auth)).await;
let server = servers
.get(CODEX_APPS_MCP_SERVER_NAME)
.ok_or("hosted plugin runtime should remain configured")?;
assert!(!server.enabled());
Ok(())
}
#[tokio::test]
async fn default_fallback_overwrites_reserved_config_without_an_extension() -> TestResult {
let codex_home = tempfile::tempdir()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![
("features.apps".to_string(), true.into()),
(
"mcp_servers.codex_apps.url".to_string(),
"https://example.com/mcp".into(),
),
])
.build()
.await?;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let manager = McpManager::new(Arc::new(plugins_manager_for_config(
&config,
AuthManager::from_auth_for_testing(auth.clone()),
)));
let servers = manager.effective_servers(&config, Some(&auth)).await;
let server = servers
.get(CODEX_APPS_MCP_SERVER_NAME)
.ok_or("default Apps MCP should be present")?
.config();
let McpServerTransportConfig::StreamableHttp { url, .. } = &server.transport else {
panic!("default Apps MCP should use streamable HTTP");
};
assert_eq!(url, "https://chatgpt.com/backend-api/ps/mcp");
Ok(())
}
#[tokio::test]
async fn later_extension_can_remove_same_name_registration() -> TestResult {
let codex_home = tempfile::tempdir()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![("features.apps".to_string(), true.into())])
.build()
.await?;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let mut builder = ExtensionRegistryBuilder::new();
codex_mcp_extension::install(&mut builder);
builder.mcp_server_contributor(Arc::new(RemoveCodexApps));
let manager = McpManager::new_with_extensions(
Arc::new(plugins_manager_for_config(
&config,
AuthManager::from_auth_for_testing(auth.clone()),
)),
Arc::new(builder.build()),
codex_core::CodexAppsToolsCache::default(),
);
let servers = manager.effective_servers(&config, Some(&auth)).await;
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
Ok(())
}
#[tokio::test]
async fn hosted_apps_mcp_requires_chatgpt_auth() -> TestResult {
let codex_home = tempfile::tempdir()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![("features.apps".to_string(), true.into())])
.build()
.await?;
let auth = CodexAuth::from_api_key("test");
let manager = installed_manager(&config, Some(auth.clone()));
let servers = manager.effective_servers(&config, Some(&auth)).await;
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
Ok(())
}
#[tokio::test]
async fn disabled_apps_remove_reserved_server_config_for_all_hosts() -> TestResult {
let codex_home = tempfile::tempdir()?;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![
("features.apps".to_string(), false.into()),
(
"mcp_servers.codex_apps.url".to_string(),
"https://example.com/mcp".into(),
),
])
.build()
.await?;
let managers = [
installed_manager(&config, /*auth*/ None),
McpManager::new(Arc::new(plugins_manager_for_config(
&config,
auth_manager_from_optional_auth(/*auth*/ None),
))),
];
for manager in managers {
let servers = manager.runtime_servers(&config).await;
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
}
Ok(())
}
fn installed_manager(config: &Config, auth: Option<CodexAuth>) -> McpManager {
let mut builder = ExtensionRegistryBuilder::new();
codex_mcp_extension::install(&mut builder);
McpManager::new_with_extensions(
Arc::new(plugins_manager_for_config(
config,
auth_manager_from_optional_auth(auth),
)),
Arc::new(builder.build()),
codex_core::CodexAppsToolsCache::default(),
)
}
struct RemoveCodexApps;
impl McpServerContributor<Config> for RemoveCodexApps {
fn id(&self) -> &'static str {
"remove_codex_apps"
}
fn contribute<'a>(
&'a self,
_context: McpServerContributionContext<'a, Config>,
) -> codex_extension_api::ExtensionFuture<'a, Vec<McpServerContribution>> {
Box::pin(async move {
vec![McpServerContribution::Remove {
name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
}]
})
}
}