Skip to content

Commit e828374

Browse files
maxisbeyOtis0408
andauthored
[v1.x] fix: reject trailing newline in tool-name validation (#3086)
Co-authored-by: otiscuilei <otiscui@icloud.com>
1 parent 5f0b6af commit e828374

4 files changed

Lines changed: 22 additions & 2 deletions

File tree

src/mcp/server/fastmcp/resources/templates.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def matches(self, uri: str) -> dict[str, Any] | None:
8686
"""Check if URI matches template and extract parameters."""
8787
# Convert template to regex pattern
8888
pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
89-
match = re.match(f"^{pattern}$", uri)
89+
match = re.fullmatch(pattern, uri)
9090
if match:
9191
return match.groupdict()
9292
return None

src/mcp/shared/tool_name_validation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def validate_tool_name(name: str) -> ToolNameValidationResult:
7777
warnings.append("Tool name starts or ends with a dot, which may cause parsing issues in some contexts")
7878

7979
# Check for invalid characters
80-
if not TOOL_NAME_REGEX.match(name):
80+
if not TOOL_NAME_REGEX.fullmatch(name):
8181
# Find all invalid characters (unique, preserving order)
8282
invalid_chars: list[str] = []
8383
seen: set[str] = set()

tests/server/fastmcp/resources/test_resource_template.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ def my_func(key: str, value: int) -> dict[str, Any]: # pragma: no cover
4848
assert template.matches("test://foo") is None
4949
assert template.matches("other://foo/123") is None
5050

51+
def test_template_matches_rejects_trailing_newline_after_literal(self):
52+
"""A trailing newline after a literal segment slipped past `$` with re.match."""
53+
54+
def my_func(key: str) -> str: # pragma: no cover
55+
return key
56+
57+
template = ResourceTemplate.from_function(
58+
fn=my_func,
59+
uri_template="test://{key}/data",
60+
name="test",
61+
)
62+
63+
assert template.matches("test://foo/data") == {"key": "foo"}
64+
assert template.matches("test://foo/data\n") is None
65+
5166
@pytest.mark.anyio
5267
async def test_create_resource(self):
5368
"""Test creating a resource from a template."""

tests/shared/test_tool_name_validation.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,17 @@ def test_rejects_name_exceeding_max_length(self) -> None:
6666
("get,user,profile", "','"),
6767
("user/profile/update", "'/'"),
6868
("user@domain.com", "'@'"),
69+
# a single trailing newline slipped past `$` with re.match
70+
("valid_name\n", "'\\n'"),
71+
("a" * 127 + "\n", "'\\n'"),
6972
],
7073
ids=[
7174
"with_spaces",
7275
"with_commas",
7376
"with_slashes",
7477
"with_at_symbol",
78+
"with_trailing_newline",
79+
"max_length_with_trailing_newline",
7580
],
7681
)
7782
def test_rejects_invalid_characters(self, tool_name: str, expected_char: str) -> None:

0 commit comments

Comments
 (0)