Skip to content

Commit 468ee8a

Browse files
authored
[MCP] Sanitize MCP tool names to ensure they are compatible with the Responses APO (#8694)
The Responses API requires that all tool names conform to '^[a-zA-Z0-9_-]+$'. This PR replaces all non-conforming characters with `_` to ensure that they can be used. Fixes #8174
1 parent 0b53aed commit 468ee8a

File tree

1 file changed

+64
-8
lines changed

1 file changed

+64
-8
lines changed

codex-rs/core/src/mcp_connection_manager.rs

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,26 +79,60 @@ pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
7979
/// Default timeout for individual tool calls.
8080
const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(60);
8181

82+
/// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`.
83+
/// MCP server/tool names are user-controlled, so sanitize the fully-qualified
84+
/// name we expose to the model by replacing any disallowed character with `_`.
85+
fn sanitize_responses_api_tool_name(name: &str) -> String {
86+
let mut sanitized = String::with_capacity(name.len());
87+
for c in name.chars() {
88+
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
89+
sanitized.push(c);
90+
} else {
91+
sanitized.push('_');
92+
}
93+
}
94+
95+
if sanitized.is_empty() {
96+
"_".to_string()
97+
} else {
98+
sanitized
99+
}
100+
}
101+
102+
fn sha1_hex(s: &str) -> String {
103+
let mut hasher = Sha1::new();
104+
hasher.update(s.as_bytes());
105+
let sha1 = hasher.finalize();
106+
format!("{sha1:x}")
107+
}
108+
82109
fn qualify_tools<I>(tools: I) -> HashMap<String, ToolInfo>
83110
where
84111
I: IntoIterator<Item = ToolInfo>,
85112
{
86113
let mut used_names = HashSet::new();
114+
let mut seen_raw_names = HashSet::new();
87115
let mut qualified_tools = HashMap::new();
88116
for tool in tools {
89-
let mut qualified_name = format!(
117+
let qualified_name_raw = format!(
90118
"mcp{}{}{}{}",
91119
MCP_TOOL_NAME_DELIMITER, tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name
92120
);
93-
if qualified_name.len() > MAX_TOOL_NAME_LENGTH {
94-
let mut hasher = Sha1::new();
95-
hasher.update(qualified_name.as_bytes());
96-
let sha1 = hasher.finalize();
97-
let sha1_str = format!("{sha1:x}");
121+
if !seen_raw_names.insert(qualified_name_raw.clone()) {
122+
warn!("skipping duplicated tool {}", qualified_name_raw);
123+
continue;
124+
}
98125

99-
// Truncate to make room for the hash suffix
100-
let prefix_len = MAX_TOOL_NAME_LENGTH - sha1_str.len();
126+
// Start from a "pretty" name (sanitized), then deterministically disambiguate on
127+
// collisions by appending a hash of the *raw* (unsanitized) qualified name. This
128+
// ensures tools like `foo.bar` and `foo_bar` don't collapse to the same key.
129+
let mut qualified_name = sanitize_responses_api_tool_name(&qualified_name_raw);
101130

131+
// Enforce length constraints early; use the raw name for the hash input so the
132+
// output remains stable even when sanitization changes.
133+
if qualified_name.len() > MAX_TOOL_NAME_LENGTH {
134+
let sha1_str = sha1_hex(&qualified_name_raw);
135+
let prefix_len = MAX_TOOL_NAME_LENGTH - sha1_str.len();
102136
qualified_name = format!("{}{}", &qualified_name[..prefix_len], sha1_str);
103137
}
104138

@@ -1035,6 +1069,28 @@ mod tests {
10351069
);
10361070
}
10371071

1072+
#[test]
1073+
fn test_qualify_tools_sanitizes_invalid_characters() {
1074+
let tools = vec![create_test_tool("server.one", "tool.two")];
1075+
1076+
let qualified_tools = qualify_tools(tools);
1077+
1078+
assert_eq!(qualified_tools.len(), 1);
1079+
let (qualified_name, tool) = qualified_tools.into_iter().next().expect("one tool");
1080+
assert_eq!(qualified_name, "mcp__server_one__tool_two");
1081+
1082+
// The key is sanitized for OpenAI, but we keep original parts for the actual MCP call.
1083+
assert_eq!(tool.server_name, "server.one");
1084+
assert_eq!(tool.tool_name, "tool.two");
1085+
1086+
assert!(
1087+
qualified_name
1088+
.chars()
1089+
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'),
1090+
"qualified name must be Responses API compatible: {qualified_name:?}"
1091+
);
1092+
}
1093+
10381094
#[test]
10391095
fn tool_filter_allows_by_default() {
10401096
let filter = ToolFilter::default();

0 commit comments

Comments
 (0)