Files
codex/codex-rs/codex-mcp/src/mcp_tool_names.rs
T
Matthew Zeng d7f99b0fa6 [mcp] Expand tool search to custom MCPs. (#16944)
- [x] Expand tool search to custom MCPs.
- [x] Rename several variables/fields to be more generic.

Updated tool & server name lifecycles:

**Raw Identity**

ToolInfo.server_name is raw MCP server name.
ToolInfo.tool.name is raw MCP tool name.
MCP calls route back to raw via parse_tool_name() returning
(tool.server_name, tool.tool.name).
mcpServerStatus/list now groups by raw server and keys tools by
Tool.name: mod.rs:599
App-server just forwards that grouped raw snapshot:
codex_message_processor.rs:5245

**Callable Names**

On list-tools, we create provisional callable_namespace / callable_name:
mcp_connection_manager.rs:1556
For non-app MCP, provisional callable name starts as raw tool name.
For codex-apps, provisional callable name is sanitized and strips
connector name/id prefix; namespace includes connector name.
Then qualify_tools() sanitizes callable namespace + name to ASCII alnum
/ _ only: mcp_tool_names.rs:128
Note: this is stricter than Responses API. Hyphen is currently replaced
with _ for code-mode compatibility.

**Collision Handling**

We do initially collapse example-server and example_server to the same
base.
Then qualify_tools() detects distinct raw namespace identities behind
the same sanitized namespace and appends a hash to the callable
namespace: mcp_tool_names.rs:137
Same idea for tool-name collisions: hash suffix goes on callable tool
name.
Final list_all_tools() map key is callable_namespace + callable_name:
mcp_connection_manager.rs:769

**Direct Model Tools**

Direct MCP tool declarations use the full qualified sanitized key as the
Responses function name.
The raw rmcp Tool is converted but renamed for model exposure.

**Tool Search / Deferred**

Tool search result namespace = final ToolInfo.callable_namespace:
tool_search.rs:85
Tool search result nested name = final ToolInfo.callable_name:
tool_search.rs:86
Deferred tool handler is registered as "{namespace}:{name}":
tool_registry_plan.rs:248
When a function call comes back, core recombines namespace + name, looks
up the full qualified key, and gets the raw server/tool for MCP
execution: codex.rs:4353

**Separate Legacy Snapshot**

collect_mcp_snapshot_from_manager_with_detail() still returns a map
keyed by qualified callable name.
mcpServerStatus/list no longer uses that; it uses
McpServerStatusSnapshot, which is raw-inventory shaped.
2026-04-09 13:34:52 -07:00

203 lines
6.8 KiB
Rust

//! Allocates model-visible MCP tool names while preserving raw MCP identities.
use std::collections::HashMap;
use std::collections::HashSet;
use sha1::Digest;
use sha1::Sha1;
use tracing::warn;
use crate::mcp::sanitize_responses_api_tool_name;
use crate::mcp_connection_manager::ToolInfo;
const MCP_TOOL_NAME_DELIMITER: &str = "__";
const MAX_TOOL_NAME_LENGTH: usize = 64;
const CALLABLE_NAME_HASH_LEN: usize = 12;
fn sha1_hex(s: &str) -> String {
let mut hasher = Sha1::new();
hasher.update(s.as_bytes());
let sha1 = hasher.finalize();
format!("{sha1:x}")
}
fn callable_name_hash_suffix(raw_identity: &str) -> String {
let hash = sha1_hex(raw_identity);
format!("_{}", &hash[..CALLABLE_NAME_HASH_LEN])
}
fn append_hash_suffix(value: &str, raw_identity: &str) -> String {
format!("{value}{}", callable_name_hash_suffix(raw_identity))
}
fn append_namespace_hash_suffix(namespace: &str, raw_identity: &str) -> String {
if let Some(namespace) = namespace.strip_suffix(MCP_TOOL_NAME_DELIMITER) {
format!(
"{}{}{}",
namespace,
callable_name_hash_suffix(raw_identity),
MCP_TOOL_NAME_DELIMITER
)
} else {
append_hash_suffix(namespace, raw_identity)
}
}
fn truncate_name(value: &str, max_len: usize) -> String {
value.chars().take(max_len).collect()
}
fn fit_callable_parts_with_hash(
namespace: &str,
tool_name: &str,
raw_identity: &str,
) -> (String, String) {
let suffix = callable_name_hash_suffix(raw_identity);
let max_tool_len = MAX_TOOL_NAME_LENGTH.saturating_sub(namespace.len());
if max_tool_len >= suffix.len() {
let prefix_len = max_tool_len - suffix.len();
return (
namespace.to_string(),
format!("{}{}", truncate_name(tool_name, prefix_len), suffix),
);
}
let max_namespace_len = MAX_TOOL_NAME_LENGTH - suffix.len();
(truncate_name(namespace, max_namespace_len), suffix)
}
fn unique_callable_parts(
namespace: &str,
tool_name: &str,
raw_identity: &str,
used_names: &mut HashSet<String>,
) -> (String, String, String) {
let qualified_name = format!("{namespace}{tool_name}");
if qualified_name.len() <= MAX_TOOL_NAME_LENGTH && used_names.insert(qualified_name.clone()) {
return (namespace.to_string(), tool_name.to_string(), qualified_name);
}
let mut attempt = 0_u32;
loop {
let hash_input = if attempt == 0 {
raw_identity.to_string()
} else {
format!("{raw_identity}\0{attempt}")
};
let (namespace, tool_name) =
fit_callable_parts_with_hash(namespace, tool_name, &hash_input);
let qualified_name = format!("{namespace}{tool_name}");
if used_names.insert(qualified_name.clone()) {
return (namespace, tool_name, qualified_name);
}
attempt = attempt.saturating_add(1);
}
}
#[derive(Debug)]
struct CallableToolCandidate {
tool: ToolInfo,
raw_namespace_identity: String,
raw_tool_identity: String,
callable_namespace: String,
callable_name: String,
}
/// Returns a qualified-name lookup for MCP tools.
///
/// Raw MCP server/tool names are kept on each [`ToolInfo`] for protocol calls, while
/// `callable_namespace` / `callable_name` are sanitized and, when necessary, hashed so
/// every model-visible `mcp__namespace__tool` name is unique and <= 64 bytes.
pub(crate) fn qualify_tools<I>(tools: I) -> HashMap<String, ToolInfo>
where
I: IntoIterator<Item = ToolInfo>,
{
let mut seen_raw_names = HashSet::new();
let mut candidates = Vec::new();
for tool in tools {
let raw_namespace_identity = format!(
"{}\0{}\0{}",
tool.server_name,
tool.callable_namespace,
tool.connector_id.as_deref().unwrap_or_default()
);
let raw_tool_identity = format!(
"{}\0{}\0{}",
raw_namespace_identity, tool.callable_name, tool.tool.name
);
if !seen_raw_names.insert(raw_tool_identity.clone()) {
warn!("skipping duplicated tool {}", tool.tool.name);
continue;
}
candidates.push(CallableToolCandidate {
callable_namespace: sanitize_responses_api_tool_name(&tool.callable_namespace),
callable_name: sanitize_responses_api_tool_name(&tool.callable_name),
raw_namespace_identity,
raw_tool_identity,
tool,
});
}
let mut namespace_identities_by_base = HashMap::<String, HashSet<String>>::new();
for candidate in &candidates {
namespace_identities_by_base
.entry(candidate.callable_namespace.clone())
.or_default()
.insert(candidate.raw_namespace_identity.clone());
}
let colliding_namespaces = namespace_identities_by_base
.into_iter()
.filter_map(|(namespace, identities)| (identities.len() > 1).then_some(namespace))
.collect::<HashSet<_>>();
for candidate in &mut candidates {
if colliding_namespaces.contains(&candidate.callable_namespace) {
candidate.callable_namespace = append_namespace_hash_suffix(
&candidate.callable_namespace,
&candidate.raw_namespace_identity,
);
}
}
let mut tool_identities_by_base = HashMap::<(String, String), HashSet<String>>::new();
for candidate in &candidates {
tool_identities_by_base
.entry((
candidate.callable_namespace.clone(),
candidate.callable_name.clone(),
))
.or_default()
.insert(candidate.raw_tool_identity.clone());
}
let colliding_tools = tool_identities_by_base
.into_iter()
.filter_map(|(key, identities)| (identities.len() > 1).then_some(key))
.collect::<HashSet<_>>();
for candidate in &mut candidates {
if colliding_tools.contains(&(
candidate.callable_namespace.clone(),
candidate.callable_name.clone(),
)) {
candidate.callable_name =
append_hash_suffix(&candidate.callable_name, &candidate.raw_tool_identity);
}
}
candidates.sort_by(|left, right| left.raw_tool_identity.cmp(&right.raw_tool_identity));
let mut used_names = HashSet::new();
let mut qualified_tools = HashMap::new();
for mut candidate in candidates {
let (callable_namespace, callable_name, qualified_name) = unique_callable_parts(
&candidate.callable_namespace,
&candidate.callable_name,
&candidate.raw_tool_identity,
&mut used_names,
);
candidate.tool.callable_namespace = callable_namespace;
candidate.tool.callable_name = callable_name;
qualified_tools.insert(qualified_name, candidate.tool);
}
qualified_tools
}