[codex] Add managed MCP server matchers (#29648)

## Summary

This PR extends the existing managed `mcp_servers` identity requirement
so that one name-qualified rule can use either:

- the released exact command or URL identity;
- an exact stdio executable with an exact-length, ordered argument
matcher list; or
- a direct MCP URL matcher.

Matcher-based rules stay under the released `identity` key and use the
same `McpServerRequirement` abstraction and `mcp_servers.<server_name>`
namespace.

## Behavior

Policy activation and name qualification are unchanged:

- If `mcp_servers` is absent, ordinary configured MCP servers remain
unrestricted.
- If `mcp_servers` is present, a server needs a matching same-name
requirement.
- `mcp_servers = {}` continues to deny every configured MCP server.
- Existing exact identity requirements keep their released semantics.

Plugin-bundled MCP servers use the same requirement shapes under
`plugins.<plugin_name>.mcp_servers.<server_name>`. Top-level non-empty
rules continue to govern only ordinary configured servers; plugin rules
remain explicitly plugin-scoped. The existing globally empty
`mcp_servers = {}` plugin kill switch is preserved.

Requirements layers continue to use the existing regular TOML merge
behavior. Atomic replacement of named MCP requirements is intentionally
out of scope here and is tracked independently in #30118.

## Requirement contract

The released exact identity contract remains valid:

```toml
[mcp_servers.docs.identity]
command = "codex-mcp"

[mcp_servers.remote.identity]
url = "https://example.com/mcp"
```

Command identities continue to check only `command`; they do not inspect
arguments, `cwd`, `env`, or `env_vars`.

A command matcher uses an exact executable plus an exact-length, ordered
argument list. Each argument position supports `exact`, `prefix`, or
full-value `regex` matching:

```toml
[mcp_servers.internal_mcp_proxy.identity]
command = { executable = "company-cli", args = [
  { match = "exact", value = "mcp" },
  { match = "exact", value = "proxy" },
  { match = "exact", value = "--server" },
  { match = "regex", expression = '^https://[A-Za-z0-9-]+\.mcp\.internal\.example\.com(?::443)?(?:/.*)?$' },
] }
```

Direct streamable HTTP MCP definitions can use the same value matcher
types through `identity.url`:

```toml
[mcp_servers.internal_http.identity]
url = {
  match = "regex",
  expression = '^https://[A-Za-z0-9-]+\.mcp\.internal\.example\.com(?:/.*)?$',
}
```

Plugin-bundled MCP matchers use the same contract inside the
plugin-qualified allowlist:

```toml
[plugins."sample@test".mcp_servers.internal_mcp_proxy.identity]
command = { executable = "company-cli", args = [
  { match = "exact", value = "mcp" },
  { match = "exact", value = "proxy" },
] }
```

Regexes are validated while managed requirements are loaded, and regex
matching must cover the complete value. Command matchers constrain only
the executable and arguments.

## Why

Enterprise administrators need to allow MCP servers by executable and
positional-argument shape, including fixed arguments plus constrained
values such as internal MCP URLs passed to a proxy.

## Validation

- `just fmt`
- `git diff --check`
- `just test -p codex-config` (198 passed)
- `just test -p codex-core mcp_servers_by_matchers --lib` (2 passed)
This commit is contained in:
felixxia-oai authored and GitHub committed 2026-06-25 22:15:50 +01:00
1 parent e23e7cbe46
commit db541f4553
10 files changed
+771 -56

No files matched your search

+190 -12
View File
@@ -6,6 +6,10 @@ use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::McpServerCommandMatcher;
use codex_config::McpServerIdentity;
use codex_config::McpServerRequirement;
use codex_config::McpServerValueMatcher;
use codex_config::ProfileV2Name;
use codex_config::RequirementSource;
use codex_config::Sourced;
@@ -113,11 +117,15 @@ use std::time::Duration;
use tempfile::TempDir;
fn stdio_mcp(command: &str) -> McpServerConfig {
stdio_mcp_with_args(command, &[])
}
fn stdio_mcp_with_args(command: &str, args: &[&str]) -> McpServerConfig {
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::Stdio {
command: command.to_string(),
args: Vec::new(),
args: args.iter().map(ToString::to_string).collect(),
env: None,
env_vars: Vec::new(),
cwd: None,
@@ -4128,7 +4136,7 @@ fn filter_mcp_servers_by_allowlist_enforces_identity_rules() {
BTreeMap::from([
(
MISMATCHED_URL_SERVER.to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Url {
url: "https://example.com/other".to_string(),
},
@@ -4136,7 +4144,7 @@ fn filter_mcp_servers_by_allowlist_enforces_identity_rules() {
),
(
MISMATCHED_COMMAND_SERVER.to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "other-cmd".to_string(),
},
@@ -4144,7 +4152,7 @@ fn filter_mcp_servers_by_allowlist_enforces_identity_rules() {
),
(
MATCHED_URL_SERVER.to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Url {
url: GOOD_URL.to_string(),
},
@@ -4152,7 +4160,7 @@ fn filter_mcp_servers_by_allowlist_enforces_identity_rules() {
),
(
MATCHED_COMMAND_SERVER.to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: GOOD_CMD.to_string(),
},
@@ -4209,6 +4217,117 @@ fn filter_mcp_servers_by_allowlist_allows_all_when_unset() {
);
}
#[test]
fn filter_mcp_servers_by_matchers_enforces_command_and_positional_args() {
let mut servers = HashMap::from([
(
"internal_mcp_proxy".to_string(),
stdio_mcp_with_args(
"company-cli",
&[
"mcp",
"proxy",
"--server",
"https://pricing.mcp.internal.example.com",
],
),
),
(
"unlisted".to_string(),
stdio_mcp_with_args(
"company-cli",
&[
"mcp",
"proxy",
"--server",
"https://pricing.mcp.internal.example.com",
],
),
),
(
"wrong-order".to_string(),
stdio_mcp_with_args(
"company-cli",
&[
"proxy",
"mcp",
"--server",
"https://pricing.mcp.internal.example.com",
],
),
),
(
"trailing-arg".to_string(),
stdio_mcp_with_args(
"company-cli",
&[
"mcp",
"proxy",
"--server",
"https://pricing.mcp.internal.example.com",
"--verbose",
],
),
),
(
"wrong-host".to_string(),
stdio_mcp_with_args(
"company-cli",
&["mcp", "proxy", "--server", "https://mcp.example.com"],
),
),
]);
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
let requirement = McpServerRequirement::Command(McpServerCommandMatcher {
executable: "company-cli".to_string(),
args: vec![
McpServerValueMatcher::Exact {
value: "mcp".to_string(),
},
McpServerValueMatcher::Exact {
value: "proxy".to_string(),
},
McpServerValueMatcher::Exact {
value: "--server".to_string(),
},
McpServerValueMatcher::Regex {
expression:
r"^https://[A-Za-z0-9-]+\.mcp\.internal\.example\.com(?::443)?(?:/.*)?$"
.to_string(),
},
],
});
let requirements = Sourced::new(
BTreeMap::from([
("internal_mcp_proxy".to_string(), requirement.clone()),
("wrong-order".to_string(), requirement.clone()),
("trailing-arg".to_string(), requirement.clone()),
("wrong-host".to_string(), requirement),
]),
source.clone(),
);
filter_mcp_servers_by_requirements(&mut servers, Some(&requirements));
let reason = Some(McpServerDisabledReason::Requirements { source });
assert_eq!(
servers
.iter()
.map(|(name, server)| (
name.clone(),
(server.enabled, server.disabled_reason.clone())
))
.collect::<HashMap<String, (bool, Option<McpServerDisabledReason>)>>(),
HashMap::from([
("internal_mcp_proxy".to_string(), (true, None)),
("unlisted".to_string(), (false, reason.clone())),
("wrong-order".to_string(), (false, reason.clone())),
("trailing-arg".to_string(), (false, reason.clone())),
("wrong-host".to_string(), (false, reason)),
])
);
}
#[test]
fn filter_mcp_servers_by_allowlist_blocks_all_when_empty() {
let mut servers = HashMap::from([
@@ -4259,7 +4378,7 @@ fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() {
mcp_servers: Some(BTreeMap::from([
(
MATCHED_SERVER.to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: GOOD_CMD.to_string(),
},
@@ -4267,7 +4386,7 @@ fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() {
),
(
MISMATCHED_SERVER.to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: GOOD_CMD.to_string(),
},
@@ -4308,7 +4427,7 @@ fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() {
codex_config::PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([(
"server-a".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "cmd-a".to_string(),
},
@@ -4339,6 +4458,65 @@ fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() {
);
}
#[test]
fn filter_plugin_mcp_servers_by_matchers_enforces_name_and_invocation() {
const MATCHED_SERVER: &str = "matched";
const MISMATCHED_SERVER: &str = "mismatched";
const UNLISTED_SERVER: &str = "unlisted";
let mut servers = HashMap::from([
(
MATCHED_SERVER.to_string(),
stdio_mcp_with_args("company-cli", &["approved"]),
),
(
MISMATCHED_SERVER.to_string(),
stdio_mcp_with_args("company-cli", &["rejected"]),
),
(
UNLISTED_SERVER.to_string(),
stdio_mcp_with_args("company-cli", &["approved"]),
),
]);
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
let requirement = McpServerRequirement::Command(McpServerCommandMatcher {
executable: "company-cli".to_string(),
args: vec![McpServerValueMatcher::Exact {
value: "approved".to_string(),
}],
});
let requirements = Sourced::new(
BTreeMap::from([(
"sample@test".to_string(),
codex_config::PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([
(MATCHED_SERVER.to_string(), requirement.clone()),
(MISMATCHED_SERVER.to_string(), requirement),
])),
},
)]),
source.clone(),
);
filter_plugin_mcp_servers_by_requirements("sample@test", &mut servers, Some(&requirements));
let reason = Some(McpServerDisabledReason::Requirements { source });
assert_eq!(
servers
.iter()
.map(|(name, server)| (
name.clone(),
(server.enabled, server.disabled_reason.clone())
))
.collect::<HashMap<String, (bool, Option<McpServerDisabledReason>)>>(),
HashMap::from([
(MATCHED_SERVER.to_string(), (true, None)),
(MISMATCHED_SERVER.to_string(), (false, reason.clone())),
(UNLISTED_SERVER.to_string(), (false, reason)),
])
);
}
#[tokio::test]
async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -4348,7 +4526,7 @@ async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::
let mcp_requirements = BTreeMap::from([
(
"session_overrides_user".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "session-command".to_string(),
},
@@ -4356,7 +4534,7 @@ async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::
),
(
"managed_overrides_session".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "managed-command".to_string(),
},
@@ -4364,7 +4542,7 @@ async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::
),
(
"fresh_global".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "fresh-global-command".to_string(),
},
@@ -4372,7 +4550,7 @@ async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::
),
(
"fresh_project".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "fresh-project-command".to_string(),
},
+2 -24
View File
@@ -14,7 +14,6 @@ use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::ConstrainedWithSource;
use codex_config::FeatureRequirementsToml;
use codex_config::McpServerIdentity;
use codex_config::McpServerRequirement;
use codex_config::PluginRequirementsToml;
use codex_config::ProfileV2Name;
@@ -40,7 +39,6 @@ use codex_config::types::AuthKeyringBackendKind;
use codex_config::types::History;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerDisabledReason;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::MemoriesConfig;
use codex_config::types::ModelAvailabilityNuxConfig;
use codex_config::types::Notice;
@@ -1890,7 +1888,7 @@ fn filter_mcp_servers_by_requirements(
let allowed = allowlist
.value
.get(name)
.is_some_and(|requirement| mcp_server_matches_requirement(requirement, server));
.is_some_and(|requirement| requirement.matches(server));
if allowed {
server.disabled_reason = None;
} else {
@@ -1919,7 +1917,7 @@ fn filter_plugin_mcp_servers_by_requirements(
for (name, server) in mcp_servers.iter_mut() {
let allowed = plugin_mcp_requirements
.and_then(|mcp_requirements| mcp_requirements.get(name))
.is_some_and(|requirement| mcp_server_matches_requirement(requirement, server));
.is_some_and(|requirement| requirement.matches(server));
if allowed {
server.disabled_reason = None;
} else {
@@ -1982,26 +1980,6 @@ where
Ok(false)
}
fn mcp_server_matches_requirement(
requirement: &McpServerRequirement,
server: &McpServerConfig,
) -> bool {
match &requirement.identity {
McpServerIdentity::Command {
command: want_command,
} => matches!(
&server.transport,
McpServerTransportConfig::Stdio { command: got_command, .. }
if got_command == want_command
),
McpServerIdentity::Url { url: want_url } => matches!(
&server.transport,
McpServerTransportConfig::StreamableHttp { url: got_url, .. }
if got_url == want_url
),
}
}
pub async fn load_global_mcp_servers(
codex_home: &Path,
) -> std::io::Result<BTreeMap<String, McpServerConfig>> {