- Python 99.9%
- Dockerfile 0.1%
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> |
||
|---|---|---|
| .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.
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: 0and read as an empty diary; - Gmail's unread total summed labels that overlap, and reported 1214 unread against 904 actual;
send_draftremoved 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 clean200, 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 Gmailget_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 themailto: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-onlycreate_free_agent_pickupanddelete_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