Skip to content

Commit 091afb8

Browse files
Implement SEP-986: Tool name validation (#1655)
1 parent 998f0ee commit 091afb8

File tree

4 files changed

+334
-0
lines changed

4 files changed

+334
-0
lines changed

src/mcp/server/fastmcp/tools/base.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from mcp.server.fastmcp.exceptions import ToolError
1212
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
1313
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
14+
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
1415
from mcp.types import Icon, ToolAnnotations
1516

1617
if TYPE_CHECKING:
@@ -56,6 +57,8 @@ def from_function(
5657
"""Create a Tool from a function."""
5758
func_name = name or fn.__name__
5859

60+
validate_and_warn_tool_name(func_name)
61+
5962
if func_name == "<lambda>":
6063
raise ValueError("You must provide a name for lambda functions")
6164

src/mcp/server/lowlevel/server.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ async def main():
9090
from mcp.shared.exceptions import McpError
9191
from mcp.shared.message import ServerMessageMetadata, SessionMessage
9292
from mcp.shared.session import RequestResponder
93+
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
9394

9495
logger = logging.getLogger(__name__)
9596

@@ -422,13 +423,15 @@ async def handler(req: types.ListToolsRequest):
422423
if isinstance(result, types.ListToolsResult): # pragma: no cover
423424
# Refresh the tool cache with returned tools
424425
for tool in result.tools:
426+
validate_and_warn_tool_name(tool.name)
425427
self._tool_cache[tool.name] = tool
426428
return types.ServerResult(result)
427429
else:
428430
# Old style returns list[Tool]
429431
# Clear and refresh the entire tool cache
430432
self._tool_cache.clear()
431433
for tool in result:
434+
validate_and_warn_tool_name(tool.name)
432435
self._tool_cache[tool.name] = tool
433436
return types.ServerResult(types.ListToolsResult(tools=result))
434437

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Tool name validation utilities according to SEP-986.
2+
3+
Tool names SHOULD be between 1 and 128 characters in length (inclusive).
4+
Tool names are case-sensitive.
5+
Allowed characters: uppercase and lowercase ASCII letters (A-Z, a-z),
6+
digits (0-9), underscore (_), dash (-), and dot (.).
7+
Tool names SHOULD NOT contain spaces, commas, or other special characters.
8+
9+
See: https://modelcontextprotocol.io/specification/draft/server/tools#tool-names
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import logging
15+
import re
16+
from dataclasses import dataclass, field
17+
18+
logger = logging.getLogger(__name__)
19+
20+
# Regular expression for valid tool names according to SEP-986 specification
21+
TOOL_NAME_REGEX = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
22+
23+
# SEP reference URL for warning messages
24+
SEP_986_URL = "https://github.com/modelcontextprotocol/modelcontextprotocol/issues/986"
25+
26+
27+
@dataclass
28+
class ToolNameValidationResult:
29+
"""Result of tool name validation.
30+
31+
Attributes:
32+
is_valid: Whether the tool name conforms to SEP-986 requirements.
33+
warnings: List of warning messages for non-conforming aspects.
34+
"""
35+
36+
is_valid: bool
37+
warnings: list[str] = field(default_factory=lambda: [])
38+
39+
40+
def validate_tool_name(name: str) -> ToolNameValidationResult:
41+
"""Validate a tool name according to the SEP-986 specification.
42+
43+
Args:
44+
name: The tool name to validate.
45+
46+
Returns:
47+
ToolNameValidationResult containing validation status and any warnings.
48+
"""
49+
warnings: list[str] = []
50+
51+
# Check for empty name
52+
if not name:
53+
return ToolNameValidationResult(
54+
is_valid=False,
55+
warnings=["Tool name cannot be empty"],
56+
)
57+
58+
# Check length
59+
if len(name) > 128:
60+
return ToolNameValidationResult(
61+
is_valid=False,
62+
warnings=[f"Tool name exceeds maximum length of 128 characters (current: {len(name)})"],
63+
)
64+
65+
# Check for problematic patterns (warnings, not validation failures)
66+
if " " in name:
67+
warnings.append("Tool name contains spaces, which may cause parsing issues")
68+
69+
if "," in name:
70+
warnings.append("Tool name contains commas, which may cause parsing issues")
71+
72+
# Check for potentially confusing leading/trailing characters
73+
if name.startswith("-") or name.endswith("-"):
74+
warnings.append("Tool name starts or ends with a dash, which may cause parsing issues in some contexts")
75+
76+
if name.startswith(".") or name.endswith("."):
77+
warnings.append("Tool name starts or ends with a dot, which may cause parsing issues in some contexts")
78+
79+
# Check for invalid characters
80+
if not TOOL_NAME_REGEX.match(name):
81+
# Find all invalid characters (unique, preserving order)
82+
invalid_chars: list[str] = []
83+
seen: set[str] = set()
84+
for char in name:
85+
if not re.match(r"[A-Za-z0-9._-]", char) and char not in seen:
86+
invalid_chars.append(char)
87+
seen.add(char)
88+
89+
warnings.append(f"Tool name contains invalid characters: {', '.join(repr(c) for c in invalid_chars)}")
90+
warnings.append("Allowed characters are: A-Z, a-z, 0-9, underscore (_), dash (-), and dot (.)")
91+
92+
return ToolNameValidationResult(is_valid=False, warnings=warnings)
93+
94+
return ToolNameValidationResult(is_valid=True, warnings=warnings)
95+
96+
97+
def issue_tool_name_warning(name: str, warnings: list[str]) -> None:
98+
"""Log warnings for non-conforming tool names.
99+
100+
Args:
101+
name: The tool name that triggered the warnings.
102+
warnings: List of warning messages to log.
103+
"""
104+
if not warnings:
105+
return
106+
107+
logger.warning(f'Tool name validation warning for "{name}":')
108+
for warning in warnings:
109+
logger.warning(f" - {warning}")
110+
logger.warning("Tool registration will proceed, but this may cause compatibility issues.")
111+
logger.warning("Consider updating the tool name to conform to the MCP tool naming standard.")
112+
logger.warning(f"See SEP-986 ({SEP_986_URL}) for more details.")
113+
114+
115+
def validate_and_warn_tool_name(name: str) -> bool:
116+
"""Validate a tool name and issue warnings for non-conforming names.
117+
118+
This is the primary entry point for tool name validation. It validates
119+
the name and logs any warnings via the logging module.
120+
121+
Args:
122+
name: The tool name to validate.
123+
124+
Returns:
125+
True if the name is valid, False otherwise.
126+
"""
127+
result = validate_tool_name(name)
128+
issue_tool_name_warning(name, result.warnings)
129+
return result.is_valid
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""Tests for tool name validation utilities (SEP-986)."""
2+
3+
import logging
4+
5+
import pytest
6+
7+
from mcp.shared.tool_name_validation import (
8+
issue_tool_name_warning,
9+
validate_and_warn_tool_name,
10+
validate_tool_name,
11+
)
12+
13+
14+
class TestValidateToolName:
15+
"""Tests for validate_tool_name function."""
16+
17+
class TestValidNames:
18+
"""Test cases for valid tool names."""
19+
20+
@pytest.mark.parametrize(
21+
"tool_name",
22+
[
23+
"getUser",
24+
"get_user_profile",
25+
"user-profile-update",
26+
"admin.tools.list",
27+
"DATA_EXPORT_v2.1",
28+
"a",
29+
"a" * 128,
30+
],
31+
ids=[
32+
"simple_alphanumeric",
33+
"with_underscores",
34+
"with_dashes",
35+
"with_dots",
36+
"mixed_characters",
37+
"single_character",
38+
"max_length_128",
39+
],
40+
)
41+
def test_accepts_valid_names(self, tool_name: str) -> None:
42+
"""Valid tool names should pass validation with no warnings."""
43+
result = validate_tool_name(tool_name)
44+
assert result.is_valid is True
45+
assert result.warnings == []
46+
47+
class TestInvalidNames:
48+
"""Test cases for invalid tool names."""
49+
50+
def test_rejects_empty_name(self) -> None:
51+
"""Empty names should be rejected."""
52+
result = validate_tool_name("")
53+
assert result.is_valid is False
54+
assert "Tool name cannot be empty" in result.warnings
55+
56+
def test_rejects_name_exceeding_max_length(self) -> None:
57+
"""Names exceeding 128 characters should be rejected."""
58+
result = validate_tool_name("a" * 129)
59+
assert result.is_valid is False
60+
assert any("exceeds maximum length of 128 characters (current: 129)" in w for w in result.warnings)
61+
62+
@pytest.mark.parametrize(
63+
"tool_name,expected_char",
64+
[
65+
("get user profile", "' '"),
66+
("get,user,profile", "','"),
67+
("user/profile/update", "'/'"),
68+
("[email protected]", "'@'"),
69+
],
70+
ids=[
71+
"with_spaces",
72+
"with_commas",
73+
"with_slashes",
74+
"with_at_symbol",
75+
],
76+
)
77+
def test_rejects_invalid_characters(self, tool_name: str, expected_char: str) -> None:
78+
"""Names with invalid characters should be rejected."""
79+
result = validate_tool_name(tool_name)
80+
assert result.is_valid is False
81+
assert any("invalid characters" in w and expected_char in w for w in result.warnings)
82+
83+
def test_rejects_multiple_invalid_chars(self) -> None:
84+
"""Names with multiple invalid chars should list all of them."""
85+
result = validate_tool_name("user name@domain,com")
86+
assert result.is_valid is False
87+
warning = next(w for w in result.warnings if "invalid characters" in w)
88+
assert "' '" in warning
89+
assert "'@'" in warning
90+
assert "','" in warning
91+
92+
def test_rejects_unicode_characters(self) -> None:
93+
"""Names with unicode characters should be rejected."""
94+
result = validate_tool_name("user-\u00f1ame") # n with tilde
95+
assert result.is_valid is False
96+
97+
class TestWarningsForProblematicPatterns:
98+
"""Test cases for valid names that generate warnings."""
99+
100+
def test_warns_on_leading_dash(self) -> None:
101+
"""Names starting with dash should generate warning but be valid."""
102+
result = validate_tool_name("-get-user")
103+
assert result.is_valid is True
104+
assert any("starts or ends with a dash" in w for w in result.warnings)
105+
106+
def test_warns_on_trailing_dash(self) -> None:
107+
"""Names ending with dash should generate warning but be valid."""
108+
result = validate_tool_name("get-user-")
109+
assert result.is_valid is True
110+
assert any("starts or ends with a dash" in w for w in result.warnings)
111+
112+
def test_warns_on_leading_dot(self) -> None:
113+
"""Names starting with dot should generate warning but be valid."""
114+
result = validate_tool_name(".get.user")
115+
assert result.is_valid is True
116+
assert any("starts or ends with a dot" in w for w in result.warnings)
117+
118+
def test_warns_on_trailing_dot(self) -> None:
119+
"""Names ending with dot should generate warning but be valid."""
120+
result = validate_tool_name("get.user.")
121+
assert result.is_valid is True
122+
assert any("starts or ends with a dot" in w for w in result.warnings)
123+
124+
125+
class TestIssueToolNameWarning:
126+
"""Tests for issue_tool_name_warning function."""
127+
128+
def test_logs_warnings(self, caplog: pytest.LogCaptureFixture) -> None:
129+
"""Warnings should be logged at WARNING level."""
130+
warnings = ["Warning 1", "Warning 2"]
131+
with caplog.at_level(logging.WARNING):
132+
issue_tool_name_warning("test-tool", warnings)
133+
134+
assert 'Tool name validation warning for "test-tool"' in caplog.text
135+
assert "- Warning 1" in caplog.text
136+
assert "- Warning 2" in caplog.text
137+
assert "Tool registration will proceed" in caplog.text
138+
assert "SEP-986" in caplog.text
139+
140+
def test_no_logging_for_empty_warnings(self, caplog: pytest.LogCaptureFixture) -> None:
141+
"""Empty warnings list should not produce any log output."""
142+
with caplog.at_level(logging.WARNING):
143+
issue_tool_name_warning("test-tool", [])
144+
145+
assert caplog.text == ""
146+
147+
148+
class TestValidateAndWarnToolName:
149+
"""Tests for validate_and_warn_tool_name function."""
150+
151+
def test_returns_true_for_valid_name(self) -> None:
152+
"""Valid names should return True."""
153+
assert validate_and_warn_tool_name("valid-tool-name") is True
154+
155+
def test_returns_false_for_invalid_name(self) -> None:
156+
"""Invalid names should return False."""
157+
assert validate_and_warn_tool_name("") is False
158+
assert validate_and_warn_tool_name("a" * 129) is False
159+
assert validate_and_warn_tool_name("invalid name") is False
160+
161+
def test_logs_warnings_for_invalid_name(self, caplog: pytest.LogCaptureFixture) -> None:
162+
"""Invalid names should trigger warning logs."""
163+
with caplog.at_level(logging.WARNING):
164+
validate_and_warn_tool_name("invalid name")
165+
166+
assert "Tool name validation warning" in caplog.text
167+
168+
def test_no_warnings_for_clean_valid_name(self, caplog: pytest.LogCaptureFixture) -> None:
169+
"""Clean valid names should not produce any log output."""
170+
with caplog.at_level(logging.WARNING):
171+
result = validate_and_warn_tool_name("clean-tool-name")
172+
173+
assert result is True
174+
assert caplog.text == ""
175+
176+
177+
class TestEdgeCases:
178+
"""Test edge cases and robustness."""
179+
180+
@pytest.mark.parametrize(
181+
"tool_name,is_valid,expected_warning_fragment",
182+
[
183+
("...", True, "starts or ends with a dot"),
184+
("---", True, "starts or ends with a dash"),
185+
("///", False, "invalid characters"),
186+
("user@name123", False, "invalid characters"),
187+
],
188+
ids=[
189+
"only_dots",
190+
"only_dashes",
191+
"only_slashes",
192+
"mixed_valid_invalid",
193+
],
194+
)
195+
def test_edge_cases(self, tool_name: str, is_valid: bool, expected_warning_fragment: str) -> None:
196+
"""Various edge cases should be handled correctly."""
197+
result = validate_tool_name(tool_name)
198+
assert result.is_valid is is_valid
199+
assert any(expected_warning_fragment in w for w in result.warnings)

0 commit comments

Comments
 (0)