From c6b2fc0f20c4ab19cea030a0e3581f693dab78a3 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:58:55 +0000 Subject: [PATCH 1/2] fix: reject trailing newline in tool-name validation With re.match, a $-anchored pattern also matches just before a single trailing newline, so tool-name validation accepted "name\n" without a SEP-986 warning. Switch to re.fullmatch. Backport of #3076. Co-authored-by: otiscuilei --- src/mcp/shared/tool_name_validation.py | 2 +- tests/shared/test_tool_name_validation.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/mcp/shared/tool_name_validation.py b/src/mcp/shared/tool_name_validation.py index f35efa5a61..96c34f7826 100644 --- a/src/mcp/shared/tool_name_validation.py +++ b/src/mcp/shared/tool_name_validation.py @@ -77,7 +77,7 @@ def validate_tool_name(name: str) -> ToolNameValidationResult: 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): + if not TOOL_NAME_REGEX.fullmatch(name): # Find all invalid characters (unique, preserving order) invalid_chars: list[str] = [] seen: set[str] = set() diff --git a/tests/shared/test_tool_name_validation.py b/tests/shared/test_tool_name_validation.py index 4746f3f9f8..e24be1cf7b 100644 --- a/tests/shared/test_tool_name_validation.py +++ b/tests/shared/test_tool_name_validation.py @@ -66,12 +66,17 @@ def test_rejects_name_exceeding_max_length(self) -> None: ("get,user,profile", "','"), ("user/profile/update", "'/'"), ("user@domain.com", "'@'"), + # a single trailing newline slipped past `$` with re.match + ("valid_name\n", "'\\n'"), + ("a" * 127 + "\n", "'\\n'"), ], ids=[ "with_spaces", "with_commas", "with_slashes", "with_at_symbol", + "with_trailing_newline", + "max_length_with_trailing_newline", ], ) def test_rejects_invalid_characters(self, tool_name: str, expected_char: str) -> None: From 5052de76a9d645fae2279d0f94ae169ce9c8e510 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:12:40 +0000 Subject: [PATCH 2/2] fix: reject trailing newline in resource template matching The same $-with-re.match pattern: a URI with a single trailing newline after a literal template segment matched as if clean. Switch to re.fullmatch. Parameter segments matching raw newlines into values is a separate pre-existing looseness, unchanged here. --- src/mcp/server/fastmcp/resources/templates.py | 2 +- .../fastmcp/resources/test_resource_template.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/fastmcp/resources/templates.py b/src/mcp/server/fastmcp/resources/templates.py index 89a8ceb36b..99534a4ff5 100644 --- a/src/mcp/server/fastmcp/resources/templates.py +++ b/src/mcp/server/fastmcp/resources/templates.py @@ -86,7 +86,7 @@ def matches(self, uri: str) -> dict[str, Any] | None: """Check if URI matches template and extract parameters.""" # Convert template to regex pattern pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)") - match = re.match(f"^{pattern}$", uri) + match = re.fullmatch(pattern, uri) if match: return match.groupdict() return None diff --git a/tests/server/fastmcp/resources/test_resource_template.py b/tests/server/fastmcp/resources/test_resource_template.py index f3d3ba5e45..ebef4ca227 100644 --- a/tests/server/fastmcp/resources/test_resource_template.py +++ b/tests/server/fastmcp/resources/test_resource_template.py @@ -48,6 +48,21 @@ def my_func(key: str, value: int) -> dict[str, Any]: # pragma: no cover assert template.matches("test://foo") is None assert template.matches("other://foo/123") is None + def test_template_matches_rejects_trailing_newline_after_literal(self): + """A trailing newline after a literal segment slipped past `$` with re.match.""" + + def my_func(key: str) -> str: # pragma: no cover + return key + + template = ResourceTemplate.from_function( + fn=my_func, + uri_template="test://{key}/data", + name="test", + ) + + assert template.matches("test://foo/data") == {"key": "foo"} + assert template.matches("test://foo/data\n") is None + @pytest.mark.anyio async def test_create_resource(self): """Test creating a resource from a template."""