• v0.13.0 3c91ce5f22

    v0.13.0 — mail M2: a writable mailbox
    All checks were successful
    CI / gate (push) Successful in 1m16s
    CI / image (push) Successful in 50s
    Stable

    joeyr released this 2026-08-12 11:56:24 -05:00 | 58 commits to main since this release

    Mail M2: the mailbox becomes writable, behind a folder allowlist that is empty by
    default.
    Five tools — mark_read, mark_unread, flag_message, move_message,
    delete_message — registered only when MAIL_WRITABLE_FOLDERS names something. A
    deployment that does not set it offers exactly the surface it had in 0.12.1.

    And the mail tenant finally has a preflight. It had none: the branch in __main__
    skipped it, so the service booted to two log lines while the calendar printed eight
    checks. A revoked app-specific password looked identical to a healthy start until the
    first tool call.

    Added — mail write tools (M2)

    • MAIL_WRITABLE_FOLDERS, comma-separated, empty by default. Note the deliberate
      asymmetry with MAIL_FOLDERS, inherited from CALENDARS vs WRITABLE_CALENDARS: empty
      read means every folder, empty write means none. Both defaults fail safe, and
      inverting one to match the other would be worse than the asymmetry.

    • mark_read and mark_unread as two tools, not one taking read: bool. A boolean is
      the kind of argument a model passes backwards, and mark_read(read=False) reads correct
      while doing the opposite.

    • move_message returns a new message_id, and says which one died. The UID changes
      on move, so the handle passed in is dead when the call returns. The response carries
      message_id and previous_message_id, and the tool description says so in the imperative
      — this is the contract most easily lost, because nothing fails loudly when it is ignored.

    • delete_message moves to trash by default and returns the handle it landed on, so
      the delete is reversible with move_message. permanent=true is \Deleted + UID EXPUNGE and returns no handle, because there is no longer anything to name.

    • Trash is not exempt from the write allowlist. A default delete writes into
      TRASH_FOLDER, so trash must be named in MAIL_WRITABLE_FOLDERS or the default delete
      is refused. A grant that quietly extended itself to a folder the operator never named
      would be the allowlist deciding its own scope.

    • TRASH_FOLDER, defaulting to Apple's documented Deleted Messages. Configuration
      first, the server's \Trash attribute second — not the reverse, because iCloud does not
      advertise SPECIAL-USE, so that attribute arrives by grace and a server that stopped
      sending it would silently relocate every delete.

    Added — the mail tenant's preflight

    • imap — logs in and counts readable folders. Zero readable is a failure, not a pass
      with an empty count: the credential worked and the allowlist or the account is wrong,
      which is a different repair.
    • mail_folders — cross-checks MAIL_FOLDERS against what the server actually has. An
      unmatched name is a folder the operator believes they granted, and this client makes an
      unmatched folder invisible, so every read of it refuses.
    • mail_writable — every writable folder must be readable, since the writable check
      runs the readable one first.
    • trash — confirms the folder a default delete writes into is writable and exists.
      Otherwise it is invisible until the first delete, which then refuses while naming a folder
      the user never mentioned.
    • Counts, never names. These are the only preflight checks whose subject is the
      operator's own mail, and the mail tenant's rule is that no folder name reaches a log. "1
      not found" is the whole message; which one is a matter of reading the stack file.

    Fixed

    • _check_secrets demanded ICLOUD_APPLE_ID from every tenant. Only CalDAV uses it —
      IMAP authenticates as the iCloud Mail address, and Config.from_env has required exactly
      the right one per tenant since 0.11.0. The check would have failed a correct mail
      deployment on a value nothing reads. Latent until now, because the mail branch ran no
      preflight at all.

    Notes

    • The move is copy → flag → UID EXPUNGE, and the order is the safety argument. iCloud
      advertises UIDPLUS but not MOVE (probe 3), so there is no atomic form available.
      Copying first means the failure mode is a duplicate, never a loss; a MOVE-shaped
      implementation that deleted first would invert that. The expunge is scoped to the one
      UID, verified by probe 5 against a folder holding another message already flagged
      \Deleted, which survived.

    • The new handle is parsed from COPYUID, never searched for. Probe 4 found that a
      just-written message is not immediately findable by SEARCH — the index lags the write —
      so looking it up would report a failure that did not happen. A copy that comes back
      without a COPYUID leaves the original in place rather than guessing: a duplicate is
      recoverable, a handle resolving to somebody else's mail is not.

    • Every write verifies against what the server said, not against the absence of an
      exception. IMAP does not error on a STORE naming a UID the folder no longer holds — it
      succeeds and affects nothing — so the flag methods read the echoed flags back. This is the
      calendar's 0.9.x lesson applied before it could cost anything: DELETE on a CalDAV
      collection returns 204 whether or not it deleted something.

    • Every write tool's error envelope carries its verdict explicitly as false. A missing
      key is not the same signal as a false one, and a model reading a verdict-less error
      envelope reports the write as done.

    • Still not built: sending. M3 (send/reply/forward/drafts) and M4 (folder
      administration) remain unwritten, and the server instructions say so — a model that
      assumes mailbox changes and composition arrive together will offer to reply.

    • Not yet live-verified. M2's own condition, from the plan, is a move that survives a
      round trip with a resolving handle and the Trash default confirmed on a phone. Green tests
      do not clear it; see docs/operations.md.

    Downloads
  • v0.12.1 6752cb82ab

    v0.12.1 — record the mail tenant and its verification checklist
    All checks were successful
    CI / gate (push) Successful in 1m16s
    CI / image (push) Successful in 49s
    Stable

    joeyr released this 2026-08-12 09:22:32 -05:00 | 61 commits to main since this release

    Documentation only. No code, no behaviour, no deploy needed — but two gaps that made the
    record wrong rather than merely thin.

    Changed

    • The iCloud Mail tenant is now described in this changelog, under 0.11.0, the tag that
      actually carries it. Its package, six tools, hostname, nginx site and live deployment
      shipped without an entry of their own: the mail commits landed between 0.10.1 and 0.11.0,
      and 0.11.0's notes described only calendar management. Grepping this file for "IMAP"
      returned nothing while the tenant was serving traffic. Recorded retroactively and labelled
      as such rather than backdated silently.

    • Mail's live-verification checklist moved into docs/operations.md, under Verifying
      against the live account → iCloud Mail
      , which is where the mail plan always said it
      should end up once M1 existed. Four failure shapes to hunt, what M1's run already
      established, the A8 cross-check, the six refusals and the log check. The plan file now
      points at it instead of carrying it, since the plan is disposable and this is not.

    • The calendar's half of that section gained a heading, so the two tenants sit side by
      side rather than one reading as the document's only subject.

    Downloads
  • v0.12.0 048459c878

    v0.12.0 — the calendar moves to its own hostname
    All checks were successful
    CI / gate (push) Successful in 1m16s
    CI / image (push) Successful in 51s
    Stable

    joeyr released this 2026-08-12 08:45:02 -05:00 | 62 commits to main since this release

    The calendar moves off the apex and onto its own hostname,
    https://icloudcalendar.mcp.jrendar.org/icloudcalendar. Nothing about what it does
    changes; where it lives does.

    Breaking, and it cannot be made otherwise. PUBLIC_BASE_URL is both the token issuer
    and the token audience, so moving it kills every token this connector has ever been
    issued. They are dead rather than refreshable: remove and re-add the connector in
    Claude
    , and its permission toggles reset with it. The Forgejo OAuth application's
    redirect URI has to be repointed at the new callback before the deploy or sign-in fails
    looking like an auth bug.

    This shipped alone, on purpose. The whole failure mode is an auth error that presents as a
    bug in whatever else went out at the same time.

    Changed

    • The hostname and the path. mcp.jrendar.org/calendar
      icloudcalendar.mcp.jrendar.org/icloudcalendar. Calendar was the last thing not
      following the rule probe 0 settled on 2026-08-11 — a tenant is a hostname — because it
      was built when the README still claimed a tenant was another path on one host. Nothing
      serves the apex now
      ; a proxy host left behind there points at a name that no longer
      resolves and answers 502 on a URL that still looks live.

    • One name end to end. The tenant was already icloudcalendar while its path was
      /calendar and its Swarm alias was calendar. Both now match the tenant, the way mail's
      have from the start: service icloudcalendar, alias icloudcalendar, MCP_PATH
      /icloudcalendar, upstream http://icloudcalendar:8787. The default mcp_path in
      config.py moved with them — deployments set it explicitly, so the default only decides
      what a local run without an .env does.

    • deploy/nginx/calendar.confdeploy/nginx/icloudcalendar.conf, which also settles
      the filename convention as <tenant>.conf rather than the <hostname>.conf three
      documents claimed. The hostname is <tenant>.mcp.jrendar.org, so the repeated suffix
      bought nothing. Both nginx files change together, as their headers require.

    • pyproject.toml's description no longer says tenant #1 is read-only. It has not been
      since 0.5.0, and there have been two tenants since M1.

    Notes

    • The procedure is now in docs/operations.md under Moving a tenant to a new
      hostname
      , in the order that matters: redirect URI, proxy host, stack repaste, delete the
      old proxy host, re-add the connector. ALLOWED_HOSTS is the step that fails loudest if
      forgotten — every request is refused before it reaches a tool.

    • The failure table gained the cached-upstream 502, which cost a live calendar outage
      on 2026-08-11 and had been fixed in the configs without ever being written down as a
      symptom. It is the 502 to expect on the new host during this move, and the one whose
      obvious suspect — the app — is innocent.

    • docs/icloud-calendar-plan.md is gone, emptied by this release as its own header
      said it would be. What is left to build lives in docs/icloud-mail-plan.md (M2–M4).

    Downloads
  • v0.11.0 a978d5ac19

    v0.11.0 — calendar management, and iCloud Mail M1
    All checks were successful
    CI / gate (push) Successful in 1m37s
    CI / image (push) Successful in 1m4s
    Stable

    joeyrendar released this 2026-08-11 22:09:03 -05:00 | 64 commits to main since this release

    Recorded retroactively on 2026-08-12. The iCloud Mail, tenant #2 section below
    describes work that was already in this tag and went undescribed — the entry as published
    covered only calendar management. It is written here, against the version that actually
    carries the code, rather than under a later heading that would misdate it. This is the
    drift the no-[Unreleased] rule exists to prevent, and it happened anyway because the
    mail commits landed between a release and its successor without a version of their own.

    Calendar management, behind CALENDAR_ADMIN. Creating, renaming and deleting whole
    collections rather than the events inside them — and the delete is the first thing here
    that cannot be undone by any later call. A wrong delete_event is repaired by creating
    the event again; a wrong delete_calendar takes every event in it, on every device, and
    CalDAV has no trash.

    Like attendees before it, this shipped only because a probe cleared it first. iCloud was
    asked on 2026-08-11 whether it permits MKCALENDAR, PROPPATCH and DELETE for an
    app-specific password. It does — and the same probe found the trap the implementation is
    now shaped around.

    Added

    • create_calendar, update_calendar and delete_calendar, registered only when
      CALENDAR_ADMIN is set. Deliberately not nested under WRITABLE_CALENDARS the way
      the attendee tools are. That setting is about the events inside a collection and this
      one is about the collection; a deployment can reasonably add events to a shared calendar
      without being allowed to delete it, and creating a scratch calendar needs write access
      to nothing that already exists. Parallel grants, not a ladder.

    • A two-step delete. delete_calendar takes expected_name, matching the shape of
      delete_event — and expected_event_count, which does not exist anywhere else. Called
      without a count it refuses and reports the real one. That refusal is the feature:
      the model cannot cheaply look the number up, so serving it through a refusal is what
      puts "this will delete 42 events" in front of a human before anything happens. The plan
      had this open as "deleting a calendar should be harder to reach than deleting an event,
      but that is not a design"; this is the design.

    • count_events, a Depth: 1 PROPFIND that counts resources without reading them.
      Bodies run to megabytes on a busy calendar and the count is all that is wanted.

    Fixed

    Both of these were found by writing the tests, not by running the code, and both would
    have presented as something other than what they were.

    • calendar-home-set was read as the first href in the multistatus — which is the
      href of the request URI, so it resolved to the principal. New calendars would have
      been created under the principal collection: no error, and nothing anyone could find.
      _organizer_address never hit this only because its mailto: filter excludes that href
      by accident.

    • The collection-level operations were filtered by the CALENDARS allowlist. A
      calendar created just now is by definition not in the allowlist, so create_calendar
      reported its own success as a failure — on every deployment that sets CALENDARS, which
      is all of them. Renaming a calendar out of the allowlist failed the same way. These
      operations now read the account rather than the readable subset, which also closes the
      duplicate-name hole: two collections sharing a name are indistinguishable in every later
      response, and the allowlist has no opinion on what exists.

    Notes

    • DELETE returns 204 whether or not it deleted anything. Observed live. Against a
      URL built from calendar-home-set it answered 204 and left the collection in place;
      against the href the calendar list reports, the same 204 removed it. So collections are
      addressed by the href the server gave us — the 0.8.1 lesson one level up — and every
      delete reads the collection back afterwards. The status is not evidence.

    • A calendar created here is not readable until an operator adds it to CALENDARS.
      The tool says so in its success envelope, because otherwise the user is looking at a new
      calendar that list_events insists is empty.

    Added — iCloud Mail, tenant #2 (M1, read-only)

    The second tenant, and the first thing here that is not a calendar. Read-only by
    construction: nothing this surface exposes can mark, move, delete or send anything, and the
    settings that would grant those (MAIL_WRITABLE_FOLDERS, MAIL_SEND_ENABLED,
    MAIL_FOLDER_ADMIN) deliberately do not exist yet — an unread setting reads as a granted
    permission.

    • Six read tools over IMAP: list_folders, get_unread_count, list_messages,
      search_messages, get_message, get_attachment. New package mcp_gateway/icloudmail/,
      mirroring the calendar's layout, with messageid.py inheriting eventid.py's reasoning
      wholesale.

    • TENANT picks the surface, one process per tenant, never both. Defaults to
      icloudcalendar so the calendar service needs no edit. Registering both tool sets in one
      process would let mail's ALLOWED_SUBJECTS hand out the calendar and vice versa, which is
      the whole boundary — so it is a process-level choice, not a registration-level one.

    • MAIL_FOLDERS is the read scope, starting at INBOX. A folder not named there is
      invisible, not empty: list_folders omits it and every read tool refuses the name
      rather than answering zero. "There is nothing in that folder" is a sentence the model will
      repeat to you as fact.

    • Nothing is marked read by being read. readonly=True on SELECT and BODY.PEEK on
      fetch, verified explicitly against the live mailbox rather than assumed from the flags.

    • Its own hostname, icloudmail.mcp.jrendar.org, with its own Forgejo OAuth
      application, client id, signing key, ALLOWED_SUBJECTS and NFS-backed OAuth store — a
      sibling of the calendar's, never nested inside it. Probe 0 settled why on 2026-08-11: a
      token is bound to PUBLIC_BASE_URL + MCP_PATH and a hostname has exactly one /token.

    • deploy/nginx/icloudmail.conf, a near-copy of the calendar's block, kept in step by
      the rule in its own header rather than by a shared file.

    Notes on the mail tenant

    • The IMAP username is not the Apple ID. Probe 1 found the account's Apple ID is a
      third-party address while IMAP authenticates as the @icloud.com mail address. There is
      deliberately no fallback: it would work on some accounts and fail here as
      [AUTHENTICATIONFAILED], which reads as a wrong password and sends you after the one
      value that was right.

    • Verified through the tool layer against the live mailbox, over real HTTP with bearer
      auth and driven by an MCP client rather than by Python calls, so registration, argument
      coercion and the JSON envelopes were all exercised. Six refusals were checked explicitly,
      because the failure that matters is an error arriving as an empty success — every one
      returned an error envelope carrying no results.

    • The log holds counts and error type names only. No folder name, subject, address or
      body, including in the boot summary, which reports the mail address as a bare domain.

    Downloads
  • v0.10.1 caa9a3006a

    joeyr released this 2026-08-11 14:39:05 -05:00 | 74 commits to main since this release

    Two tools renamed so the connector's tool list groups by verb.

    The connector lists tools alphabetically by name, so the name is the only lever on
    ordering. Every tool was already verb-first except two, and those two scattered a read and
    a write into the middle of the list.

    • find_free_timelist_free_time
    • add_attendeesupdate_attendees

    The eight now group as create_, delete_, list_ ×4, update_ ×2.

    Breaking

    Tool names are part of the surface, and the permission toggles in Claude's connector
    settings are keyed to them
    — they reset to defaults after this, so the write tools have
    to be set back to ask.

    Also

    update_attendees now says up front that it only adds. The old name did that work by
    itself; the new one implies removing a guest and changing a reply are on offer, and
    neither is. Letting the model discover that by being refused is the same mistake as
    registering a tool with no writable calendars.

    Nothing else changed — same behaviour, same envelopes, same gates.

    Downloads
  • v0.10.0 4364a132c1

    joeyr released this 2026-08-11 14:27:15 -05:00 | 76 commits to main since this release

    Adding guests to an event and reporting who accepted, behind INVITES_ENABLED.

    The first feature here that can do something the user cannot undo. Apple mails every
    address the moment the event is written, and an invitation cannot be recalled the way a
    wrong event can be deleted. The gate is separate from WRITABLE_CALENDARS rather than
    folded into it, and the tools are registered only when both are set — with either unset
    the served tool surface is identical to 0.9.4.

    Cleared by a probe first

    Free-busy sat on the roadmap for weeks before a ten-minute check found iCloud refuses it
    outright, so attendees were probed live on 2026-08-11 before any of this was designed.
    The implementation follows what that probe found rather than what RFC 6638 says should
    happen, because the two disagree:

    • SCHEDULE-AGENT=CLIENT does not suppress delivery. There is no quiet mode, so
      INVITES_ENABLED is the entire boundary and no live test of this path can avoid mailing
      a disposable alias.
    • The ORGANIZER round trip is not stable — written, stored and mailed are three
      different values. An organiser iCloud has already canonicalised is left alone.
    • Delivery outcome lives in SCHEDULE-STATUS, not in the HTTP status. A 201 means
      stored, not sent.
    • The organiser address is resolved from the account, never from config. An
      ORGANIZER iCloud does not recognise is worse than an error: it keeps the ATTENDEE
      lines as inert text and mails nobody, which reads as success from every response.

    Reply status and delivery status are separate

    An attendee can be silent because they are thinking about it or because the invitation
    never left the building. NEEDS-ACTION renders as "no reply yet" rather than passing
    through, because a model summarising the raw token files it next to DECLINED as though
    both were answers.

    Refused rather than guessed at

    Repeating events, a line break in an address (iCalendar is line-based, so that appends a
    property of the caller's choosing to an event about to be mailed), and re-adding somebody
    already invited. SEQUENCE is not bumped when a guest is added, so the people already
    coming are not told the meeting changed when it did not.

    Verification

    533 tests green, up from 477. The new ones run against the exact bytes iCloud returned
    during the probe rather than a hand-written fixture, which is what pins the href-organiser
    and the server-set SCHEDULE-STATUS.

    Not yet exercised through the tool layer against the live account. What the probe
    verified is iCloud's behaviour, not this code's use of it. The procedure and what it costs
    are in docs/operations.md.

    Downloads
  • v0.9.4 3023889582

    v0.9.4
    All checks were successful
    CI / gate (push) Successful in 1m26s
    CI / image (push) Successful in 54s
    Stable

    joeyr released this 2026-08-10 22:31:01 -05:00 | 84 commits to main since this release

    Hardening and tidying — phase 4 of docs/audit.md, which finishes it. No tool behaviour
    changes; the only runtime difference is a default that production already overrides.

    Changed

    • ALLOWED_HOSTS defaults to the PUBLIC_BASE_URL host plus loopback, instead of
      *. * disables the host check entirely. Production sets the var, so nothing was
      actually open — but the safe default for a service reached through a named proxy is the
      name, and a deployment that forgets the var should not silently lose DNS-rebinding
      protection when PUBLIC_BASE_URL is right there, already required and already
      validated. An explicit value still wins, including an explicit *, which is a
      deliberate local-development choice rather than something to second-guess. Loopback is
      included because the container healthcheck has no proxy in front of it, and the port is
      stripped because Starlette compares against Host without one.

    • line-length = 100 is now a rule. It had been set under [tool.ruff] with nothing
      selecting E501 and the formatter never run, so it read as enforced while nine lines
      exceeded it. E501 is selected and those lines are wrapped. A setting that looks like a
      rule and is not is worse than no setting.

    • deploy/README.md no longer contradicts the release procedure. It said "on a
      release, bump image: there", which was true before the stack moved to :latest and
      survived the change — sending anyone who followed it looking for a line that does not
      exist, and past the re-pull step that actually decides whether a release takes.

    Added

    • X-Frame-Options: DENY and Referrer-Policy: no-referrer on the browser-facing OAuth
      routes.
      /consent is a button that grants a connector access to a personal calendar,
      so it must not be frameable; and the consent URL carries a transaction id whose next hop
      is Forgejo, which does not need it in a Referer.

      Deliberately not a full CSP: FastMCP already sends one for the consent page, and a
      second complete policy is intersected with the first, so a stricter guess made at the
      proxy would break the page it is protecting.

      The rate limit is documented rather than applied, and that is the honest outcome
      rather than a shortfall: limit_req requires a limit_req_zone in nginx's http
      context, and everything in deploy/nginx/calendar.conf is pasted into NPM's Advanced
      tab, which lands inside a server block. Adding limit_req there alone makes
      nginx -t fail and NPM refuse to save the proxy host — breaking the sign-in path the
      change was meant to protect. The two-step fix, including the file to create on the NPM
      host, is recorded in the config next to the block.

    • The resource cache is bounded in principle, not only by its TTL. It is keyed by
      collection and window, so a model sweeping many distinct ranges minted a key each
      time; the 120-second TTL bounded that in practice while nothing bounded it in principle.
      Now capped at 256 entries, evicting expired entries first and then oldest-first, with a
      re-inserted key moved to the back so a hot key is not evicted on its original age.

    Fixed

    • _addressed_event decoded the same event_id up to three times per candidate inside
      its match loop. Decoded once — cosmetic on a handful of occurrences, but the condition
      read as though the three calls could disagree.
    Downloads
  • v0.9.3 12342d8356

    v0.9.3
    All checks were successful
    CI / gate (push) Successful in 1m28s
    CI / image (push) Successful in 57s
    Stable

    joeyr released this 2026-08-10 22:15:35 -05:00 | 85 commits to main since this release

    Secret and supply-chain hygiene — phase 3 of docs/audit.md. No behaviour change to any
    tool. Also records a live finding that removes a feature from the roadmap.

    Fixed

    • Feed URLs are redacted wherever they are written down. Config.summary() logged
      every ICS_FEEDS entry in full, under a comment asserting they "are public subscription
      URLs and carry no credential". True of Apple's holiday file; false of most other ways
      people subscribe to a calendar. Google's "secret address in iCal format", an iCloud
      private share link, an Outlook published-calendar URL and virtually every self-hosted
      equivalent are capability URLs — possession of the string is the authorisation, and
      there is no revoking one short of regenerating the address.

      Nothing validated which kind was configured, and a boot log is durable and gets pasted
      into issues, which is the same argument this project already makes for never logging
      even a prefix of the app password. Now reduced to scheme, host and the last path
      segment, with the query string dropped — enough to tell a typo'd host from a 404. The
      fetch-failure log line and every FeedUnavailable message go through the same
      reduction, since those reach the boot log through the preflight check's detail.

    • Feed decompression is bounded. MAX_FEED_BYTES was enforced against the compressed
      body only, and _decode then handed up to 5 MB to gzip.decompress with no ceiling on
      the output. Gzip reaches roughly 1000:1 on repetitive input, which is exactly what an
      ICS file is, so a hostile or compromised feed host could turn one allowed response into
      gigabytes of allocation in a process running one replica. Now decompressed incrementally
      with the cap checked before each chunk is requested, so the bomb is never held.

    • REGISTRY_TOKEN is passed via env: rather than interpolated into the step's
      script text, matching the rule the workflow already stated for GITHUB_TOKEN and
      applied nowhere else.

    Added

    • pip-audit runs in gate. Clean at the time of writing. Deliberately advisory
      rather than blocking: image has needs: gate and the stack tracks :latest, so a red
      gate stops production receiving any update — an advisory published overnight against a
      transitive dependency would block the very push that fixes something urgent. The
      reasoning is recorded next to the step rather than left to be rediscovered.

    • The base image is pinned by digest, so two builds of the same commit produce the
      same image and a rollback to a :<sha> tag is a rollback to the same base. Pinned to
      the multi-arch index digest, so it still resolves to linux/arm64/v8 on the Pi runners.

    • Upper bounds on the four version-sensitive dependenciesfastmcp, caldav,
      icalendar, recurring-ical-events. Each has already renamed or moved an API this code
      calls, and each such change was silent here rather than loud: caldav's expand=
      became server_expand= and the old spelling is swallowed into **searchargs, which
      presents as a read that quietly stops expanding.

    • secrets/ and *.secret excluded from the Docker build context. No secret reaches
      the image today, because the Dockerfile copies only pyproject.toml and mcp_gateway/.
      The exposure was entirely future-tense: one COPY . . would put a live OAuth client
      secret and the token signing key into a layer of a public image.

    Changed

    • Free-busy is off the roadmap permanently, and INVITES_ENABLED now gates attendees
      alone. Probed against the live account, read-only: iCloud answers a free-busy-query
      REPORT with 400 Bad Request and refuses the RFC 6638 scheduling-outbox POST with
      403 Forbidden, even when asking about the account's own address — despite
      advertising both the outbox and the inbox, returning 207 on them, and granting
      read-free-busy. That is Apple declining the operation for an app-specific password,
      the same shape as the 0.8.1 412. Recorded in docs/plan.md so nobody rediscovers it.

      find_free_time is unaffected: it computes availability from events this server reads
      itself, which is now the only way availability will be computed here.

    Downloads
  • v0.9.2 d2e11d1dbb

    v0.9.2
    All checks were successful
    CI / gate (push) Successful in 1m9s
    CI / image (push) Successful in 53s
    Stable

    joeyr released this 2026-08-10 21:07:03 -05:00 | 88 commits to main since this release

    The answers-short-without-erroring group, which is the defect class this project keeps
    finding: three of the four live defects found so far were in it. Also the OAuth regression
    coverage that had been outstanding since before 0.9.0. Phase 2 of docs/audit.md.

    Added

    • truncated and events_found in the read envelopes. MAX_EVENTS (default 500)
      truncates in two places — per calendar during expansion, and again across the merged
      list. Both logged a warning; neither reached the caller. So list_events over a busy
      month returned count: 500 and a clean envelope, and the model reported that as the
      user's whole month. A request that failed, reporting success.

      FetchResult now carries whether anything was dropped and how many there were, counted
      before the limit rather than inferred from the length that came back — which cannot tell
      a window holding exactly the limit from one holding ten times it.

      The find_free_time wording is deliberately harder than list_events', because the
      consequence inverts. Dropping events from a listing shortens the answer. Dropping them
      from an availability search removes busy time, so the tool invents free slots and
      offers to book over meetings that exist — the same argument
      _reject_unknown_calendars already makes about unknown calendar names. Both tools log
      truncated_from too, so a short count has one more explanation on the same line.

    • The browser half of the sign-in flow is regression-tested. Dynamic registration,
      /authorize, the consent screen and the hand-off to Forgejo now run in the suite
      against the real ASGI stack. It pins the things that fail silently: the upstream
      redirect URI is this server's /auth/callback rather than Claude's (the
      redirect_uri_mismatch in the operations table), PKCE is forwarded under a second
      challenge of the proxy's own, declining consent never reaches Forgejo, a registered
      client survives a restart, tokens are checked by introspection with no required scope,
      and the signing key is derived from configuration rather than generated per process —
      the last of which presents only as everyone being signed out for no visible reason.

      Forgejo's own answer and the token exchange still need a network. Neither has ever been
      where this broke.

    Fixed

    • Two calendars sharing a display name shared a cache entry. fetch_events keys its
      results by index, with a comment saying why: two collections on one account can share a
      name, and collapsing them would drop a calendar's events while reporting success. The
      resource cache key, seven lines later, was the name. First request, both missed and both
      searched — correct, by luck. Any request inside EVENT_CACHE_TTL, both indices hit
      whichever was written second: one calendar's events returned twice, the other's gone,
      nothing in unavailable. A follow-up question about the same week is exactly the
      traffic the cache exists to serve. Keyed by collection URL now, which is unique, stable,
      and stays out of the log line because it carries the account DSID.

    • A basic-format end date silently excluded its own day. parse_when decided a value
      was date-only with len(text) == 10, which is true of 2026-08-07 and false of
      20260807 — a spelling datetime.fromisoformat accepts perfectly happily on 3.11+. So
      end="20260807" parsed clean to midnight, skipped the end-of-day widening, and dropped
      everything on the 7th with no error. The tool description asks for YYYY-MM-DD, but it
      also accepts "a full ISO 8601 datetime", and basic format is ISO 8601. Decided from the
      parse now, which cannot drift from what was actually parsed.

    Downloads
  • v0.9.1 2723baa472

    v0.9.1
    All checks were successful
    CI / gate (push) Successful in 1m4s
    CI / image (push) Successful in 51s
    Stable

    joeyr released this 2026-08-10 20:20:00 -05:00 | 89 commits to main since this release

    Two gaps between a guarantee this project had written down and what the code actually
    enforced. Both were found by a read-only audit of 0.9.0 (docs/audit.md), neither by a
    failing test — the suite was green through both.

    Fixed

    • An event_id could name a path, not just a filename. calendar/eventid.py argues
      at length that the handle does not need signing, because a forged one names a calendar
      and WRITABLE_CALENDARS is checked on that name before iCloud is contacted. That
      argument holds only while the handle's resource field contributes a filename — and
      nothing checked. A hand-built segment of ../Private/target.ics was joined onto the
      allowlisted collection URL verbatim, and caldav's URL.canonical() does not remove dot
      segments, so the traversal went on the wire for iCloud to normalise. Reachable for both
      update_event and delete_event, and the confused deputy is realistic: event titles
      from a subscribed feed or an emailed invite land in the model's context, and inducing a
      tool call with a crafted argument is the standard shape of that attack.

      decode() now refuses a separator, a dot segment, or either one percent-encoded — the
      check runs against the unquoted form so the encodings do not have to be enumerated,
      while a double-encoded %252f still decodes as the filename character it is. The
      precondition is now recorded in the module docstring next to the argument that depends
      on it, rather than left implicit.

      The tests could not have caught this, which is worth recording separately: the fake
      at tests/test_client.py resolved an event by taking the last segment of whatever URL
      it was handed, throwing the traversal away. A test asserting the refusal would have
      passed against it no matter what the code did. The fake now rejects anything that is not
      the collection URL plus one segment, so the new refusal cases prove something.

    • The identity allowlist only guarded tool calls. IdentityAllowlist implemented
      on_call_tool and nothing else, so any account on the Forgejo instance — which is every
      account, since Forgejo has no OAuth scopes and that is the whole reason
      ALLOWED_SUBJECTS exists — could complete the flow, initialize a session and read the
      server instructions, which name the writable calendars. Calendar names are precisely
      the value the 2026-08-08 history rewrite was performed to remove.

      The hook is now on_message, which covers initialize, the listings and calls alike.
      on_call_tool is kept as a second check behind it, so a change in how FastMCP
      dispatches middleware cannot silently reopen the write surface.

    Downloads