Skip to main content
Glama
README.md
# caldav-mcp

[English](README.md) | [简体中文](README.zh-CN.md)

A self-hosted, read-only MCP server for Lark / Feishu calendars. Ask an MCP client to list meetings or retrieve event details without passing large iCalendar payloads into the conversation.

The server exposes two tools over Streamable HTTP, checks calendar freshness on every request, and supports Docker deployment behind Cloudflare Tunnel. It has been tested against Lark CalDAV; compatibility with other CalDAV providers is not guaranteed. This is an independent project, not an official Lark or Feishu product.

## Features

- Compact event lists with organizer, attendee count, meeting link, and your RSVP when identifiable.
- Full descriptions, attendees, and recurrence rules available through a separate detail tool.
- Local expansion of recurring events, exceptions, rescheduled instances, all-day events, and time zones.
- In-memory caches validated by ctag, with explicitly marked stale responses during upstream outages.
- Mandatory access-key validation through a Bearer header or a URL key for clients without custom headers.

## Requirements

- Python 3.12 or later and [uv](https://docs.astral.sh/uv/).
- A Lark / Feishu CalDAV username and dedicated CalDAV password.
- Docker Compose and a Cloudflare Tunnel token for the container deployment below.

The service selects one calendar. Dates and output timestamps use `Asia/Shanghai`. It does not create, edit, or delete calendar events.

## Quick start

```sh
gh repo clone jokerlin/caldav-mcp
cd caldav-mcp
cp .env.example .env
uv sync --frozen --python 3.12
openssl rand -hex 32
```

Edit `.env`. Set `CALDAV_USERNAME`, `CALDAV_PASSWORD`, and `MCP_BEARER_TOKEN` (use the generated value). Keep these values in the local file.

Start a local server:

```sh
uv run uvicorn caldav_mcp.server:create_app --factory --host 127.0.0.1 --port 8080 --no-access-log
```

The endpoint is `http://127.0.0.1:8080/mcp`. The `caldav-mcp` console command binds to `0.0.0.0` for container networking; the command above explicitly binds to loopback.

## Configuration

| Variable | Required | Default / meaning |
|---|---|---|
| `CALDAV_URL` | No | `https://caldav.larkoffice.com` |
| `CALDAV_USERNAME` | Yes | CalDAV account username |
| `CALDAV_PASSWORD` | Yes | Dedicated CalDAV password |
| `MCP_BEARER_TOKEN` | Yes | Shared access key generated with `openssl rand -hex 32` |
| `CALDAV_CALENDAR_ID` | No | Empty: discover the account's single calendar |
| `SELF_CN` | No | Empty: use the calendar display name for RSVP identification |
| `MCP_PORT` | No | `8080` for the console command and Compose |
| `CLOUDFLARE_TUNNEL_TOKEN` | For Compose | Credential for the dedicated tunnel |
| `MCP_URL` | For verification scripts | Set explicitly to the local or public `/mcp` endpoint |

The Cloudflare management fields in `.env.example` are optional deployment bookkeeping. The running MCP container does not receive the Cloudflare API token.

## Deploy with Docker and Cloudflare Tunnel

1. Create a dedicated Cloudflare Tunnel and put its token in `CLOUDFLARE_TUNNEL_TOKEN`.
2. Route a hostname such as `calendar.example.com` to `http://mcp:8080`. If you change `MCP_PORT`, update the tunnel's origin port as well.
3. Start the containers:

```sh
docker compose up -d --build
docker compose ps
```

Compose exposes the host port only on `127.0.0.1`. The tunnel reaches `mcp` over the Docker network. Both containers restart unless explicitly stopped. The host and Docker must stay running; caches are cleared when the service restarts.

See [deployment notes](docs/public-deployment.md) for authentication and verification details. All domains and identities in public examples are placeholders, not a shared calendar service.

## Connect an MCP client

For clients with custom headers, configure:

```text
URL: https://calendar.example.com/mcp
Authorization: Bearer <MCP_BEARER_TOKEN>
```

For clients without a Bearer field, including the ChatGPT connection flow tested with this project, choose **None / No Authentication** in the client and use:

```text
https://calendar.example.com/mcp?key=<MCP_BEARER_TOKEN>
```

The server still validates the URL key. Missing or invalid keys return HTTP 401. An explicit Authorization header takes precedence. Treat a URL containing the key as a credential: do not share it or include it in screenshots, issues, or logs. The service does not implement OAuth, and Cloudflare credentials are not MCP access keys.

After connecting, enable the plugin in a conversation and ask it to list your meetings, then retrieve one meeting's details.

## Tools

| Tool | Arguments | Result |
|---|---|---|
| `list_events` | `start`, `end`, optional `keyword`, optional `include_cancelled` | Sorted, expanded event instances without attendee lists or descriptions |
| `get_event` | `instance_id` from a list result | Event fields plus full description, attendees, RRULE, creation and modification times |

Example tool arguments:

```json
{"start":"2026-09-14","end":"2026-09-21","keyword":"review"}
```

`start` and `end` are dates in `YYYY-MM-DD` format. The range is `[start, end)` and must span 1 to 90 days. Keywords match summary or location, ignoring case. Cancelled events are excluded by default.

An instance ID combines the UID and its actual start time, for example `example-event@2026-09-15T11:00:00+08:00`. Detail lookup works with a cold cache and reports a missing instance if that start time no longer exists.

For all-day events, the displayed `end` is midnight on the final included day, converted from iCalendar's exclusive end date. Floating times are interpreted in `Asia/Shanghai`. `my_response` is `null` when the account's attendee cannot be identified reliably.

Every request checks ctag before reusing cached results. Upstream outages can return available cached data with `stale: true`; authentication failures remain errors. Invalid event components are counted in `skipped`.

## Development and verification

```sh
uv sync --frozen
uv run pytest -q
uv run ruff check --config pyproject.toml src tests scripts
uv run ruff format --config pyproject.toml --check src tests scripts
```

Synthetic tests run without credentials. The optional private-fixture test is skipped in a fresh clone. To capture your own fixtures, configure `.env` and run:

```sh
uv run python scripts/capture_fixtures.py 2026-09-08 2026-09-15
```

Captures and identity metadata stay in the ignored `tests/fixtures/` directory. Do not attach real captures to bug reports.

With the server running:

```sh
MCP_URL=http://127.0.0.1:8080/mcp scripts/smoke.sh
MCP_URL=https://calendar.example.com/mcp scripts/smoke.sh --auth query
MCP_URL=https://calendar.example.com/mcp uv run python scripts/benchmark.py --samples 20
```

The benchmark checks an unchanged ctag and measures local and public requests separately. Warm-cache targets are median ≤300 ms / P95 ≤500 ms locally and median ≤1 s / P95 ≤2 s publicly, under normal network conditions. These exclude model reasoning time. See the [measured performance report](docs/performance-2026-09-13.md) for the sample size and limitations.

## Contributing

Bug reports and pull requests are welcome. Use [GitHub Issues](https://github.com/jokerlin/caldav-mcp/issues) with reproducible steps and synthetic calendar data. Run the checks above before submitting a change. Never include account credentials, access-key URLs, colleague names, or private calendar content.

The [design document](docs/specs/2026-09-12-caldav-mcp-design.md) describes the protocol constraints and module boundaries.

## License

[MIT](LICENSE), copyright 2026 jokerlin.