Skip to content

Commit 23102e3

Browse files
committed
fix: validate whole strings with re.fullmatch
With re.match, a $-anchored pattern also matches just before a single trailing newline, so tool-name validation accepted "name\n". Switch the tool-name and URI-template varname checks to re.fullmatch, which puts the whole-string requirement at the call site, and fold the regression cases into the existing invalid-character parametrizations.
1 parent e04b721 commit 23102e3

4 files changed

Lines changed: 11 additions & 22 deletions

File tree

src/mcp/shared/tool_name_validation.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,8 @@
1717

1818
logger = logging.getLogger(__name__)
1919

20-
# Regular expression for valid tool names according to SEP-986 specification.
21-
# End-anchored with \Z rather than $: in Python's default mode $ also matches
22-
# just before a single trailing newline, which would let "name\n" validate.
23-
TOOL_NAME_REGEX = re.compile(r"^[A-Za-z0-9._-]{1,128}\Z")
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}$")
2422

2523
# SEP reference URL for warning messages
2624
SEP_986_URL = "https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names"
@@ -79,7 +77,7 @@ def validate_tool_name(name: str) -> ToolNameValidationResult:
7977
warnings.append("Tool name starts or ends with a dot, which may cause parsing issues in some contexts")
8078

8179
# Check for invalid characters
82-
if not TOOL_NAME_REGEX.match(name):
80+
if not TOOL_NAME_REGEX.fullmatch(name):
8381
# Find all invalid characters (unique, preserving order)
8482
invalid_chars: list[str] = []
8583
seen: set[str] = set()

src/mcp/shared/uri_template.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -813,7 +813,7 @@ def _parse_expression(template: str, body: str, pos: int) -> _Expression:
813813
explode = spec.endswith("*")
814814
name = spec[:-1] if explode else spec
815815

816-
if not _VARNAME_RE.match(name):
816+
if not _VARNAME_RE.fullmatch(name):
817817
raise InvalidUriTemplate(
818818
f"Invalid variable name {name!r} at position {pos}",
819819
template=template,

tests/shared/test_tool_name_validation.py

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,17 @@ def test_validate_tool_name_rejects_name_exceeding_max_length() -> None:
6565
("get,user,profile", "','"),
6666
("user/profile/update", "'/'"),
6767
("user@domain.com", "'@'"),
68+
# a single trailing newline slipped past `$` with re.match
69+
("valid_name\n", "'\\n'"),
70+
("a" * 127 + "\n", "'\\n'"),
6871
],
6972
ids=[
7073
"with_spaces",
7174
"with_commas",
7275
"with_slashes",
7376
"with_at_symbol",
77+
"with_trailing_newline",
78+
"max_length_with_trailing_newline",
7479
],
7580
)
7681
def test_validate_tool_name_rejects_invalid_characters(tool_name: str, expected_char: str) -> None:
@@ -80,22 +85,6 @@ def test_validate_tool_name_rejects_invalid_characters(tool_name: str, expected_
8085
assert any("invalid characters" in w and expected_char in w for w in result.warnings)
8186

8287

83-
@pytest.mark.parametrize(
84-
"tool_name",
85-
["valid_name\n", "a" * 127 + "\n"],
86-
ids=["trailing_newline", "max_length_plus_newline"],
87-
)
88-
def test_validate_tool_name_rejects_trailing_newline(tool_name: str) -> None:
89-
"""A trailing newline is not an allowed character and must be rejected.
90-
91-
Regression test: Python's ``$`` (unlike ``\\Z``) also matches just before a
92-
single trailing newline, so a name like ``"valid_name\\n"`` slipped through.
93-
"""
94-
result = validate_tool_name(tool_name)
95-
assert result.is_valid is False
96-
assert any("invalid characters" in w for w in result.warnings)
97-
98-
9988
def test_validate_tool_name_rejects_multiple_invalid_chars() -> None:
10089
"""Names with multiple invalid chars should list all of them."""
10190
result = validate_tool_name("user name@domain,com")

tests/shared/test_uri_template.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,8 @@ def test_parse_rejects_operator_without_variable():
144144
# RFC §2.3: dots only between varchars, not consecutive or trailing
145145
"foo..bar",
146146
"foo.",
147+
# a single trailing newline slipped past `$` with re.match
148+
"foo\n",
147149
],
148150
)
149151
def test_parse_rejects_invalid_varname(name: str):

0 commit comments

Comments
 (0)