-
released this
2026-08-09 00:07:00 -05:00 | 119 commits to main since this releaseFirst tagged release. Live on the swarm at
https://mcp.jrendar.org/calendar.Container image:
forgejo.jrendar.org/joeyr/mcp-gateway:0.3.1
Not yet tagged. Runbook A8 is done and the whole thing is deployed. As of 2026-08-09
this is live on the swarm athttps://mcp.jrendar.org/calendar, behind the nginx
allowlist, and the OAuth flow has been driven end to end through Forgejo — the gate that
stood in front of the first tagged release. Phases 1 through 7 of the runbook are marked
done there.A8 against a real Apple ID. Discovery followed the partition redirect, iCloud
acceptedcalendar-queryas sent, and all three tools answered correctly over the full
MCP transport: six calendars read, recurrence expansion holding local clock time across
the 2026-11-01 DST boundary, all-day events not bleeding into the next day, and
find_free_timeon a fully booked day merging two overlapping flights and rejecting a
40-minute gap against a 60-minute request.It also found four defects that the test suite could not have — all the same shape, a
request that fails and reports success.Added
- CI builds and publishes the image (
.forgejo/workflows/ci.yml, renamed from
test.yml), on every push tomain— the same shapelol-companion-dataand
aufhockeralready use, rather than a hand-rundocker buildover SSH.gateruns
ruff/mypy/pytest,imageneeds it, so a red suite cannot reach the registry. Three
tags per build::latest,:X.Y.Zfrompyproject.toml, and:<sha>as a pinned
rollback target. It runs onmcp-gateway-docker, a label served byrunner-dockerin
theforgejo-runner.yamlhomelab stack: one user-scoped runner replacing the three
repo-scoped ones, so this added no container — it removed one. The labels were kept
verbatim, so no existing workflow changed. The trade is that the isolation boundary is
now the owner rather than the repo, which matters because this repo is public: a fork
PR naming that label would run untrusted code with the host Docker socket mounted.
Forgejo's fork-PR approval default is the mitigation and must stay on.
Two differences from the sibling repos, both of which fail unhelpfully if missed: the
checkout authenticates with the per-runsecrets.GITHUB_TOKENbecause this repo is
private and Forgejo answers an anonymous request for one with a 404 rather than a
403; and the timeout is 30 minutes rather than 15, since pip resolvinglxmland
cryptographyon arm64 with a cold cache is not a 15-minute job. - CI fails the build when
pyproject.tomlandmcp_gateway/__init__.py.__version__
disagree.CLAUDE.mdhas always required them to match; nothing enforced it, and the
only symptom of a drift is a boot log line reporting a version that was never released.
Changed
- Credentials are Portainer stack environment variables, not Swarm secrets. No code
change was needed:config.pyalready resolved<NAME>_FILE, then
/run/secrets/<name>, then the plain env var, and the file path is kept working
rather than deleted so moving back is a stack edit. The trade is explicit — env vars
are readable indocker inspect, in Portainer's stack editor, and by anything that
can read the container's environment, which is what the file path avoided, and it
matters here because an app-specific password grants CalDAV read and write. The
names lose the_v1suffix, which existed only because Swarm secrets are immutable.
The boot error for a missing credential now names the env var and says to set it as a
stack variable; it used to say "add it to the service'ssecrets:list", which under
this deployment sends you to the wrong screen. - The stack file moved out of the repo to
homelab/docker_swarm/compose/mcp-gateway.yaml, where every other stack on this swarm
lives and where Portainer's web editor reads it from.deploy/docker-stack.ymlwas
deleted rather than kept in sync —lol-companion-dataalready sets the pattern (dev
compose in the repo, production stack inhomelab/), and two copies nobody diffs will
drift.deploy/README.mdnow says so to whoever looks in the obvious place, and both
the release flow inCLAUDE.mdand the runbook point at the new path. Deployment is
Portainer throughout, so runbook Phase 3 is no longerdocker secret createand
Phase 4 is no longerdocker stack deploy. Phase 2 is no longer a manual build over
SSH either — see Added. --with-registry-authhas no Portainer equivalent, so the Forgejo package must be
public. Private, the manager pulls fine and workers fail withno basic auth credentialson whichever node the task lands — presenting as an intermittent fault.
The image contains no secrets.
Fixed
-
fastmcp's log lines bypassed the JSON formatter entirely.
fastmcp/__init__.py
installs arich.RichHandleron stderr at import and setspropagate = False, so
its records never reached the root handler — and Rich word-wraps to the console width,
so a single message arrived as three lines, the continuations carrying no level, no
caller and no timestamp.configure()already disarmed uvicorn for exactly this
reason;fastmcpis now in the same list, and its level is pinned to the app's so
LOG_LEVELgoverns one thing rather than two. Seen in production on 2026-08-09 as the
CIMD line during the first connector sign-in. -
configure()had no tests at all, which is why the uvicorn list was never noticed
to be incomplete. It has four now, including a check that exactly one handler is
installed after a repeated call — the failure there is every line printed twice. -
nginx would have got a 502 from a correct-looking config. It proxies to
http://calendar:8787, but Swarm registers a stack service as<stack>_<service>—
mcp-gateway_calendar— and there is no barecalendarentry. (Compare
lol-companion-crawler_api, which is written out in full.) The stack file now declares
acalendarnetwork alias, chosen over hard-coding the qualified name so that renaming
the stack cannot break the connector. -
.env.examplecarried the same invented calendar names the stack file did. A8
fixedCALENDARSin one place and not the other, leavingHome,Workin the file the
README tells you to copy — and a name matching nothing is not an error, it is an empty
day. Now empty, with a note to run once wide to discover the real names. -
A partial read was indistinguishable from an empty calendar.
fetch_events
isolates failures per calendar so one bad collection cannot take down the request, but
said nothing about which ones it skipped: the tool returnedcount: 0, noerror, and
the model reports "nothing scheduled" as fact. Live trigger was iCloud timing out
calendar-queryon one busy calendar under repeated requests — a transient condition
in ordinary use, not an exotic one.fetch_eventsnow returns aFetchResultnaming
the calendars it could not read, and both tools surfaceunavailable_calendarsplus a
warningtelling the model not to report a clear schedule. Worst infind_free_time,
where an unread calendar contributes no busy time and the day comes back wide open. -
An unknown calendar name returned zero events instead of an error.
calendars= ["Work"]— a name that does not exist on the account — was silently dropped and the
request "succeeded" empty. Both tools now reject unrecognised names and list the real
ones in the message, so the retry needs no second round trip. -
The log dropped any line containing a non-ASCII character. The formatter emits
ensure_ascii=Falseontosys.stdout; where that stream is not UTF-8 (a Windows
console is cp1252)loggingswallows theUnicodeEncodeErrorand discards the whole
record. Found because this account has a calendar with an emoji in its name, and the
line naming it was the one that vanished. stdout is now reconfigured to UTF-8 with
errors="replace". -
CALENDARSnamed two calendars that do not exist. The stack file shipped
"Home,Work", plausible and matching nothing on the account; deploying it would have
produced a service whose every answer was an empty day, with one startup warning as the
only clue. Set to the real names. They contain colons, so the value must stay quoted —
an unquoted YAML scalar containing": "is a mapping, not a string. -
.envwas documented but never read..env.exampleshipped,.gitignorecovered
.env, and both the README and runbook A8 saidcp .env.example .env— but no code
ever loaded it, so following those instructions produced "configuration is incomplete"
from a file that looked correctly filled in.__main__now callsload_dotenv.
It loads".env"explicitly rather than usingload_dotenv()'s default upward search,
which walks the directory tree to the nearest.env: from a checkout under
.vscode/forgejo/that resolves to the workspace-level file holding Forgejo API
credentials, and it setsFORGEJO_URL— a key this app reads underAUTH_MODE=oauth.
The search was verified to reach it.override=False, so Swarm and CI still win. -
python-dotenv>=1.0added as a runtime dependency rather than a dev one, since
__main__imports it unconditionally; a guarded import would turn a packaging mistake
into a silently unconfigured process. There is no.envin the image, so the call is
a no-op there.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- CI builds and publishes the image (