jcabillot 0a61f4b7f8
CI Shared Jobs / hadolint (push) Successful in 27s
Renovate / renovate (push) Successful in 44s
CI Shared Jobs / build-test (push) Successful in 5m9s
CI Shared Jobs / pytest (push) Successful in 4m36s
Main Release / ci (push) Successful in 5m50s
Main Release / build (push) Waiting to run
Merge pull request 'ci(pipelines): bump perso/gitea-pipelines vers v1.1.2 (v1.1.1 avait un motif cassé)' (#44) from ci/pipelines-v1.1.2 into main
Reviewed-on: #44
2026-10-21 14:16:06 -04:00
2026-03-15 19:35:49 -04:00
2026-03-15 19:35:49 -04:00

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 .ics files from a mounted data directory, never writes, never syncs.
  • 🧠 Recurring event expansion: RRULE (daily/weekly/monthly/…) handled by recurring-ical-events, so a weekly stand-up counts once per occurrence inside the window, not once.
  • 🗓️ Events and tasks, kept apart: get_agenda returns VEVENT (start/end) expanded over a window; get_tasks lists VTODO as stored (start/due, both null when the task carries no date), completed ones filtered out by default.
  • 👥 Multi-user / multi-calendar: aggregates every VCALENDAR collection found under the Radicale collection-root. CALDAV_USERS restricts 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 via include_birthdays=true).
  • 🚀 MCP Standard: two tools (get_agenda, get_tasks), instantly usable by any MCP client (OpenCode, Claude, …).

🏗️ Architecture

  1. Source: a Radicale data directory (collections/collection-root/<user>/<collection>/*.ics).
  2. Discovery: every collection holding a .Radicale.props with "tag": "VCALENDAR" is selected. Address books (VADDRESSBOOK), dot-prefixed folders (.trash, .stfolder, …) and broken props are skipped.
  3. Parsing: each .ics is parsed with icalendar; unparseable files are logged and ignored rather than failing the request.
  4. Expansion: VEVENT components are expanded in range with recurring-ical-events. VTODO components are not expanded: a task is not an occurrence, and expanding one is what pinned undated tasks to a fabricated 1970-01-01 → 2038-01-01 window. Both are then filtered and formatted.
  5. 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

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 on GET /health (a startup_probe with failure_threshold: 24 covers slow first reads);
  • a hostPath/PVC volume mounted read-only at CALDAV_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 .ics scan adds tens of megabytes on top of it. Give the container at least 256 Mi of memory. A 128Mi limit makes the kernel OOMKill the pod (exit code 137) in the middle of a get_agenda call — 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, set CALDAV_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_tasks returns each VTODO exactly once, the way it is stored. A recurring task therefore shows a single line, not its occurrences.
  • Full rescan per call: each get_agenda and get_tasks call re-reads and re-parses the store; there is no index or cache. Fine for thousands of files, not for hundreds of thousands.
S
Description
MCP server exposing a read-only CalDAV/Radicale agenda tool to AI agents
Readme
220 KiB
0 Stars 4 Watchers 0 Forks
v0.0.17
Latest
2026-10-21 09:04:50 -04:00
Languages
Python 98.8%
Dockerfile 1.2%