Self-hosted MCP servers behind mcp.jrendar.org; tenant #1 is iCloud Calendar (read-only)
  • Python 99.9%
  • Dockerfile 0.1%
Find a file
joeyr ab1341ccd2
All checks were successful
CI / gate (push) Successful in 2m35s
CI / image (push) Successful in 17s
0.81.0: get_ensemble_tracks, each model's track for a storm
The weather tenant's twenty-first tool and the last of the tropical-storm
plan, which this release empties and deletes. NHC's model-track file
(the ATCF a-deck on ftp.nhc.noaa.gov), read down to the main models the
owner chose: NHC's official forecast, the global models and their
ensemble means, the hurricane models and NHC's three blends, each under
its own name, every 12 hours to 120.

Each model is read at its own latest run, never the file's: a run's
models arrive over hours, so the newest run held 33 to 35 models against
74 to 82 in a full one. The model as run is read rather than NHC's
time-shifted copy, which carries no pressure. Missing is written as zero
in the file (0N 0W, wind 0, pressure 0) and is reported null. The
European model is not in NHC's public file and is named as missing.
The file is streamed, with ten times the body cap on what it unpacks to.

Checked live against all three active storms (probes/s10_verify.py, 4
checks): 45 of 45 model runs matched the file point for point, and the
official track matched get_storm_forecast at 12 of 12 times. Every rule
broken once to watch a test fail (11 of 11).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 23:25:47 -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 0.57.3: the weather tenant is deployed 2026-09-21 21:59:51 -05:00
docs 0.81.0: get_ensemble_tracks, each model's track for a storm 2026-10-09 23:25:47 -05:00
mcp_gateway 0.81.0: get_ensemble_tracks, each model's track for a storm 2026-10-09 23:25:47 -05:00
tests 0.81.0: get_ensemble_tracks, each model's track for a storm 2026-10-09 23:25:47 -05:00
.dockerignore Treat a feed URL as a secret, and pin what ships 2026-08-10 22:15:35 -05:00
.env.example docs: fix three more stale descriptions of the project 2026-09-29 21:35:27 -05:00
.gitattributes 0.77.0: get_storm_surge, NHC's surge forecasts at a point 2026-10-09 17:58:38 -05:00
.gitignore Lock the dependency set, and make a warning fatal 2026-08-13 20:23:03 -05:00
CHANGELOG.md 0.81.0: get_ensemble_tracks, each model's track for a storm 2026-10-09 23:25:47 -05:00
CLAUDE.md 0.81.0: get_ensemble_tracks, each model's track for a storm 2026-10-09 23:25:47 -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 0.81.0: get_ensemble_tracks, each model's track for a storm 2026-10-09 23:25:47 -05:00
README.md 0.81.0: get_ensemble_tracks, each model's track for a storm 2026-10-09 23:25:47 -05:00
requirements-dev.txt 0.52.0: a league that answers the wrong question rather than refusing 2026-09-10 20:04:31 -05:00
requirements.txt 0.52.0: a league that answers the wrong question rather than refusing 2026-09-10 20:04:31 -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.

iCloud · Gmail · Canvas · ESPN · public weather sources
   → mcp-gateway, one process per tenant (Docker Swarm, 3× Pi)
      → Nginx Proxy Manager  https://<tenant>.mcp.jrendar.org/<tenant>
         → Claude

Tenants

One codebase, one process per tenant, and TENANT picks which one a process serves.

# Tenant Reads Credential Tools Deployed
1 icloudcalendar iCloud Calendar over CalDAV, plus any ICS_FEEDS Apple app-specific password 11 yes
2 icloudmail iCloud Mail over IMAP; sends over SMTP Apple app-specific password 20 yes
3 subscriptions read-only iCalendar URLs none 3 no — code only
4 gmail Gmail over IMAP; sends over SMTP Google app password 21 yes
5 canvas Canvas LMS coursework, read-only browser session cookie 13 yes
6 espnff one ESPN fantasy football league ESPN espn_s2 + SWID cookies 10 + 6 yes, internal only
7 weather NWS, AWC, Open-Meteo, CPC, NCEI, IEM none 11 yes

Everything reads by default, and nothing writes until a setting says so — see Writes are off until granted.

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.

espnff is the exception to that and to most of what follows. Its consumer is a Discord bot in the same swarm, not Claude, so it has no hostname, no proxy host, no certificate and no OAuth app. It sits on an internal overlay network shared with the bot and nothing else, and authenticates with a shared bearer token, because a bot cannot click Authorize.

Subscriptions is the cheapest tenant: 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. It has been built and waiting since 0.21.0: deploying it needs its own OAuth app, stack service and proxy host.

Weather is the other one that holds no credential, and it is the only tenant here that holds no place either. Every tool takes a latitude and a longitude as arguments, so it answers for a hotel in another state as readily as for the kitchen — and nothing in its configuration records where the operator is. What makes it a tenant rather than one of the many public NWS connectors is that its correctness claim is about time: every response carries the issuance timestamp read out of the payload itself, how old that is now, and whether it is past the threshold for that source. It exists because a cached weather product was once served silently as current, eight months stale, under a clean HTTP 200.

The second thing it is careful about is fabricated precision: the National Weather Service's gridded forecast is mostly multi-hour blocks — on a measured gridpoint only 17% of the wind direction series was a one-hour entry — so a change between two blocks is usually the encoding rather than the weather. It returns the blocks as blocks, marks every interpolated value it hands over, and reports a forecast change as a window unless the source genuinely stated a time. Sources are never averaged together, because they are not independent: the forecaster's products and the model runs share guidance.

Canvas authenticates with a browser session cookie, because the institution has disabled personal access tokens. Canvas re-issues that cookie as it is used, so the tenant keeps it alive with its own hourly request and writes each new value back to its volume. Re-seeding a dead session is manual and always will be; the tools say needs_reseed rather than failing as a transport error.

Status

Six of the seven tenants are deployed. Five are on their own hostnames, espnff is on the internal network, and subscriptions is not deployed.

The deployed tenants have been driven against real accounts and live upstreams, not just against fakes. The calendar's read/write surface was checked on a phone rather than by reading the tools' own responses back, and every recurrence scope was run on a scratch calendar. Both mail tenants' reads, writes, deletes, sends and folder administration went through the tool layer against real mailboxes. Canvas and weather had live passes through the connector. ESPN's change requests were each driven against the live league before the code that sends them was written. The passes, their dates and what re-triggers each one are in docs/operations.md.

Nearly every one of those passes, and ordinary use since, found something the suite could not. Most of those defects 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;
  • Gmail's unread total summed labels that overlap, and reported 1214 unread against 904 actual;
  • send_draft removed the draft even when saving the Sent copy had failed, destroying the only record of a message that had already been delivered;
  • every post in a live Canvas discussion came back as "(unknown)" under a clean 200, because the tool read a field the chosen endpoint does not send;
  • the weather tenant's boot check declared its main upstream unavailable while every tool worked — the same shape, inverted.

None of them could be reached from a fake at the time. That is the shape to keep looking for, and the reason the live checks are worth the trouble.

What is not proved live, on purpose or by circumstance:

  • Calendar invitations. Verified against iCloud with raw CalDAV, never through the tool layer — each run costs a real invitation. Working but unproven.
  • Mail delivery beyond this account. Every send in every pass is addressed to the account itself, so delivery to another provider, Bcc to a separate recipient and a real mailto: unsubscribe are unexercised.
  • Three Canvas paths that normal use never reaches: a discussion locked until you post, a grade the instructor has not released yet, and whether the session cookie has an absolute lifetime.
  • ESPN changes through the bot, dry run and then live, owed on the first deploy that carries them. And three changes — a free-agent pickup, a claim with a FAAB bid, and cancelling a claim — are built and run every check but are never sent in any mode, because none has been measured against ESPN yet.

Auth

Connector auth is OAuth, with Forgejo as the identity provider. Claude's custom-connector dialog on this org cannot send a static request header, so a bearer token cannot reach this server from a connector. OAuthProxy supplies the Dynamic Client Registration Claude requires and Forgejo lacks, and ALLOWED_SUBJECTS decides which Forgejo accounts may use each tenant. The suite drives registration, /authorize, the consent screen and the hand-off to Forgejo; Forgejo's own answer and the token exchange are verified only by using the connector.

AUTH_MODE=token keeps the simple path for local development and for clients that can send a header — MCP Inspector, curl, Claude Code — and it is the production mode for espnff alone, for the reason above.

That is connector auth. How each tenant signs in to its provider is the Credential column in the table.

Tools

Names are verb-first — list_, get_, search_, create_, update_, delete_ and so on — because the connector lists tools alphabetically, so the prefix is what groups them in front of the user. A test pins it.

iCloud Calendar

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

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 means "not editable" — subscriptions and read-only calendars have none, because a handle that is always refused is worse than no handle. Every occurrence of a repeating event 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.

create_event and update_event take alerts, a list of whole minutes before the start ([30], [1440, 30]), at most two. An all-day event's only alert is 09:00 on the day, [0]. On an update the list replaces the plain alerts, [] removes them, and leaving it out keeps them. Travel-time and location alerts set on a phone are never touched. list_events reports each event's alerts, except on subscriptions, whose alerts the phone does not show.

Subscriptions

list_calendars, list_events and list_free_time, answering from iCalendar feeds rather than CalDAV. Nothing else, under any configuration.

iCloud Mail and Gmail

The two mail tenants share their tool names, and a tool on both answers the same shape on both, because a client holding both connectors shows them side by side.

  • Read: list_folders, list_unread_counts, list_messages, search_messages, get_message, get_message_security, get_attachment, and on Gmail get_thread.
  • Change: update_message (read, flagged), move_message, delete_message (to trash), expunge_message (destroyed).
  • Folders: create_folder, rename_folder, delete_folder — labels, on Gmail.
  • Send: send_message, reply_message, forward_message, create_draft, send_draft.
  • Unsubscribe: unsubscribe_message, the RFC 8058 one-click POST or the mailto: offer. It refuses unless the sender authenticated, because contacting a hostile sender's endpoint confirms the mailbox is live. The refusal is the feature.

get_message_security reports header-hygiene signals beside the authentication results the receiving server recorded — and says outright that passing SPF, DKIM and DMARC proves the domain, never the sender, because a competent phish passes all three.

Where a provider does something different under the same verb, the tool does the provider-correct thing and its description says so. On Gmail an IMAP expunge outside the Bin archives a message rather than destroying it, so expunge_message moves to the Bin and expunges there; and deleting a label destroys no mail, so the count delete_folder reports is how many messages lose the label, not how many are lost.

Canvas

Thirteen tools, all reads, and no write path exists: list_courses, list_assignments, get_assignment, list_upcoming_assignments, get_grades, get_submission_feedback, get_quiz_results, list_announcements, get_syllabus, list_modules, get_page, get_discussion, list_discussion_entries. get_quiz_results reads a graded New Quiz question by question, through the endpoints Canvas's own results page uses; its two POSTs mint a token and open the quiz, the way the browser does, and change nothing.

ESPN fantasy football

  • Read: get_league_info, list_teams, get_team_roster, get_team_info, get_standings, get_matchups, get_box_score, search_players, get_free_agents, get_league_activity.
  • Change, only ever ESPN_TEAM_ID's team: update_lineup, create_waiver_claim, list_waiver_claims, update_waiver_claim_order, and the rehearsal-only create_free_agent_pickup and delete_waiver_claim.

Weather

Twenty-one tools, all reads: get_forecast_grid, get_hourly_forecast, get_model_run, get_alerts, get_forecast_discussion, get_observations, get_observation_history, get_taf, list_stations, search_wind_shifts, get_outlook, get_buoy_observations, what NOAA's buoys measure at sea near a coordinate, get_marine_forecast, the marine forecast for the waters there, get_rainfall_outlook, the flash-flooding rain risk for the next five days, and seven from the National Hurricane Center: get_active_storms, the tropical cyclones it is tracking, get_storm_advisory, one storm's latest advisory and discussion, get_storm_forecast, its forecast track and cone, get_tropical_outlook, where a storm may form, get_wind_probabilities, the chance of damaging wind at a coordinate and when it could arrive, get_storm_surge, NHC's surge forecasts at a point on the US coast, and get_ensemble_tracks, each forecast model's track for a storm.

Every tool takes latitude and longitude, except the storm tools: get_active_storms and get_tropical_outlook take an optional basin, and get_storm_advisory and get_storm_forecast a storm_id. All take units (metric, the default, or imperial) and timezone (local to the coordinate, utc, or an IANA name; local is UTC on a tool with no coordinate). Inside the United States every source is available; elsewhere the NWS products are not, and the response says so rather than failing — observations, terminal forecasts, the model run and the archive work anywhere.

Writes are off until granted

Every capability that changes something is its own setting, off by default. Off means the tools are not registered — absent from the list, not refusing when called — because a tool in the list is one the model will offer the user before discovering it cannot deliver.

Setting Tenant Registers
WRITABLE_CALENDARS calendar create_event, update_event, delete_event, on the named calendars only
INVITES_ENABLED, with the above calendar list_attendees, update_attendees
CALENDAR_ADMIN calendar create_calendar, update_calendar, delete_calendar
MAIL_WRITABLE_FOLDERS both mail update_message, move_message, delete_message, in the named folders only
MAIL_EXPUNGE_ENABLED, with the above both mail expunge_message
MAIL_FOLDER_ADMIN both mail create_folder, rename_folder, delete_folder
MAIL_SEND_ENABLED both mail send_message, reply_message, forward_message, create_draft, send_draft
MAIL_UNSUBSCRIBE_ENABLED both mail unsubscribe_message
ESPN_WRITES = dry_run or live espnff the six change tools; dry_run sends nothing

Attaching a guest to an event means Apple mails them, immediately and irreversibly, which is why invitations are a grant of their own. CALENDAR_ADMIN and MAIL_FOLDER_ADMIN are parallel to the write allowlists rather than larger than them: being trusted to add events to a calendar is not consent to delete the calendar. On Gmail, create_draft and send_draft also need MAIL_WRITABLE_FOLDERS, because a draft is written into the mailbox.

Destructive tools make the caller name what they are destroying. delete_event takes expected_title, delete_message and expunge_message take expected_subject, and a reply-all takes expected_recipients. delete_calendar and delete_folder take the name and a count of what is inside — which the first call deliberately refuses in order to report, so a human sees the number before anything is destroyed. "Moved to 3pm" is equally true of the wrong event, so the echo is the only place a wrong target is ever visible.

Irreversible is a separate tool, never a flag. delete_message moves to trash; expunge_message destroys, has its own grant, and has a description about nothing else.

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

.env.example documents every setting, tenant by tenant. TENANT defaults to icloudcalendar.

Set AUTH_MODE=token locally. The default is oauth, which needs a reachable Forgejo and a public base URL; .env.example ships in token mode. With every write setting empty — also the default — no write tool is registered at all, so a local run against a 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 internet-facing 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. espnff has none, deliberately: a conf file there would read as an invitation to give it a proxy host.

License

MIT