mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
## Why MCP servers discovered on different executors can share a name. Resolving them by name alone exposes only one server and prevents tools and resources from being addressed independently. ## What changed - Add the disabled-by-default `multi_environment_mcp` feature to expose environment-scoped servers, including multiple installations of the same plugin. - Carry exact `McpServerKey` identities through catalogs, connections, tool calls, resources, and approval tracking. Give environment-scoped tools stable namespaces derived from their environment. - Include `environment_id` in scoped resource listings and accept it in resource requests. Require it for environment-owned servers when multiple environments are selected. - Preserve server ownership through hooks and use the owning environment's permissions for scoped elicitation review. ## Testing Add coverage for same-named servers across environments, independent calls and approvals, resource selection, plugin discovery, and rejection of hook changes to the tool environment. GitOrigin-RevId: 5593372b06a15c16aa3220faa3b19f5d3b20e67e
923 lines
33 KiB
Rust
923 lines
33 KiB
Rust
use super::*;
|
|
use crate::McpPluginAttribution;
|
|
use crate::McpServerKey;
|
|
use crate::McpServerPermissions;
|
|
use crate::McpServerRegistration;
|
|
use crate::connection_manager::tests::create_ready_async_managed_client;
|
|
use crate::mcp::auth::McpAuthStatusEntry;
|
|
use crate::rmcp_client::StartupOutcomeError;
|
|
use crate::server::McpCredentialPolicy;
|
|
use anyhow::Context;
|
|
use codex_config::Constrained;
|
|
use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
|
|
use codex_config::types::AppToolApproval;
|
|
use codex_config::types::AuthKeyringBackendKind;
|
|
use codex_exec_server_test_support::environment_manager_without_environments;
|
|
use codex_login::AuthCredentialsStoreMode;
|
|
use codex_login::AuthManager;
|
|
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;
|
|
use std::time::Duration;
|
|
|
|
#[tokio::test]
|
|
async fn ema_auth_statuses_do_not_wait_for_account_refresh() -> anyhow::Result<()> {
|
|
let home = tempfile::tempdir()?;
|
|
// Account ID token for user/workspace, issued by idp.example to account-client.
|
|
let id_token = concat!(
|
|
"eyJhbGciOiJFUzI1NiJ9.",
|
|
"eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlIiwic3ViIjoidXNlciIsImF1ZCI6ImFjY291bnQt",
|
|
"Y2xpZW50IiwiZXhwIjoxLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91",
|
|
"c2VyX2lkIjoidXNlciIsImNoYXRncHRfYWNjb3VudF9pZCI6IndvcmtzcGFjZSJ9fQ",
|
|
".signature",
|
|
);
|
|
std::fs::write(
|
|
home.path().join("auth.json"),
|
|
serde_json::to_vec(&serde_json::json!({
|
|
"auth_mode": "chatgpt",
|
|
"tokens": {
|
|
"id_token": id_token,
|
|
"access_token": "account-access",
|
|
"refresh_token": "account-refresh",
|
|
"account_id": "workspace",
|
|
"login_owner_id": "completed-login",
|
|
"oauth_provenance": {
|
|
"issuer": "https://idp.example",
|
|
"client_id": "account-client"
|
|
}
|
|
},
|
|
"last_refresh": "2099-01-01T00:00:00Z"
|
|
}))?,
|
|
)?;
|
|
let manager = AuthManager::shared(
|
|
home.path().to_path_buf(),
|
|
/*enable_codex_api_key_env*/ false,
|
|
AuthCredentialsStoreMode::File,
|
|
/*forced_chatgpt_workspace_id*/ None,
|
|
/*chatgpt_base_url*/ None,
|
|
AuthKeyringBackendKind::default(),
|
|
codex_login::test_support::transport_default_auth_route_config(),
|
|
)
|
|
.await;
|
|
let auth = manager.auth_cached().context("account auth")?;
|
|
let lease = manager
|
|
.lease_account_oauth_grant()
|
|
.await?
|
|
.context("account grant")?;
|
|
let mut servers = HashMap::new();
|
|
for name in ["first", "second"] {
|
|
let mut server: McpServerConfig = serde_json::from_value(serde_json::json!({
|
|
"url": format!("https://resource.example/{name}"),
|
|
"auth": "ema_auth"
|
|
}))?;
|
|
server
|
|
.resolve_ema_registration(&codex_config::McpServerIdpOAuthConfig {
|
|
issuer: "https://idp.example".into(),
|
|
client_id: "enterprise-client".into(),
|
|
client_secret: None,
|
|
token_endpoint_auth_method: None,
|
|
})
|
|
.map_err(anyhow::Error::msg)?;
|
|
servers.insert(
|
|
name.to_string(),
|
|
EffectiveMcpServer::from_host_config(server),
|
|
);
|
|
}
|
|
let runtime_context = McpRuntimeContext::new(
|
|
Arc::new(environment_manager_without_environments()),
|
|
home.path().to_path_buf(),
|
|
);
|
|
let statuses = tokio::time::timeout(
|
|
Duration::from_secs(5),
|
|
compute_auth_statuses(
|
|
servers.iter(),
|
|
OAuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
Some(&auth),
|
|
Some(&manager),
|
|
&crate::EmaState::default(),
|
|
&runtime_context,
|
|
),
|
|
)
|
|
.await
|
|
.context("MCP auth status waited for the held account lease")?;
|
|
assert_eq!(
|
|
statuses
|
|
.into_iter()
|
|
.map(|(name, entry)| (name, entry.auth_state))
|
|
.collect::<HashMap<_, _>>(),
|
|
HashMap::from([
|
|
("first".to_string(), McpAuthState::BearerToken),
|
|
("second".to_string(), McpAuthState::BearerToken),
|
|
]),
|
|
);
|
|
drop(lease);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn status_snapshot_only_downgrades_oauth_and_ema_authentication_failures() {
|
|
let auth_failure = StartupOutcomeError::Failed {
|
|
error: "OAuth refresh token was rejected".to_string(),
|
|
is_authentication_required: true,
|
|
is_ema_authentication_required: false,
|
|
shutdown: None,
|
|
};
|
|
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,
|
|
is_ema_authentication_required: false,
|
|
shutdown: None,
|
|
}),
|
|
),
|
|
(
|
|
"ema-failed",
|
|
McpAuthState::BearerToken,
|
|
Some(auth_failure.clone()),
|
|
),
|
|
("ema-ready", McpAuthState::BearerToken, None),
|
|
(
|
|
"ema-provider-error",
|
|
McpAuthState::BearerToken,
|
|
Some(anyhow::anyhow!("provider temporarily unavailable").into()),
|
|
),
|
|
("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: name.starts_with("ema-").then(|| {
|
|
serde_json::from_value(serde_json::json!({
|
|
"url": "https://example.com/mcp", "auth": "ema_auth",
|
|
}))
|
|
.expect("EMA config")
|
|
}),
|
|
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),
|
|
("ema-failed".to_string(), McpAuthStatus::NotLoggedIn),
|
|
("ema-ready".to_string(), McpAuthStatus::BearerToken),
|
|
("ema-provider-error".to_string(), McpAuthStatus::BearerToken),
|
|
("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,
|
|
requires_read_only_mcp_tools: false,
|
|
codex_home,
|
|
mcp_enterprise_managed_auth: None,
|
|
ema_state: Default::default(),
|
|
mcp_ema_cancellation: codex_config::McpEmaCancellation::default(),
|
|
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(),
|
|
host_cwd: AbsolutePathBuf::current_dir().expect("test working directory"),
|
|
config_layer_stack: codex_config::ConfigLayerStack::default(),
|
|
plugins: Default::default(),
|
|
approvals_reviewer: codex_config::types::ApprovalsReviewer::default(),
|
|
environment_cwds: HashMap::new(),
|
|
environment_use_mxc: HashMap::new(),
|
|
server_permissions: HashMap::new(),
|
|
codex_self_exe: None,
|
|
codex_linux_sandbox_exe: None,
|
|
use_legacy_landlock: false,
|
|
apps_enabled: false,
|
|
apps_early_resource_discovery: false,
|
|
prefix_mcp_tool_names: true,
|
|
non_prefixed_mcp_tool_servers: Vec::new(),
|
|
protocol_mode: McpProtocolMode::Legacy,
|
|
discover_local_stdio: false,
|
|
mcp_tool_list_changed: true,
|
|
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<McpConfig> {
|
|
let mut config = test_mcp_config(PathBuf::new());
|
|
config.approval_policy = Constrained::allow_any(approval_policy);
|
|
config.permission_profile = permission_profile;
|
|
config.server_permissions.insert(
|
|
McpServerKey::unscoped(server_name),
|
|
McpServerPermissions::Thread,
|
|
);
|
|
Arc::new(config)
|
|
}
|
|
|
|
#[test]
|
|
fn ema_catalog_allows_only_configured_servers_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,
|
|
codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID,
|
|
server,
|
|
));
|
|
let mut config = test_mcp_config(PathBuf::new());
|
|
let idp = codex_config::McpServerIdpOAuthConfig {
|
|
issuer: "https://idp.example".into(),
|
|
client_secret: None,
|
|
token_endpoint_auth_method: None,
|
|
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"] {
|
|
let eligible = name == "configured" && xaa_enabled;
|
|
let key = if name == "configured" {
|
|
McpServerKey::unscoped(name)
|
|
} else {
|
|
McpServerKey::in_environment(DEFAULT_MCP_SERVER_ENVIRONMENT_ID, name)
|
|
};
|
|
assert_eq!(servers[&key].enabled(), eligible && !denied, "{name}");
|
|
assert_eq!(servers[&key].config().oauth_idp(), eligible.then_some(&idp));
|
|
assert_eq!(servers[&key].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,
|
|
codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID,
|
|
selected_server,
|
|
));
|
|
config.mcp_server_catalog = catalog.build();
|
|
let servers = effective_mcp_servers(&config, /*auth*/ None);
|
|
let selected_key = McpServerKey::in_environment("unattached", "selected");
|
|
assert_eq!(config.permission_profile_for_server(&selected_key), None);
|
|
config.set_server_permissions(&servers, std::iter::empty());
|
|
|
|
assert_eq!(
|
|
config.permission_profile_for_server(&selected_key),
|
|
Some(&PermissionProfile::Disabled)
|
|
);
|
|
assert_eq!(
|
|
config.permission_profile_for_server(&McpServerKey::unscoped("missing")),
|
|
None
|
|
);
|
|
|
|
let config = config.for_threadless_operations(&servers);
|
|
assert_eq!(
|
|
config.permission_profile_for_server(&selected_key),
|
|
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 tool_plugin_context_collects_app_and_mcp_sources() {
|
|
let alpha_key = McpServerKey::in_environment(DEFAULT_MCP_SERVER_ENVIRONMENT_ID, "alpha");
|
|
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_server: HashMap::from([(
|
|
alpha_key.clone(),
|
|
vec!["alpha-plugin".to_string()],
|
|
)]),
|
|
plugin_ids_by_server: HashMap::from([(alpha_key.clone(), "alpha@test".to_string(),)]),
|
|
selected_plugin_server_keys: HashSet::new(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
provenance.plugin_id_for_server(&alpha_key),
|
|
Some("alpha@test")
|
|
);
|
|
assert_eq!(
|
|
provenance.plugin_id_for_server(&McpServerKey::unscoped("beta")),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn selected_mcp_attribution_does_not_join_an_unrelated_local_summary() {
|
|
let key = McpServerKey::in_environment(DEFAULT_MCP_SERVER_ENVIRONMENT_ID, "github");
|
|
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,
|
|
"executor-1",
|
|
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_server: HashMap::from([(
|
|
key.clone(),
|
|
vec!["Executor GitHub".to_string()],
|
|
)]),
|
|
plugin_ids_by_server: HashMap::from([(key.clone(), "shared-plugin-id".to_string(),)]),
|
|
selected_plugin_server_keys: HashSet::from([key.clone()]),
|
|
}
|
|
);
|
|
assert!(provenance.is_selected_plugin_server(&key));
|
|
}
|
|
|
|
#[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("https://gov.chatgpt.com/backend-api/"),
|
|
"https://gov.chatgpt.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_executor_credential_policy() {
|
|
let mut config = test_mcp_config(PathBuf::new());
|
|
let server: McpServerConfig = serde_json::from_value(serde_json::json!({
|
|
"url": "https://executor.example/mcp",
|
|
"environment_id": "remote",
|
|
"bearer_token_env_var": "MCP_EXECUTOR_CREDENTIAL_CANARY",
|
|
}))
|
|
.expect("valid MCP server config");
|
|
let mut catalog = ResolvedMcpCatalog::builder();
|
|
catalog.register(McpServerRegistration::from_executor_config(
|
|
"executor".to_string(),
|
|
server.clone(),
|
|
));
|
|
catalog.register(McpServerRegistration::from_config(
|
|
"host".to_string(),
|
|
server,
|
|
));
|
|
config.mcp_server_catalog = catalog.build();
|
|
let effective = effective_mcp_servers(&config, /*auth*/ None);
|
|
|
|
assert_eq!(
|
|
effective[&McpServerKey::in_environment("remote", "executor")].credential_policy(),
|
|
McpCredentialPolicy::ExecutorOnly,
|
|
);
|
|
assert_eq!(
|
|
effective[&McpServerKey::unscoped("host")].credential_policy(),
|
|
McpCredentialPolicy::HostFallbackAllowed,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn effective_mcp_servers_preserve_chatgpt_auth_for_trusted_origins() {
|
|
for (base_url, server_url) in [
|
|
("https://chatgpt-staging.com", "https://chatgpt-staging.com"),
|
|
(
|
|
"https://preview.chatgpt-staging.com",
|
|
"https://preview.chatgpt-staging.com",
|
|
),
|
|
("https://gov.chatgpt.com", "https://chatgpt.com"),
|
|
(
|
|
"https://chatgpt.com",
|
|
"https://gov.chatgpt.com/backend-api/",
|
|
),
|
|
("https://chatgpt.com", "https://GOV.CHATGPT.COM:443"),
|
|
] {
|
|
let mut config = test_mcp_config(PathBuf::new());
|
|
config.chatgpt_base_url = base_url.to_string();
|
|
let server = codex_apps_mcp_server_config(
|
|
server_url, /*apps_mcp_product_sku*/ None, /*originator*/ None,
|
|
);
|
|
let mut catalog = ResolvedMcpCatalog::builder();
|
|
catalog.register(McpServerRegistration::from_config(
|
|
"trusted".to_string(),
|
|
server,
|
|
));
|
|
config.mcp_server_catalog = catalog.build();
|
|
|
|
let effective = effective_mcp_servers(&config, /*auth*/ None);
|
|
|
|
assert_eq!(
|
|
effective[&McpServerKey::unscoped("trusted")].config().auth,
|
|
McpServerAuth::ChatGpt,
|
|
"backend {base_url}, server {server_url}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn effective_mcp_servers_reject_untrusted_origins_for_chatgpt_auth() {
|
|
for url in [
|
|
"http://gov.chatgpt.com",
|
|
"https://gov.chatgpt.com:8443",
|
|
"https://gov.chatgpt.com.evil.example",
|
|
"https://sub.gov.chatgpt.com",
|
|
"https://gov.chatgpt.com.",
|
|
"https://gov.chatgpt.com@evil.example",
|
|
"https://example.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 mut catalog = ResolvedMcpCatalog::builder();
|
|
catalog.register(McpServerRegistration::from_config(
|
|
"untrusted".to_string(),
|
|
server,
|
|
));
|
|
config.mcp_server_catalog = catalog.build();
|
|
|
|
let effective = effective_mcp_servers(&config, /*auth*/ None);
|
|
|
|
assert_eq!(
|
|
effective[&McpServerKey::unscoped("untrusted")]
|
|
.config()
|
|
.auth,
|
|
McpServerAuth::OAuth,
|
|
"server {url}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
startup_readiness: Default::default(),
|
|
supports_parallel_tool_calls: false,
|
|
tool_input_schema_max_bytes: None,
|
|
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,
|
|
startup_readiness: Default::default(),
|
|
supports_parallel_tool_calls: false,
|
|
tool_input_schema_max_bytes: None,
|
|
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(&McpServerKey::unscoped("sample"))
|
|
.expect("user server should exist");
|
|
let docs = effective
|
|
.get(&McpServerKey::unscoped("docs"))
|
|
.expect("configured server should exist");
|
|
let codex_apps = effective
|
|
.get(&McpServerKey::unscoped(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:?}"),
|
|
}
|
|
|
|
// The reserved Apps name stays auth-gated even when a plugin supplied it.
|
|
let mut catalog = ResolvedMcpCatalog::builder();
|
|
catalog.register(McpServerRegistration::from_selected_plugin(
|
|
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
McpPluginAttribution::agent_plugin("plugin@test".into(), "Plugin".into()),
|
|
/*selection_order*/ 0,
|
|
"remote",
|
|
codex_apps.clone(),
|
|
));
|
|
config.mcp_server_catalog = catalog.build();
|
|
assert!(effective_mcp_servers(&config, /*auth*/ None).is_empty());
|
|
}
|