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:
co-authored by
Claude Opus 4.7
parent
728a9ae252
commit
9d5ac01f24
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user