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
+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]