Reviewed-on: #42
mcp-ics
mcp-ics is an MCP (Model Context Protocol) server exposing read-only agenda and task tools to AI agents, built on top of a self-hosted Radicale CalDAV server.
Instead of speaking CalDAV over HTTP, it reads the .ics files directly from the Radicale data directory: VEVENT components are parsed and their recurrences (RRULE) expanded over a requested date range, while VTODO components are listed as they are stored.
That design makes it 100% offline and strict read-only: no CalDAV credentials, no network call, no write path. The worst an agent can do is read its own agenda.
✨ Features
- 🔒 Strict Read-Only: reads
.icsfiles from a mounted data directory, never writes, never syncs. - 🧠 Recurring event expansion:
RRULE(daily/weekly/monthly/…) handled byrecurring-ical-events, so a weekly stand-up counts once per occurrence inside the window, not once. - 🗓️ Events and tasks, kept apart:
get_agendareturnsVEVENT(start/end) expanded over a window;get_taskslistsVTODOas stored (start/due, bothnullwhen the task carries no date), completed ones filtered out by default. - 👥 Multi-user / multi-calendar: aggregates every
VCALENDARcollection found under the Radicalecollection-root.CALDAV_USERSrestricts discovery to the listed users, so a shared Radicale instance does not leak the calendars of other accounts. - 🔇 Noise filtering: regex-based exclusion by summary (
EXCLUDE_PATTERN) and birthdays/anniversaries hidden by default (BIRTHDAY_PATTERN, opt-in viainclude_birthdays=true). - 🚀 MCP Standard: two tools (
get_agenda,get_tasks), instantly usable by any MCP client (OpenCode, Claude, …).
🏗️ Architecture
- Source: a Radicale data directory (
collections/collection-root/<user>/<collection>/*.ics). - Discovery: every collection holding a
.Radicale.propswith"tag": "VCALENDAR"is selected. Address books (VADDRESSBOOK), dot-prefixed folders (.trash,.stfolder, …) and broken props are skipped. - Parsing: each
.icsis parsed withicalendar; unparseable files are logged and ignored rather than failing the request. - Expansion:
VEVENTcomponents are expanded in range withrecurring-ical-events.VTODOcomponents are not expanded: a task is not an occurrence, and expanding one is what pinned undated tasks to a fabricated1970-01-01 → 2038-01-01window. Both are then filtered and formatted. - Transport: the FastMCP server is served over Streamable HTTP (modern MCP transport). Legacy SSE is not served at all — a client only speaking SSE gets a
405.
Radicale data dir ──► discover_calendars() ──► iter_ics_files() ──► format_component()
│
get_agenda() ◄── sort by start ◄── recurring_ical_events (VEVENT)
get_tasks() ◄── sort by date desc ◄── (VTODO, as stored)
undated last
🔌 Endpoints
| Path | Method | Purpose |
|---|---|---|
/mcp |
POST/GET | MCP JSON-RPC endpoint (Streamable HTTP) |
/health |
GET | Liveness/readiness probe — always {"status": "ok"} |
Default bind: 0.0.0.0:8000.
⚙️ Configuration
All configuration is environment based (a .env file is loaded automatically if present).
| Variable | Required | Default | Description |
|---|---|---|---|
CALDAV_DATA_PATH |
yes | — | Root of the Radicale data directory (must contain collections/collection-root/). Server refuses to start without it. |
CALDAV_USERS |
no | (empty) | Comma-separated allowlist of Radicale users, matched case-insensitively against the directory name under collection-root. Empty means every user of the store is read. Example: jcabillot, carole. |
EXCLUDE_PATTERN |
no | (empty) | Regex matched case-insensitively against the summary of every component, events and tasks alike; matching components are dropped. Example: ^Poubelle hides trash-day reminders. |
BIRTHDAY_PATTERN |
no | ^Anniversaire|^Anniv |
Regex identifying birthdays/anniversaries. Matching components are hidden unless include_birthdays=true. |
MCP_ICS_VALIDATE_ON_IMPORT |
no | (unset) | 1 forces CALDAV_DATA_PATH validation at import time (used by the test suite). |
An invalid EXCLUDE_PATTERN / BIRTHDAY_PATTERN is logged and simply disables that filter — it never crashes the server. Both patterns are matched against the summary of every component, tasks included. CALDAV_USERS deliberately behaves the other way: a non-empty value listing no usable name fails closed (no calendar is read at all) instead of silently exposing every user, and an entry matching no user is logged as a warning.
🤖 Usage with an MCP Client
Tool: get_agenda
get_agenda(start_date: str, end_date: str = "", include_birthdays: bool = False)
| Argument | Description |
|---|---|
start_date |
Start of the window, ISO 8601 (2026-03-15 or 2026-03-15T09:00:00). |
end_date |
End of the window, ISO 8601. Defaults to 7 days after start_date. The range is inclusive of the start and exclusive of the end, as expanded by the recurrence engine. |
include_birthdays |
Include events matching BIRTHDAY_PATTERN (default false). |
Response:
{
"start_date": "2026-03-15T00:00:00",
"end_date": "2026-03-22T00:00:00",
"count": 1,
"items": [
{
"type": "event",
"summary": "Réunion équipe",
"start": "2026-03-15T09:00:00",
"end": "2026-03-15T10:00:00",
"calendar": "Perso",
"uid": "evt-1@example",
"location": "Bureau",
"description": "Point hebdo"
}
]
}
location and description are omitted when empty. end only exists on events, due/status only on tasks. On failure the tool returns {"error": "get_agenda failed: …"} instead of raising. Tasks are not part of the agenda: expanding a VTODO over a window is what forged dates for the ones that carry none, so they are served by get_tasks instead.
Tool: get_tasks
get_tasks(status: str = "open")
| Argument | Description |
|---|---|
status |
"open" (default) returns every task that is neither COMPLETED nor CANCELLED; "all" returns every task of the store. Any other value comes back as an error. |
Response:
{
"status": "open",
"count": 1,
"items": [
{
"type": "todo",
"summary": "Ranger le garage",
"start": null,
"due": "2026-03-20T17:00:00",
"status": "NEEDS-ACTION",
"calendar": "Tâches",
"uid": "todo-3@example"
}
]
}
No date range is involved: a task is returned once, with its own DTSTART/DUE — or with both set to null when it carries no date at all. Dated tasks come first, most recent first; undated ones last. A task carrying a recurrence rule is returned once, as stored: occurrences of tasks are not expanded.
Client configuration
Example for an opencode.jsonc-style client:
{
"mcp": {
"caldav": {
"type": "remote",
"url": "http://mcp-ics:8000/mcp"
}
}
}
Smoke test without a client:
curl -sS -D- http://localhost:8000/health # {"status":"ok"}
curl -sS -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
🚀 Installation & Setup
With Docker Compose (recommended)
git clone https://scm.cabillot.eu/perso/mcp-ics.git
cd mcp-ics
cp .env.example .env
# point CALDAV_DATA_PATH at your Radicale data directory
docker compose up -d --build
The compose file mounts the source (./src) and your Radicale data (read-only) and publishes 127.0.0.1:8000.
With a local virtualenv
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
CALDAV_DATA_PATH=/path/to/radicale/data python src/server.py
☸️ Kubernetes Deployment
The server is designed to be deployed next to Radicale, with its data directory mounted read-only:
- container port
8000, probes onGET /health(astartup_probewithfailure_threshold: 24covers slow first reads); - a
hostPath/PVC volume mounted read-only atCALDAV_DATA_PATH(e.g./data) — the container needs no CalDAV credentials at all; - auto-update friendly: the image is published to Docker Hub as
jcabillot/mcp-ics, and the deployment can be annotated for Keel (keel.sh/policy=force,keel.sh/trigger=poll,keel.sh/match-tag=true).
Resource sizing matters. The Python + FastMCP + icalendar stack alone sits at ~90 MB RSS before doing any work, and a full
.icsscan adds tens of megabytes on top of it. Give the container at least 256 Mi of memory. A128Milimit makes the kernel OOMKill the pod (exit code 137) in the middle of aget_agendacall — the HTTP response stream is then severed, the MCP client sees a broken transport and the tool call never returns.
🧪 Tests & CI
pip install -r requirements.txt
pytest
The suite covers discovery, parsing, filters, RRULE expansion, the get_agenda and get_tasks contracts (including the absence of synthetic dates on undated tasks) and the HTTP surface (/mcp exposed, /sse absent, /health → 200).
CI runs on Gitea Actions:
| Workflow | Trigger | Purpose |
|---|---|---|
_ci-common.yaml |
workflow_call |
Shared jobs: hadolint on pkg/Dockerfile + build & test the image |
pr.yaml |
pull request → main |
Blocking PR validation |
main.yaml |
push → main |
Validation, build, then patch version bump and tag push |
tag.yaml |
tag push | Publish vX.Y.Z and vX.Y.Z-latest to Docker Hub |
cron.yaml |
nightly 0 0 * * * |
Rebuild and republish the latest <tag>-latest from main HEAD |
renovate.yaml |
push → main |
Dependency updates via the shared perso/gitea-pipelines workflow |
📝 Design Notes & Limits
- No timezone normalisation: datetimes are returned exactly as stored in the
.ics(floating/naive stays naive). No conversion to the caller's timezone. - No CalDAV protocol: Radicale ACLs, shared-collection permissions and HTTP authentication are bypassed — the tool reads whatever the mounted directory contains, except the users excluded by
CALDAV_USERS. Mount it read-only, setCALDAV_USERS, and restrict who can reach the MCP port. - Birthday detection is a heuristic: it relies on the summary matching
BIRTHDAY_PATTERN, not on a dedicated property. - Tasks are never expanded:
get_tasksreturns eachVTODOexactly once, the way it is stored. A recurring task therefore shows a single line, not its occurrences. - Full rescan per call: each
get_agendaandget_taskscall re-reads and re-parses the store; there is no index or cache. Fine for thousands of files, not for hundreds of thousands.