- Python 99.7%
- Dockerfile 0.3%
expunge_message behind MAIL_EXPUNGE_ENABLED, unsubscribe_message behind MAIL_UNSUBSCRIBE_ENABLED. A straight port of icloudmail.expunge_message would have reported destroying mail that still existed. That method flags \Deleted and expunges in place, which destroys the message on iCloud. Probe 11 ran the identical sequence here and it ARCHIVES: Gmail has a per-account setting for what an expunge from the last visible IMAP folder does, its default is Archive, and it is not readable over IMAP. Measured on this account -- expunge in a label removes the label; expunge in the last label archives into All Mail; expunge in All Mail does nothing at all; only an expunge in the Bin ends a message. So this one moves to the Bin and expunges there, unconditionally. Not as an optimisation for one account's setting -- the setting cannot be read, so the Bin route is the only implementation that means the same thing everywhere. On an account set to delete forever it costs one redundant command; here it is the difference between the tool working and the tool lying, inside the one tool whose entire description is a promise of irreversibility. It was already happening. Every Gmail probe's teardown expunged inside its scratch label and printed "removed N", and a sweep found 14 supposedly-destroyed fixtures in All Mail -- including from probe 8, the probe whose own finding is that a label expunge does not delete. probes/_gmail_cleanup.py is now the one correct implementation. unsubscribe_message is one-click only here: the mailto half sends mail and this tenant has no submission path. send_enabled is hard-coded False rather than read from config, so a deployment that turns MAIL_SEND_ENABLED on cannot get a gate approving a mailto this client cannot then send. It deliberately does NOT nest inside the write gate, unlike expunge -- it changes nothing in this mailbox, so tying it to the write allowlist would tie talking to strangers to filing mail. That non-nesting had a consequence worth the fix it forced: the server could register it while still announcing itself READ-ONLY. The surface test caught it, not review. READ-ONLY is kept for the case where it is true, which is also the default deployment. And the plan's reason for shipping unsubscribe after send turned out to be about a different mailbox. It rested on 24 of 40 iCloud senders offering only mailto. On Gmail: 200 INBOX messages sampled, 61 offering anything, 30 both, 27 one-click only, 4 mailto only -- 93% usable with no sending capability at all. The lesson is not that unsubscribe goes early but that a phase-ordering argument built on one account's header census is a fact about that account. Live pass: probes/g5_verify.py, 21 checks, all green, including that the message is gone from every label on the real server afterwards. Still owed is one real one-click unsubscribe, chosen deliberately -- succeeding at that tells a stranger the mailbox is live, which is not a thing to do for a test. Breaking: the Gmail connector's tool list changed, so reconnect it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| deploy | ||
| docs | ||
| mcp_gateway | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitattributes | ||
| .gitignore | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| Dockerfile | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| requirements-dev.txt | ||
| requirements.txt | ||
mcp-gateway
Self-hosted MCP servers on my own hardware, so Claude can read data that lives there — in Cowork and in scheduled tasks.
iPhone Calendar
→ iCloud (already syncing)
→ CalDAV
→ mcp-gateway (Docker Swarm, 3× Pi)
→ Nginx Proxy Manager https://icloudcalendar.mcp.jrendar.org/icloudcalendar
→ Claude
Tenant #1 is iCloud Calendar — reads every calendar it is given, writes only to the
ones named in WRITABLE_CALENDARS, which is empty by default. Tenant #2 is iCloud
Mail. Tenant #3 is Subscriptions.
Subscriptions is the odd one and the cheapest: read-only iCalendar URLs — a work
Google Calendar's "secret address in iCal format", a shared family calendar — and it
holds no credential of any kind. ICS_FEEDS already works on the calendar tenant, so
this exists purely for isolation: a work calendar is then read by a process with no Apple
password in it, behind its own ALLOWED_SUBJECTS and its own token, revocable without
touching the personal calendar. It registers no write tool under any configuration, and
tests/test_subscriptions.py asserts both properties
against the built server rather than against a setting.
Every tenant gets its own hostname, <tenant>.mcp.jrendar.org, rather than a path on
a shared one. That is not where this started: FastMCP binds every token it issues to
PUBLIC_BASE_URL + MCP_PATH, and a hostname has exactly one /token, so two
independently-connectable tenants cannot share one — the second is handed tokens minted
for the first and refused. CLAUDE.md has the mechanism. With a wildcard DNS record,
adding a tenant is a subpackage, a stack service, and an NPM proxy host that issues its
own certificate.
Status
Deployed, and the one-off read/write surface is verified against a real iCloud account. 829 tests pass. Reads, creates, edits and deletes have all been checked on a phone rather than by reading the tools' own responses back — including every all-day boundary case, reminders surviving an edit, and deletes across timed, all-day and multi-day events.
OAuth through Forgejo works end to end: the connector completes Dynamic Client
Registration and calls tools as joeyr. The tests only assert on the metadata documents,
so a regression in the flow would not be caught by the suite.
Recurrence (0.9.0) is covered by fakes only. Creating a series, and editing or
deleting this / following / all occurrences, have never run against the live
account. That is the outstanding live step, and it wants a scratch calendar — one of its
checks deliberately tries to take a whole series.
Four defects have been found against the live account so far, and every one of them reported success while being wrong:
- a calendar iCloud timed out was skipped silently, so a partial read came back as
count: 0and read as an empty diary; update_eventanddelete_eventfailed on every call, because caldav'sget_event_by_uidissues a UID-filtered REPORT and iCloud answers it with 412 (0.8.1);list_eventscould under-report, because expansion merged a whole calendar into one document and two resources sharing a UID collapsed into one occurrence (0.8.2);create_eventandlist_eventsdescribed the same all-day event two different ways (0.8.3) — found not by a failing check but by reading two passing responses side by side.
None of them were reachable from a fake at the time. That is the shape to keep looking for, and the reason the live checks are worth the trouble.
Auth is OAuth, via Forgejo. Phase 0 asked whether Claude custom connectors
can send a static request header on this org. They cannot — the dialog offers OAuth
credentials and nothing else — so the bearer token in auth.py cannot reach this server
from a connector. Forgejo is the identity provider; OAuthProxy supplies the Dynamic
Client Registration Claude requires and Forgejo lacks. AUTH_MODE=token keeps the
simple path for local development and for clients that can send a header.
The deployment is up — CI runner, stack, nginx site and connector all working. Recurrence was verified against the live account on 2026-08-10. What remains is a run of the attendee path through the tool layer, which costs a real invitation.
Tools
| Tool | Returns |
|---|---|
list_calendars() |
Names of readable calendars |
list_events(start, end, calendars?, query?) |
Concrete occurrences, recurrences already expanded |
list_free_time(start, end, duration_minutes, …) |
Gaps inside working hours |
create_event(calendar, title, start, …) |
The created event |
update_event(event_id, scope?, …) |
The event before and after |
delete_event(event_id, expected_title, scope?) |
The event that was removed |
list_attendees(event_id, expected_title) |
Who is invited, and how they replied |
update_attendees(event_id, expected_title, attendees) |
The event, and who was added |
create_calendar(name) |
The new, empty calendar |
update_calendar(name, new_name) |
The calendar before and after |
delete_calendar(name, expected_name, expected_event_count?) |
What was removed, and how much |
The three write tools are registered only when WRITABLE_CALENDARS is set, and the two
attendee tools only when INVITES_ENABLED joins it — attaching a guest means Apple
mails them, immediately and irreversibly, so it is a separate grant.
The three calendar tools need CALENDAR_ADMIN, which is a parallel grant rather than a
larger one: WRITABLE_CALENDARS is about the events inside a collection, CALENDAR_ADMIN
is about the collection itself, and neither implies the other.
Names are verb-first because the connector lists tools alphabetically, so the prefix is what groups them in front of the user.
Every response also carries now and timezone, so the model has an anchor instead of
substituting its own idea of the current date.
Events that can be changed carry an opaque event_id, and its absence still means
"not editable" — subscriptions and read-only calendars have none, because a handle that
is always refused is worse than no handle. Until 0.9.0 repeating events had none either;
now every occurrence carries one naming itself, and changing or deleting it requires a
scope of this, following or all. There is no default: "cancel Tuesday's standup"
means one occurrence to a person, and under a default of all it ends the standup.
WRITABLE_CALENDARS is empty by default, and empty means create_event is not
registered — absent from the tool list, not refusing when called. See CLAUDE.md for
why the allowlist is the entire boundary.
delete_calendar is the one irreversible tool here. It takes every event in the
collection, on every device, and CalDAV has no trash. Two guards stand in front of it: the
name must match, and so must the caller's belief about how many events are inside — which
the first call deliberately refuses in order to report, so a human sees the number before
anything is destroyed.
Development
python -m venv .venv
.venv/bin/pip install --require-hashes -r requirements-dev.txt
.venv/bin/pip install --no-deps -e .
cp .env.example .env # fill in; it's gitignored
.venv/bin/pytest
.venv/bin/ruff check .
.venv/bin/mypy
Those three are exactly what CI runs, so a green local gate means a green gate job.
Dependencies are locked. requirements.txt is what the image installs and
requirements-dev.txt is what the gate installs; both are compiled from
pyproject.toml with hashes, and the editable install is a second --no-deps
step because a local package has no hash to check. After changing a dependency,
regenerate both — the command is in pyproject.toml next to the ceilings. CI
runs pip check and goes red if a declared dependency is missing from the lock,
which is what forgetting that step looks like.
To actually start it:
.venv/bin/mcp-gateway # or: .venv/bin/python -m mcp_gateway
curl localhost:8787/healthz
Set AUTH_MODE=token locally. The default is oauth, which needs a reachable Forgejo
and a public base URL; token mode is kept for exactly this, since MCP Inspector, curl and
Claude Code can all send a header. .env.example ships that way. With
WRITABLE_CALENDARS empty — also the default — the write tools are not registered at all,
so a local run against the real account cannot change anything.
Locally, credentials come from .env. In production they are Portainer stack
environment variables, and .env is not consulted at all — there is none in the image.
config.py also still reads /run/secrets/<name> ahead of the environment, so a move
back to Docker secrets is a stack edit and no code change.
Deployment
See docs/operations.md. The short version: push to main and CI
builds the image, arm64-native on a Pi, tagged :latest, :X.Y.Z and :<sha>. Then
deploy the stack from Portainer. There is no manual docker build step.
The stack file is not in this repo. It lives with every other stack on the swarm, at
homelab/docker_swarm/compose/mcp-gateway.yaml, and is deployed by pasting into
Portainer's web editor. See deploy/README.md for why. It tracks
:latest, so a release needs no edit there — but the redeploy must tick re-pull
image, or Swarm reuses the digest it pinned last time and changes nothing.
The nginx site configs are version-controlled here, one per tenant at deploy/nginx/, even though they're pasted into NPM's UI — they're the most fragile artifact in the deployment and the UI keeps no history.
License
MIT