You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This commit was created on GitHub.com and signed with GitHub’s verified signature.
Breaking changes
Node.js 22 or newer is required. Node.js 18 and 20 are end-of-life; engines, the MCP Bundle's runtime range and the CI matrix now start at 22,
and the Node.js 18 workarounds (WebCrypto shim, Digest startup error) are
removed (#85).
Write tools refuse parameters they do not take (#126). Unknown keys
were dropped silently, so a call with a misspelled or newer parameter
reported success for a change it never made (dav-mcp 4.3.1 drops cancel_occurrences and answers "Updated 0 field(s)"). The create, update
and delete tools now answer such a call with a validation error naming the
parameter.
update_event and update_todo refuse EXDATE and RDATE in fields (#126), with a validation error naming cancel_occurrences / restore_occurrences. Written as a field, either list replaced every value
already in the series. Single occurrences are cancelled and restored with cancel_occurrences / restore_occurrences (see Fixed); extra dates
(RDATE) of a series are edited only through update_event_raw / update_todo_raw.
Moving a recurring event or todo moves the whole series (#107). A new
start given to update_event (or DTSTART to update_todo) now takes every
occurrence along: moved and cancelled occurrences, extra dates and the end
of the series shift by the same amount, so they keep applying to the
occurrences they were made for. A weekday or day of month the rule only
repeats from the start follows it: a series every Monday, moved to a Tuesday,
becomes every Tuesday. Where the rule cannot follow (every Monday and
Wednesday, moved by one day), the update is refused and says what to give
instead in the same call. Likewise, a new rule that would leave a moved or
cancelled occurrence on a date the series no longer has is refused. The
reply says what else moved: the old and new rule and how many changed,
cancelled and extra dates went along.
This includes starting a series later: a start several weeks later used to
leave cancelled and moved occurrences where they were; it now takes them
along, so a cancellation can land on a meeting nobody cancelled. To start a
series later without moving it, give the new start together with an RRULE that keeps the occurrences, and fix its exceptions with restore_occurrences / cancel_occurrences in the same call; a series
with changed occurrences (overrides) to keep in place is rewritten with the
raw tools. The start_date and DTSTART descriptions now say it is the
series' first start, not the occurrence a listing showed.
Times without a zone are read in the calendar's time zone (#117). An
event written without a zone (DTSTART:20261010T090000) or as an all-day
date was placed on the clock of the machine dav-mcp runs on, so an HTTP or
Docker deployment in UTC put a 09:00 Berlin meeting at 09:00 UTC: wrong in freebusy_query, in time-range searches and in listings. Such times are now
read in the calendar's own time zone, as CalDAV specifies, and only for a
calendar without one in the zone dav-mcp runs in (TZ). All-day events
now cover the calendar's day rather than the UTC day. This applies to every
tool that reads events or todos, and to the times without a zone given to
the create and update tools: create_event with 2026-10-26T09:00:00 in a
Berlin calendar starts at 09:00 Berlin time on any server. Since servers
apply their own reading to a time-range search (Nextcloud and Baïkal read
such times as UTC), list_events, calendar_query and freebusy_query
ask the server for a wider range and decide themselves what falls inside: a
recurring event with no occurrence in the range is no longer listed with a
note saying so, it is left out.
make_calendar and update_calendar refuse a time zone that is no IANA
name (#78). make_calendar used to accept any timezone and not apply
it; a name such as CEST, Berlin or +02:00 is now invalid input, and
nothing is sent. Any IANA zone name, UTC included, is taken (see
Changed).
freebusy_query prints local times, no longer UTC (#125). Free and
busy slots and the window were printed in UTC (01:30 PM UTC) while list_events and calendar_query print local times (03:30 PM GMT+2), so
an answer to "when am I free?" could pass UTC times off as local ones. They
are now printed in the calendar's time zone, the same wall times the
listings show, and a new Time zone line names it. Several calendars that
set the same zone are shown in it; calendars in different zones are shown
in the zone dav-mcp runs in, and the line says which calendar has which.
A failed login is a tool error with its own code (#123). Over stdio,
a login that failed at startup is retried on the first tool call; its
failure used to escape the call as a JSON-RPC internal error (-32603). It
is now the tool's error result: an authentication error (-32003) when the
server or the OAuth token endpoint refused the credentials, a network
error (-32004) when the server could not be reached, and a new
configuration error (-32008) when it answered but not as a DAV server (a
405, 404 or other 4xx to the login, or a web page), saying to check the
server URL; a wrong AUTH_METHOD or missing setting has that code too.
OAuth without GOOGLE_USER now stops the server at startup, like every
other missing setting, instead of failing each tool call.
Lookup and input mistakes carry their own error code; nothing is
guessed from a message any more (#115). Every tool that takes a calendar_url or addressbook_url looks it up first, and one that names
none of the collections is a not-found error (-32006); it used to be
classified by the words in the message, which carries the URL, so on a host
like caldav.icloud.com it came back as a CalDAV server error (-32000,
-32001 for CardDAV), and addressbook_query searched nothing. An event_url, todo_url or vcard_url with nothing behind it is a not-found
error too. Data the caller wrote (the raw update tools, field updates,
creates) that the server refuses as invalid (400, 415, 422) is a validation
error (-32002); the same status on a request dav-mcp builds itself stays an
internal error. An error without a type or status is now always an
internal error (-32603). Errors tsdav throws for an error status
(DAVResponseError, DAVAuthenticationError, @philflow/tsdav 2.5.0) are
classified by that status: a refused login during a call (an OAuth refresh
token that expired, a 401 on a calendar query) is an authentication error
(-32003) with the settings hint, a 404 not found, a 429 a network error. A
server that cannot be reached (fetch failed with ECONNREFUSED,
ENOTFOUND, a timeout, ...) is a network error (-32004) that says to check
the server URL; it used to be invalid params (-32602). A write refused
because the calendar's time zone could not be looked up is reported by
what the lookup ran into (a 401 as a refused login, no answer as a
network error); a zone value the server stores but nobody can read stays
a CalDAV error.
Invalid input is a validation error (-32002), no longer an internal
error (-32603) (#107, refs #115 in part). A refused series change (above)
keeps the reason and the suggested rule, and names the tools to use
instead. An event ending before it starts, or a todo moved by DTSTART
alone past its due date, is a validation error too, and the todo case says
to give DTSTART and DUE together. A date or rule the update tools
cannot read (EXDATE: "garbage", an unknown rule part) is a validation
error that names the property to correct, for events, to-dos and contacts
alike. A stored object that cannot be parsed is reported as a
CalDAV/CardDAV error, with the raw tool that can replace it.
Weak and malformed ETags are neither accepted nor handed out (#124, #136). An etag parameter that cannot be an entity-tag (inner whitespace, a
stray quote, more than 1024 characters) or is weak (W/"…") is a
validation error before any request: If-Match compares strongly, so a weak
one could only end in a 412. The list, query and multi-get tools passed a
weak W/"…" getetag on as etag, and todos labelled it "required for
updates": a model fetched, was refused, fetched the same ETag again. An etag field, in listings and in write results, is now only ever one the
etag parameters take (one rule for both); a weak, missing or malformed one
is an etag_note saying the object cannot be updated or deleted through
dav-mcp and that the server has to be fixed (after a write: fetch the
object first). The delete tools say what an etag_note means. CalDAV and
CardDAV require strong ETags, and a compressing proxy, which only changes
ETag headers, cannot cause a weak getetag. Strong ETags are shown as
before; a todo without one no longer reads undefined.
Added
Listings name occurrences the way the update tools take them (#126).
For a recurring event or todo, calendar_query, list_events, calendar_multi_get and the todo listings show an Occurrence ID (the
original start of the occurrence shown, or of the first one), the Cancelled occurrences (EXDATE) and the Changed occurrences
(overrides, by original start, with where each is now), all in the series'
own form: the wall-clock time in its zone, UTC with Z, or a date. An
exclusion stored in another zone is shown in that form too, and a date that
excludes a whole day of a timed series is marked "(whole day)" (the marked
text is accepted as it is). Every name is converted from the instant its
value names — an extra date (RDATE) stored in UTC next to a Berlin series is
named on the Berlin wall clock. A stored exclusion that names no occurrence
(a floating EXDATE next to a zoned series, a time the rule never yields)
cancels nothing and is listed apart, as "Exclusions that match no
occurrence". Recurring todos now show their rule.
The reply of update_event/update_todo says which occurrences were
cancelled or restored and which changed versions were removed, compared by
occurrence, not by the stored text.
Listed events show their STATUS (e.g. CANCELLED; for a series, the
occurrence listed), and freebusy_query's event details list exactly the
occurrences that make up the busy time.
Changed
Docker builds and CI no longer pull from Docker Hub (#140), which
refused CI runners' anonymous pulls with 429. The image's build stage pulls
Node.js from ECR Public, the Docker Official Images mirror, at the same
digest, and the Dockerfile uses BuildKit's built-in frontend instead of
fetching docker/dockerfile. The CI image builds take QEMU and BuildKit
from ECR Public at Docker Hub's digests. The smoke tests' CalDAV backend
is Radicale 3.8.3 from PyPI, pinned with hashes, on the official Python
image from ECR Public, configured like the tomsquest/docker-radicale:latest
image it replaces.
Dependencies: @philflow/tsdav 2.5.0 and @philflow/tsdav-utils 0.7.0.
tsdav for its typed errors (DAVResponseError, DAVAuthenticationError)
that carry the status and URL of a failed request; dav-mcp classifies
errors by them (#115, #123). tsdav-utils for its bounded, zone-correct
occurrence expansion and its occurrence edits (#98, #107, #126).
Calendars get a real time zone (#78). make_calendar now applies timezone (it used to create the calendar without one and say so), and update_calendar sends it as the VTIMEZONE CalDAV asks for instead of the
bare name (Europe/Berlin), which stricter servers reject or ignore. update_calendar reports the zone the server holds afterwards, and list_calendars shows each calendar's zone.
One more request for a calendar's time zone (#117). Tools that know a
calendar only by its URL ask the server for its time zone once per call: calendar_multi_get, list_todos, todo_multi_get (once per task list), create_todo with a due date, and update_event / update_todo when they
write a date. Time-range searches (list_events, calendar_query, freebusy_query) ask the server for 26 hours more on each side and filter
the extra events out themselves, which costs some transfer on busy
calendars.
A calendar time zone that cannot be read is reported (#117). A read
goes ahead in the zone dav-mcp runs in and says so in a note; a write that
would need the zone (a time without a zone, a move of an event stored
without one) is refused, so nothing lands an hour off unsaid. A local time
the clock change skips or shows twice is refused as well where it would
otherwise be stored shifted or shortened, with what to give instead; for
an event with its own time zone too, where a time without an offset in
the repeated autumn hour was silently taken as the first.
A time given in UTC keeps an event in its own time zone (#107). A
start like 2026-10-06T08:00:00Z for a weekly 09:00 Europe/Berlin series
used to turn the whole series into UTC, so after the change to winter time
every meeting sat an hour earlier in Berlin. The time is now written in the
event's (or to-do's) own zone at the same instant: 10:00 in Berlin, every
week.
Moving a series without a time zone keeps it without one (#128). An
event stored with a "floating" time (DTSTART:20270402T113000) was written
back in UTC when moved with update_event, so it stopped following the
calendar's local time and its changed occurrences sat off by the server's
UTC offset. It now stays floating: a new start without a zone is kept as
given, and one with Z or an offset becomes its local time in the
calendar's time zone (2027-04-03T09:30:00Z in a Berlin calendar is 11:30).
The same goes for update_todo on a todo without a zone.
Single occurrences are moved or retitled with the raw tools (#107). update_event and update_todo edit the whole series and refuse RECURRENCE-ID, which would have turned the series into one occurrence. To
move or retitle one occurrence, fetch the object with calendar_multi_get / todo_multi_get and send it back with update_event_raw / update_todo_raw; to cancel one, use cancel_occurrences (#126).
An update that asks for nothing writes nothing.update_event and update_todo without fields, dates or occurrence names (empty lists count
as none), or whose occurrences are already as asked, reply "not changed"
instead of writing the object back with a new etag.
update_calendar finds the calendar under another spelling of its URL
(an escaped character, a trailing slash) when it reads the result back. If
it still is not among the calendars the server lists, it says that the
update was accepted but cannot be confirmed, instead of an internal error.
Fixed
Cancelling one occurrence no longer brings back the ones cancelled
before (#126). update_event wrote fields.EXDATE as the complete list of
exclusions, so a model that wrote one date to cancel one more occurrence
dropped every earlier exclusion and those occurrences came back. update_event and update_todo now take cancel_occurrences and restore_occurrences: each names an occurrence by its original start, as
the listings show it, and adds to or takes from the exclusions without
touching the others (tsdav-utils cancelOccurrences/restoreOccurrences).
Cancelling a changed occurrence removes its changed version too; restoring
one occurrence of a day excluded as a whole brings back only that one. A
name in the wrong form is refused rather than cancelling another
occurrence: a time without a zone on a UTC series is not read in the
host's zone. A refused name comes back as a validation error naming it,
with the occurrences that day (or the cancelled ones) and how the series
names occurrences, and saying to use the name exactly as listed. Names
refer to the series as it is before the call; restores, then cancels, then
fields and dates are applied, so a move in the same call takes the new
exclusions along. On an event or todo that does not recur both are refused
(instead of excluding its only occurrence). A new RRULE that would leave an
exclusion naming no occurrence is refused as a validation error that names
the way out: restore_occurrences in the same call, or the raw tool.
update_todo writes into the to-do, not an event next to it (#107).
For a calendar object holding both an event and a to-do, update_todo wrote
its fields (a new due date, say) into the event. It now writes into the
to-do, update_event into the event, and either tool refuses an object that
holds nothing of its kind and names the other tool.
An etag passed without its quotes no longer fails the write with 412
(#124). The update and delete tools sent the etag parameter unchanged as If-Match, so abc123 instead of the "abc123" the server returned never
matched its entity-tag, and Baïkal and Nextcloud refused the write as if the
object had changed. Every etag parameter is now normalised in one place: a
bare tag gets its quotes, a quoted one is kept, and surrounding whitespace
is dropped. A stale etag is still refused with 412, quoted or not; when
dav-mcp added the quotes, the error says so, since a server that does not
quote its ETags fails the same way.
One contact or event with a huge parameter list no longer stalls the
server (#109). ical.js reads parameters in time that grows with their
number times the line length: a 1 MB vCard line with 500 000 parameters
took 11 s to read, blocking every other request meanwhile, in listings,
filters and edits alike. dav-mcp now refuses a property with more than
1 000 parameters before parsing, vCards and calendar objects alike (real
ones carry fewer than ten). update_contact, update_event and update_todo refuse to edit it with a validation error naming the
property; both take milliseconds now.
Listings say which objects they could not read, and why. An event,
contact or todo that does not parse (over the bound above, or not valid
iCalendar/vCard) used to show up as an empty "Untitled" entry, or not at
all once a query filter ran. Lists now count them up front and mark each
with the reason and the tools to replace or delete it; the query tools
name the ones no filter could search; freebusy_query reports its answer
as incomplete, naming the object, instead of losing its busy time
silently. The reason never quotes the stored text.
A wrong collection or object URL says what to send instead (#115).
The error names the parameter and the URL, lists up to 20 of the calendars
or address books there are (and how many more the list tool shows), and
names that tool; the query tools add that the parameter can be left out. list_todos, create_todo, calendar_multi_get, addressbook_multi_get
and addressbook_query used to skip the lookup; delete_calendar lists
the calendars when the server answers 404 for a URL that is not one of
them. A missing event, to-do or contact names the tools that return its
current URL. A refused object says so in the caller's terms ("The server
refused the event as invalid (415 …): … Correct the iCalendar data in updated_ical_data"), so the caller corrects it rather than retrying.
A failed login says where to fix the settings (#123). The hint used to
point only at a .env file, which users of the Claude Code plugin and
the Claude Desktop extension do not have (and it sat in a function nothing
called, so the message carried no hint at all). A refused or failed login,
and a write refused with 401 later on, now name the server and the three
places the settings live: the plugin's options (/plugin → Installed →
dav-mcp → Configure options), the extension's settings in Claude Desktop
(Settings → Extensions → dav-mcp: Calendars, Contacts & Tasks),
and the CALDAV_* environment variables (MCP client config, docker -e
or .env) for npx, Docker and checkouts. dav-mcp cannot tell which of
them started it, so it names all three; with OAuth, which only the
environment variables set up, it names the GOOGLE_* variables. Whether
the credentials were refused comes from the error tsdav throws
(DAVAuthenticationError, @philflow/tsdav 2.5.0), so a server that answers
401 on its DAV path and 405 on its root (Baïkal) is reported as a refused
login, not as a CalDAV error. A login answered with another error status
says which: a 403 is forbidden (-32003), an OAuth token endpoint answering
429 a rate limit (-32004), instead of "cannot find principalUrl".
Free/busy and event queries judge each occurrence as it now stands
(#98). A recurring series was expanded from its original dates, and every
occurrence counted with the series' own STATUS and TRANSP. So a single
occurrence cancelled (or marked transparent) still showed as busy, one
moved from the 20th to the 14th was missing from the 14th and still
reported on the 20th, and an opaque occurrence of a transparent series was
free. Each occurrence is now taken at its effective time with its own
STATUS and TRANSP (compared case-insensitively), in freebusy_query, calendar_query and list_events alike. An override is a full component:
one without STATUS of a cancelled series is not cancelled, and blocks time.
One definition of "in the range" (#98). Free/busy counted an
occurrence that overlaps the window, the event lists one that starts in it
(end included): a meeting running into the window was busy, yet listed as
"no occurrence falls inside the queried range". All of them now use the
CalDAV time-range test (RFC 4791 9.9): it starts before the range ends and
ends after it starts; one without duration counts when it starts in
[start, end). A floating time is read the same way throughout, in the
calendar's time zone (see Breaking changes).
Recurring events are expanded by tsdav-utils (#98), the reader the
write side uses, so dav-mcp and the library agree on which occurrences a
series has. An override replaces the occurrence its RECURRENCE-ID names,
read in the series' frame by the instant it names: an id written in UTC
for a series in Europe/Berlin used not to match, and at a DST change a
local time shown twice is its first pass (RFC 5545 3.3.5; ical.js was up
to an hour off there). A floating id is read by its digits, a date-time id
on an all-day series by its date (a Berlin-midnight id for the 13th used
to cancel the 12th), a date id on a timed series names that day's
midnight, and a UTC id on a floating series names nothing. An EXDATE
excludes by instant, a date on a timed series its whole day; a floating
EXDATE in a zoned series, or a UTC one in a floating series, names
nothing. A series the library cannot read (a UTC UNTIL on a floating
series, say) is reported incomplete, with the reason, rather than guessed
at. An all-day occurrence without DTEND or DURATION lasts its day, a timed
one no time (RFC 5545 3.6.1); a recurring all-day event without an end
used to block nothing. Times are shown as the library converts them, so a
time a DST change shows twice reads with the offset of its first pass.
THISANDFUTURE and moved overrides (#98). A RANGE=THISANDFUTURE
override moves every later occurrence by its own move, on its own wall
clock; one whose RECURRENCE-ID is no occurrence, or an EXDATEd one, is
ignored like any such override. An override moved into the range from
anywhere in the series is found from the overrides themselves.
Recurring events are expanded from the range, not from their start
(#98): from the start of the period that holds the range, for SECONDLY to
WEEKLY rules with BYDAY, BYMONTH or a BYHOUR/BYMINUTE/BYSECOND that
expands the rule (BYHOUR on DAILY, BYMINUTE on HOURLY); from a whole
number of months or years later for MONTHLY and YEARLY rules where every
month or year surely has an occurrence (a BYMONTHDAY within ±28, an
ordinal BYDAY within ±4 of a month, BYSETPOS over the weekdays every month
has, ...); and for a COUNT rule without BY parts. Other rules — a limiting
BYHOUR (on HOURLY), the 31st, 29 February, a fifth Monday, week 53,
MONTHLY with BYMONTH — are walked from DTSTART, as ical.js shifts its
INTERVAL grid where a period is empty. freebusy_query used to walk
every series from its start.
No series can stall a tool call (#98). ical.js tests rule candidates
one by one without a bound of its own: FREQ=SECONDLY;BYHOUR=9 from 1950
took 27 s, fifty MINUTELY;BYHOUR=10 series 45 s per call, and FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30 never returned. Each tool call now
has one budget of rule candidates (300 000, at most 100 000 per call into
the library) and of time (1.5 s of expansion), whichever runs out first;
each calendar object gets a fair share of both and at least 20 ms, so one
heavy series cannot leave the others unexpanded. The clock is checked
before each call into the library, so expansion takes about 1.5 s plus up
to one call per remaining object: it grows with the number of objects and
overrides in an answer (20 objects with 4 000 overrides each in range:
about 10 s), and under machine load a series may come back incomplete
where it otherwise would not. A year of 100 ordinary series takes about
1.5 s in all. A walk that needs more than 100 000 candidates is
incomplete even for a lone series (FREQ=DAILY;BYMONTHDAY=1,15 from
1970, which cannot start near the range). ical.js yields a BY list in the
order it is written (BYHOUR=17,9) and the library stops at the first
candidate past its window, so such rules are read one period further. A
series that needs more is never reported wrong, only incomplete: freebusy_query, which used to answer "Nothing blocks this window" for
it, names it and says its busy time may be missing, and an event list
says the series could not be expanded fully.