From 5e5ef263dd9b4236111f0fb30f3da3c4957ad8c9 Mon Sep 17 00:00:00 2001 From: Nimra Khalid Date: Wed, 9 Sep 2026 00:33:12 +0500 Subject: [PATCH] fix(time): flag ambiguous DST fall-back times in convert_time convert_time constructs the source datetime by attaching tzinfo directly to a naive year/month/day/hour/minute tuple. On the day a DST fall-back transition happens, the local wall-clock hour that repeats (e.g. 1:30 AM in America/New_York on the first Sunday of November) is ambiguous: two real, distinct moments share that same clock reading. Attaching tzinfo this way always resolves to fold=0, the earlier of the two, with nothing in the response indicating a choice was made. This adds a `note` field to TimeResult, populated only when the requested source time is ambiguous, explaining which of the two occurrences was used. Detection compares the tzinfo-attached datetime's UTC offset against the same wall-clock time under fold=1; a mismatch means two valid offsets exist for that reading. This is scoped to the ambiguous (fall-back) case only, distinct from and complementary to #4719, which addresses the separate nonexistent (spring-forward gap) case -- confirmed via search that the two PRs don't overlap in what they change. 4 new tests covering: an ambiguous NY time, a non-ambiguous time on the same transition day, a timezone with no such transition on that date (to confirm the check is genuinely timezone-specific, not date-triggered), and a second timezone/month (Europe/Warsaw, October) to confirm the fix isn't hardcoded to one region's transition rules. All 42 existing plus new tests pass; pyright clean. --- src/time/src/mcp_server_time/server.py | 18 ++++++++++ src/time/test/time_server_test.py | 46 ++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) 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")