use super::*; use crate::McpPluginAttribution; use crate::McpServerRegistration; use crate::connection_manager::tests::create_ready_async_managed_client; use crate::mcp::auth::McpAuthStatusEntry; use crate::rmcp_client::StartupOutcomeError; use codex_config::Constrained; use codex_config::types::AppToolApproval; use codex_config::types::AuthKeyringBackendKind; use codex_login::CodexAuth; use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GranularApprovalConfig; use codex_rmcp_client::McpAuthState; use futures::FutureExt; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; #[tokio::test] async fn status_snapshot_only_downgrades_oauth_authentication_failures() { let auth_failure = StartupOutcomeError::Failed { error: "OAuth refresh token was rejected".to_string(), is_authentication_required: true, }; let mut manager = McpConnectionSet::empty(/*prefix_mcp_tool_names*/ true); let mut auth_status_entries = HashMap::new(); for (name, auth_state, startup_error) in [ ( "oauth-failed", McpAuthState::OAuth, Some(auth_failure.clone()), ), ("oauth-ready", McpAuthState::OAuth, None), ( "oauth-provider-error", McpAuthState::OAuth, Some(StartupOutcomeError::Failed { error: "provider temporarily unavailable".to_string(), is_authentication_required: false, }), ), ("bearer", McpAuthState::BearerToken, Some(auth_failure)), ] { let mut client = create_ready_async_managed_client(Vec::new()).await; if let Some(error) = startup_error { client.client = futures::future::ready(Err(error)).boxed().shared(); } manager.insert_test_client(name, client); auth_status_entries.insert( name.to_string(), McpAuthStatusEntry { config: None, auth_state, }, ); } let server_names = auth_status_entries.keys().cloned().collect(); let snapshot = collect_mcp_server_status_snapshot_from_manager( &manager, auth_status_entries, server_names, McpSnapshotDetail::ToolsAndAuthOnly, ) .await; assert_eq!( snapshot.auth_statuses, HashMap::from([ ("oauth-failed".to_string(), McpAuthStatus::NotLoggedIn), ("oauth-ready".to_string(), McpAuthStatus::OAuth), ("oauth-provider-error".to_string(), McpAuthStatus::OAuth), ("bearer".to_string(), McpAuthStatus::BearerToken), ]), ); } pub(crate) fn test_mcp_config(codex_home: PathBuf) -> McpConfig { McpConfig { chatgpt_base_url: "https://chatgpt.com".to_string(), apps_mcp_product_sku: None, codex_home, mcp_enterprise_managed_auth: None, xaa_enabled: false, mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::default(), oauth_refresh_mode: McpOAuthRefreshMode::Legacy, auth_keyring_backend_kind: AuthKeyringBackendKind::default(), mcp_oauth_callback_port: None, mcp_oauth_callback_url: None, optional_mcp_startup_grace: DEFAULT_OPTIONAL_MCP_STARTUP_GRACE, skill_mcp_dependency_install_enabled: true, approval_policy: Constrained::allow_any(AskForApproval::OnRequest), permission_profile: PermissionProfile::default(), config_layer_stack: codex_config::ConfigLayerStack::default(), approvals_reviewer: codex_config::types::ApprovalsReviewer::default(), environment_cwds: HashMap::new(), server_permission_profiles: HashMap::new(), codex_linux_sandbox_exe: None, use_legacy_landlock: false, apps_enabled: false, prefix_mcp_tool_names: true, non_prefixed_mcp_tool_servers: Vec::new(), protocol_mode: McpProtocolMode::Legacy, host_owned_apps_protocol_mode: McpProtocolMode::Legacy, client_elicitation_capability: ElicitationCapability::default(), mcp_server_catalog: ResolvedMcpCatalog::default(), connector_snapshot: codex_connectors::ConnectorSnapshot::default(), } } pub(crate) fn test_elicitation_config( server_name: &str, approval_policy: AskForApproval, permission_profile: PermissionProfile, ) -> Arc { let mut config = test_mcp_config(PathBuf::new()); config.approval_policy = Constrained::allow_any(approval_policy); config.permission_profile = permission_profile.clone(); config .server_permission_profiles .insert(server_name.to_string(), permission_profile); Arc::new(config) } #[test] fn ema_catalog_supports_configured_installed_and_selected_plugins_without_widening_policy() { let server: McpServerConfig = serde_json::from_value(serde_json::json!({ "url": "https://resource.example/mcp", "auth": "ema_auth", "oauth": { "client_id": "resource-client", "authorization_server_issuer": "https://as.example" } })).unwrap(); let plugin = McpPluginAttribution::agent_plugin("plugin@test".into(), "Plugin".into()); let mut catalog = ResolvedMcpCatalog::builder(); catalog.register(McpServerRegistration::from_config( "configured".into(), server.clone(), )); catalog.register(McpServerRegistration::from_plugin( "installed".into(), plugin.clone(), /*plugin_order*/ 0, server.clone(), )); catalog.register(McpServerRegistration::from_selected_plugin( "selected".into(), plugin, /*selection_order*/ 0, server, )); let mut config = test_mcp_config(PathBuf::new()); let idp = codex_config::McpServerIdpOAuthConfig { issuer: "https://idp.example".into(), client_id: "enterprise-client".into(), }; let deny_all = codex_protocol::mcp_policy::EnvironmentMcpPolicy { servers: Some(Default::default()), plugins: None, }; for (xaa_enabled, denied) in [(true, false), (true, true), (false, false)] { let mut catalog = catalog.clone(); if xaa_enabled { catalog.enable_ema(idp.clone()); } config.mcp_server_catalog = catalog.build_with_environment_authority(|_| { if denied { crate::McpEnvironmentAuthority::Restricted(&deny_all) } else { crate::McpEnvironmentAuthority::Unrestricted } }); let servers = effective_mcp_servers(&config, /*auth*/ None); for name in ["configured", "installed", "selected"] { assert_eq!(servers[name].enabled(), xaa_enabled && !denied, "{name}"); assert_eq!( servers[name].config().oauth_idp(), xaa_enabled.then_some(&idp) ); assert_eq!(servers[name].config().auth, McpServerAuth::EmaAuth); } } } #[test] fn qualified_mcp_tool_name_prefix_sanitizes_server_names_without_lowercasing() { assert_eq!( qualified_mcp_tool_name_prefix("Some-Server"), "mcp__Some_Server__".to_string() ); } #[test] fn mcp_server_permissions_handle_unattached_and_threadless_servers() { let mut config = test_mcp_config(PathBuf::new()); config.permission_profile = PermissionProfile::Disabled; let mut missing_server = codex_apps_mcp_server_config( "https://example.com", /*apps_mcp_product_sku*/ None, /*originator*/ None, ); missing_server.environment_id = "missing".to_string(); let mut selected_server = missing_server.clone(); selected_server.environment_id = "unattached".to_string(); let mut catalog = ResolvedMcpCatalog::builder(); catalog.register(McpServerRegistration::from_config( "missing".to_string(), missing_server, )); catalog.register(McpServerRegistration::from_selected_plugin( "selected".to_string(), McpPluginAttribution::new("selected@test".to_string(), "Selected".to_string()), /*selection_order*/ 0, selected_server, )); config.mcp_server_catalog = catalog.build(); let servers = effective_mcp_servers(&config, /*auth*/ None); assert_eq!(config.permission_profile_for_server("selected"), None); config.set_server_permission_profiles(&servers, std::iter::empty()); assert_eq!( config.permission_profile_for_server("selected"), Some(&PermissionProfile::Disabled) ); assert_eq!(config.permission_profile_for_server("missing"), None); let config = config.for_threadless_operations(&servers); assert_eq!( config.permission_profile_for_server("selected"), Some(&PermissionProfile::default()) ); } #[test] fn mcp_prompt_auto_approval_honors_unrestricted_managed_profiles() { assert!(mcp_permission_prompt_is_auto_approved( AskForApproval::Never, &PermissionProfile::Managed { file_system: ManagedFileSystemPermissions::Unrestricted, network: NetworkSandboxPolicy::Enabled, }, McpPermissionPromptAutoApproveContext::default(), )); assert!(mcp_permission_prompt_is_auto_approved( AskForApproval::Never, &PermissionProfile::Managed { file_system: ManagedFileSystemPermissions::Unrestricted, network: NetworkSandboxPolicy::Restricted, }, McpPermissionPromptAutoApproveContext::default(), )); assert!(!mcp_permission_prompt_is_auto_approved( AskForApproval::Never, &PermissionProfile::read_only(), McpPermissionPromptAutoApproveContext::default(), )); assert!(!mcp_permission_prompt_is_auto_approved( AskForApproval::OnRequest, &PermissionProfile::Managed { file_system: ManagedFileSystemPermissions::Unrestricted, network: NetworkSandboxPolicy::Enabled, }, McpPermissionPromptAutoApproveContext::default(), )); } #[test] fn mcp_prompt_auto_approval_honors_approved_tools_in_all_permission_modes() { for approval_policy in [ AskForApproval::UnlessTrusted, AskForApproval::OnRequest, AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: true, rules: true, skill_approval: true, request_permissions: true, mcp_elicitations: true, }), AskForApproval::Never, ] { assert!(mcp_permission_prompt_is_auto_approved( approval_policy, &PermissionProfile::read_only(), McpPermissionPromptAutoApproveContext { tool_approval_mode: Some(AppToolApproval::Approve), }, )); } assert!(!mcp_permission_prompt_is_auto_approved( AskForApproval::OnRequest, &PermissionProfile::read_only(), McpPermissionPromptAutoApproveContext { tool_approval_mode: Some(AppToolApproval::Auto), }, )); } #[test] fn mcp_prompt_auto_approval_rejects_auto_mode_in_default_permission_mode() { assert!(!mcp_permission_prompt_is_auto_approved( AskForApproval::OnRequest, &PermissionProfile::read_only(), McpPermissionPromptAutoApproveContext { tool_approval_mode: Some(AppToolApproval::Auto), }, )); } #[test] fn tool_plugin_context_collects_app_and_mcp_sources() { let mut config = test_mcp_config(PathBuf::new()); let mut catalog = ResolvedMcpCatalog::builder(); catalog.register(McpServerRegistration::from_plugin( "alpha".to_string(), McpPluginAttribution::new("alpha@test".to_string(), "alpha-plugin".to_string()), /*plugin_order*/ 0, codex_apps_mcp_server_config( "https://alpha.example", /*apps_mcp_product_sku*/ None, /*originator*/ None, ), )); config.mcp_server_catalog = catalog.build(); config.connector_snapshot = codex_connectors::ConnectorSnapshot::from_plugin_capability_summaries(&[ PluginCapabilitySummary { config_name: "alpha@test".to_string(), display_name: "alpha-plugin".to_string(), plugin_namespace: None, app_connector_ids: vec![AppConnectorId("connector_example".to_string())], mcp_server_names: vec!["alpha".to_string()], ..PluginCapabilitySummary::default() }, PluginCapabilitySummary { config_name: "beta@test".to_string(), display_name: "beta-plugin".to_string(), plugin_namespace: None, app_connector_ids: vec![ AppConnectorId("connector_example".to_string()), AppConnectorId("connector_gmail".to_string()), ], mcp_server_names: vec!["beta".to_string()], ..PluginCapabilitySummary::default() }, ]); let provenance = tool_plugin_context(&config); assert_eq!( provenance, ToolPluginContext { disabled_connector_ids: HashSet::new(), plugin_display_names_by_connector_id: HashMap::from([ ( "connector_example".to_string(), vec!["alpha-plugin".to_string(), "beta-plugin".to_string()], ), ( "connector_gmail".to_string(), vec!["beta-plugin".to_string()], ), ]), plugin_display_names_by_mcp_server_name: HashMap::from([( "alpha".to_string(), vec!["alpha-plugin".to_string()], )]), plugin_ids_by_mcp_server_name: HashMap::from([( "alpha".to_string(), "alpha@test".to_string(), )]), selected_plugin_mcp_server_names: HashSet::new(), } ); assert_eq!( provenance.plugin_id_for_mcp_server_name("alpha"), Some("alpha@test") ); assert_eq!(provenance.plugin_id_for_mcp_server_name("beta"), None); } #[test] fn selected_mcp_attribution_does_not_join_an_unrelated_local_summary() { let mut config = test_mcp_config(PathBuf::new()); let mut catalog = ResolvedMcpCatalog::builder(); catalog.register(McpServerRegistration::from_selected_plugin( "github".to_string(), McpPluginAttribution::new( "shared-plugin-id".to_string(), "Executor GitHub".to_string(), ), /*selection_order*/ 0, codex_apps_mcp_server_config( "https://github.example", /*apps_mcp_product_sku*/ None, /*originator*/ None, ), )); config.mcp_server_catalog = catalog.build(); config.connector_snapshot = codex_connectors::ConnectorSnapshot::from_plugin_capability_summaries(&[ PluginCapabilitySummary { config_name: "shared-plugin-id".to_string(), display_name: "Local GitHub".to_string(), plugin_namespace: None, mcp_server_names: vec!["github".to_string()], ..PluginCapabilitySummary::default() }, ]); let provenance = tool_plugin_context(&config); assert_eq!( provenance, ToolPluginContext { disabled_connector_ids: HashSet::new(), plugin_display_names_by_connector_id: HashMap::new(), plugin_display_names_by_mcp_server_name: HashMap::from([( "github".to_string(), vec!["Executor GitHub".to_string()], )]), plugin_ids_by_mcp_server_name: HashMap::from([( "github".to_string(), "shared-plugin-id".to_string(), )]), selected_plugin_mcp_server_names: HashSet::from(["github".to_string()]), } ); assert!(provenance.is_selected_plugin_mcp_server("github")); } #[test] fn codex_apps_mcp_url_for_base_url_uses_plugin_service_paths() { assert_eq!( codex_apps_mcp_url_for_base_url("https://chatgpt.com/backend-api"), "https://chatgpt.com/backend-api/ps/mcp" ); assert_eq!( codex_apps_mcp_url_for_base_url("https://chat.openai.com"), "https://chat.openai.com/backend-api/ps/mcp" ); assert_eq!( codex_apps_mcp_url_for_base_url("http://localhost:8080/api/codex"), "http://localhost:8080/api/codex/ps/mcp" ); assert_eq!( codex_apps_mcp_url_for_base_url("http://localhost:8080"), "http://localhost:8080/api/codex/ps/mcp" ); } #[test] fn codex_apps_server_config_uses_plugin_service_path() { let config = codex_apps_mcp_server_config( "https://chatgpt.com", /*apps_mcp_product_sku*/ None, /*originator*/ None, ); let url = match &config.transport { McpServerTransportConfig::StreamableHttp { url, .. } => url, _ => panic!("expected streamable http transport for codex apps"), }; assert_eq!(url, "https://chatgpt.com/backend-api/ps/mcp"); } #[test] fn codex_apps_server_config_forwards_thread_originator_header() { let config = codex_apps_mcp_server_config( "https://chatgpt.com", /*apps_mcp_product_sku*/ None, Some("thread_originator"), ); match &config.transport { McpServerTransportConfig::StreamableHttp { http_headers, env_http_headers, .. } => { assert_eq!( http_headers, &Some(HashMap::from([ ("originator".to_string(), "thread_originator".to_string()), ("X-OpenAI-Product-Sku".to_string(), "codex".to_string()), ])) ); assert!(env_http_headers.is_none()); } other => panic!("expected streamable http transport, got {other:?}"), } } #[test] fn codex_apps_server_config_sets_product_sku_header() { for (configured_product_sku, expected_product_sku) in [(None, "codex"), (Some("tpp"), "tpp")] { let config = codex_apps_mcp_server_config( "https://chatgpt.com", configured_product_sku, /*originator*/ None, ); match &config.transport { McpServerTransportConfig::StreamableHttp { http_headers, env_http_headers, .. } => { assert_eq!( http_headers, &Some(HashMap::from([( "X-OpenAI-Product-Sku".to_string(), expected_product_sku.to_string(), )])) ); assert!(env_http_headers.is_none()); } other => panic!("expected streamable http transport, got {other:?}"), } } } #[test] fn codex_apps_server_config_forwards_originator_and_configured_product_sku_headers() { let config = codex_apps_mcp_server_config( "https://chatgpt.com", Some("tpp"), Some("thread_originator"), ); match &config.transport { McpServerTransportConfig::StreamableHttp { http_headers, env_http_headers, .. } => { assert_eq!( http_headers, &Some(HashMap::from([ ("originator".to_string(), "thread_originator".to_string()), ("X-OpenAI-Product-Sku".to_string(), "tpp".to_string()), ])) ); assert!(env_http_headers.is_none()); } other => panic!("expected streamable http transport, got {other:?}"), } } #[test] fn effective_mcp_servers_preserve_chatgpt_auth_for_staging() { for url in [ "https://chatgpt-staging.com", "https://preview.chatgpt-staging.com", ] { let mut config = test_mcp_config(PathBuf::new()); config.chatgpt_base_url = url.to_string(); let server = codex_apps_mcp_server_config( url, /*apps_mcp_product_sku*/ None, /*originator*/ None, ); let configured = HashMap::from([("staging".to_string(), server)]); let effective = effective_mcp_servers_from_configured(configured, &config, /*auth*/ None); assert_eq!(effective["staging"].config().auth, McpServerAuth::ChatGpt); } } #[tokio::test] async fn effective_mcp_servers_preserve_runtime_servers() { let codex_home = tempfile::tempdir().expect("tempdir"); let mut config = test_mcp_config(codex_home.path().to_path_buf()); config.apps_enabled = true; let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let mut catalog = ResolvedMcpCatalog::builder(); catalog.register(McpServerRegistration::from_config( "sample".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://user.example/mcp".to_string(), bearer_token_env_var: None, http_headers: None, env_http_headers: None, http_headers_helper: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, )); catalog.register(McpServerRegistration::from_config( "docs".to_string(), McpServerConfig { auth: Default::default(), transport: McpServerTransportConfig::StreamableHttp { url: "https://docs.example/mcp".to_string(), bearer_token_env_var: None, http_headers: None, env_http_headers: None, http_headers_helper: None, }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, omit_tools_from: None, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, default_tools_approval_mode: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth: None, oauth_resource: None, tools: HashMap::new(), }, )); catalog.register(McpServerRegistration::from_config( CODEX_APPS_MCP_SERVER_NAME.to_string(), codex_apps_mcp_server_config( &config.chatgpt_base_url, config.apps_mcp_product_sku.as_deref(), /*originator*/ None, ), )); config.mcp_server_catalog = catalog.build(); let effective = effective_mcp_servers(&config, Some(&auth)); let sample = effective.get("sample").expect("user server should exist"); let docs = effective .get("docs") .expect("configured server should exist"); let codex_apps = effective .get(CODEX_APPS_MCP_SERVER_NAME) .expect("codex apps server should exist"); let sample = sample.config(); let docs = docs.config(); let codex_apps = codex_apps.config(); match &sample.transport { McpServerTransportConfig::StreamableHttp { url, .. } => { assert_eq!(url, "https://user.example/mcp"); } other => panic!("expected streamable http transport, got {other:?}"), } match &docs.transport { McpServerTransportConfig::StreamableHttp { url, .. } => { assert_eq!(url, "https://docs.example/mcp"); } other => panic!("expected streamable http transport, got {other:?}"), } match &codex_apps.transport { McpServerTransportConfig::StreamableHttp { url, .. } => { assert_eq!(url, "https://chatgpt.com/backend-api/ps/mcp"); } other => panic!("expected streamable http transport, got {other:?}"), } }