CalCaddy
by foghorn
README.md
# CalCaddy
A tiny, stateless [MCP](https://modelcontextprotocol.io) server that gives AI
agents read-only access to your calendars through their iCal (`.ics`) feed
links.
Point it at any number of feeds (Google Calendar, Outlook/Microsoft 365,
iCloud, Fastmail, Proton, sports schedules, holiday lists, anything that
publishes `.ics`). Give each one an id and a short description, and your agent
can list them, read events, search them, and find free time. CalCaddy runs as a
single container whose only configuration is one YAML file.
- **One config file, nothing else.** No database, no volumes, no OAuth dance.
- **Ephemeral.** Feeds are prefetched and kept fresh in memory. The container
runs on a read-only filesystem and can be killed and replaced at any time.
- **Correct recurring events.** Expands RRULE/RDATE series and honors EXDATE
exceptions and moved instances (RECURRENCE-ID), all in your timezone.
- **Streamable HTTP + bearer token.** Works with any MCP client that can send an
`Authorization` header.
- **Feed URLs stay secret.** They never appear in tool output, errors, or logs.
## Tools
| Tool | What it does |
|---|---|
| `list_calendars` | Ids, descriptions, and fetch status of every configured calendar, plus the current date/time and timezone. The agent's starting point. |
| `get_events` | Every event in a time window (default: the next 7 days, up to a year), sorted, with recurring events expanded into occurrences. |
| `search_events` | Keyword search over title, description, and location (every word must match). Defaults to 30 days back through a year ahead. |
| `find_free_time` | Open slots within working hours (default 09:00–17:00, weekdays) that no busy event covers, across any set of calendars. Up to 62 days. |
Every tool takes optional `calendar_ids` (omit for all calendars). Time
arguments accept `2026-10-20`, `2026-10-20T14:00`, `2026-10-20T14:00-04:00`,
`now`, `today`, `tomorrow`, or `yesterday`. A bare date as an end bound covers
that whole day, so `start=2026-10-20, end=2026-10-20` means all of
October 20.
Every response includes `now` and `timezone`, so the agent always knows what
"today" means. A feed that fails to load is reported in an `errors` list while
the other calendars still answer. If an older cached copy exists, CalCaddy keeps
serving it and says so.
An example event:
```json
{
"calendar_id": "work",
"title": "Acme risk review",
"start": "2026-10-20T14:00:00-04:00",
"end": "2026-10-20T15:00:00-04:00",
"all_day": false,
"busy": true,
"location": "Zoom",
"description": "Quarterly risk assessment walkthrough.",
"organizer": "Jane Doe <jane@example.com>",
"uid": "abc123@google.com"
}
```
All-day events report dates rather than datetimes, and their `end` is the
**last day, inclusive**. That's different from raw iCalendar, where the end is
exclusive.
## Quick start
```bash
git clone https://github.com/foghorn/CalCaddy.git
cd CalCaddy
cp calcaddy.example.yaml calcaddy.yaml
openssl rand -hex 32 # paste this into auth_token
$EDITOR calcaddy.yaml # add your feeds
docker compose up -d --build
curl http://localhost:8000/health # {"status":"ok"}
```
The MCP endpoint is `http://<host>:8000/mcp`.
To check the config and test-fetch every feed without starting the server:
```bash
docker compose run --rm calcaddy check
```
```
Config OK: 3 calendar(s), timezone America/New_York
OK work: 42 event(s) in the next 30 days
OK personal: 9 event(s) in the next 30 days
FAIL us-holidays: feed returned HTTP 404
```
`check` exits with 0 when everything is fine, 1 when a feed failed, and 2 when
the config is invalid.
## Configuration
Everything lives in `calcaddy.yaml` (see
[`calcaddy.example.yaml`](calcaddy.example.yaml)):
```yaml
auth_token: "output of: openssl rand -hex 32"
timezone: America/New_York
cache_ttl_seconds: 600
calendars:
- id: work
description: "Work calendar: client meetings, calls, and deadlines"
url: "https://outlook.office365.com/owa/calendar/.../calendar.ics"
- id: personal
description: "Personal and family commitments"
url: "webcal://p01-caldav.icloud.com/published/2/..."
```
| Key | Default | Meaning |
|---|---|---|
| `auth_token` | *required* | Bearer token clients must send. At least 16 characters. The `CALCADDY_AUTH_TOKEN` env var overrides it. |
| `timezone` | `UTC` | IANA zone used for all output and for times given without an offset. |
| `cache_ttl_seconds` | `600` | How long a fetched feed is reused. `0` fetches on every call. |
| `fetch_timeout_seconds` | `20` | Per-feed HTTP timeout. |
| `max_feed_bytes` | `20000000` | Feeds larger than this are refused. |
| `max_description_chars` | `2000` | Longer event descriptions are truncated. |
| `host` / `port` / `path` | `0.0.0.0` / `8000` / `/mcp` | Listen address and endpoint inside the container. |
| `calendars[].id` | *required* | What the agent uses to pick a calendar: lowercase letters, digits, `-`, `_`. |
| `calendars[].description` | `""` | Tells the agent what the calendar is for. Write it for the agent: "Dentist, school, and family events" beats "Cal 2". |
| `calendars[].url` | *required* | `https://`, `http://`, or `webcal://` (fetched over https). |
The config is read once at startup. To change it, edit the file and recreate the
container (`docker compose up -d --force-recreate`).
### Finding your feed URL
- **Google Calendar:** Settings → *your calendar* → Integrate calendar →
*Secret address in iCal format*.
- **Outlook / Microsoft 365:** Settings → Calendar → Shared calendars → Publish
a calendar → pick "Can view all details" → copy the **ICS** link.
- **iCloud:** Calendar app → share icon next to the calendar → *Public
Calendar* → copy the `webcal://` link.
## Connecting a client
Any MCP client that supports streamable HTTP with custom headers works.
**Claude Code**
```bash
claude mcp add --transport http calcaddy http://<host>:8000/mcp \
--header "Authorization: Bearer <auth_token>"
```
**JSON-configured clients** (Claude Desktop via a remote connector, Cursor, and
others):
```json
{
"mcpServers": {
"calcaddy": {
"type": "http",
"url": "http://<host>:8000/mcp",
"headers": { "Authorization": "Bearer <auth_token>" }
}
}
}
```
## How it works
```
agent ──HTTP + Bearer──▶ auth middleware ──▶ MCP tools ──▶ in-memory feed cache ──▶ .ics URLs
(/health is open) (TTL, stale fallback)
```
1. `BearerAuthMiddleware` checks `Authorization: Bearer …` with a
constant-time compare. Only `GET /health` skips the check.
2. The MCP server runs **stateless** streamable HTTP. No sessions are kept, so
restarting the container never strands a client.
3. At startup a background task fetches every feed, then refreshes each one
before its copy is older than `cache_ttl_seconds`. Large calendars can take
several seconds to download, and this keeps that wait off tool calls. If a
copy is somehow stale when a tool needs it, `FeedCache` fetches it inline
(with a size cap and timeout), and concurrent calls share that one fetch. If
a fetch fails, the last good copy is served with a warning, and the next call
retries right away.
4. [`recurring-ical-events`](https://github.com/niccokunzmann/python-recurring-ical-events)
expands the data into concrete occurrences inside the requested window.
CalCaddy then converts times to your timezone, drops cancelled events, and
returns plain JSON.
### Busy vs. free
`busy` follows the event's `TRANSP` property when it has one ("Show as: Free"
in most apps sets `TRANSPARENT`). Without it, timed events count as busy and
all-day events as free. Most all-day entries are birthdays and holidays, and
vacation blocks are usually marked busy explicitly. `find_free_time` only
treats busy events as conflicts.
## Security notes
- **Treat `calcaddy.yaml` as a secret.** Most iCal links are bearer
credentials: anyone with the URL can read the calendar. The file is
gitignored and dockerignored. CalCaddy never echoes the URLs. It also turns
down `httpx`'s request logging, which would otherwise print them.
- **The agent picks ids, never URLs.** It can't make CalCaddy fetch arbitrary
addresses.
- **Event text is untrusted input.** Anyone who sends you an invite controls
its title and description. CalCaddy tells the agent (in the server
instructions) to treat that text as data. Still, think about what other tools
the same agent holds. An agent that can read your calendar and also send email
is an exfiltration path for a crafted invite.
- **Use TLS beyond your LAN.** CalCaddy speaks plain HTTP. Put it behind a
reverse proxy with TLS if the token will cross an untrusted network.
- The container runs as a non-root user on a read-only root filesystem, and the
provided compose file drops every capability.
## Development
```bash
python -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
pytest
ruff check .
CALCADDY_CONFIG=./calcaddy.yaml calcaddy serve # run locally without Docker
```
## License
MIT. See [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues