- Rust 100%
README: expand tool reference with field details, add typical workflow section, document status/priority values and array types for contacts. ARCHITECTURE.md: new file covering crate layout, request lifecycle, key design decisions in each module (client-per-call pattern, ical text escaping, raw-string update strategy, UID substring matching), auth model, and a guide for adding new tools. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|---|---|---|
| src | ||
| tests | ||
| .gitignore | ||
| ARCHITECTURE.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| config.example.toml | ||
| README.md | ||
| RESEARCH.md | ||
mcp_webcal
A self-hosted MCP server that gives Claude access to calendars, tasks, and contacts on a CalDAV/CardDAV server (tested with Radicale).
Works with both Claude Code (streamable HTTP) and the claude.ai web/mobile app (remote MCP connector).
Features
- CalDAV: list, create, update, and delete calendar events (VEVENT) and tasks (VTODO)
- CardDAV: list, create, update, and delete contacts (vCard 4.0)
- Correct timezone handling — events and tasks are stored and displayed in the configured IANA timezone
- Bearer token authentication with
--no-authescape hatch for local use
Build
cargo build --release
# binary: target/release/mcp_webcal
Configuration
Copy config.example.toml to config.toml:
[server]
host = "127.0.0.1"
port = 8000
# Bearer token required in Authorization header. Omit to allow unauthenticated access.
# auth_token = "change-me-to-a-random-secret"
[caldav]
url = "https://radicale.example.com/"
username = "alice"
password = "secret"
# IANA timezone name — all event/task times are stored and returned in this zone.
timezone = "Europe/Berlin"
The [caldav] section is used for both CalDAV and CardDAV; Radicale serves both from the same URL.
Running
mcp_webcal # reads config.toml in the current directory
mcp_webcal /path/to/config.toml # explicit config path
mcp_webcal --no-auth # disable bearer token check (for local use)
The server listens on http://<host>:<port>/mcp.
Authentication
When auth_token is set, every HTTP request must carry:
Authorization: Bearer <your-token>
Use --no-auth to skip the check entirely — useful for local testing or when a reverse proxy (e.g. Caddy, nginx, Cloudflare Tunnel) handles authentication upstream.
Connecting Claude
Claude Code
Add to .claude/settings.json (project) or ~/.claude.json (global):
{
"mcpServers": {
"webcal": {
"type": "http",
"url": "http://127.0.0.1:8000/mcp",
"headers": {
"Authorization": "Bearer <your-token>"
}
}
}
}
claude.ai web / mobile
Go to Settings → Integrations → Add custom integration and enter:
- URL:
https://cal.example.com/mcp - Authentication: Bearer token →
<your-token>
A Cloudflare Tunnel or similar is required to expose the server publicly over HTTPS.
Typical workflow
The server follows a discovery-first pattern. Hrefs returned by the discovery tools are the stable identifiers used by all other tools.
Events and tasks
- Call
list_calendars→ get a list of{ href, display_name }objects. - Use an
hrefwithlist_eventsorlist_tasks. - Use the
uidfrom a listed item to update or delete it.
Contacts
- Call
list_address_books→ get a list of{ href, display_name }objects. - Use an
hrefwithlist_contacts. - Use the
uidfrom a listed contact to update or delete it.
Tool reference
CalDAV — calendars
| Tool | Inputs | Returns |
|---|---|---|
list_calendars |
— | [{ href, display_name }] |
CalDAV — events
Times use YYYY-MM-DDTHH:MM:SS (local time in the configured timezone) or YYYY-MM-DD for all-day events.
| Tool | Key inputs | Returns / notes |
|---|---|---|
list_events |
calendar_href |
[{ uid, summary, start, end, timezone, all_day, description, location }] |
create_event |
calendar_href, summary, start, end, description?, location? |
uid of created event |
update_event |
calendar_href, uid, summary |
Updates the event title |
delete_event |
calendar_href, uid |
— |
CalDAV — tasks
Due dates use the same format as event times.
Valid status values: NEEDS-ACTION, IN-PROCESS, COMPLETED, CANCELLED.
priority is an integer from 1 (highest) to 9 (lowest).
| Tool | Key inputs | Returns / notes |
|---|---|---|
list_tasks |
calendar_href |
[{ uid, summary, due, status, priority, description, timezone }] |
create_task |
calendar_href, summary, due?, description?, priority? |
uid of created task |
update_task |
calendar_href, uid, status |
Updates task status |
delete_task |
calendar_href, uid |
— |
CardDAV — address books
| Tool | Inputs | Returns |
|---|---|---|
list_address_books |
— | [{ href, display_name }] |
CardDAV — contacts
| Tool | Key inputs | Returns / notes |
|---|---|---|
list_contacts |
address_book_href |
[{ uid, full_name, phones, emails, org, note }] |
create_contact |
address_book_href, full_name, phones?, emails?, org?, note? |
uid of created contact |
update_contact |
address_book_href, uid, full_name |
Updates the display name (FN) |
delete_contact |
address_book_href, uid |
— |
phones and emails are arrays of strings (a contact can have multiple of each).
Integration Tests
The integration tests require rootless podman. Each test spins up an isolated Radicale container automatically.
cargo test --test integration
Tests cover events (CRUD, all-day, timezone round-trip), tasks (CRUD, minimal, datetime due, all status transitions, priority values, multiple tasks), and contacts (CRUD, multiple phones/emails).