@@ -79,26 +79,60 @@ pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
7979/// Default timeout for individual tool calls.
8080const 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+
82109fn qualify_tools < I > ( tools : I ) -> HashMap < String , ToolInfo >
83110where
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