• v0.3.1 3d34cddbe7

    v0.3.1 — first tagged release
    All checks were successful
    CI / gate (push) Successful in 1m17s
    CI / image (push) Successful in 49s
    Stable

    joeyr released this 2026-08-09 00:07:00 -05:00 | 119 commits to main since this release

    First 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 at https://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
    accepted calendar-query as 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_time on 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 to main — the same shape lol-companion-data and
      aufhocker already use, rather than a hand-run docker build over SSH. gate runs
      ruff/mypy/pytest, image needs it, so a red suite cannot reach the registry. Three
      tags per build: :latest, :X.Y.Z from pyproject.toml, and :<sha> as a pinned
      rollback target. It runs on mcp-gateway-docker, a label served by runner-docker in
      the forgejo-runner.yaml homelab 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-run secrets.GITHUB_TOKEN because 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 resolving lxml and
      cryptography on arm64 with a cold cache is not a 15-minute job.
    • CI fails the build when pyproject.toml and mcp_gateway/__init__.py.__version__
      disagree.
      CLAUDE.md has 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.py already 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 in docker 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 _v1 suffix, 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's secrets: 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.yml was
      deleted rather than kept in sync — lol-companion-data already sets the pattern (dev
      compose in the repo, production stack in homelab/), and two copies nobody diffs will
      drift. deploy/README.md now says so to whoever looks in the obvious place, and both
      the release flow in CLAUDE.md and the runbook point at the new path. Deployment is
      Portainer throughout, so runbook Phase 3 is no longer docker secret create and
      Phase 4 is no longer docker stack deploy. Phase 2 is no longer a manual build over
      SSH either — see Added.
    • --with-registry-auth has no Portainer equivalent, so the Forgejo package must be
      public. Private, the manager pulls fine and workers fail with no basic auth credentials on 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 a rich.RichHandler on stderr at import and sets propagate = 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; fastmcp is now in the same list, and its level is pinned to the app's so
      LOG_LEVEL governs 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 bare calendar entry. (Compare
      lol-companion-crawler_api, which is written out in full.) The stack file now declares
      a calendar network alias, chosen over hard-coding the qualified name so that renaming
      the stack cannot break the connector.

    • .env.example carried the same invented calendar names the stack file did. A8
      fixed CALENDARS in one place and not the other, leaving Home,Work in 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 returned count: 0, no error, and
      the model reports "nothing scheduled" as fact. Live trigger was iCloud timing out
      calendar-query on one busy calendar under repeated requests — a transient condition
      in ordinary use, not an exotic one. fetch_events now returns a FetchResult naming
      the calendars it could not read, and both tools surface unavailable_calendars plus a
      warning telling the model not to report a clear schedule. Worst in find_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=False onto sys.stdout; where that stream is not UTF-8 (a Windows
      console is cp1252) logging swallows the UnicodeEncodeError and 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".

    • CALENDARS named 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.

    • .env was documented but never read. .env.example shipped, .gitignore covered
      .env, and both the README and runbook A8 said cp .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 calls load_dotenv.
      It loads ".env" explicitly rather than using load_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 sets FORGEJO_URL — a key this app reads under AUTH_MODE=oauth.
      The search was verified to reach it. override=False, so Swarm and CI still win.

    • python-dotenv>=1.0 added 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 .env in the image, so the call is
      a no-op there.

    Downloads