mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
committed by
GitHub
Unverified
parent
e23e7cbe46
commit
db541f4553
Generated
+1
@@ -2557,6 +2557,7 @@ dependencies = [
|
||||
"multimap",
|
||||
"pretty_assertions",
|
||||
"prost 0.14.3",
|
||||
"regex-lite",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_ignored",
|
||||
|
||||
@@ -30,6 +30,7 @@ gethostname = { workspace = true }
|
||||
indexmap = { workspace = true, features = ["serde"] }
|
||||
multimap = { workspace = true }
|
||||
prost = "0.14.3"
|
||||
regex-lite = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_ignored = { workspace = true }
|
||||
|
||||
@@ -19,6 +19,7 @@ use super::requirements_exec_policy::RequirementsExecPolicyToml;
|
||||
use crate::Constrained;
|
||||
use crate::ConstraintError;
|
||||
use crate::ManagedHooksRequirementsToml;
|
||||
use crate::mcp_requirements::McpServerRequirement;
|
||||
use crate::mcp_types::AppToolApproval;
|
||||
use crate::permissions_toml::PermissionProfileToml;
|
||||
use crate::types::WindowsSandboxModeToml;
|
||||
@@ -217,18 +218,6 @@ impl ConfigRequirements {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum McpServerIdentity {
|
||||
Command { command: String },
|
||||
Url { url: String },
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpServerRequirement {
|
||||
pub identity: McpServerIdentity,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PluginRequirementsToml {
|
||||
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
|
||||
@@ -1201,6 +1190,25 @@ impl ConfigRequirementsToml {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_mcp_server_requirements(
|
||||
requirements: &BTreeMap<String, McpServerRequirement>,
|
||||
source: &RequirementSource,
|
||||
plugin_name: Option<&str>,
|
||||
) -> Result<(), ConstraintError> {
|
||||
for (server_name, requirement) in requirements {
|
||||
requirement
|
||||
.validate()
|
||||
.map_err(|reason| ConstraintError::McpServerRequirementParse {
|
||||
server_name: plugin_name
|
||||
.map(|plugin_name| format!("{plugin_name}/{server_name}"))
|
||||
.unwrap_or_else(|| server_name.clone()),
|
||||
requirement_source: source.clone(),
|
||||
reason,
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
type Error = ConstraintError;
|
||||
|
||||
@@ -1233,6 +1241,25 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
|
||||
guardian_policy_config,
|
||||
} = toml;
|
||||
|
||||
if let Some(requirements) = &mcp_servers {
|
||||
validate_mcp_server_requirements(
|
||||
&requirements.value,
|
||||
&requirements.source,
|
||||
/*plugin_name*/ None,
|
||||
)?;
|
||||
}
|
||||
if let Some(plugin_requirements) = &plugins {
|
||||
for (plugin_name, plugin) in &plugin_requirements.value {
|
||||
if let Some(requirements) = &plugin.mcp_servers {
|
||||
validate_mcp_server_requirements(
|
||||
requirements,
|
||||
&plugin_requirements.source,
|
||||
Some(plugin_name),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let approval_policy = match allowed_approval_policies {
|
||||
Some(Sourced {
|
||||
value: policies,
|
||||
@@ -1542,6 +1569,9 @@ pub fn sandbox_mode_requirement_for_permission_profile(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::HookEventsToml;
|
||||
use crate::McpServerCommandMatcher;
|
||||
use crate::McpServerIdentity;
|
||||
use crate::McpServerValueMatcher;
|
||||
use anyhow::Result;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Evaluation;
|
||||
@@ -3454,6 +3484,9 @@ command = "python3 /enterprise/hooks/pre.py"
|
||||
#[test]
|
||||
fn deserialize_mcp_server_requirements() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
[mcp_servers.docs]
|
||||
description = "ignored legacy field"
|
||||
|
||||
[mcp_servers.docs.identity]
|
||||
command = "codex-mcp"
|
||||
|
||||
@@ -3469,7 +3502,7 @@ command = "python3 /enterprise/hooks/pre.py"
|
||||
BTreeMap::from([
|
||||
(
|
||||
"docs".to_string(),
|
||||
McpServerRequirement {
|
||||
McpServerRequirement::Identity {
|
||||
identity: McpServerIdentity::Command {
|
||||
command: "codex-mcp".to_string(),
|
||||
},
|
||||
@@ -3477,7 +3510,7 @@ command = "python3 /enterprise/hooks/pre.py"
|
||||
),
|
||||
(
|
||||
"remote".to_string(),
|
||||
McpServerRequirement {
|
||||
McpServerRequirement::Identity {
|
||||
identity: McpServerIdentity::Url {
|
||||
url: "https://example.com/mcp".to_string(),
|
||||
},
|
||||
@@ -3490,6 +3523,74 @@ command = "python3 /enterprise/hooks/pre.py"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_mcp_server_matcher_requirements() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
[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)?(?:/.*)?$' },
|
||||
] }
|
||||
"#;
|
||||
let requirements: ConfigRequirements =
|
||||
with_unknown_source(from_str(toml_str)?).try_into()?;
|
||||
|
||||
assert_eq!(
|
||||
requirements.mcp_servers,
|
||||
Some(Sourced::new(
|
||||
BTreeMap::from([(
|
||||
"internal_mcp_proxy".to_string(),
|
||||
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(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
)]),
|
||||
RequirementSource::Unknown,
|
||||
))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_mcp_server_requirement_regex_reports_the_server_name_and_source() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
[mcp_servers.broken_rule.identity]
|
||||
url = { match = "regex", expression = "[" }
|
||||
"#;
|
||||
|
||||
let err = ConfigRequirements::try_from(with_unknown_source(from_str(toml_str)?))
|
||||
.expect_err("invalid matcher regex should fail requirements normalization");
|
||||
let ConstraintError::McpServerRequirementParse {
|
||||
server_name,
|
||||
requirement_source,
|
||||
reason,
|
||||
} = err
|
||||
else {
|
||||
panic!("unexpected error: {err:?}");
|
||||
};
|
||||
|
||||
assert_eq!(server_name, "broken_rule");
|
||||
assert_eq!(requirement_source, RequirementSource::Unknown);
|
||||
assert!(reason.contains("invalid regex `[`"), "{reason}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_plugin_mcp_server_requirements() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
@@ -3511,7 +3612,7 @@ command = "python3 /enterprise/hooks/pre.py"
|
||||
PluginRequirementsToml {
|
||||
mcp_servers: Some(BTreeMap::from([(
|
||||
"remote".to_string(),
|
||||
McpServerRequirement {
|
||||
McpServerRequirement::Identity {
|
||||
identity: McpServerIdentity::Url {
|
||||
url: "https://example.com/mcp".to_string(),
|
||||
},
|
||||
@@ -3524,7 +3625,7 @@ command = "python3 /enterprise/hooks/pre.py"
|
||||
PluginRequirementsToml {
|
||||
mcp_servers: Some(BTreeMap::from([(
|
||||
"sample".to_string(),
|
||||
McpServerRequirement {
|
||||
McpServerRequirement::Identity {
|
||||
identity: McpServerIdentity::Command {
|
||||
command: "sample-mcp".to_string(),
|
||||
},
|
||||
@@ -3539,6 +3640,70 @@ command = "python3 /enterprise/hooks/pre.py"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_plugin_mcp_server_matcher_requirement() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
[plugins."sample@test".mcp_servers.internal_proxy.identity]
|
||||
command = { executable = "company-cli", args = [
|
||||
{ match = "exact", value = "mcp" },
|
||||
{ match = "regex", expression = '^https://[a-z]+\.example\.com$' },
|
||||
] }
|
||||
"#;
|
||||
let requirements: ConfigRequirements =
|
||||
with_unknown_source(from_str(toml_str)?).try_into()?;
|
||||
|
||||
assert_eq!(
|
||||
requirements.plugins,
|
||||
Some(Sourced::new(
|
||||
BTreeMap::from([(
|
||||
"sample@test".to_string(),
|
||||
PluginRequirementsToml {
|
||||
mcp_servers: Some(BTreeMap::from([(
|
||||
"internal_proxy".to_string(),
|
||||
McpServerRequirement::Command(McpServerCommandMatcher {
|
||||
executable: "company-cli".to_string(),
|
||||
args: vec![
|
||||
McpServerValueMatcher::Exact {
|
||||
value: "mcp".to_string(),
|
||||
},
|
||||
McpServerValueMatcher::Regex {
|
||||
expression: r"^https://[a-z]+\.example\.com$".to_string(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
)])),
|
||||
},
|
||||
)]),
|
||||
RequirementSource::Unknown,
|
||||
))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_plugin_mcp_server_regex_reports_plugin_and_server_name() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
[plugins."sample@test".mcp_servers.broken_rule.identity]
|
||||
url = { match = "regex", expression = "[" }
|
||||
"#;
|
||||
|
||||
let err = ConfigRequirements::try_from(with_unknown_source(from_str(toml_str)?))
|
||||
.expect_err("invalid plugin MCP regex should fail requirements normalization");
|
||||
let ConstraintError::McpServerRequirementParse {
|
||||
server_name,
|
||||
requirement_source,
|
||||
reason,
|
||||
} = err
|
||||
else {
|
||||
panic!("unexpected error: {err:?}");
|
||||
};
|
||||
|
||||
assert_eq!(server_name, "sample@test/broken_rule");
|
||||
assert_eq!(requirement_source, RequirementSource::Unknown);
|
||||
assert!(reason.contains("invalid regex `[`"), "{reason}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_exec_policy_requirements() -> Result<()> {
|
||||
let toml_str = r#"
|
||||
|
||||
@@ -24,6 +24,15 @@ pub enum ConstraintError {
|
||||
requirement_source: RequirementSource,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"invalid requirement for MCP server `{server_name}` (set by {requirement_source}): {reason}"
|
||||
)]
|
||||
McpServerRequirementParse {
|
||||
server_name: String,
|
||||
requirement_source: RequirementSource,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ConstraintError {
|
||||
|
||||
@@ -12,6 +12,7 @@ mod key_aliases;
|
||||
pub mod loader;
|
||||
mod marketplace_edit;
|
||||
mod mcp_edit;
|
||||
mod mcp_requirements;
|
||||
mod mcp_types;
|
||||
mod merge;
|
||||
mod overrides;
|
||||
@@ -66,8 +67,6 @@ pub use config_requirements::FilesystemDenyReadPattern;
|
||||
pub use config_requirements::MarketplaceAllowedSourceKind;
|
||||
pub use config_requirements::MarketplaceAllowedSourceToml;
|
||||
pub use config_requirements::MarketplaceRequirementsToml;
|
||||
pub use config_requirements::McpServerIdentity;
|
||||
pub use config_requirements::McpServerRequirement;
|
||||
pub use config_requirements::NetworkConstraints;
|
||||
pub use config_requirements::NetworkDomainPermissionToml;
|
||||
pub use config_requirements::NetworkDomainPermissionsToml;
|
||||
@@ -113,6 +112,10 @@ pub use marketplace_edit::remove_user_marketplace;
|
||||
pub use marketplace_edit::remove_user_marketplace_config;
|
||||
pub use mcp_edit::ConfigEditsBuilder;
|
||||
pub use mcp_edit::load_global_mcp_servers;
|
||||
pub use mcp_requirements::McpServerCommandMatcher;
|
||||
pub use mcp_requirements::McpServerIdentity;
|
||||
pub use mcp_requirements::McpServerRequirement;
|
||||
pub use mcp_requirements::McpServerValueMatcher;
|
||||
pub use mcp_types::AppToolApproval;
|
||||
pub use mcp_types::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
|
||||
pub use mcp_types::McpServerAuth;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
use crate::mcp_types::McpServerConfig;
|
||||
use crate::mcp_types::McpServerTransportConfig;
|
||||
use regex_lite::Regex;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum McpServerIdentity {
|
||||
Command { command: String },
|
||||
Url { url: String },
|
||||
}
|
||||
|
||||
/// String matching operations available to managed MCP server matchers.
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(tag = "match", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum McpServerValueMatcher {
|
||||
Exact { value: String },
|
||||
Prefix { value: String },
|
||||
Regex { expression: String },
|
||||
}
|
||||
|
||||
impl McpServerValueMatcher {
|
||||
fn compile_full_regex(expression: &str) -> Result<Regex, String> {
|
||||
Regex::new(&format!(r"\A(?:{expression})\z")).map_err(|err| {
|
||||
format!("regex `{expression}` cannot be used for full-value matching: {err}")
|
||||
})
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
let Self::Regex { expression } = self else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
Regex::new(expression).map_err(|err| format!("invalid regex `{expression}`: {err}"))?;
|
||||
Self::compile_full_regex(expression).map(|_| ())
|
||||
}
|
||||
|
||||
fn matches(&self, candidate: &str) -> bool {
|
||||
match self {
|
||||
Self::Exact { value } => candidate == value,
|
||||
Self::Prefix { value } => candidate.starts_with(value),
|
||||
Self::Regex { expression } => Self::compile_full_regex(expression)
|
||||
.ok()
|
||||
.is_some_and(|regex| regex.is_match(candidate)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct McpServerCommandMatcher {
|
||||
pub executable: String,
|
||||
pub args: Vec<McpServerValueMatcher>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawMcpServerCommandIdentity {
|
||||
command: McpServerCommandMatcher,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawMcpServerUrlIdentity {
|
||||
url: McpServerValueMatcher,
|
||||
}
|
||||
|
||||
/// A requirement for one named MCP server.
|
||||
///
|
||||
/// The `Identity` variant preserves the released exact-match contract. The
|
||||
/// command and URL variants are the normalized matcher-based forms accepted
|
||||
/// under the `identity` key.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum McpServerRequirement {
|
||||
Identity { identity: McpServerIdentity },
|
||||
Command(McpServerCommandMatcher),
|
||||
Url(McpServerValueMatcher),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawMcpServerRequirement {
|
||||
identity: RawMcpServerIdentity,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RawMcpServerIdentity {
|
||||
Exact(McpServerIdentity),
|
||||
Command(RawMcpServerCommandIdentity),
|
||||
Url(RawMcpServerUrlIdentity),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for McpServerRequirement {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let RawMcpServerRequirement { identity } =
|
||||
RawMcpServerRequirement::deserialize(deserializer)?;
|
||||
match identity {
|
||||
RawMcpServerIdentity::Exact(identity) => Ok(Self::Identity { identity }),
|
||||
RawMcpServerIdentity::Command(matcher) => Ok(Self::Command(matcher.command)),
|
||||
RawMcpServerIdentity::Url(matcher) => Ok(Self::Url(matcher.url)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl McpServerRequirement {
|
||||
pub(crate) fn validate(&self) -> Result<(), String> {
|
||||
match self {
|
||||
Self::Identity { .. } => Ok(()),
|
||||
Self::Command(matcher) => {
|
||||
for (index, arg) in matcher.args.iter().enumerate() {
|
||||
arg.validate().map_err(|err| {
|
||||
format!("invalid argument matcher at index {index}: {err}")
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Self::Url(matcher) => matcher.validate(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches(&self, server: &McpServerConfig) -> bool {
|
||||
match (self, &server.transport) {
|
||||
(
|
||||
Self::Identity {
|
||||
identity:
|
||||
McpServerIdentity::Command {
|
||||
command: want_command,
|
||||
},
|
||||
},
|
||||
McpServerTransportConfig::Stdio {
|
||||
command: got_command,
|
||||
..
|
||||
},
|
||||
) => got_command == want_command,
|
||||
(
|
||||
Self::Identity {
|
||||
identity: McpServerIdentity::Url { url: want_url },
|
||||
},
|
||||
McpServerTransportConfig::StreamableHttp { url: got_url, .. },
|
||||
) => got_url == want_url,
|
||||
(Self::Command(matcher), McpServerTransportConfig::Stdio { command, args, .. }) => {
|
||||
matcher.executable == *command
|
||||
&& matcher.args.len() == args.len()
|
||||
&& matcher
|
||||
.args
|
||||
.iter()
|
||||
.zip(args)
|
||||
.all(|(matcher, arg)| matcher.matches(arg))
|
||||
}
|
||||
(Self::Url(matcher), McpServerTransportConfig::StreamableHttp { url, .. }) => {
|
||||
matcher.matches(url)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mcp_requirements_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,217 @@
|
||||
use super::*;
|
||||
use crate::mcp_types::McpServerConfig;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn stdio_server(command: &str, args: &[&str]) -> McpServerConfig {
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio {
|
||||
command: command.to_string(),
|
||||
args: args.iter().map(ToString::to_string).collect(),
|
||||
env: None,
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_matcher_matches_exact_positional_arguments() {
|
||||
let requirement = McpServerRequirement::Command(McpServerCommandMatcher {
|
||||
executable: "company-cli".to_string(),
|
||||
args: vec![
|
||||
McpServerValueMatcher::Exact {
|
||||
value: "mcp".to_string(),
|
||||
},
|
||||
McpServerValueMatcher::Regex {
|
||||
expression: r"https://[a-z]+\.example\.com".to_string(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert!(requirement.matches(&stdio_server(
|
||||
"company-cli",
|
||||
&["mcp", "https://pricing.example.com"]
|
||||
)));
|
||||
assert!(!requirement.matches(&stdio_server(
|
||||
"company-cli",
|
||||
&["https://pricing.example.com", "mcp"]
|
||||
)));
|
||||
assert!(!requirement.matches(&stdio_server(
|
||||
"company-cli",
|
||||
&["mcp", "https://pricing.example.com", "--verbose"]
|
||||
)));
|
||||
assert!(!requirement.matches(&stdio_server(
|
||||
"/usr/local/bin/company-cli",
|
||||
&["mcp", "https://pricing.example.com"]
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_matcher_requires_a_full_value_match() {
|
||||
let matcher = McpServerValueMatcher::Regex {
|
||||
expression: "mcp".to_string(),
|
||||
};
|
||||
|
||||
assert!(matcher.matches("mcp"));
|
||||
assert!(!matcher.matches("mcp-proxy"));
|
||||
assert!(!matcher.matches("prefix-mcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_matcher_allows_a_later_alternative_to_match_the_full_value() {
|
||||
let matcher = McpServerValueMatcher::Regex {
|
||||
expression: r"https://api\.example\.com|https://api\.example\.com/mcp".to_string(),
|
||||
};
|
||||
|
||||
assert!(matcher.matches("https://api.example.com/mcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_matcher_validation_rejects_expression_that_cannot_be_wrapped() {
|
||||
let matcher = McpServerValueMatcher::Regex {
|
||||
expression: "(?x)mcp # trailing comment".to_string(),
|
||||
};
|
||||
|
||||
let err = matcher
|
||||
.validate()
|
||||
.expect_err("expression should not be valid for full-value matching");
|
||||
assert!(
|
||||
err.contains("cannot be used for full-value matching"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_command_identity_keeps_ignoring_arguments() {
|
||||
let requirement: McpServerRequirement = toml::from_str(
|
||||
r#"
|
||||
[identity]
|
||||
command = "company-cli"
|
||||
"#,
|
||||
)
|
||||
.expect("legacy command identity");
|
||||
|
||||
assert!(requirement.matches(&stdio_server(
|
||||
"company-cli",
|
||||
&["any", "arguments", "remain", "allowed"]
|
||||
)));
|
||||
assert!(!requirement.matches(&stdio_server("different-cli", &[])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requirement_deserializes_command_and_url_matcher_shapes() {
|
||||
let command: McpServerRequirement = toml::from_str(
|
||||
r#"
|
||||
[identity]
|
||||
command = { executable = "company-cli", args = [
|
||||
{ match = "exact", value = "mcp" },
|
||||
{ match = "regex", expression = '^https://[a-z]+\.example\.com$' },
|
||||
] }
|
||||
"#,
|
||||
)
|
||||
.expect("command matcher");
|
||||
let url: McpServerRequirement = toml::from_str(
|
||||
r#"
|
||||
[identity]
|
||||
url = { match = "prefix", value = "https://mcp.example.com/" }
|
||||
"#,
|
||||
)
|
||||
.expect("URL matcher");
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
McpServerRequirement::Command(McpServerCommandMatcher {
|
||||
executable: "company-cli".to_string(),
|
||||
args: vec![
|
||||
McpServerValueMatcher::Exact {
|
||||
value: "mcp".to_string(),
|
||||
},
|
||||
McpServerValueMatcher::Regex {
|
||||
expression: r"^https://[a-z]+\.example\.com$".to_string(),
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
url,
|
||||
McpServerRequirement::Url(McpServerValueMatcher::Prefix {
|
||||
value: "https://mcp.example.com/".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requirement_rejects_matchers_outside_identity() {
|
||||
for contents in [
|
||||
r#"
|
||||
command = "company-cli"
|
||||
"#,
|
||||
r#"
|
||||
command = { executable = "company-cli", args = [] }
|
||||
"#,
|
||||
r#"
|
||||
url = { match = "prefix", value = "https://mcp.example.com/" }
|
||||
"#,
|
||||
] {
|
||||
let err = toml::from_str::<McpServerRequirement>(contents)
|
||||
.expect_err("MCP server requirements should use the identity key");
|
||||
assert!(
|
||||
err.to_string().contains("missing field `identity`"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_identity_rejects_unknown_fields() {
|
||||
for contents in [
|
||||
r#"
|
||||
[identity]
|
||||
unknown = "value"
|
||||
command = { executable = "company-cli", args = [] }
|
||||
"#,
|
||||
r#"
|
||||
[identity]
|
||||
command = { executable = "company-cli", args = [], unknown = "value" }
|
||||
"#,
|
||||
] {
|
||||
toml::from_str::<McpServerRequirement>(contents)
|
||||
.expect_err("matcher identities should reject unknown fields");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_requirement_keeps_ignoring_unrelated_sibling_fields() {
|
||||
let requirement: McpServerRequirement = toml::from_str(
|
||||
r#"
|
||||
unrelated = "ignored"
|
||||
[identity]
|
||||
command = "company-cli"
|
||||
"#,
|
||||
)
|
||||
.expect("legacy identity with unrelated sibling field");
|
||||
|
||||
assert_eq!(
|
||||
requirement,
|
||||
McpServerRequirement::Identity {
|
||||
identity: McpServerIdentity::Command {
|
||||
command: "company-cli".to_string(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -699,7 +699,7 @@ mod tests {
|
||||
mcp_servers: Some(Sourced::new(
|
||||
BTreeMap::from([(
|
||||
"docs".to_string(),
|
||||
McpServerRequirement {
|
||||
McpServerRequirement::Identity {
|
||||
identity: McpServerIdentity::Command {
|
||||
command: "codex-mcp".to_string(),
|
||||
},
|
||||
@@ -778,7 +778,7 @@ mod tests {
|
||||
hooks: None,
|
||||
mcp_servers: Some(BTreeMap::from([(
|
||||
"docs".to_string(),
|
||||
McpServerRequirement {
|
||||
McpServerRequirement::Identity {
|
||||
identity: McpServerIdentity::Command {
|
||||
command: "codex-mcp".to_string(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user