|
12 | 12 | from collections.abc import Callable |
13 | 13 | from dataclasses import dataclass |
14 | 14 | from typing import Any |
| 15 | +import re |
| 16 | +import logging |
15 | 17 |
|
16 | 18 | import mcp.types |
17 | 19 | import nexusrpc |
18 | 20 | import pydantic |
19 | 21 |
|
20 | 22 | from .service import MCPService |
21 | 23 |
|
| 24 | +# Set up logging |
| 25 | +logger = logging.getLogger(__name__) |
| 26 | + |
| 27 | +# Compile regex patterns ahead of time for better performance |
| 28 | +_TOOL_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") |
| 29 | +_SERVICE_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9-]{1,64}$") |
| 30 | + |
22 | 31 |
|
23 | 32 | @dataclass |
24 | 33 | class _Tool: |
@@ -50,8 +59,18 @@ def to_mcp_tool(self, service: nexusrpc.ServiceDefinition) -> mcp.types.Tool: |
50 | 59 | Returns: |
51 | 60 | An MCP Tool object ready for use by MCP clients |
52 | 61 | """ |
| 62 | + # Generate LLM-compatible tool name using underscores instead of forward slashes |
| 63 | + tool_name = f"{service.name}_{self.defn.name}" |
| 64 | + |
| 65 | + # Validate tool name meets LLM provider requirements |
| 66 | + if not _is_valid_tool_name(tool_name): |
| 67 | + raise ValueError( |
| 68 | + f"Generated tool name '{tool_name}' does not meet LLM provider requirements. " |
| 69 | + f"Tool names must match pattern ^[a-zA-Z0-9_-]{{1,64}}$ for Claude Desktop or ^[a-zA-Z0-9_-]{{1,128}}$ for other clients." |
| 70 | + ) |
| 71 | + |
53 | 72 | return mcp.types.Tool( |
54 | | - name=f"{service.name}/{self.defn.name}", |
| 73 | + name=tool_name, |
55 | 74 | description=(self.func.__doc__.strip() if self.func.__doc__ is not None else None), |
56 | 75 | inputSchema=( |
57 | 76 | self.defn.input_type.model_json_schema() |
@@ -119,9 +138,14 @@ def register(self, cls: type) -> type: |
119 | 138 | service_defn = nexusrpc.get_service_definition(cls) |
120 | 139 | if service_defn is None: |
121 | 140 | raise ValueError(f"Class {cls.__name__} is not a Nexus Service") |
122 | | - if "/" in service_defn.name: |
| 141 | + # Validate service name contains only characters that will create valid tool names |
| 142 | + if not _is_valid_service_name(service_defn.name): |
| 143 | + invalid_chars = set(service_defn.name) - set( |
| 144 | + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-" |
| 145 | + ) |
123 | 146 | raise ValueError( |
124 | | - f"Service name {service_defn.name} cannot contain '/' as it is used to separate service and operation names in MCP tools" |
| 147 | + f"Service name '{service_defn.name}' contains invalid characters {invalid_chars}. " |
| 148 | + f"Only alphanumeric characters and hyphens are allowed for LLM provider compatibility." |
125 | 149 | ) |
126 | 150 |
|
127 | 151 | tools: list[_Tool] = [] |
@@ -153,7 +177,10 @@ async def list_tools(self, _ctx: nexusrpc.handler.StartOperationContext, _input: |
153 | 177 | Returns: |
154 | 178 | List of MCP Tool objects representing all available Operations |
155 | 179 | """ |
156 | | - return [tool.to_mcp_tool(service.defn) for service in self._tool_services for tool in service.tools] |
| 180 | + tools = [tool.to_mcp_tool(service.defn) for service in self._tool_services for tool in service.tools] |
| 181 | + if logger.isEnabledFor(logging.DEBUG): |
| 182 | + logger.debug(f"MCP.list_tools found {len(tools)} tools: {[tool.name for tool in tools]}") |
| 183 | + return tools |
157 | 184 |
|
158 | 185 |
|
159 | 186 | ExcludedCallable = Callable[..., Any] |
@@ -191,3 +218,35 @@ def internal_operation(self, _ctx: nexusrpc.handler.StartOperationContext, _inpu |
191 | 218 | """ |
192 | 219 | setattr(fn, "__nexus_mcp_tool__", False) |
193 | 220 | return fn |
| 221 | + |
| 222 | + |
| 223 | +def _is_valid_tool_name(name: str) -> bool: |
| 224 | + """Validate tool name against LLM provider requirements. |
| 225 | +
|
| 226 | + Tool names must match the pattern ^[a-zA-Z0-9_-]{1,64}$ for Claude Desktop |
| 227 | + or ^[a-zA-Z0-9_-]{1,128}$ for other clients (Goose, OpenAI, etc.). |
| 228 | + Validates against the most restrictive (64 chars) for maximum compatibility. |
| 229 | +
|
| 230 | + Args: |
| 231 | + name: The tool name to validate |
| 232 | +
|
| 233 | + Returns: |
| 234 | + True if the name is valid, False otherwise |
| 235 | + """ |
| 236 | + return bool(_TOOL_NAME_PATTERN.match(name)) |
| 237 | + |
| 238 | + |
| 239 | +def _is_valid_service_name(name: str) -> bool: |
| 240 | + """Validate service name for tool naming compatibility. |
| 241 | +
|
| 242 | + Service names cannot contain underscores as they are used to |
| 243 | + split service names from operation names when creating tool names. |
| 244 | +
|
| 245 | + Args: |
| 246 | + name: The service name to validate |
| 247 | +
|
| 248 | + Returns: |
| 249 | + True if the service name is valid, False otherwise |
| 250 | + """ |
| 251 | + # Service names cannot contain underscores (used as delimiter) |
| 252 | + return bool(_SERVICE_NAME_PATTERN.match(name)) |
0 commit comments