Self-hosted MCP servers behind mcp.jrendar.org; tenant #1 is iCloud Calendar (read-only)
  • Python 99.7%
  • Dockerfile 0.3%
Find a file
Joey Davis 35e035fea3
All checks were successful
CI / gate (push) Successful in 1m48s
CI / image (push) Successful in 19s
Give Gmail the irreversible surface, and stop an expunge from lying
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>
2026-08-22 22:07:02 -05:00
.forgejo/workflows Release 0.20.2: hold the gate to the rules the gate enforces 2026-08-13 21:31:54 -05:00
deploy G1: a fourth tenant, reading Gmail 2026-08-21 21:29:11 -05:00
docs Give Gmail the irreversible surface, and stop an expunge from lying 2026-08-22 22:07:02 -05:00
mcp_gateway Give Gmail the irreversible surface, and stop an expunge from lying 2026-08-22 22:07:02 -05:00
tests Give Gmail the irreversible surface, and stop an expunge from lying 2026-08-22 22:07:02 -05:00
.dockerignore Treat a feed URL as a secret, and pin what ships 2026-08-10 22:15:35 -05:00
.env.example Let the Gmail tenant write, and say what a "move" really does to a label 2026-08-22 13:59:36 -05:00
.gitattributes Scaffold mcp-gateway repo for self-hosted MCP servers 2026-08-07 22:04:33 -05:00
.gitignore Lock the dependency set, and make a warning fatal 2026-08-13 20:23:03 -05:00
CHANGELOG.md Give Gmail the irreversible surface, and stop an expunge from lying 2026-08-22 22:07:02 -05:00
CLAUDE.md Give Gmail the irreversible surface, and stop an expunge from lying 2026-08-22 22:07:02 -05:00
Dockerfile Release 0.20.2: hold the gate to the rules the gate enforces 2026-08-13 21:31:54 -05:00
LICENSE Scaffold mcp-gateway repo for self-hosted MCP servers 2026-08-07 22:04:33 -05:00
pyproject.toml Give Gmail the irreversible surface, and stop an expunge from lying 2026-08-22 22:07:02 -05:00
README.md Release 0.21.0: a third tenant that holds no credential 2026-08-13 22:10:07 -05:00
requirements-dev.txt Release 0.20.2: hold the gate to the rules the gate enforces 2026-08-13 21:31:54 -05:00
requirements.txt Compile the locks universally, not for the machine that ran uv 2026-08-13 20:44:01 -05:00

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: 0 and read as an empty diary;
  • update_event and delete_event failed on every call, because caldav's get_event_by_uid issues a UID-filtered REPORT and iCloud answers it with 412 (0.8.1);
  • list_events could 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_event and list_events described 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