33"""
44
55from typing import Any , Dict , List , Optional
6+ import re
67
78import click
89import mcp .types as types
910
1011from .decorator import get_mcp_metadata
1112
1213
14+ def sanitize_tool_name (name : str ) -> str :
15+ """
16+ Sanitize a tool name to conform to the regex pattern [a-zA-Z][a-zA-Z0-9_]*
17+
18+ Args:
19+ name: The tool name to sanitize
20+
21+ Returns:
22+ A sanitized tool name that conforms to the pattern
23+ """
24+ # Replace any non-alphanumeric characters (except underscore) with underscore
25+ sanitized = re .sub (r'[^a-zA-Z0-9_]' , '_' , name )
26+
27+ # Ensure the name starts with a letter
28+ if sanitized and not re .match (r'^[a-zA-Z]' , sanitized ):
29+ sanitized = 'tool_' + sanitized
30+
31+ # Handle empty string case
32+ if not sanitized :
33+ sanitized = 'tool'
34+
35+ return sanitized
36+
37+
1338# Dictionary to store positional arguments for each tool
1439_tool_positional_args : Dict [str , List [str ]] = {}
1540
@@ -40,14 +65,15 @@ def scan_click_command(command: click.Group, parent_path: str = "") -> List[type
4065
4166 # Determine command path
4267 custom_name = metadata .get ("name" , name )
43- # Ensure paths use underscore separator instead of dot
44- cmd_path = _sanitize_tool_name ( f"{ parent_path } { custom_name } " if parent_path else custom_name )
68+ custom_name = sanitize_tool_name ( custom_name )
69+ cmd_path = f"{ parent_path } { custom_name } " if parent_path else custom_name
4570
4671 if "commands" in cmd_info :
4772 # Handle subgroup
4873 cmd = command .get_command (ctx , name )
4974 if isinstance (cmd , click .Group ):
5075 group_name = metadata .get ("name" , name )
76+ group_name = sanitize_tool_name (group_name )
5177 group_path = (
5278 f"{ parent_path } { group_name } _" if parent_path else f"{ group_name } _"
5379 )
@@ -68,43 +94,14 @@ def scan_click_command(command: click.Group, parent_path: str = "") -> List[type
6894
6995
7096def get_positional_args (tool_name : str ) -> List [str ]:
71- # Sanitize the tool name to ensure consistency with how they're stored
72- sanitized_name = _sanitize_tool_name (tool_name )
73- return _tool_positional_args .get (sanitized_name , [])
97+ return _tool_positional_args .get (tool_name , [])
7498
7599
76- def _sanitize_tool_name (name : str ) -> str :
77- """
78- Sanitize tool name to conform to regex [a-zA-Z][a-zA-Z0-9_]*
79-
80- Args:
81- name: The original tool name
82-
83- Returns:
84- A sanitized name that conforms to the regex
85- """
86- # Replace any non-alphanumeric characters with underscore (except we keep existing underscores)
87- import re
88- sanitized = re .sub (r'[^a-zA-Z0-9_]' , '_' , name )
89-
90- # Ensure first character is a letter
91- if sanitized and not sanitized [0 ].isalpha ():
92- sanitized = 'tool_' + sanitized
93-
94- # Handle empty name
95- if not sanitized :
96- sanitized = 'tool'
97-
98- return sanitized
99-
100100def _convert_command_to_tool (
101101 command : click .Command , command_info : Dict [str , Any ], name : str
102102) -> tuple [types .Tool , List [str ]]:
103103 """
104104 Convert a Click command to an MCP tool.
105-
106- Returns:
107- A tuple of (Tool, positional_args_list)
108105 """
109106 description = command_info .get ("help" ) or command_info .get ("short_help" ) or ""
110107
@@ -138,7 +135,7 @@ def _convert_command_to_tool(
138135 input_schema ["required" ] = sorted (required_params ) # Sort for consistent output
139136
140137 tool = types .Tool (
141- name = _sanitize_tool_name (name ),
138+ name = sanitize_tool_name (name ),
142139 description = description ,
143140 inputSchema = input_schema ,
144141 )
0 commit comments