diff --git a/src/mcp/shared/tool_name_validation.py b/src/mcp/shared/tool_name_validation.py index f35efa5a61..9614399108 100644 --- a/src/mcp/shared/tool_name_validation.py +++ b/src/mcp/shared/tool_name_validation.py @@ -17,8 +17,10 @@ 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}$") +# Regular expression for valid tool names according to SEP-986 specification. +# End-anchored with \Z rather than $: in Python's default mode $ also matches +# just before a single trailing newline, which would let "name\n" validate. +TOOL_NAME_REGEX = re.compile(r"^[A-Za-z0-9._-]{1,128}\Z") # SEP reference URL for warning messages SEP_986_URL = "https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names" diff --git a/tests/shared/test_tool_name_validation.py b/tests/shared/test_tool_name_validation.py index 97b3dffcd3..f03e1a52a8 100644 --- a/tests/shared/test_tool_name_validation.py +++ b/tests/shared/test_tool_name_validation.py @@ -80,6 +80,22 @@ def test_validate_tool_name_rejects_invalid_characters(tool_name: str, expected_ assert any("invalid characters" in w and expected_char in w for w in result.warnings) +@pytest.mark.parametrize( + "tool_name", + ["valid_name\n", "a" * 127 + "\n"], + ids=["trailing_newline", "max_length_plus_newline"], +) +def test_validate_tool_name_rejects_trailing_newline(tool_name: str) -> None: + """A trailing newline is not an allowed character and must be rejected. + + Regression test: Python's ``$`` (unlike ``\\Z``) also matches just before a + single trailing newline, so a name like ``"valid_name\\n"`` slipped through. + """ + result = validate_tool_name(tool_name) + assert result.is_valid is False + assert any("invalid characters" in w for w in result.warnings) + + def test_validate_tool_name_rejects_multiple_invalid_chars() -> None: """Names with multiple invalid chars should list all of them.""" result = validate_tool_name("user name@domain,com")