From 05b0bf97d702ccd1795aa31065511accd38c3db7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 27 May 2026 14:57:38 -0400 Subject: [PATCH] fix(mcp): list_events tz-aware UTC range + end-of-day inclusive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 smoke caught: Error executing tool list_events: can't compare offset-naive and offset-aware datetimes Event.start_dt is stored timezone-aware; the wrapper was passing naive datetimes built from datetime.fromisoformat("YYYY-MM-DD"), so the SQL comparison crashed. Also: the docstring promises "date_to inclusive at end-of-day" but the code was using midnight-of-date_to, which would silently miss same-day events after midnight. Extracted the range math into _day_range_utc() so create/update_event's _combine() can stay as-is (it stays naive — the service localizes create/update inputs against the user's tz, that path didn't crash). Test updated to match: assert tz-aware UTC datetimes and the +24h bump for end-of-day-inclusive semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/fabledassistant/mcp/tools/events.py | 29 +++++++++++++++++++++---- tests/test_mcp_tool_events.py | 10 ++++++--- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/fabledassistant/mcp/tools/events.py b/src/fabledassistant/mcp/tools/events.py index 74d3ecb..592584d 100644 --- a/src/fabledassistant/mcp/tools/events.py +++ b/src/fabledassistant/mcp/tools/events.py @@ -17,18 +17,40 @@ For update, sentinels: """ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta, timezone from fabledassistant.mcp._context import current_user_id from fabledassistant.services import events as events_svc def _combine(start_date: str, start_time: str) -> datetime: - """Combine YYYY-MM-DD + HH:MM into a naive datetime.""" + """Combine YYYY-MM-DD + HH:MM into a naive datetime. + + The events service interprets naive datetimes for create/update against + the user's configured timezone, so we don't attach tzinfo here. + """ t = start_time or "00:00" return datetime.fromisoformat(f"{start_date}T{t}:00") +def _day_range_utc(date_from: str, date_to: str) -> tuple[datetime, datetime]: + """Return a UTC datetime range [start_of_date_from, end_of_date_to). + + Event.start_dt is stored timezone-aware in the DB; comparing it against a + naive datetime raises TypeError. We anchor the range in UTC, which is a + reasonable default — refining to the user's local timezone for the + range boundaries is a separate improvement. + """ + start = datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc) + # `date_to` is inclusive at the day level — bump by 24h so events later + # on date_to are included. + end = ( + datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + + timedelta(days=1) + ) + return start, end + + def _event_dict(event) -> dict: """Render an Event model to a dict, handling list_events (already dicts).""" return event if isinstance(event, dict) else event.to_dict() @@ -41,8 +63,7 @@ async def list_events(date_from: str, date_to: str) -> dict: Recurring events are expanded into individual occurrences within the range. """ uid = current_user_id() - start = datetime.fromisoformat(date_from) - end = datetime.fromisoformat(date_to) + start, end = _day_range_utc(date_from, date_to) rows = await events_svc.list_events(uid, start, end) return {"events": [_event_dict(e) for e in rows], "total": len(rows)} diff --git a/tests/test_mcp_tool_events.py b/tests/test_mcp_tool_events.py index 1a570f8..d0cc239 100644 --- a/tests/test_mcp_tool_events.py +++ b/tests/test_mcp_tool_events.py @@ -31,7 +31,10 @@ def _fake_event(**overrides) -> MagicMock: @pytest.mark.asyncio -async def test_list_events_passes_datetime_range(): +async def test_list_events_passes_timezone_aware_range(): + """Range must be tz-aware (UTC) and date_to inclusive at end-of-day — + Event.start_dt is tz-aware in the DB; naive comparisons raise TypeError.""" + from datetime import timezone mock = AsyncMock(return_value=[ {"id": 1, "title": "morning standup"}, ]) @@ -39,8 +42,9 @@ async def test_list_events_passes_datetime_range(): out = await list_events(date_from="2026-06-01", date_to="2026-06-08") args, _ = mock.call_args assert args[0] == 7 # user_id - assert args[1] == datetime(2026, 6, 1) - assert args[2] == datetime(2026, 6, 8) + assert args[1] == datetime(2026, 6, 1, tzinfo=timezone.utc) + # date_to is end-of-day inclusive → start of 2026-06-09 (24h past start of 2026-06-08) + assert args[2] == datetime(2026, 6, 9, tzinfo=timezone.utc) assert out["total"] == 1