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`
This commit is contained in:
pakrym-oai
2026-05-26 08:21:15 -07:00
committed by GitHub
parent b637fd26aa
commit ff7513cd83
30 changed files with 611 additions and 146 deletions
+21
View File
@@ -5304,6 +5304,27 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<(
Ok(())
}
#[tokio::test]
async fn to_mcp_config_flows_mcp_tool_prefix_from_feature() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let mut config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert!(mcp_config.prefix_mcp_tool_names);
let _ = config.features.enable(Feature::NonPrefixedMcpToolNames);
let mcp_config = config.to_mcp_config(&plugins_manager).await;
assert!(!mcp_config.prefix_mcp_tool_names);
Ok(())
}
#[tokio::test]
async fn to_mcp_config_preserves_auth_elicitation_feature_from_config() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+5
View File
@@ -1334,6 +1334,7 @@ impl Config {
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
use_legacy_landlock: self.features.use_legacy_landlock(),
apps_enabled: self.features.enabled(Feature::Apps),
prefix_mcp_tool_names: self.prefix_mcp_tool_names(),
client_elicitation_capability: if self.features.enabled(Feature::AuthElicitation) {
ElicitationCapability {
form: Some(FormElicitationCapability::default()),
@@ -1350,6 +1351,10 @@ impl Config {
}
}
pub(crate) fn prefix_mcp_tool_names(&self) -> bool {
!self.features.enabled(Feature::NonPrefixedMcpToolNames)
}
pub async fn rebuild_preserving_session_layers(
&self,
refreshed_config: &Config,
+1
View File
@@ -276,6 +276,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
host_owned_codex_apps_enabled,
mcp_config.prefix_mcp_tool_names,
mcp_config.client_elicitation_capability,
ToolPluginProvenance::default(),
auth.as_ref(),
+3 -3
View File
@@ -110,7 +110,7 @@ pub(crate) async fn handle_mcp_tool_call(
call_id: String,
server: String,
tool_name: String,
hook_tool_name: String,
hook_tool_name: HookToolName,
arguments: String,
) -> HandledMcpToolCall {
// Parse the `arguments` as JSON. An empty string is OK, but invalid JSON
@@ -1158,7 +1158,7 @@ async fn maybe_request_mcp_tool_approval(
turn_context: &Arc<TurnContext>,
call_id: &str,
invocation: &McpInvocation,
hook_tool_name: &str,
hook_tool_name: &HookToolName,
metadata: Option<&McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
) -> Option<McpToolApprovalDecision> {
@@ -1193,7 +1193,7 @@ async fn maybe_request_mcp_tool_approval(
turn_context,
call_id,
PermissionRequestPayload {
tool_name: HookToolName::new(hook_tool_name),
tool_name: hook_tool_name.clone(),
tool_input: invocation
.arguments
.clone()
+11 -9
View File
@@ -5,6 +5,7 @@ use crate::session::tests::make_session_and_context;
use crate::session::tests::make_session_and_context_with_rx;
use crate::state::ActiveTurn;
use crate::test_support::models_manager_with_provider;
use crate::tools::hook_names::HookToolName;
use crate::turn_metadata::McpTurnMetadataContext;
use codex_config::CONFIG_TOML_FILE;
use codex_config::config_toml::ConfigToml;
@@ -1270,6 +1271,7 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context:
turn_context.config.codex_home.to_path_buf(),
codex_mcp::codex_apps_tools_cache_key(auth.as_ref()),
/*host_owned_codex_apps_enabled*/ true,
turn_context.config.prefix_mcp_tool_names(),
rmcp::model::ElicitationCapability::default(),
codex_mcp::ToolPluginProvenance::default(),
auth.as_ref(),
@@ -2293,7 +2295,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
&turn_context,
"call-1",
&invocation,
"mcp__test__tool",
&HookToolName::new("mcp__test__tool"),
Some(&metadata),
AppToolApproval::Approve,
)
@@ -2367,7 +2369,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
&turn_context,
"call-guardian",
&invocation,
"mcp__test__tool",
&HookToolName::new("mcp__test__tool"),
Some(&metadata),
AppToolApproval::Auto,
)
@@ -2424,7 +2426,7 @@ async fn permission_request_hook_allows_mcp_tool_call() {
&turn_context,
"call-mcp-hook",
&invocation,
"mcp__memory__create_entities",
&HookToolName::new("mcp__memory__create_entities"),
Some(&metadata),
AppToolApproval::Auto,
)
@@ -2486,7 +2488,7 @@ async fn permission_request_hook_uses_hook_tool_name_without_metadata() {
&turn_context,
"call-mcp-hook-no-metadata",
&invocation,
"mcp__memory__create_entities",
&HookToolName::new("mcp__memory__create_entities"),
/*metadata*/ None,
AppToolApproval::Auto,
)
@@ -2566,7 +2568,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() {
&turn_context,
"call-mcp-remembered",
&invocation,
"mcp__memory__create_entities",
&HookToolName::new("mcp__memory__create_entities"),
Some(&metadata),
AppToolApproval::Auto,
)
@@ -2647,7 +2649,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
&turn_context,
"call-guardian-deny",
&invocation,
"mcp__test__tool",
&HookToolName::new("mcp__test__tool"),
Some(&metadata),
AppToolApproval::Auto,
)
@@ -2705,7 +2707,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
&turn_context,
"call-prompt",
&invocation,
"mcp__test__tool",
&HookToolName::new("mcp__test__tool"),
Some(&metadata),
AppToolApproval::Prompt,
)
@@ -2761,7 +2763,7 @@ async fn full_access_mode_skips_mcp_tool_approval_for_all_approval_modes() {
&turn_context,
"call-2",
&invocation,
"mcp__test__tool",
&HookToolName::new("mcp__test__tool"),
Some(&metadata),
approval_mode,
)
@@ -2849,7 +2851,7 @@ async fn approve_mode_skips_guardian_in_every_permission_mode() {
&turn_context,
"call-3",
&invocation,
"mcp__test__tool",
&HookToolName::new("mcp__test__tool"),
Some(&metadata),
AppToolApproval::Approve,
)
+3 -3
View File
@@ -70,7 +70,7 @@ fn numbered_mcp_tools(count: usize) -> Vec<ToolInfo> {
make_mcp_tool(
"rmcp",
&tool_name,
"mcp__rmcp__",
"mcp__rmcp",
&tool_name,
/*connector_id*/ None,
/*connector_name*/ None,
@@ -127,7 +127,7 @@ async fn always_defer_feature_defers_apps_too() {
make_mcp_tool(
"rmcp",
"tool",
"mcp__rmcp__",
"mcp__rmcp",
"tool",
/*connector_id*/ None,
/*connector_name*/ None,
@@ -156,7 +156,7 @@ async fn always_defer_feature_defers_apps_too() {
.as_ref()
.expect("MCP tools should be discoverable through tool_search");
let deferred_tool_names = tool_names(deferred_tools);
assert!(deferred_tool_names.contains(&ToolName::namespaced("mcp__rmcp__", "tool")));
assert!(deferred_tool_names.contains(&ToolName::namespaced("mcp__rmcp", "tool")));
assert!(deferred_tool_names.contains(&ToolName::namespaced(
"mcp__codex_apps__calendar",
"_create_event"
+1
View File
@@ -353,6 +353,7 @@ impl Session {
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
host_owned_codex_apps_enabled,
mcp_config.prefix_mcp_tool_names,
mcp_config.client_elicitation_capability,
tool_plugin_provenance,
auth.as_ref(),
+2
View File
@@ -978,6 +978,7 @@ impl Session {
McpConnectionManager::new_uninitialized_with_permission_profile(
&config.permissions.approval_policy,
config.permissions.permission_profile(),
config.prefix_mcp_tool_names(),
),
)),
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
@@ -1156,6 +1157,7 @@ impl Session {
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth),
host_owned_codex_apps_enabled,
config.prefix_mcp_tool_names(),
client_elicitation_capability,
tool_plugin_provenance,
auth,
+2
View File
@@ -4519,6 +4519,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
McpConnectionManager::new_uninitialized_with_permission_profile(
&config.permissions.approval_policy,
config.permissions.permission_profile(),
config.prefix_mcp_tool_names(),
),
)),
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
@@ -6348,6 +6349,7 @@ where
McpConnectionManager::new_uninitialized_with_permission_profile(
&config.permissions.approval_policy,
config.permissions.permission_profile(),
config.prefix_mcp_tool_names(),
),
)),
mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()),
+102 -11
View File
@@ -9,7 +9,10 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::flat_tool_name;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolExecutor;
use crate::tools::registry::ToolTelemetryTags;
use crate::tools::tool_search_entry::ToolSearchInfo;
@@ -20,6 +23,11 @@ use codex_tools::ToolName;
use codex_tools::ToolSearchSourceInfo;
use codex_tools::ToolSpec;
use codex_tools::mcp_tool_to_responses_api_tool;
use serde_json::Map;
use serde_json::Value;
const LEGACY_MCP_TOOL_NAME_PREFIX: &str = "mcp__";
const MCP_TOOL_NAME_DELIMITER: &str = "__";
pub struct McpHandler {
tool_info: ToolInfo,
@@ -31,6 +39,29 @@ impl McpHandler {
let spec = create_tool_spec(&tool_info)?;
Ok(Self { tool_info, spec })
}
fn hook_tool_name(&self) -> HookToolName {
HookToolName::new(ensure_mcp_prefix(&join_tool_name(&self.tool_name())))
}
}
fn join_tool_name(tool_name: &ToolName) -> String {
match tool_name.namespace.as_deref() {
Some(namespace) => {
let namespace = namespace.trim_end_matches('_');
let name = tool_name.name.trim_start_matches('_');
format!("{namespace}{MCP_TOOL_NAME_DELIMITER}{name}")
}
None => tool_name.name.clone(),
}
}
fn ensure_mcp_prefix(name: &str) -> String {
if name.starts_with(LEGACY_MCP_TOOL_NAME_PREFIX) {
name.to_string()
} else {
format!("{LEGACY_MCP_TOOL_NAME_PREFIX}{name}")
}
}
#[async_trait::async_trait]
@@ -84,7 +115,7 @@ impl ToolExecutor<ToolInvocation> for McpHandler {
call_id.clone(),
self.tool_info.server_name.clone(),
self.tool_info.tool.name.to_string(),
self.tool_name().to_string(),
self.hook_tool_name(),
payload,
)
.await;
@@ -138,6 +169,58 @@ impl CoreToolRuntime for McpHandler {
tags
})
}
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
let ToolPayload::Function { arguments } = &invocation.payload else {
return None;
};
Some(PreToolUsePayload {
tool_name: self.hook_tool_name(),
tool_input: mcp_hook_tool_input(arguments),
})
}
fn with_updated_hook_input(
&self,
mut invocation: ToolInvocation,
updated_input: Value,
) -> Result<ToolInvocation, FunctionCallError> {
invocation.payload = match invocation.payload {
ToolPayload::Function { .. } => ToolPayload::Function {
arguments: serde_json::to_string(&updated_input).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to serialize rewritten MCP arguments: {err}"
))
})?,
},
payload => {
return Err(FunctionCallError::RespondToModel(format!(
"tool {} does not support hook input rewriting for payload {payload:?}",
self.tool_name()
)));
}
};
Ok(invocation)
}
fn post_tool_use_payload(
&self,
invocation: &ToolInvocation,
result: &dyn crate::tools::context::ToolOutput,
) -> Option<PostToolUsePayload> {
let ToolPayload::Function { .. } = &invocation.payload else {
return None;
};
let tool_response =
result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
Some(PostToolUsePayload {
tool_name: self.hook_tool_name(),
tool_use_id: invocation.call_id.clone(),
tool_input: result.post_tool_use_input(&invocation.payload)?,
tool_response,
})
}
}
fn create_tool_spec(tool_info: &ToolInfo) -> Result<ToolSpec, serde_json::Error> {
@@ -166,6 +249,14 @@ fn create_tool_spec(tool_info: &ToolInfo) -> Result<ToolSpec, serde_json::Error>
}))
}
fn mcp_hook_tool_input(raw_arguments: &str) -> Value {
if raw_arguments.trim().is_empty() {
return Value::Object(Map::new());
}
serde_json::from_str(raw_arguments).unwrap_or_else(|_| Value::String(raw_arguments.to_string()))
}
fn build_mcp_search_text(info: &ToolInfo) -> String {
let tool_name = info.canonical_tool_name();
let mut schema_properties = info
@@ -233,7 +324,7 @@ mod tests {
use tokio::sync::Mutex;
#[tokio::test]
async fn mcp_pre_tool_use_payload_uses_model_tool_name_and_raw_args() {
async fn mcp_pre_tool_use_payload_uses_prefixed_tool_name_and_raw_args() {
let payload = ToolPayload::Function {
arguments: json!({
"entities": [{
@@ -244,7 +335,7 @@ mod tests {
.to_string(),
};
let (session, turn) = make_session_and_context().await;
let handler = McpHandler::new(tool_info("memory", "mcp__memory__", "create_entities"))
let handler = McpHandler::new(tool_info("memory", "memory", "create_entities"))
.expect("MCP tool spec should build");
assert_eq!(
handler.pre_tool_use_payload(&ToolInvocation {
@@ -253,7 +344,7 @@ mod tests {
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-mcp-pre".to_string(),
tool_name: codex_tools::ToolName::namespaced("mcp__memory__", "create_entities"),
tool_name: codex_tools::ToolName::namespaced("memory", "create_entities"),
source: ToolCallSource::Direct,
payload,
}),
@@ -275,7 +366,7 @@ mod tests {
arguments: json!({ "message": "hello" }).to_string(),
};
let (session, turn) = make_session_and_context().await;
let handler = McpHandler::new(tool_info("foo", "mcp__foo__", "exec_command"))
let handler = McpHandler::new(tool_info("foo", "mcp__foo", "exec_command"))
.expect("MCP tool spec should build");
assert_eq!(
@@ -285,7 +376,7 @@ mod tests {
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-mcp-pre-builtin-like".to_string(),
tool_name: codex_tools::ToolName::namespaced("mcp__foo__", "exec_command"),
tool_name: codex_tools::ToolName::namespaced("mcp__foo", "exec_command"),
source: ToolCallSource::Direct,
payload,
}),
@@ -302,7 +393,7 @@ mod tests {
arguments: json!({ "message": "hello" }).to_string(),
};
let (session, turn) = make_session_and_context().await;
let handler = McpHandler::new(tool_info("foo", "mcp__foo__", "exec_command"))
let handler = McpHandler::new(tool_info("foo", "mcp__foo", "exec_command"))
.expect("MCP tool spec should build");
let invocation = handler
@@ -313,7 +404,7 @@ mod tests {
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-mcp-rewrite-builtin-like".to_string(),
tool_name: codex_tools::ToolName::namespaced("mcp__foo__", "exec_command"),
tool_name: codex_tools::ToolName::namespaced("mcp__foo", "exec_command"),
source: ToolCallSource::Direct,
payload,
},
@@ -328,7 +419,7 @@ mod tests {
}
#[tokio::test]
async fn mcp_post_tool_use_payload_uses_model_tool_name_args_and_result() {
async fn mcp_post_tool_use_payload_uses_prefixed_tool_name_args_and_result() {
let payload = ToolPayload::Function {
arguments: json!({ "path": "/tmp/notes.txt" }).to_string(),
};
@@ -352,7 +443,7 @@ mod tests {
truncation_policy: codex_utils_output_truncation::TruncationPolicy::Bytes(1024),
};
let (session, turn) = make_session_and_context().await;
let handler = McpHandler::new(tool_info("filesystem", "mcp__filesystem__", "read_file"))
let handler = McpHandler::new(tool_info("filesystem", "filesystem", "read_file"))
.expect("MCP tool spec should build");
let invocation = ToolInvocation {
session: session.into(),
@@ -360,7 +451,7 @@ mod tests {
cancellation_token: tokio_util::sync::CancellationToken::new(),
tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
call_id: "call-mcp-post".to_string(),
tool_name: codex_tools::ToolName::namespaced("mcp__filesystem__", "read_file"),
tool_name: codex_tools::ToolName::namespaced("filesystem", "read_file"),
source: ToolCallSource::Direct,
payload,
};
@@ -197,8 +197,8 @@ mod tests {
tools,
vec![
LoadableToolSpec::Namespace(ResponsesApiNamespace {
name: "mcp__calendar__".to_string(),
description: "Tools in the mcp__calendar__ namespace.".to_string(),
name: "mcp__calendar".to_string(),
description: "Tools in the mcp__calendar namespace.".to_string(),
tools: vec![
ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "create_event".to_string(),
@@ -256,7 +256,7 @@ mod tests {
supports_parallel_tool_calls: false,
server_origin: None,
callable_name: tool_name.to_string(),
callable_namespace: format!("mcp__{server_name}__"),
callable_namespace: format!("mcp__{server_name}"),
namespace_description: None,
tool: Tool {
name: tool_name.to_string().into(),
+10 -11
View File
@@ -447,7 +447,7 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() {
let direct_mcp = probe_with(
|_| {},
ToolPlanInputs {
mcp_tools: Some(vec![mcp_tool("direct", "mcp__direct__", "lookup")]),
mcp_tools: Some(vec![mcp_tool("direct", "mcp__direct", "lookup")]),
..ToolPlanInputs::default()
},
)
@@ -458,12 +458,12 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() {
"read_mcp_resource",
]);
assert_eq!(
direct_mcp.namespace_function_names("mcp__direct__"),
direct_mcp.namespace_function_names("mcp__direct"),
&["lookup".to_string()]
);
let searchable_mcp = ToolPlanInputs {
deferred_mcp_tools: Some(vec![mcp_tool("searchable", "mcp__searchable__", "lookup")]),
deferred_mcp_tools: Some(vec![mcp_tool("searchable", "mcp__searchable", "lookup")]),
..ToolPlanInputs::default()
};
@@ -512,7 +512,10 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() {
)
.await;
enabled.assert_visible_contains(&["tool_search"]);
enabled.assert_registered_contains(&["tool_search", "mcp__searchable__lookup"]);
enabled.assert_registered_contains(&[
"tool_search",
&ToolName::namespaced("mcp__searchable", "lookup").to_string(),
]);
}
#[tokio::test]
@@ -520,18 +523,14 @@ async fn invalid_mcp_tools_are_not_registered() {
let plan = probe_with(
|_| {},
ToolPlanInputs {
mcp_tools: Some(vec![invalid_mcp_tool(
"invalid",
"mcp__invalid__",
"lookup",
)]),
mcp_tools: Some(vec![invalid_mcp_tool("invalid", "mcp__invalid", "lookup")]),
..ToolPlanInputs::default()
},
)
.await;
plan.assert_visible_lacks(&["mcp__invalid__"]);
plan.assert_registered_lacks(&["mcp__invalid__lookup"]);
plan.assert_visible_lacks(&["mcp__invalid"]);
plan.assert_registered_lacks(&[&ToolName::namespaced("mcp__invalid", "lookup").to_string()]);
}
#[tokio::test]