Changelog
0.1.5 - 2026-10-13
Fixed
get_free_busyreported the whole queried window as busy, once per calendar, against Radicale 3.8.2 and later and any server answering in the RFC 4791 shape: the bounding DTSTART/DTEND of the VFREEBUSY component were read as a busy period. A component carrying FREEBUSY properties is now read for those alone, and an empty one spanning exactly the window means free.
Changed
- The container image is built on newer digests of the same Node LTS base image, and the dependencies baked into it are refreshed. The npm package's declared dependency ranges are unchanged.
Documentation
- The README's Socket badge uses Socket's current badge URL.
0.1.4 - 2026-09-08
Fixed
- A document wrapped in
<![CDATA[…]]>is read instead of counted as unreadable.calendar-datais astopNodesentry, so the parser hands back its raw source rather than its text — and the XML packaging comes with it. Open-Xchange wraps the payload that way, which in the sibling server (carddav-mcp, against the same mailbox.org account) meant an address book of 79 cards listing as empty. CalDAV there happens to serve the payload unwrapped, so this is the dialect this server has not met yet rather than one it can rule out. The sections are now taken off and joined, including the split a server has to make around a document containing]]>. Entity references inside a section are left alone, because that is what a CDATA section means. - An indented response is read too.
trimValuesdoes not reach a stop node, so a server that pretty-prints its XML handed over\n BEGIN:VCALENDAR…, and an iCalendar document has to start atBEGIN:.
Changed
- A CR or LF reference is decoded where a real newline follows it. They used to be refused everywhere in
calendar-data, to stopSUMMARY:harmless ATTENDEE;PARTSTAT=ACCEPTED:mailto:xbecoming two properties. That still holds — a reference smuggled into a value has no real newline behind it and stays literal. What is new is the other shape: a server that encodes its line endings writes the reference immediately before the newline it stands for, because XML normalises a raw CR to LF on the way in and escaping it is the only way to keep it. sabre/dav does exactly that, and refusing it made every card unreadable in carddav-mcp until 0.1.0. The two servers disagreed about this node and only one of them could have been right; they now apply the same rule in the same words. mcp-tool-allowlist0.2.2,oxlint1.82.
0.1.3 - 2026-09-07
Security
The second internal hardening pass, 2026-09-07. Every item below was a real defect in a tree that had passed npm audit, Dependabot, CodeQL, Scorecard and Trivy, and each ships with a test that asserts on the wire or on the result.
mcp-approval0.8.1: a sealed dialog answer is single-use. On protocol revision2026-07-28the dialog is a return value, and with 0.8.0 the samerequestStateand the same ticked box executed the edit again for as long as the state lived — fifteen minutes — and the key for changing a whole series is the same on every call, which is what made every replay land. A test drives the 2026 revision through the in-memory transport and pins that a replayed state asks again and writes nothing.- A stored value that is not an integer no longer fails the whole answer. The output schema promises
int()forsequence,priorityandpercent_complete, and the SDK fails the whole call when a result breaks that promise. ical.js handsPERCENT-COMPLETE;VALUE=FLOAT:1.5over as 1.5, a twenty-digitSEQUENCEas 1e20, and anATTACH;SIZE=of three hundred digits reachesNumber()as Infinity — so one such line, written by anybody with access to a shared calendar, took every listing it appeared in down.sequencesits in every kind, solist_events,list_tasksandlist_journalsalike. The field is left out now; aSEQUENCEthis server cannot read restarts at 1 on the next write. A property test over arbitrary property values pins the whole class, and its first run found two more: an alarmTRIGGER:0, which ical.js throws on when first read — after the per-entry guard, so the listing died — andSEQUENCE:-0, on which the two halves of a result disagreed. - An approval is keyed on a tuple, not a set. The library's resource key sorts its parts, which is right for a set and wrong for (from, to): the key for moving
e.icsfrom Work to Private was the key for moving thee.icsin Private to Work, so a token issued for the one authorised the other. Every part now carries its position: the keys are built withorderedResourceKey, which this release takes from mcp-approval 0.8.2. - The status is decided before the body is read, and a
401is remembered. Every verb read the body under the success ceiling and only then looked at the status, so a reverse proxy answering401with a two-megabyte login page surfaced as "larger than 1048576 bytes and was refused". An error body is now read up to 64 KiB and cut there. And because every request is a login and a hosted provider locks an account after a handful of failed ones, a401is repeated from memory for ten seconds — the repeated error says how old it is and what to check — so a model retrying a tool annotated cheap and read-only cannot lock the account. - A tool call has a budget as a whole. One REPORT per calendar, thirty seconds allowed for each, over every calendar the credentials can see: a slow server with a hundred collections turned one call into an hour with no way to say so. A call now has a 30-second budget checked before each request, a ceiling of 5000 collected occurrences, and an answer that says after how many of how many calendars it stopped. Discovery has the same shape one level up — eight home sets, twenty addresses, 256 calendars, each overflow counted and reported — and a collection a home set lists is used only if it sits under that home set.
- Every string walk is linear at its ceiling.
/\/+$/and/[^/]*$/are tried from every position of a run and consume the run each time: eighty thousand slashes followed by one character cost two seconds, from any href the server chose, and a segment filling the 16 MiB multistatus ceiling would have cost hours. A counted walk andlastIndexOfreplace them, an href past 8 KiB is refused before anything walks it, andlinear-time.test.tstimes every such function at its ceiling. - An ETag is held to the RFC 9110 grammar before it becomes an
If-Match. undici refuses a header value with a control character, which reached the model asfetch failedon every write to that resource for as long as the server kept sending it. An ETag that is not an entity-tag is treated like a weak one, which the write path already refuses with a sentence. - A recurrence rule and an alarm trigger are checked as values, not only parsed. ical.js reads what it understands and drops the rest:
COUNT=1e9becameCOUNT=1,INTERVAL=0and an unknown part vanished, the second of twoCOUNTs won, everything after a line break was gone;PT-5Mwas written verbatim,P1W2DasP9D, a hundred billion hours accepted. Each was then written into a shared calendar as if it were what the caller asked for. Both are held to their RFC 5545 grammar and the rule is compared part by part with what would be written. The test for the accepted triggers found that every absolute reminder went out withVALUE=DATE-TIMEtwice on one property, which is not iCalendar. - An untyped error is quoted like every other value an error repeats. ical.js writes the offending value into its messages, and the last catch in
run()repeated that text to the model whole, in this server's own voice, outside any fence. - The fenced text block of a single entry is measured as emitted, with the datamark on every line, against the same ceiling as the JSON beside it; it used to go out unmeasured, so an entry just under the ceiling was answered three times over. The budget also descends one level, so an attendee list under
eventcan be shortened instead of the whole answer refused. - An allowlist entry that matched nothing is no longer printed in full.
CALDAV_CALENDARSsits one line belowCALDAV_PASSWORDin every compose file, and an entry that matches nothing is what a secret pasted into the wrong line looks like. Only a value shaped like a calendar reference is quoted; the rest is described by its length, to stderr and to the model alike, and so are the other three "got …" diagnostics. - The publish job installs with
--ignore-scripts. It is the job withid-token: writefor npm Trusted Publishing, and every dependency's install hook ran while that token was available.gh release createverifies the tag; adependency-reviewjob checks what a pull request changes. - The runtime image no longer carries yarn, corepack or a lockfile. npm and npx had been removed; the other two package managers stayed behind.
Fixed
- A
404or a405on a discovery guess — the origin root behind a/dav.phpprefix is usually a plain web server — ended discovery one step before the home-set fallback written for exactly that case. Those two statuses now degrade like an HTML page; a401, a403and a5xxare still the answer. - A calendar's id is printed byte for byte, and the same in every tool.
list_calendarsran the path through the text cleaner, which rewrote, normalised to NFKC and cut a long path — an id no tool could resolve — whileget_server_infoprinted the raw path beside it. An identifier that has to round-trip is validated, not cleaned. The same sweep cleaned whatget_server_inforepeats from the server and the attendeeROLE,PARTSTATand attachmentFMTTYPEin a listing. is_selfis decided the same way in every tool.CALDAV_USER_EMAILwas lower-cased for listings and not forget_eventorget_server_info.- A URL-form
CALDAV_CALENDARSentry matches only on the configured origin; an entry for another account no longer stands in for the same path on this one. - The id tag was looked up in an object literal, where
constructoris a key too, and the sentence built from it said "the id of a undefined". CALDAV_URLis kept as the parsed origin and path rather than as the environment string, so the whitespace a copied line carries is no longer glued in front of every path.
Added
- The server introduces itself in full.
title,description,websiteUrlandiconsnow travel withnameandversion, so a client that shows a server to a person has something to show. All four were already inserver.jsonfor the registry and reached no client at all; a test compares the two so they cannot drift. - An OpenSSF Scorecard run, weekly and on every push to
main, reporting into the Security tab next to CodeQL and Trivy. The badge is the second in the row. - The demo recording is embedded in the README and on the docs home page. It had been rendered and shipped since 0.1.0 and was linked from neither.
- Four test suites: the discovery walk shape by shape, every approval outcome and every write-path refusal, the insecure-TLS switch with undici mocked, and the property tests above. The coverage floor rises to 97 / 88 / 99 / 98.
Changed
- oxlint's
suspiciouscategory is on. Thirty of its thirty-nine findings weresort()on a fresh array in a test, whichtoSorted()says without the mutation; the TypeScript target moves to ES2023 for it. docs/reference/tools.mdis written by hand again. It used to be generated from the registered tools, which kept it in step with the code at the price of a page nobody could edit:--checkcompared it byte for byte, so every line had to be derivable and a paragraph about how an endpoint really behaves had nowhere to go. A test now asserts what the generator guaranteed — the page documents exactly the tools that exist, marks exactly theessentialpreset, and marks exactly the tools that ask a person first — and leaves the prose to a person.- Source maps are no longer published in the npm tarball. Node reads them only under
--enable-source-maps, which nothing here sets, and the maps pointed at asrc/this package does not ship — so a stack trace under that flag named a file nobody could open.dist/**/*.jsis unchanged; the package is about a fifth smaller. - The docs workflow builds and deploys in two jobs, so
contents: writeis scoped to the deploy step rather than granted to the whole workflow. oxlintto 1.81.
0.1.2 - 2026-09-06
Fixed
- A cross-host
/.well-known/caldavredirect ended discovery instead of degrading past it. RFC 6764 §6 defines that route as a redirect and permits it to point at another host, which is how a hosted provider sends a client from the domain you typed to the one that serves DAV. Refusing to follow it is correct — following it would send the credentials somewhere you did not configure — but the refusal was thrown rather than returned, so discovery never reached its later steps or its home-set fallback. Because the principal is resolved once and memoised, that turned into the same error on every tool call for the life of the process. It is now a note naming the origin that was not followed, which is the addressCALDAV_URLshould have had. - The tool reference generator built its server from seven of
Config's thirteen fields, under a comment claiming all of them. It now passes the whole record and asserts that every tool in the catalogue came back, so an incomplete literal fails the build instead of quietly producing a short page.
Changed
npm run docs:tools:checkruns in CI, which it never had, so the generated tool reference can no longer drift from the code.
Documentation
- The guide's three unfinished pages are written.
/guide/securityshipped as four empty headings — with the brief for writing one of them published as an HTML comment./guide/configurationpromised "Getting a token" and "Required scopes", which CalDAV does not have at all./guide/clientsdocumented one of five clients, with a command that passes no credentials. - One authentication story everywhere: username and app-specific password, as the README always had it. Getting started, the clients page and the mcp-hub example had been describing a bearer token and scopes.
/reference/environmentlists all fourteen variables instead of eight, and no longer marksCALDAV_TOKENas required.- The Glama badge, and both asset markers now name
svg-asset-setrather than a generator script deleted some releases ago.
0.1.0 - 2026-09-05
Added
First release. An MCP server for CalDAV: 22 tools over events, tasks and journal entries, on any server that speaks the standard.
- 22 tools, 10 read and 12 write.
CALDAV_READ_ONLY=trueleaves the write tools unregistered rather than failing them, so they do not appear intools/listat all. - Recurrence is expanded here, not by the server. Servers disagree about
expand, and several get overrides wrong. The expansion walks the rule and then sweeps the overrides the rule never reaches, so an occurrence moved outside its own series still appears. Three independent bounds — the result cap, a per-series iteration cap and a wall-clock deadline — becauseFREQ=SECONDLYwith noUNTILis legal iCalendar. A window wider than 366 days is refused rather than quietly shortened: a truncated ten-year window looks exactly like "nothing more in the calendar". - Times carry their zone. Every timestamp is reported as an ISO 8601 instant, the original
TZID, and anall_dayflag.TZIDnames the platform does not know do not travel, and the document's ownVTIMEZONEis never registered globally — a hostile entry that redefinesEurope/Berlinaffects only itself. - Writes are read-modify-write over the parsed tree, never a document rebuilt from the fields this server models. Unknown
X-properties, alarms, attachments, attendees and parameters survive because they are never touched. Guarded withIf-Matchfrom the same read; neverIf-Match: *; a weak ETag is refused; a 412 is not retried but answered with what the entry is now and the fact that nothing was written. CALDAV_CALENDARSfences the server to named calendars, enforced where an id is decoded rather than at the edge of each tool, so no tool can forget it. A listing reports how many collections it withheld instead of quietly being shorter. An entry matching two calendars is refused at startup rather than resolved to whichever matched first.- Ids are opaque and carry no origin. The host is rebuilt from
CALDAV_URLon every decode, so a forged id cannot point this server at another server. Every join of a calendar URL and a resource name is checked on the resolved path: checking the name for a literal/is not the same check, because the URL parser normalises%2E%2Eand treats a backslash as a separator. Names that would address something the id does not say — carrying?,#, a control character — are refused, while percent-encoded names still work. - Calendar content is treated as content a stranger wrote, because on a server with scheduling anyone who knows your address can put an event in your calendar. Summaries, descriptions, locations, attendee names and calendar display names are fenced with a per-call nonce and marked line by line; invisible and directional characters are removed; markdown image syntax is defused so a rendering client cannot be induced to fetch a URL carrying data. Injection shapes are reported as a signal, never used to drop an entry.
- Nothing this server says quotes calendar content. Not the approval dialogs, not the error messages. Every value an error repeats — an id, a calendar name, an href, a timestamp — is escaped, collapsed to one line and cut first, because an error message reaches the model in the server's own voice, outside any fence.
- The dialogs describe what is actually there. Deleting reads the entry first and says whether it is one event or a recurring one and how many occurrences — including a resource made only of detached occurrences, which has no master to read a rule from. A series id with
scope: this_occurrenceis refused before anyone is asked rather than being shown one sentence and performing another. An approval is bound to the change as well as to the target, and a field left out is not the same as a field passed asnull, so a yes to "change the summary" cannot execute "change the summary and clear everything else". - It never fetches an address somebody else chose.
ATTACHURLs are reported and not retrieved; no tool takes a URL. Links returned by the server are pinned to the configured origin and refused if they carry credentials or a scheme this server does not speak — checked again at the point the credentials would leave the process. CALDAV_ALLOW_PLAINTEXT. A plainhttp://URL to a host that is not loopback refuses to start instead of printing a warning that a stdio deployment never shows. The switch lifts the refusal and is read strictly, likeCALDAV_INSECURE_TLS.- Bearer or Basic authentication, RFC 6764 discovery from a server root or a collection URL,
get_free_busywith a client-side fallback where the server will not compute one, and full-text search per allowed calendar — never at the home set, which would reach every calendar underneath it.