diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index 2cb0926134..4ff0e7e9e7 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -24,6 +24,7 @@ class TimeResult(BaseModel): datetime: str day_of_week: str is_dst: bool + note: str | None = None class TimeConversionResult(BaseModel): @@ -92,6 +93,22 @@ def convert_time( tzinfo=source_timezone, ) + # A local wall-clock time can occur twice on the day a DST fall-back + # transition happens (e.g. 1:30 AM in America/New_York on the first + # Sunday of November occurs once before the clocks go back and once + # after). Attaching tzinfo directly, as above, always resolves to + # `fold=0` -- the earlier of the two real offsets -- with no + # indication to the caller that a second, equally valid answer + # exists. Detect that case by comparing both folds' offsets, and + # surface it instead of silently picking one. + source_note = None + other_fold_offset = source_time.replace(fold=1).utcoffset() + if other_fold_offset != source_time.utcoffset(): + source_note = ( + f"{parsed_time.strftime('%H:%M')} occurs twice in {source_tz} on this date due to a " + "DST transition; this result uses the earlier of the two occurrences." + ) + target_time = source_time.astimezone(target_timezone) source_offset = source_time.utcoffset() or timedelta() target_offset = target_time.utcoffset() or timedelta() @@ -109,6 +126,7 @@ def convert_time( datetime=source_time.isoformat(timespec="seconds"), day_of_week=source_time.strftime("%A"), is_dst=bool(source_time.dst()), + note=source_note, ), target=TimeResult( timezone=target_tz, diff --git a/src/time/test/time_server_test.py b/src/time/test/time_server_test.py index 8d963508d7..ebc408a13b 100644 --- a/src/time/test/time_server_test.py +++ b/src/time/test/time_server_test.py @@ -462,6 +462,52 @@ def test_convert_time(test_time, source_tz, time_str, target_tz, expected): assert result.time_difference == expected["time_difference"] +def test_convert_time_flags_ambiguous_fall_back_time(): + # America/New_York's DST fall-back in 2024 happens at 2:00 AM on Nov 3, + # when clocks go back to 1:00 AM -- so 1:30 AM occurs twice that day. + # Freeze at noon UTC (already past the local transition) so `now` in NY + # time also lands on Nov 3, the actual transition date. + with freeze_time("2024-11-03 12:00:00+00:00"): + time_server = TimeServer() + result = time_server.convert_time("America/New_York", "01:30", "Europe/London") + + assert result.source.note is not None + assert "occurs twice" in result.source.note + assert "America/New_York" in result.source.note + # The earlier (fold=0) occurrence is used: -04:00 (EDT), not -05:00 (EST). + assert result.source.datetime == "2024-11-03T01:30:00-04:00" + + +def test_convert_time_no_note_for_an_unambiguous_time_on_the_same_day(): + with freeze_time("2024-11-03 12:00:00+00:00"): + time_server = TimeServer() + result = time_server.convert_time("America/New_York", "10:00", "Europe/London") + + assert result.source.note is None + + +def test_convert_time_no_note_for_a_timezone_without_the_transition(): + # Same real moment, but a source timezone with no DST fall-back at all -- + # the note must not fire just because *some* zone somewhere is ambiguous. + with freeze_time("2024-11-03 12:00:00+00:00"): + time_server = TimeServer() + result = time_server.convert_time("UTC", "01:30", "Europe/London") + + assert result.source.note is None + + +def test_convert_time_flags_ambiguous_time_in_a_different_zone_and_month(): + # Europe/Warsaw's DST fall-back in 2024 is Oct 27, a different date and + # a different offset pair (+02:00 -> +01:00) than the US case above -- + # confirms the check isn't hardcoded to one zone's transition. + with freeze_time("2024-10-27 12:00:00+00:00"): + time_server = TimeServer() + result = time_server.convert_time("Europe/Warsaw", "02:30", "UTC") + + assert result.source.note is not None + assert result.source.datetime == "2024-10-27T02:30:00+02:00" + + def test_get_local_tz_with_override(): """Test that timezone override works correctly.""" result = get_local_tz("America/New_York")