diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index 2cb0926134..6a817ecb57 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from enum import Enum import json from typing import Sequence @@ -92,6 +92,23 @@ def convert_time( tzinfo=source_timezone, ) + # Attaching ZoneInfo does not validate local wall times. During a + # spring-forward transition, times in the skipped interval are silently + # assigned the pre-transition offset even though they never occurred. + # A UTC round trip normalizes such a value to the first valid local time, + # allowing us to reject the invalid input instead of returning a + # misleading conversion. + round_tripped_source_time = source_time.astimezone(timezone.utc).astimezone( + source_timezone + ) + if round_tripped_source_time.replace(tzinfo=None) != source_time.replace( + tzinfo=None + ): + raise ValueError( + f"Time {time_str} does not exist in {source_tz} on " + f"{source_time.date()} due to a UTC offset transition" + ) + target_time = source_time.astimezone(target_timezone) source_offset = source_time.utcoffset() or timedelta() target_offset = target_time.utcoffset() or timedelta() diff --git a/src/time/test/time_server_test.py b/src/time/test/time_server_test.py index 8d963508d7..c95031a930 100644 --- a/src/time/test/time_server_test.py +++ b/src/time/test/time_server_test.py @@ -120,6 +120,37 @@ def test_convert_time_errors(source_tz, time_str, target_tz, expected_error): time_server.convert_time(source_tz, time_str, target_tz) +def test_convert_time_rejects_nonexistent_dst_time(): + # New York advances from 01:59 to 03:00 on this date, so 02:30 never + # occurs. ZoneInfo otherwise silently assigns it the earlier UTC-05:00 + # offset and produces a conversion for a nonexistent wall time. + with freeze_time("2026-03-08 12:00:00+00:00"): + time_server = TimeServer() + + with pytest.raises( + ValueError, + match=( + r"Time 02:30 does not exist in America/New_York on 2026-03-08 " + r"due to a UTC offset transition" + ), + ): + time_server.convert_time("America/New_York", "02:30", "UTC") + + +@pytest.mark.parametrize( + "time_str,expected_utc", + [ + ("01:30", "2026-03-08T06:30:00+00:00"), + ("03:30", "2026-03-08T07:30:00+00:00"), + ], +) +def test_convert_time_accepts_valid_times_around_dst_gap(time_str, expected_utc): + with freeze_time("2026-03-08 12:00:00+00:00"): + result = TimeServer().convert_time("America/New_York", time_str, "UTC") + + assert result.target.datetime == expected_utc + + @pytest.mark.parametrize( "test_time,source_tz,time_str,target_tz,expected", [