mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
## Why Configuration reloads must honor current managed restrictions without granting new enterprise MCP authority to existing sessions or overwriting newer configuration with stale snapshots. ## What changed - Allow enterprise managed authentication only for configured MCP servers. Remove plugin `ema_auth` overlays from the schema and disable servers that still use them. - Refresh ordinary MCP settings while preserving session settings and initially admitted enterprise registrations. Disable enterprise MCP when its registration changes, policy revokes access, or configuration loading or resolution fails; require a new session to restore enterprise authority. - Publish refreshes only when their captured configuration is still current, with bounded retries in app-server reloads. Preserve updated managed restrictions even when a refresh is rejected. - Finish processing other thread refreshes before `config/mcpServer/reload` reports a rejection, and document that an error can accompany partially applied changes. ## Testing Add regression coverage for stale refreshes, bounded retries, managed-policy revocation, persistent enterprise disablement, ordinary MCP transport changes, and rejection of plugin enterprise authentication. Verify that denied enterprise servers receive no requests. GitOrigin-RevId: 34429cd339c1ec152a2ee8697cb29f0cff5d9e5f
826 lines
26 KiB
Rust
826 lines
26 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::collections::HashMap;
|
|
use std::time::Duration;
|
|
|
|
use codex_config::AppToolApproval;
|
|
use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
|
|
use codex_config::McpServerAuth;
|
|
use codex_config::McpServerConfig;
|
|
use codex_config::McpServerIdpOAuthConfig;
|
|
use codex_config::McpServerToolConfig;
|
|
use codex_config::McpServerTransportConfig;
|
|
use codex_protocol::mcp_policy::EnvironmentMcpPolicy;
|
|
use codex_protocol::mcp_policy::PluginMcpRequirements;
|
|
use codex_utils_path_uri::PathUri;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
use crate::CODEX_APPS_MCP_SERVER_NAME;
|
|
use crate::McpProtocolMode;
|
|
use crate::server::McpCredentialPolicy;
|
|
|
|
use super::McpEnvironmentAuthority;
|
|
use super::McpPluginAttribution;
|
|
use super::McpServerConflict;
|
|
use super::McpServerConflictAction;
|
|
use super::McpServerRegistration;
|
|
use super::McpServerSource;
|
|
use super::ResolvedMcpCatalog;
|
|
use super::ResolvedMcpServer;
|
|
|
|
fn server(url: &str) -> McpServerConfig {
|
|
McpServerConfig {
|
|
auth: Default::default(),
|
|
transport: McpServerTransportConfig::StreamableHttp {
|
|
url: url.to_string(),
|
|
bearer_token_env_var: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
http_headers_helper: None,
|
|
},
|
|
environment_id: DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
|
|
enabled: true,
|
|
required: true,
|
|
startup_readiness: Default::default(),
|
|
supports_parallel_tool_calls: true,
|
|
tool_input_schema_max_bytes: None,
|
|
omit_tools_from: None,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: Some(Duration::from_secs(7)),
|
|
tool_timeout_sec: Some(Duration::from_secs(11)),
|
|
default_tools_approval_mode: Some(AppToolApproval::Prompt),
|
|
enabled_tools: Some(vec!["read".to_string()]),
|
|
disabled_tools: Some(vec!["write".to_string()]),
|
|
scopes: None,
|
|
oauth: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::from([(
|
|
"read".to_string(),
|
|
McpServerToolConfig {
|
|
approval_mode: Some(AppToolApproval::Approve),
|
|
..Default::default()
|
|
},
|
|
)]),
|
|
}
|
|
}
|
|
|
|
fn plugin(plugin_id: &str) -> McpPluginAttribution {
|
|
McpPluginAttribution::new(plugin_id.to_string(), plugin_id.to_string())
|
|
}
|
|
|
|
fn plugin_source(plugin_id: &str) -> McpServerSource {
|
|
McpServerSource::Plugin(plugin(plugin_id))
|
|
}
|
|
|
|
fn selected_plugin_source(plugin_id: &str) -> McpServerSource {
|
|
McpServerSource::SelectedPlugin(plugin(plugin_id))
|
|
}
|
|
|
|
fn compatibility_source(id: &str) -> McpServerSource {
|
|
McpServerSource::Compatibility { id: id.to_string() }
|
|
}
|
|
|
|
fn extension_source(id: &str) -> McpServerSource {
|
|
McpServerSource::Extension {
|
|
id: id.to_string(),
|
|
host_owned_apps: false,
|
|
}
|
|
}
|
|
|
|
fn register(source: McpServerSource) -> McpServerConflictAction {
|
|
McpServerConflictAction::Register(source)
|
|
}
|
|
|
|
fn remove(source: McpServerSource) -> McpServerConflictAction {
|
|
McpServerConflictAction::Remove(source)
|
|
}
|
|
|
|
#[test]
|
|
fn executor_credential_policy_survives_catalog_rebuilds_and_materialization() {
|
|
let mut config = server("https://executor.example/mcp");
|
|
config.environment_id = "remote".to_string();
|
|
let mut executor = ResolvedMcpCatalog::builder();
|
|
executor.register(McpServerRegistration::from_executor_config(
|
|
"docs".to_string(),
|
|
config.clone(),
|
|
));
|
|
let executor = executor.build();
|
|
let mut host = ResolvedMcpCatalog::builder();
|
|
host.register(McpServerRegistration::from_config(
|
|
"docs".to_string(),
|
|
config,
|
|
));
|
|
let host = host.build();
|
|
|
|
for catalog in [
|
|
executor.to_builder().build(),
|
|
executor.with_materialized_servers(executor.configured_servers()),
|
|
] {
|
|
assert_eq!(
|
|
catalog.server("docs").unwrap(),
|
|
executor.server("docs").unwrap(),
|
|
);
|
|
assert_eq!(
|
|
catalog.server("docs").unwrap().credential_policy(),
|
|
McpCredentialPolicy::ExecutorOnly,
|
|
);
|
|
assert!(!catalog.has_same_servers(&host));
|
|
}
|
|
assert_eq!(
|
|
host.server("docs").unwrap().credential_policy(),
|
|
McpCredentialPolicy::HostFallbackAllowed,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[should_panic(expected = "materialized MCP server must have a catalog registration")]
|
|
fn materialized_server_requires_catalog_registration() {
|
|
let catalog = ResolvedMcpCatalog::default();
|
|
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");
|
|
|
|
catalog.with_materialized_servers(HashMap::from([("unregistered".to_string(), server)]));
|
|
}
|
|
|
|
#[test]
|
|
fn selected_plugin_credential_policy_follows_origin_and_survives_catalog_rebuild() {
|
|
let mut remote_stdio: McpServerConfig =
|
|
serde_json::from_value(serde_json::json!({ "command": "remote-server" }))
|
|
.expect("valid stdio server");
|
|
remote_stdio.environment_id = "executor-1".to_string();
|
|
let mut remote_destination = server("https://executor.example/mcp");
|
|
remote_destination.environment_id = "executor-1".to_string();
|
|
|
|
for (source_environment_id, config, expected_policy) in [
|
|
(
|
|
"executor-1",
|
|
server("https://executor.example/mcp"),
|
|
McpCredentialPolicy::ExecutorOnly,
|
|
),
|
|
(
|
|
DEFAULT_MCP_SERVER_ENVIRONMENT_ID,
|
|
remote_destination,
|
|
McpCredentialPolicy::HostFallbackAllowed,
|
|
),
|
|
(
|
|
"executor-1",
|
|
remote_stdio,
|
|
McpCredentialPolicy::HostFallbackAllowed,
|
|
),
|
|
] {
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(McpServerRegistration::from_selected_plugin(
|
|
"docs".to_string(),
|
|
plugin("selected-root"),
|
|
/*selection_order*/ 0,
|
|
source_environment_id,
|
|
config,
|
|
));
|
|
let catalog = builder.build();
|
|
for rebuilt in [
|
|
catalog.to_builder().build(),
|
|
catalog.with_materialized_servers(catalog.configured_servers()),
|
|
] {
|
|
let registration = rebuilt.server("docs").unwrap();
|
|
assert_eq!(
|
|
registration.source(),
|
|
&selected_plugin_source("selected-root")
|
|
);
|
|
assert_eq!(registration.credential_policy(), expected_policy);
|
|
assert_eq!(registration, catalog.server("docs").unwrap());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn plugin_host_root_is_retained_in_catalog_identity() {
|
|
let original_root = PathUri::parse("file:///plugins/original").expect("valid plugin root URI");
|
|
let replacement_root =
|
|
PathUri::parse("file:///plugins/replacement").expect("valid plugin root URI");
|
|
let catalog_for_root = |root| {
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(McpServerRegistration::from_plugin(
|
|
"docs".to_string(),
|
|
plugin("plugin@test").with_host_root(root),
|
|
/*plugin_order*/ 0,
|
|
server("https://plugin.example/mcp"),
|
|
));
|
|
builder.build()
|
|
};
|
|
let original = catalog_for_root(original_root.clone());
|
|
let replacement = catalog_for_root(replacement_root);
|
|
|
|
let Some(McpServerSource::Plugin(attribution)) =
|
|
original.server("docs").map(ResolvedMcpServer::source)
|
|
else {
|
|
panic!("expected host-discovered plugin registration");
|
|
};
|
|
assert_eq!(attribution.host_root(), Some(&original_root));
|
|
assert!(!original.has_same_servers(&replacement));
|
|
}
|
|
|
|
#[test]
|
|
fn ema_policy_survives_rebuilds_and_rebinds_materialized_servers() {
|
|
let idp = McpServerIdpOAuthConfig {
|
|
issuer: "https://idp.example".to_string(),
|
|
client_id: "enterprise-client".to_string(),
|
|
};
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.enable_ema(idp.clone());
|
|
let enabled_empty = builder.build();
|
|
assert!(!enabled_empty.has_same_servers(&ResolvedMcpCatalog::default()));
|
|
|
|
let mut builder = enabled_empty.to_builder();
|
|
let mut config = server("https://resource.example/mcp");
|
|
config.auth = McpServerAuth::EmaAuth;
|
|
builder.register(McpServerRegistration::from_config(
|
|
"enterprise".to_string(),
|
|
config,
|
|
));
|
|
let original = builder.build();
|
|
assert!(original.has_same_servers(&original.to_builder().build()));
|
|
|
|
let mut servers = original.configured_servers();
|
|
let config = servers.get_mut("enterprise").expect("EMA server");
|
|
let McpServerTransportConfig::StreamableHttp { url, .. } = &mut config.transport else {
|
|
panic!("expected HTTP server");
|
|
};
|
|
*url = "https://resource.example/revised".to_string();
|
|
let materialized = original.with_materialized_servers(servers);
|
|
let config = materialized
|
|
.server("enterprise")
|
|
.expect("materialized EMA server")
|
|
.config();
|
|
let registration = config
|
|
.ema_registration()
|
|
.expect("finalized EMA registration");
|
|
assert_eq!(
|
|
(
|
|
config.enabled,
|
|
registration.idp(),
|
|
registration.server_url()
|
|
),
|
|
(true, &idp, "https://resource.example/revised")
|
|
);
|
|
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(McpServerRegistration::from_config(
|
|
"enterprise".to_string(),
|
|
config.clone(),
|
|
));
|
|
let denied = builder.build();
|
|
let config = denied
|
|
.server("enterprise")
|
|
.expect("denied EMA server")
|
|
.config();
|
|
assert_eq!((config.enabled, config.ema_registration()), (false, None));
|
|
}
|
|
|
|
#[test]
|
|
fn rejected_plugin_ema_registration_does_not_veto_hosted_apps() {
|
|
let mut rejected = server("https://plugin.example/mcp");
|
|
rejected.auth = McpServerAuth::EmaAuth;
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(McpServerRegistration::from_plugin(
|
|
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
plugin("plugin@test"),
|
|
/*plugin_order*/ 0,
|
|
rejected,
|
|
));
|
|
let catalog = builder.build();
|
|
assert!(
|
|
!catalog
|
|
.server(CODEX_APPS_MCP_SERVER_NAME)
|
|
.unwrap()
|
|
.config()
|
|
.enabled
|
|
);
|
|
let materialized = catalog.with_materialized_servers(catalog.configured_servers());
|
|
for catalog in [catalog, materialized] {
|
|
let mut builder = catalog.to_builder();
|
|
let expected = server("https://chatgpt.com/mcp");
|
|
builder.register(McpServerRegistration::from_hosted_apps(
|
|
"apps",
|
|
/*contribution_order*/ 0,
|
|
expected.clone(),
|
|
));
|
|
assert_eq!(
|
|
builder
|
|
.build()
|
|
.server(CODEX_APPS_MCP_SERVER_NAME)
|
|
.unwrap()
|
|
.config(),
|
|
&expected,
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn source_precedence_preserves_the_winning_registration() {
|
|
let extension = server("https://extension.example/mcp");
|
|
let mut plugin_server = server("https://plugin.example/mcp");
|
|
plugin_server.enabled = false;
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(McpServerRegistration::from_extension(
|
|
"docs".to_string(),
|
|
"hosted",
|
|
/*contribution_order*/ 0,
|
|
extension.clone(),
|
|
));
|
|
builder.register(McpServerRegistration::from_plugin(
|
|
"docs".to_string(),
|
|
plugin("plugin@test"),
|
|
/*plugin_order*/ 0,
|
|
plugin_server,
|
|
));
|
|
builder.register(McpServerRegistration::from_plugin(
|
|
"docs".to_string(),
|
|
plugin("other-plugin@test"),
|
|
/*plugin_order*/ 1,
|
|
server("https://other-plugin.example/mcp"),
|
|
));
|
|
builder.register(McpServerRegistration::from_compatibility(
|
|
"docs".to_string(),
|
|
"legacy",
|
|
server("https://compatibility.example/mcp"),
|
|
));
|
|
builder.register(McpServerRegistration::from_config(
|
|
"docs".to_string(),
|
|
server("https://config.example/mcp"),
|
|
));
|
|
|
|
let catalog = builder.build();
|
|
let resolved = catalog.server("docs").expect("resolved server");
|
|
|
|
assert_eq!(
|
|
resolved.source(),
|
|
&McpServerSource::Extension {
|
|
id: "hosted".to_string(),
|
|
host_owned_apps: false,
|
|
}
|
|
);
|
|
assert_eq!(resolved.config(), &extension);
|
|
assert!(catalog.plugin_attributions_by_server_name().is_empty());
|
|
assert_eq!(
|
|
catalog.conflicts(),
|
|
&[McpServerConflict {
|
|
name: "docs".to_string(),
|
|
outcome: register(extension_source("hosted")),
|
|
contenders: vec![
|
|
register(plugin_source("other-plugin@test")),
|
|
register(plugin_source("plugin@test")),
|
|
],
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_veto_only_disables_the_winning_registration() {
|
|
let extension = server("https://extension.example/mcp");
|
|
let mut expected = extension.clone();
|
|
expected.enabled = false;
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(McpServerRegistration::from_extension(
|
|
"docs".to_string(),
|
|
"hosted",
|
|
/*contribution_order*/ 0,
|
|
extension,
|
|
));
|
|
builder.disable("docs".to_string());
|
|
|
|
let actual = builder
|
|
.build()
|
|
.server("docs")
|
|
.expect("resolved server")
|
|
.config()
|
|
.clone();
|
|
|
|
assert_eq!(actual, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_winner_remains_a_veto_when_the_catalog_is_extended() {
|
|
let mut disabled = server("https://config.example/mcp");
|
|
disabled.enabled = false;
|
|
let mut expected = server("https://extension.example/mcp");
|
|
expected.enabled = false;
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(McpServerRegistration::from_config(
|
|
"docs".to_string(),
|
|
disabled,
|
|
));
|
|
let mut builder = builder.build().to_builder();
|
|
builder.register(McpServerRegistration::from_extension(
|
|
"docs".to_string(),
|
|
"hosted",
|
|
/*contribution_order*/ 0,
|
|
server("https://extension.example/mcp"),
|
|
));
|
|
|
|
let resolved = builder.build();
|
|
|
|
assert_eq!(
|
|
resolved.server("docs"),
|
|
Some(&super::ResolvedMcpServer {
|
|
source: extension_source("hosted"),
|
|
config: expected,
|
|
credential_policy: McpCredentialPolicy::HostFallbackAllowed,
|
|
protocol_mode: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_discovered_plugin_remains_a_veto_for_runtime_overlays() {
|
|
let mut disabled = server("https://plugin.example/mcp");
|
|
disabled.enabled = false;
|
|
let mut expected = server("https://extension.example/mcp");
|
|
expected.enabled = false;
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(McpServerRegistration::from_plugin(
|
|
"docs".to_string(),
|
|
plugin("plugin@test"),
|
|
/*plugin_order*/ 0,
|
|
disabled,
|
|
));
|
|
let mut builder = builder.build().to_builder();
|
|
builder.register(McpServerRegistration::from_extension(
|
|
"docs".to_string(),
|
|
"hosted",
|
|
/*contribution_order*/ 0,
|
|
server("https://extension.example/mcp"),
|
|
));
|
|
|
|
let resolved = builder.build();
|
|
|
|
assert_eq!(
|
|
resolved.server("docs"),
|
|
Some(&super::ResolvedMcpServer {
|
|
source: extension_source("hosted"),
|
|
config: expected,
|
|
credential_policy: McpCredentialPolicy::HostFallbackAllowed,
|
|
protocol_mode: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn earlier_plugin_wins_with_an_explicit_conflict() {
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(McpServerRegistration::from_plugin(
|
|
"docs".to_string(),
|
|
plugin("alpha@test"),
|
|
/*plugin_order*/ 0,
|
|
server("https://alpha.example/mcp"),
|
|
));
|
|
builder.register(McpServerRegistration::from_plugin(
|
|
"docs".to_string(),
|
|
plugin("beta@test"),
|
|
/*plugin_order*/ 1,
|
|
server("https://beta.example/mcp"),
|
|
));
|
|
|
|
let catalog = builder.build();
|
|
|
|
assert_eq!(
|
|
catalog.plugin_attributions_by_server_name(),
|
|
HashMap::from([("docs".to_string(), plugin("alpha@test"))])
|
|
);
|
|
assert_eq!(
|
|
catalog.conflicts(),
|
|
&[McpServerConflict {
|
|
name: "docs".to_string(),
|
|
outcome: register(plugin_source("alpha@test")),
|
|
contenders: vec![
|
|
register(plugin_source("beta@test")),
|
|
register(plugin_source("alpha@test")),
|
|
],
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn selected_plugins_override_discovered_plugins_but_not_config() {
|
|
let selected = server("https://selected-alpha.example/mcp");
|
|
let mut discovered = server("https://local.example/mcp");
|
|
discovered.enabled = false;
|
|
discovered.default_tools_approval_mode = Some(AppToolApproval::Auto);
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(McpServerRegistration::from_plugin(
|
|
"docs".to_string(),
|
|
plugin("local@test"),
|
|
/*plugin_order*/ 0,
|
|
discovered,
|
|
));
|
|
builder.register(McpServerRegistration::from_selected_plugin(
|
|
"docs".to_string(),
|
|
plugin("selected-beta"),
|
|
/*selection_order*/ 1,
|
|
DEFAULT_MCP_SERVER_ENVIRONMENT_ID,
|
|
server("https://selected-beta.example/mcp"),
|
|
));
|
|
builder.register(McpServerRegistration::from_selected_plugin(
|
|
"docs".to_string(),
|
|
plugin("selected-alpha"),
|
|
/*selection_order*/ 0,
|
|
DEFAULT_MCP_SERVER_ENVIRONMENT_ID,
|
|
selected.clone(),
|
|
));
|
|
|
|
let catalog = builder.build();
|
|
|
|
assert_eq!(
|
|
catalog.server("docs"),
|
|
Some(&super::ResolvedMcpServer {
|
|
source: selected_plugin_source("selected-alpha"),
|
|
config: selected,
|
|
credential_policy: McpCredentialPolicy::HostFallbackAllowed,
|
|
protocol_mode: None,
|
|
})
|
|
);
|
|
assert_eq!(
|
|
catalog.plugin_attributions_by_server_name(),
|
|
HashMap::from([("docs".to_string(), plugin("selected-alpha"))])
|
|
);
|
|
assert_eq!(
|
|
catalog.conflicts(),
|
|
&[McpServerConflict {
|
|
name: "docs".to_string(),
|
|
outcome: register(selected_plugin_source("selected-alpha")),
|
|
contenders: vec![
|
|
register(selected_plugin_source("selected-beta")),
|
|
register(selected_plugin_source("selected-alpha")),
|
|
],
|
|
}]
|
|
);
|
|
|
|
let refreshed = server("https://refreshed.example/mcp");
|
|
let catalog =
|
|
catalog.with_materialized_servers(HashMap::from([("docs".to_string(), refreshed.clone())]));
|
|
assert_eq!(
|
|
catalog.server("docs"),
|
|
Some(&super::ResolvedMcpServer {
|
|
source: selected_plugin_source("selected-alpha"),
|
|
config: refreshed,
|
|
credential_policy: McpCredentialPolicy::HostFallbackAllowed,
|
|
protocol_mode: None,
|
|
})
|
|
);
|
|
|
|
let mut builder = catalog.to_builder();
|
|
let configured = server("https://config.example/mcp");
|
|
builder.register(McpServerRegistration::from_config(
|
|
"docs".to_string(),
|
|
configured.clone(),
|
|
));
|
|
let catalog = builder.build();
|
|
|
|
assert_eq!(
|
|
catalog.server("docs"),
|
|
Some(&super::ResolvedMcpServer {
|
|
source: McpServerSource::Config,
|
|
config: configured,
|
|
credential_policy: McpCredentialPolicy::HostFallbackAllowed,
|
|
protocol_mode: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_selected_plugin_does_not_veto_runtime_overlays() {
|
|
let mut disabled = server("https://selected.example/mcp");
|
|
disabled.enabled = false;
|
|
let extension = server("https://extension.example/mcp");
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(McpServerRegistration::from_selected_plugin(
|
|
"docs".to_string(),
|
|
plugin("selected"),
|
|
/*selection_order*/ 0,
|
|
DEFAULT_MCP_SERVER_ENVIRONMENT_ID,
|
|
disabled,
|
|
));
|
|
let mut builder = builder.build().to_builder();
|
|
builder.register(McpServerRegistration::from_extension(
|
|
"docs".to_string(),
|
|
"hosted",
|
|
/*contribution_order*/ 0,
|
|
extension.clone(),
|
|
));
|
|
|
|
let resolved = builder.build();
|
|
|
|
assert_eq!(
|
|
resolved.server("docs"),
|
|
Some(&super::ResolvedMcpServer {
|
|
source: extension_source("hosted"),
|
|
config: extension,
|
|
credential_policy: McpCredentialPolicy::HostFallbackAllowed,
|
|
protocol_mode: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn equal_precedence_uses_insertion_order_not_source_identity() {
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(McpServerRegistration::from_compatibility(
|
|
"docs".to_string(),
|
|
"z-first",
|
|
server("https://first.example/mcp"),
|
|
));
|
|
builder.register(McpServerRegistration::from_compatibility(
|
|
"docs".to_string(),
|
|
"a-second",
|
|
server("https://second.example/mcp"),
|
|
));
|
|
|
|
let catalog = builder.build();
|
|
|
|
assert_eq!(
|
|
catalog.server("docs"),
|
|
Some(&super::ResolvedMcpServer {
|
|
source: compatibility_source("a-second"),
|
|
config: server("https://second.example/mcp"),
|
|
credential_policy: McpCredentialPolicy::HostFallbackAllowed,
|
|
protocol_mode: None,
|
|
})
|
|
);
|
|
let mut builder = catalog.to_builder();
|
|
builder.remove_compatibility("docs".to_string(), "remove-last");
|
|
|
|
let catalog = builder.build();
|
|
|
|
assert_eq!(catalog.server("docs"), None);
|
|
assert_eq!(
|
|
catalog.conflicts(),
|
|
&[McpServerConflict {
|
|
name: "docs".to_string(),
|
|
outcome: remove(compatibility_source("remove-last")),
|
|
contenders: vec![
|
|
register(compatibility_source("z-first")),
|
|
register(compatibility_source("a-second")),
|
|
remove(compatibility_source("remove-last")),
|
|
],
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extension_protocol_mode_follows_the_winner_through_materialization() {
|
|
let config = server("https://apps.example/mcp");
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(
|
|
McpServerRegistration::from_extension(
|
|
"apps".to_string(),
|
|
"loser",
|
|
/*contribution_order*/ 0,
|
|
config.clone(),
|
|
)
|
|
.with_protocol_mode(McpProtocolMode::V20260728),
|
|
);
|
|
builder.register(McpServerRegistration::from_extension(
|
|
"apps".to_string(),
|
|
"winner",
|
|
/*contribution_order*/ 1,
|
|
config.clone(),
|
|
));
|
|
let without_override = builder.build();
|
|
assert_eq!(
|
|
without_override
|
|
.server("apps")
|
|
.and_then(ResolvedMcpServer::protocol_mode),
|
|
None
|
|
);
|
|
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(
|
|
McpServerRegistration::from_extension(
|
|
"apps".to_string(),
|
|
"winner",
|
|
/*contribution_order*/ 1,
|
|
config,
|
|
)
|
|
.with_protocol_mode(McpProtocolMode::Legacy),
|
|
);
|
|
let with_override = builder.build();
|
|
assert!(!without_override.has_same_servers(&with_override));
|
|
|
|
let refreshed = server("https://refreshed.example/mcp");
|
|
let materialized = with_override
|
|
.with_materialized_servers(HashMap::from([("apps".to_string(), refreshed.clone())]));
|
|
assert_eq!(
|
|
materialized.server("apps"),
|
|
Some(&ResolvedMcpServer {
|
|
source: extension_source("winner"),
|
|
config: refreshed,
|
|
credential_policy: McpCredentialPolicy::HostFallbackAllowed,
|
|
protocol_mode: Some(McpProtocolMode::Legacy),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn environment_policy_exempts_only_explicitly_host_owned_apps() {
|
|
let policy = EnvironmentMcpPolicy {
|
|
servers: Some(BTreeMap::new()),
|
|
plugins: None,
|
|
};
|
|
for (registration, expected) in [
|
|
(
|
|
McpServerRegistration::from_extension(
|
|
CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
"apps",
|
|
/*contribution_order*/ 0,
|
|
server("https://apps.example/mcp"),
|
|
),
|
|
false,
|
|
),
|
|
(
|
|
McpServerRegistration::from_hosted_apps(
|
|
"apps",
|
|
/*contribution_order*/ 0,
|
|
server("https://apps.example/mcp"),
|
|
),
|
|
true,
|
|
),
|
|
] {
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(registration);
|
|
let catalog = builder
|
|
.build_with_environment_authority(|_| McpEnvironmentAuthority::Restricted(&policy));
|
|
assert_eq!(
|
|
catalog
|
|
.server(CODEX_APPS_MCP_SERVER_NAME)
|
|
.expect("Apps registration")
|
|
.config()
|
|
.enabled,
|
|
expected
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn environment_policy_preserves_selected_plugin_and_empty_server_allowlist_semantics() {
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(McpServerRegistration::from_selected_plugin(
|
|
"selected".to_string(),
|
|
plugin("selected-plugin"),
|
|
/*selection_order*/ 0,
|
|
DEFAULT_MCP_SERVER_ENVIRONMENT_ID,
|
|
server("https://plugin.example/mcp"),
|
|
));
|
|
let metadata_only_policy = EnvironmentMcpPolicy {
|
|
servers: None,
|
|
plugins: Some(BTreeMap::from([(
|
|
"metadata-only-plugin".to_string(),
|
|
PluginMcpRequirements { mcp_servers: None },
|
|
)])),
|
|
};
|
|
let deny_all_policy = EnvironmentMcpPolicy {
|
|
servers: Some(BTreeMap::new()),
|
|
plugins: None,
|
|
};
|
|
|
|
for (policy, expected) in [(&metadata_only_policy, true), (&deny_all_policy, false)] {
|
|
let resolved = builder
|
|
.clone()
|
|
.build_with_environment_authority(|_| McpEnvironmentAuthority::Restricted(policy));
|
|
assert_eq!(
|
|
resolved
|
|
.server("selected")
|
|
.expect("selected plugin")
|
|
.config()
|
|
.enabled,
|
|
expected
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn retired_plugin_ema_does_not_veto_a_later_runtime_registration() {
|
|
let mut retired = server("https://plugin.example/mcp");
|
|
retired.auth = McpServerAuth::EmaAuth;
|
|
retired.enabled = false;
|
|
let mut builder = ResolvedMcpCatalog::builder();
|
|
builder.register(McpServerRegistration::from_plugin(
|
|
"docs".to_string(),
|
|
plugin("plugin@test"),
|
|
/*plugin_order*/ 0,
|
|
retired,
|
|
));
|
|
let catalog = builder.build();
|
|
assert!(!catalog.server("docs").unwrap().config().enabled);
|
|
let mut builder = catalog.to_builder();
|
|
let expected = server("https://extension.example/mcp");
|
|
builder.register(McpServerRegistration::from_extension(
|
|
"docs".to_string(),
|
|
"hosted",
|
|
/*contribution_order*/ 0,
|
|
expected.clone(),
|
|
));
|
|
assert_eq!(builder.build().server("docs").unwrap().config(), &expected);
|
|
}
|