Files
codex/codex-rs/codex-mcp/src/mcp/mod_tests.rs
T
pakrym-oai ff7513cd83 Move MCP tool naming mode into manager (#21576)
## Why

The `non_prefixed_mcp_tool_names` feature should be applied where MCP
tools become model-visible, not by remapping names later in core.
Keeping the decision in `McpConnectionManager` construction makes
`ToolInfo` the single shaped view that spec building, deferred tool
search, routing, and unavailable-tool placeholders can consume directly.

This also preserves the existing external behavior while the feature is
off, and keeps the feature-on behavior for code mode and hooks explicit
at the manager boundary.

## What Changed

- Add `McpToolNameMode` to `codex-mcp` and flow it through `McpConfig`
into `McpConnectionManager::new`.
- Normalize MCP `ToolInfo` names in the manager using either
legacy-prefixed namespaces or non-prefixed namespaces; the legacy path
adds `mcp__` without restoring the old trailing namespace suffix.
- Remove the core-side MCP name remapping path so specs, tool search,
session resolution, and unavailable-tool placeholder construction use
the manager-provided `ToolName` values directly.
- Keep code mode flattening on the `__` namespace separator.
- Preserve hook compatibility by giving non-prefixed MCP hook names
legacy `mcp__...` matcher aliases.
- Add/adjust integration and unit coverage for non-prefixed code-mode
behavior, hook matching with the feature on and off, and manager-level
legacy prefixing.

## Testing

- `cargo test -p codex-mcp --lib`
- `cargo test -p codex-core --lib tools::spec::tests -- --nocapture`
- `cargo test -p codex-core --lib mcp_tools -- --nocapture`
- `cargo test -p codex-core --lib mcp_tool_exposure -- --nocapture`
- `cargo test -p codex-core --test all mcp_tool -- --nocapture`
- `cargo test -p codex-core --test all search_tool -- --nocapture`
- `cargo test -p codex-core --test all hooks_mcp -- --nocapture`
- `cargo test -p codex-core --test all
code_mode_uses_non_prefixed_mcp_tool_names_when_feature_enabled --
--nocapture`
- `cargo test -p codex-tools`
- `cargo test -p codex-features`
2026-05-26 08:21:15 -07:00

401 lines
14 KiB
Rust

use super::*;
use codex_config::Constrained;
use codex_config::types::AppToolApproval;
use codex_config::types::ApprovalsReviewer;
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 pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::PathBuf;
fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
McpConfig {
chatgpt_base_url: "https://chatgpt.com".to_string(),
apps_mcp_path_override: None,
apps_mcp_product_sku: None,
codex_home,
mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::default(),
mcp_oauth_callback_port: None,
mcp_oauth_callback_url: None,
skill_mcp_dependency_install_enabled: true,
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
codex_linux_sandbox_exe: None,
use_legacy_landlock: false,
apps_enabled: false,
prefix_mcp_tool_names: true,
client_elicitation_capability: ElicitationCapability::default(),
configured_mcp_servers: HashMap::new(),
plugin_ids_by_mcp_server_name: HashMap::new(),
plugin_capability_summaries: Vec::new(),
}
}
#[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_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::OnFailure,
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 {
approvals_reviewer: Some(ApprovalsReviewer::User),
tool_approval_mode: Some(AppToolApproval::Approve),
},
));
}
assert!(!mcp_permission_prompt_is_auto_approved(
AskForApproval::OnRequest,
&PermissionProfile::read_only(),
McpPermissionPromptAutoApproveContext {
approvals_reviewer: Some(ApprovalsReviewer::AutoReview),
tool_approval_mode: Some(AppToolApproval::Auto),
},
));
}
#[test]
fn mcp_prompt_auto_approval_rejects_auto_mode_in_default_permission_mode() {
assert!(!mcp_permission_prompt_is_auto_approved(
AskForApproval::OnRequest,
&PermissionProfile::read_only(),
McpPermissionPromptAutoApproveContext {
approvals_reviewer: Some(ApprovalsReviewer::User),
tool_approval_mode: Some(AppToolApproval::Auto),
},
));
}
#[test]
fn tool_plugin_provenance_collects_app_and_mcp_sources() {
let mut config = test_mcp_config(PathBuf::new());
config.plugin_ids_by_mcp_server_name =
HashMap::from([("alpha".to_string(), "alpha@test".to_string())]);
config.plugin_capability_summaries = vec![
PluginCapabilitySummary {
display_name: "alpha-plugin".to_string(),
app_connector_ids: vec![AppConnectorId("connector_example".to_string())],
mcp_server_names: vec!["alpha".to_string()],
..PluginCapabilitySummary::default()
},
PluginCapabilitySummary {
display_name: "beta-plugin".to_string(),
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_provenance(&config);
assert_eq!(
provenance,
ToolPluginProvenance {
plugin_display_names_by_connector_id: HashMap::from([
(
"connector_example".to_string(),
vec!["alpha-plugin".to_string(), "beta-plugin".to_string()],
),
(
"connector_gmail".to_string(),
vec!["beta-plugin".to_string()],
),
]),
plugin_display_names_by_mcp_server_name: HashMap::from([
("alpha".to_string(), vec!["alpha-plugin".to_string()]),
("beta".to_string(), vec!["beta-plugin".to_string()]),
]),
plugin_ids_by_mcp_server_name: HashMap::from([(
"alpha".to_string(),
"alpha@test".to_string(),
)]),
}
);
assert_eq!(
provenance.plugin_id_for_mcp_server_name("alpha"),
Some("alpha@test")
);
assert_eq!(provenance.plugin_id_for_mcp_server_name("beta"), None);
}
#[test]
fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() {
assert_eq!(
codex_apps_mcp_url_for_base_url(
"https://chatgpt.com/backend-api",
/*apps_mcp_path_override*/ None,
),
"https://chatgpt.com/backend-api/wham/apps"
);
assert_eq!(
codex_apps_mcp_url_for_base_url(
"https://chat.openai.com",
/*apps_mcp_path_override*/ None,
),
"https://chat.openai.com/backend-api/wham/apps"
);
assert_eq!(
codex_apps_mcp_url_for_base_url(
"http://localhost:8080/api/codex",
/*apps_mcp_path_override*/ None,
),
"http://localhost:8080/api/codex/apps"
);
assert_eq!(
codex_apps_mcp_url_for_base_url(
"http://localhost:8080",
/*apps_mcp_path_override*/ None,
),
"http://localhost:8080/api/codex/apps"
);
}
#[test]
fn codex_apps_mcp_url_uses_legacy_codex_apps_path() {
let config = test_mcp_config(PathBuf::from("/tmp"));
assert_eq!(
codex_apps_mcp_url(&config),
"https://chatgpt.com/backend-api/wham/apps"
);
}
#[test]
fn codex_apps_server_config_uses_legacy_codex_apps_path() {
let mut config = test_mcp_config(PathBuf::from("/tmp"));
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let mut servers = with_codex_apps_mcp(HashMap::new(), /*auth*/ None, &config);
assert!(!servers.contains_key(CODEX_APPS_MCP_SERVER_NAME));
config.apps_enabled = true;
servers = with_codex_apps_mcp(servers, Some(&auth), &config);
let server = servers
.get(CODEX_APPS_MCP_SERVER_NAME)
.expect("codex apps should be present when apps is enabled");
let config = server
.configured_config()
.expect("codex apps should use configured transport");
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/wham/apps");
}
#[test]
fn codex_apps_server_config_uses_configured_apps_mcp_path_override() {
let mut config = test_mcp_config(PathBuf::from("/tmp"));
config.apps_mcp_path_override = Some("/custom/mcp".to_string());
config.apps_enabled = true;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let servers = with_codex_apps_mcp(HashMap::new(), Some(&auth), &config);
let server = servers
.get(CODEX_APPS_MCP_SERVER_NAME)
.expect("codex apps should be present when apps is enabled");
let config = server
.configured_config()
.expect("codex apps should use configured transport");
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/custom/mcp");
}
#[test]
fn codex_apps_server_config_forwards_configured_product_sku_header() {
let mut config = test_mcp_config(PathBuf::from("/tmp"));
config.apps_mcp_product_sku = Some("tpp".to_string());
config.apps_enabled = true;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let servers = with_codex_apps_mcp(HashMap::new(), Some(&auth), &config);
let server = servers
.get(CODEX_APPS_MCP_SERVER_NAME)
.expect("codex apps should be present when apps is enabled");
let config = server
.configured_config()
.expect("codex apps should use configured transport");
match &config.transport {
McpServerTransportConfig::StreamableHttp {
http_headers,
env_http_headers,
..
} => {
assert_eq!(
http_headers,
&Some(HashMap::from([(
"X-OpenAI-Product-Sku".to_string(),
"tpp".to_string(),
)]))
);
assert!(env_http_headers.is_none());
}
other => panic!("expected streamable http transport, got {other:?}"),
}
}
#[tokio::test]
async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
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();
config.configured_mcp_servers.insert(
"sample".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "https://user.example/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
environment_id: codex_config::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(),
},
);
config.configured_mcp_servers.insert(
"docs".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "https://docs.example/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
environment_id: codex_config::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(),
},
);
let effective = effective_mcp_servers(&config, Some(&auth));
let sample = effective.get("sample").expect("user server should exist");
let docs = effective
.get("docs")
.expect("configured server should exist");
let codex_apps = effective
.get(CODEX_APPS_MCP_SERVER_NAME)
.expect("codex apps server should exist");
let sample = sample
.configured_config()
.expect("configured server should retain transport");
let docs = docs
.configured_config()
.expect("configured server should retain transport");
let codex_apps = codex_apps
.configured_config()
.expect("codex apps should use configured transport");
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/wham/apps");
}
other => panic!("expected streamable http transport, got {other:?}"),
}
}