Implement SEP-986: Tool name validation (#1655)
This commit is contained in:
parent
998f0ee4db
commit
091afb82dc
3 changed files with 334 additions and 0 deletions
|
|
@ -11,6 +11,7 @@ from pydantic import BaseModel, Field
|
|||
from mcp.server.fastmcp.exceptions import ToolError
|
||||
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
|
||||
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
|
||||
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
|
||||
from mcp.types import Icon, ToolAnnotations
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -56,6 +57,8 @@ class Tool(BaseModel):
|
|||
"""Create a Tool from a function."""
|
||||
func_name = name or fn.__name__
|
||||
|
||||
validate_and_warn_tool_name(func_name)
|
||||
|
||||
if func_name == "<lambda>":
|
||||
raise ValueError("You must provide a name for lambda functions")
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ from mcp.shared.context import RequestContext
|
|||
from mcp.shared.exceptions import McpError
|
||||
from mcp.shared.message import ServerMessageMetadata, SessionMessage
|
||||
from mcp.shared.session import RequestResponder
|
||||
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -422,6 +423,7 @@ class Server(Generic[LifespanResultT, RequestT]):
|
|||
if isinstance(result, types.ListToolsResult): # pragma: no cover
|
||||
# Refresh the tool cache with returned tools
|
||||
for tool in result.tools:
|
||||
validate_and_warn_tool_name(tool.name)
|
||||
self._tool_cache[tool.name] = tool
|
||||
return types.ServerResult(result)
|
||||
else:
|
||||
|
|
@ -429,6 +431,7 @@ class Server(Generic[LifespanResultT, RequestT]):
|
|||
# Clear and refresh the entire tool cache
|
||||
self._tool_cache.clear()
|
||||
for tool in result:
|
||||
validate_and_warn_tool_name(tool.name)
|
||||
self._tool_cache[tool.name] = tool
|
||||
return types.ServerResult(types.ListToolsResult(tools=result))
|
||||
|
||||
|
|
|
|||
129
src/mcp/shared/tool_name_validation.py
Normal file
129
src/mcp/shared/tool_name_validation.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""Tool name validation utilities according to SEP-986.
|
||||
|
||||
Tool names SHOULD be between 1 and 128 characters in length (inclusive).
|
||||
Tool names are case-sensitive.
|
||||
Allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z),
|
||||
digits (0-9), underscore (_), dash (-), and dot (.).
|
||||
Tool names SHOULD NOT contain spaces, commas, or other special characters.
|
||||
|
||||
See: https://modelcontextprotocol.io/specification/draft/server/tools#tool-names
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Regular expression for valid tool names according to SEP-986 specification
|
||||
TOOL_NAME_REGEX = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
|
||||
|
||||
# SEP reference URL for warning messages
|
||||
SEP_986_URL = "https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolNameValidationResult:
|
||||
"""Result of tool name validation.
|
||||
|
||||
Attributes:
|
||||
is_valid: Whether the tool name conforms to SEP-986 requirements.
|
||||
warnings: List of warning messages for non-conforming aspects.
|
||||
"""
|
||||
|
||||
is_valid: bool
|
||||
warnings: list[str] = field(default_factory=lambda: [])
|
||||
|
||||
|
||||
def validate_tool_name(name: str) -> ToolNameValidationResult:
|
||||
"""Validate a tool name according to the SEP-986 specification.
|
||||
|
||||
Args:
|
||||
name: The tool name to validate.
|
||||
|
||||
Returns:
|
||||
ToolNameValidationResult containing validation status and any warnings.
|
||||
"""
|
||||
warnings: list[str] = []
|
||||
|
||||
# Check for empty name
|
||||
if not name:
|
||||
return ToolNameValidationResult(
|
||||
is_valid=False,
|
||||
warnings=["Tool name cannot be empty"],
|
||||
)
|
||||
|
||||
# Check length
|
||||
if len(name) > 128:
|
||||
return ToolNameValidationResult(
|
||||
is_valid=False,
|
||||
warnings=[f"Tool name exceeds maximum length of 128 characters (current: {len(name)})"],
|
||||
)
|
||||
|
||||
# Check for problematic patterns (warnings, not validation failures)
|
||||
if " " in name:
|
||||
warnings.append("Tool name contains spaces, which may cause parsing issues")
|
||||
|
||||
if "," in name:
|
||||
warnings.append("Tool name contains commas, which may cause parsing issues")
|
||||
|
||||
# Check for potentially confusing leading/trailing characters
|
||||
if name.startswith("-") or name.endswith("-"):
|
||||
warnings.append("Tool name starts or ends with a dash, which may cause parsing issues in some contexts")
|
||||
|
||||
if name.startswith(".") or name.endswith("."):
|
||||
warnings.append("Tool name starts or ends with a dot, which may cause parsing issues in some contexts")
|
||||
|
||||
# Check for invalid characters
|
||||
if not TOOL_NAME_REGEX.match(name):
|
||||
# Find all invalid characters (unique, preserving order)
|
||||
invalid_chars: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for char in name:
|
||||
if not re.match(r"[A-Za-z0-9._-]", char) and char not in seen:
|
||||
invalid_chars.append(char)
|
||||
seen.add(char)
|
||||
|
||||
warnings.append(f"Tool name contains invalid characters: {', '.join(repr(c) for c in invalid_chars)}")
|
||||
warnings.append("Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)")
|
||||
|
||||
return ToolNameValidationResult(is_valid=False, warnings=warnings)
|
||||
|
||||
return ToolNameValidationResult(is_valid=True, warnings=warnings)
|
||||
|
||||
|
||||
def issue_tool_name_warning(name: str, warnings: list[str]) -> None:
|
||||
"""Log warnings for non-conforming tool names.
|
||||
|
||||
Args:
|
||||
name: The tool name that triggered the warnings.
|
||||
warnings: List of warning messages to log.
|
||||
"""
|
||||
if not warnings:
|
||||
return
|
||||
|
||||
logger.warning(f'Tool name validation warning for "{name}":')
|
||||
for warning in warnings:
|
||||
logger.warning(f" - {warning}")
|
||||
logger.warning("Tool registration will proceed, but this may cause compatibility issues.")
|
||||
logger.warning("Consider updating the tool name to conform to the MCP tool naming standard.")
|
||||
logger.warning(f"See SEP-986 ({SEP_986_URL}) for more details.")
|
||||
|
||||
|
||||
def validate_and_warn_tool_name(name: str) -> bool:
|
||||
"""Validate a tool name and issue warnings for non-conforming names.
|
||||
|
||||
This is the primary entry point for tool name validation. It validates
|
||||
the name and logs any warnings via the logging module.
|
||||
|
||||
Args:
|
||||
name: The tool name to validate.
|
||||
|
||||
Returns:
|
||||
True if the name is valid, False otherwise.
|
||||
"""
|
||||
result = validate_tool_name(name)
|
||||
issue_tool_name_warning(name, result.warnings)
|
||||
return result.is_valid
|
||||
Loading…
Reference in a new issue