Changelog
[Unreleased]
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.