fix(calendar): preserve floating/TZID semantics across CalDAV roundtrip (#782)

The CalDAV REPORT in `_search_events_by_date` unconditionally requested
server-side `<C:expand>`. Per RFC 4791 §9.6.5 the server then normalizes
every expanded DTSTART/DTEND to UTC `Z`, which destroyed two pieces of
information on the read path:

- RFC 5545 floating local times came back as fake-UTC (a `+00:00` suffix
  that did not match the stored value), so a 2:30 PM floating event was
  indistinguishable from a 14:30 UTC event in the MCP response.
- TZID-bound events lost their IANA TZID context — a "10am America/New_York"
  event came back as `14:00:00+00:00`, making it impossible for callers to
  reconstruct DST-aware recurrence semantics.

Replace `<C:expand>` with client-side recurrence expansion via the
`recurring-ical-events` library (promoted from transitive to direct dep),
so the wire response retains its original DTSTART format. Surface the
TZID parameter as new `start_tz`/`end_tz` fields on `CalendarEventSummary`.

Add an optional `timezone` (IANA name) parameter to `nc_calendar_create_event`
and `nc_calendar_update_event` so callers can pin a TZID for naive input;
the helper attaches `ZoneInfo(...)` and emits a paired `VTIMEZONE`
component. Naive input without `timezone` continues to store as RFC 5545
floating local time (with a warning logged). Offset-aware input continues
to store as UTC `Z`.

Drive-by: switch the update path's DTSTART/DTEND assignment from raw
`datetime` to `vDDDTypes(dt)` wrappers — the previous code produced invalid
iCal like `DTSTART:2026-05-14 10:00:00+00:00` for any TZ-aware update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-11 02:41:10 +02:00
co-authored by Claude Opus 4.7
parent 728a9ae252
commit 9d5ac01f24
6 changed files with 469 additions and 75 deletions
+29 -3
View File
@@ -38,6 +38,8 @@ def _event_dict_to_summary(event: dict) -> CalendarEventSummary:
summary=event.get("title", ""),
start=start,
end=event.get("end_datetime"),
start_tz=event.get("start_tz"),
end_tz=event.get("end_tz"),
all_day=event.get("all_day", False),
location=event.get("location") or None,
description=event.get("description") or None,
@@ -92,13 +94,23 @@ def configure_calendar_tools(mcp: FastMCP):
attendees: str = "",
url: str = "",
color: str = "",
timezone: str = "",
):
"""Create a comprehensive calendar event with full feature support
"""Create a comprehensive calendar event with full feature support.
Args:
calendar_name: Name of the calendar to create the event in
title: Event title
start_datetime: ISO format: "2025-01-15T14:00:00" or "2025-01-15" for all-day
start_datetime: ISO format. Three modes:
- ``"2025-01-15T14:00:00Z"`` or ``"2025-01-15T14:00:00+00:00"``
→ stored as UTC.
- ``"2025-01-15T14:00:00"`` with ``timezone="America/New_York"``
→ stored as TZID-bound (server emits ``DTSTART;TZID=...:...``
plus a VTIMEZONE component).
- ``"2025-01-15T14:00:00"`` alone → stored as RFC 5545 floating
local time (interpreted by viewers in their own zone). A
warning is logged so the choice is visible.
- ``"2025-01-15"`` for all-day events.
ctx: MCP context
end_datetime: ISO format end time, empty for all-day events
all_day: Whether this is an all-day event
@@ -116,6 +128,10 @@ def configure_calendar_tools(mcp: FastMCP):
attendees: Comma-separated email addresses
url: Related URL for the event
color: Event color (hex or name)
timezone: Optional IANA timezone name (e.g. ``"America/New_York"``).
Applied only when ``start_datetime``/``end_datetime`` are naive
(no offset, no ``Z``). Ignored — with a warning — when the
inputs already carry an explicit offset.
Returns:
Dict with event creation result
@@ -141,6 +157,7 @@ def configure_calendar_tools(mcp: FastMCP):
"attendees": attendees,
"url": url,
"color": color,
"timezone": timezone,
}
return await client.calendar.create_event(calendar_name, event_data)
@@ -312,9 +329,16 @@ def configure_calendar_tools(mcp: FastMCP):
attendees: str | None = None,
url: str | None = None,
color: str | None = None,
timezone: str | None = None,
etag: str = "",
):
"""Update any aspect of an existing event"""
"""Update any aspect of an existing event.
Pass ``timezone`` (IANA name, e.g. ``"America/New_York"``) together
with a naive ``start_datetime`` / ``end_datetime`` to rewrite DTSTART
/ DTEND as TZID-bound. See ``nc_calendar_create_event`` for the full
encoding rules.
"""
client = await get_client(ctx)
# Build update data with only non-None values
@@ -353,6 +377,8 @@ def configure_calendar_tools(mcp: FastMCP):
event_data["url"] = url
if color is not None:
event_data["color"] = color
if timezone is not None:
event_data["timezone"] = timezone
return await client.calendar.update_event(
calendar_name, event_uid, event_data, etag