caldav-todo-mcp
by dansimau
README.md
# caldav-todo-mcp
An [MCP](https://modelcontextprotocol.io) server that lets an AI assistant read and
write TODO items over **CalDAV**. Your tasks live on your own server (Nextcloud Tasks,
Radicale, iCloud Reminders, Fastmail, Baïkal, …) and every other CalDAV client you use
sees the same data.
Lists are CalDAV collections; items are RFC 5545 `VTODO` components. The server
exposes tools to list, create, update, complete, move and delete items, plus a
`sync_todos` tool that pushes a whole batch to the remote server in one call.
It speaks both MCP transports: **stdio** for local clients that spawn it, and
**streamable HTTP** for running it as a service, e.g. in Docker.
## Requirements
- Python 3.11+
- A CalDAV account (URL, username, password or app-specific password)
## Install
```bash
# with uv (recommended)
uv tool install caldav-todo-mcp # once published; or from a checkout:
uv tool install /path/to/caldav-todo-mcp
# or with pip
pip install /path/to/caldav-todo-mcp
```
For development:
```bash
uv venv && uv pip install -e ".[dev]"
uv run pytest
```
## Configuration
The server reads its settings from environment variables (see `.env.example`):
| Variable | Required | Meaning |
|---|---|---|
| `CALDAV_URL` | yes | Base URL of the CalDAV server, e.g. `https://cloud.example.com/remote.php/dav/` |
| `CALDAV_USERNAME` | usually | Login name |
| `CALDAV_PASSWORD` | usually | Password or app-specific password |
| `CALDAV_DEFAULT_LIST` | no | List id or name used when a tool call omits `list_id` |
| `CALDAV_SSL_VERIFY` | no | `false` to accept self-signed certificates (default `true`) |
| `CALDAV_ALLOW_INSECURE_HTTP` | no | `true` to allow plain `http://` URLs (local servers only) |
| `CALDAV_TIMEOUT` | no | HTTP timeout in seconds (default `30`) |
Typical URLs:
| Provider | `CALDAV_URL` |
|---|---|
| Nextcloud | `https://HOST/remote.php/dav/` |
| Radicale | `https://HOST/USERNAME/` |
| iCloud | `https://caldav.icloud.com/` (use an app-specific password) |
| Fastmail | `https://caldav.fastmail.com/dav/` |
| Baïkal | `https://HOST/dav.php/` |
## Running in Docker (HTTP transport)
The image serves MCP over streamable HTTP on port 8000 at `/mcp`, and a liveness
probe at `/health`. CalDAV credentials are passed as environment variables; nothing is
baked into the image.
```bash
cp .env.example .env # fill in CALDAV_URL / CALDAV_USERNAME / CALDAV_PASSWORD
docker compose up -d --build
curl http://127.0.0.1:8000/health
```
Or without compose:
```bash
docker build -t caldav-todo-mcp .
docker run -d --name caldav-todo-mcp -p 127.0.0.1:8000:8000 \
-e CALDAV_URL=https://cloud.example.com/remote.php/dav/ \
-e CALDAV_USERNAME=alice \
-e CALDAV_PASSWORD=app-password \
-e CALDAV_DEFAULT_LIST=Tasks \
-e MCP_AUTH_TOKEN=change-me \
caldav-todo-mcp
```
Then point your client at `http://127.0.0.1:8000/mcp`:
```bash
claude mcp add --transport http caldav-todo http://127.0.0.1:8000/mcp \
--header "Authorization: Bearer change-me"
```
Server-side settings (all optional):
| Variable | Default | Meaning |
|---|---|---|
| `MCP_TRANSPORT` | `stdio` (image: `http`) | `stdio` or `http` |
| `MCP_HOST` | `127.0.0.1` (image: `0.0.0.0`) | Bind address for HTTP |
| `MCP_PORT` | `8000` | HTTP port |
| `MCP_PATH` | `/mcp` | HTTP endpoint path |
| `MCP_AUTH_TOKEN` | unset | When set, `/mcp` requires `Authorization: Bearer <token>`; `/health` stays open |
| `MCP_ALLOWED_HOSTS` | unset | Comma-separated `Host` allow-list (e.g. `localhost:*,todo.lan`); turns on DNS-rebinding protection |
| `MCP_STATELESS` | `false` | Stateless streamable HTTP mode (no server-side sessions) |
| `MCP_JSON_RESPONSE` | `false` | Plain JSON responses instead of SSE |
| `MCP_LOG_LEVEL` | `INFO` | Log level |
The same options are available as CLI flags (`caldav-todo-mcp --transport http --port 8000`),
which take precedence over the environment. The compose file binds the port to
`127.0.0.1` only; if you expose it more widely, set `MCP_AUTH_TOKEN`, because whoever
can reach the endpoint can read and write your tasks.
## Using it with a local (stdio) MCP client
### Claude Code
```bash
claude mcp add caldav-todo \
-e CALDAV_URL=https://cloud.example.com/remote.php/dav/ \
-e CALDAV_USERNAME=alice \
-e CALDAV_PASSWORD=app-password \
-e CALDAV_DEFAULT_LIST=Tasks \
-- caldav-todo-mcp
```
### Claude Desktop / other stdio clients
```json
{
"mcpServers": {
"caldav-todo": {
"command": "caldav-todo-mcp",
"env": {
"CALDAV_URL": "https://cloud.example.com/remote.php/dav/",
"CALDAV_USERNAME": "alice",
"CALDAV_PASSWORD": "app-password",
"CALDAV_DEFAULT_LIST": "Tasks"
}
}
}
}
```
You can also run it directly with `python -m caldav_todo_mcp`, or serve HTTP outside
Docker with `caldav-todo-mcp --transport http`.
## Tools
| Tool | What it does |
|---|---|
| `list_todo_lists` | List the TODO-capable collections on the server |
| `create_todo_list` | Create a new list |
| `delete_todo_list` | Delete a list and everything in it |
| `list_todos` | List items (open first, by due date then priority); filters: `include_completed`, `due_before`, `category` |
| `get_todo` | Fetch one item by UID |
| `create_todo` | Create an item: `summary`, `description`, `due`, `start`, `priority`, `categories`, `status` |
| `update_todo` | Change any of those fields; `clear_due` / `clear_start` / `clear_description` remove a value |
| `complete_todo` / `reopen_todo` | Toggle completion (sets `COMPLETED` and `PERCENT-COMPLETE`) |
| `delete_todo` | Delete an item |
| `move_todo` | Move an item to another list |
| `sync_todos` | Upsert a batch of items by UID; `delete_missing=true` makes the server mirror the batch |
| `export_todos` | Export a list as an `.ics` document |
| `import_todos` | Import VTODOs from `.ics` text, replacing items with the same UID |
Every tool takes an optional `list_id`, which may be the collection id, its display
name (case-insensitive) or its URL. When omitted, `CALDAV_DEFAULT_LIST` is used; if
that is unset and the server holds exactly one TODO list, that list is used.
Dates are ISO 8601 strings: `2026-11-10` for an all-day due date,
`2026-11-10T17:00:00+01:00` for a specific time. Priority follows RFC 5545
(1 highest … 9 lowest).
## Syncing a local list to the server
`sync_todos` is designed for "push my list to the server" workflows. Give each local
item a stable `uid` and send the whole list:
```json
{
"list_id": "Tasks",
"delete_missing": true,
"items": [
{"uid": "local-1", "summary": "Renew passport", "due": "2026-12-01", "priority": 2},
{"uid": "local-2", "summary": "Call the bank", "status": "COMPLETED"}
]
}
```
The response reports which UIDs were `created`, `updated`, `unchanged` and
`deleted`. Without `delete_missing` the call never removes anything. To pull the
server state instead, use `list_todos` (structured) or `export_todos` (iCalendar).
## Library use
The CalDAV layer is usable without MCP:
```python
from caldav_todo_mcp import Settings, TodoStore
from caldav_todo_mcp.models import TodoInput
store = TodoStore(
Settings(
url="https://cloud.example.com/remote.php/dav/", username="alice", password="app-password"
)
)
for lst in store.list_lists():
print(lst.id, lst.name)
store.create_todo(TodoInput(summary="Water the plants", due="2026-11-10"), "Tasks")
```
## Tests
The test suite starts a throwaway [Radicale](https://radicale.org) server and runs
the full create/update/complete/sync/import flow against it: in-process, over a real
stdio MCP connection, and over streamable HTTP (including the bearer-token check):
```bash
uv run pytest
```
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues