Skip to main content
Glama
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