calendar-api is a small self-hosted calendar adapter that sits in front of an existing CalDAV server.
CalDAV remains the source of truth. This project exposes the same safe calendar operations through:
- an HTTP JSON API
- an MCP stdio server
Both transports share the same validation, dry-run behavior, and ETag-based conflict handling.
HTTP:
GET /healthzGET /calendarsGET /eventsGET /events/upcomingGET /events/{id}POST /eventsPATCH /events/{id}POST /events/{id}/moveDELETE /events/{id}GET /todosGET /todos/{id}POST /todosPATCH /todos/{id}DELETE /todos/{id}GET /availability
MCP tools:
healthlist_calendarslist_eventslist_upcoming_eventsget_eventcreate_eventlist_todosget_todocreate_todoupdate_tododelete_todoupdate_eventmove_eventdelete_eventget_availability
- Go 1.26+
- A running CalDAV instance
- Localhost-only bind address such as
127.0.0.1:8090
Required environment variables:
CALDAV_BASE_URLCALDAV_USERNAMECALDAV_PASSWORDCALENDAR_DEFAULT_NAMEAPI_BIND_ADDR
Optional environment variables:
DEFAULT_TIMEZONE
Example values are in .env.example.
Build the binary:
go build -o calendar-api ./cmd/calendar-apiBuild the MCP binary:
go build -o calendar-api-mcp ./cmd/calendar-api-mcpRun the HTTP server:
CALDAV_BASE_URL=https://caldav.example.com \
CALDAV_USERNAME=you@example.com \
CALDAV_PASSWORD=<FILL IN PASSWORD> \
CALENDAR_DEFAULT_NAME=personal \
API_BIND_ADDR=127.0.0.1:8090 \
DEFAULT_TIMEZONE=Europe/Paris \
./calendar-apiRun the MCP server over stdio:
CALDAV_BASE_URL=https://caldav.example.com \
CALDAV_USERNAME=you@example.com \
CALDAV_PASSWORD=<FILL IN PASSWORD> \
CALENDAR_DEFAULT_NAME=personal \
API_BIND_ADDR=127.0.0.1:8090 \
DEFAULT_TIMEZONE=Europe/Paris \
./calendar-api-mcpThe MCP binary writes protocol traffic on stdout, so application logs go to stderr.
Both binaries also accept explicit runtime flags such as:
--caldav-base-url--caldav-username--caldav-password--calendar-default-name--api-bind-addr--default-timezone
The MCP server is intended to be launched by an MCP client over stdio.
It exposes the same calendar behavior as the HTTP API, including:
- dry-run support for create, update, move, and delete
- explicit short error messages
- ETag enforcement for update, move, and delete unless
dryRunis true
Recommended write flow for agents:
- call
get_eventfirst to retrieve the currentetag - pass that
etagtoupdate_event,move_event, ordelete_event - use
dryRun: trueif you want a preview before making the write
All responses are JSON.
GET /events:
- Defaults to
CALENDAR_DEFAULT_NAMEwhencalendaris omitted. - If
fromandtoare provided, the service asks the CalDAV server for events in that range and expands recurring instances for that window. - If
fromandtoare omitted, the service lists the calendar objects returned by CalDAV without adding a synthetic time window.
GET /events/upcoming:
- Returns the next upcoming events from now.
- The service queries CalDAV over increasing future windows until it has enough events or reaches a hard upper horizon.
GET /todos:
- Defaults to
CALENDAR_DEFAULT_NAMEwhencalendaris omitted. - Returns normalized
VTODOitems, optionally filtered byfrom,to, andq.
Writes:
POST /events,PATCH /events/{id},POST /events/{id}/move, andDELETE /events/{id}supportdryRun=true.POST /todos,PATCH /todos/{id}, andDELETE /todos/{id}supportdryRun=true.PATCH,move, andDELETErequire an ETag for non-dry-run writes.- Todo
PATCHandDELETEalso require an ETag for non-dry-run writes. - You can supply the ETag with
If-Match. PATCHandmovealso accept anetagfield in the JSON body.- Todo
PATCHalso accepts anetagfield in the JSON body. DELETEalso accepts?etag=...as a query parameter.
All-day events:
- Use explicit RFC3339 timestamps.
- For all-day events,
startandendmust be midnight boundaries in the chosen timezone. endremains exclusive.
List calendars:
curl -s http://127.0.0.1:8090/calendarsList events in a range:
curl -s "http://127.0.0.1:8090/events?from=2026-03-24T00:00:00+01:00&to=2026-03-25T00:00:00+01:00"Create an event:
curl -s http://127.0.0.1:8090/events \
-H 'Content-Type: application/json' \
-d '{
"title": "Gemeente bezoek",
"description": "Afspraak bij de gemeente.",
"start": "2026-03-24T12:30:00+01:00",
"end": "2026-03-24T13:00:00+01:00",
"allDay": false,
"timezone": "Europe/Paris",
"location": ""
}'Patch an event:
curl -s -X PATCH "http://127.0.0.1:8090/events/gemeente-bezoek?calendar=wall" \
-H 'Content-Type: application/json' \
-H 'If-Match: "abc123"' \
-d '{
"title": "Gemeente bezoek verplaatst",
"start": "2026-03-24T13:00:00+01:00",
"end": "2026-03-24T13:30:00+01:00"
}'Move an event:
curl -s -X POST "http://127.0.0.1:8090/events/gemeente-bezoek/move?calendar=wall" \
-H 'Content-Type: application/json' \
-H 'If-Match: "abc123"' \
-d '{
"start": "2026-03-24T14:00:00+01:00",
"end": "2026-03-24T14:30:00+01:00"
}'Delete an event:
curl -s -X DELETE "http://127.0.0.1:8090/events/gemeente-bezoek?calendar=wall&etag=%22abc123%22"Create a todo:
curl -s http://127.0.0.1:8090/todos \
-H 'Content-Type: application/json' \
-d '{
"title": "Submit income taxes",
"due": "2026-04-15T18:00:00+02:00",
"timezone": "Europe/Paris",
"status": "NEEDS-ACTION"
}'Availability:
curl -s "http://127.0.0.1:8090/availability?from=2026-03-24T09:00:00+01:00&to=2026-03-24T18:00:00+01:00&duration_minutes=30"Run the test suite with:
go test ./...Live end-to-end testing against the configured CalDAV server:
just e2e-http
just e2e-mcpThese tests build the real calendar-api and calendar-api-mcp binaries, run them against the configured CalDAV server, create a dedicated calendar-api-test collection if needed, and verify reads and writes through the live upstream.
cmd/calendar-api/
cmd/calendar-api-mcp/
internal/api/
internal/availability/
internal/config/
internal/events/
internal/mcpserver/
internal/caldav/
internal/service/
runit/