-
released this
2026-08-12 11:56:24 -05:00 | 58 commits to main since this releaseMail 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 whenMAIL_WRITABLE_FOLDERSnames 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 withMAIL_FOLDERS, inherited fromCALENDARSvsWRITABLE_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_readandmark_unreadas two tools, not one takingread: bool. A boolean is
the kind of argument a model passes backwards, andmark_read(read=False)reads correct
while doing the opposite. -
move_messagereturns a newmessage_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_idandprevious_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_messagemoves to trash by default and returns the handle it landed on, so
the delete is reversible withmove_message.permanent=trueis\Deleted+UID EXPUNGEand 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 inMAIL_WRITABLE_FOLDERSor 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 documentedDeleted Messages. Configuration
first, the server's\Trashattribute 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-checksMAIL_FOLDERSagainst 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_secretsdemandedICLOUD_APPLE_IDfrom every tenant. Only CalDAV uses it —
IMAP authenticates as the iCloud Mail address, andConfig.from_envhas 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
advertisesUIDPLUSbut notMOVE(probe 3), so there is no atomic form available.
Copying first means the failure mode is a duplicate, never a loss; aMOVE-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 bySEARCH— the index lags the write —
so looking it up would report a failure that did not happen. A copy that comes back
without aCOPYUIDleaves 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 aSTOREnaming 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:DELETEon 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 serverinstructionssay 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; seedocs/operations.md.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-12 09:22:32 -05:00 | 61 commits to main since this releaseDocumentation 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-12 08:45:02 -05:00 | 62 commits to main since this releaseThe 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_URLis 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
icloudcalendarwhile its path was
/calendarand its Swarm alias wascalendar. Both now match the tenant, the way mail's
have from the start: serviceicloudcalendar, aliasicloudcalendar,MCP_PATH
/icloudcalendar, upstreamhttp://icloudcalendar:8787. The defaultmcp_pathin
config.pymoved with them — deployments set it explicitly, so the default only decides
what a local run without an.envdoes. -
deploy/nginx/calendar.conf→deploy/nginx/icloudcalendar.conf, which also settles
the filename convention as<tenant>.confrather than the<hostname>.confthree
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.mdunder 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_HOSTSis 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.mdis gone, emptied by this release as its own header
said it would be. What is left to build lives indocs/icloud-mail-plan.md(M2–M4).
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-11 22:09:03 -05:00 | 64 commits to main since this releaseRecorded 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 wrongdelete_eventis repaired by creating
the event again; a wrongdelete_calendartakes 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 permitsMKCALENDAR,PROPPATCHandDELETEfor an
app-specific password. It does — and the same probe found the trap the implementation is
now shaped around.Added
-
create_calendar,update_calendaranddelete_calendar, registered only when
CALENDAR_ADMINis set. Deliberately not nested underWRITABLE_CALENDARSthe 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_calendartakesexpected_name, matching the shape of
delete_event— andexpected_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, aDepth: 1PROPFINDthat 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-setwas read as the firsthrefin 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_addressnever hit this only because itsmailto:filter excludes that href
by accident. -
The collection-level operations were filtered by the
CALENDARSallowlist. A
calendar created just now is by definition not in the allowlist, socreate_calendar
reported its own success as a failure — on every deployment that setsCALENDARS, 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
-
DELETEreturns204whether or not it deleted anything. Observed live. Against a
URL built fromcalendar-home-setit 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 thatlist_eventsinsists 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 packagemcp_gateway/icloudmail/,
mirroring the calendar's layout, withmessageid.pyinheritingeventid.py's reasoning
wholesale. -
TENANTpicks the surface, one process per tenant, never both. Defaults to
icloudcalendarso the calendar service needs no edit. Registering both tool sets in one
process would let mail'sALLOWED_SUBJECTShand out the calendar and vice versa, which is
the whole boundary — so it is a process-level choice, not a registration-level one. -
MAIL_FOLDERSis the read scope, starting atINBOX. A folder not named there is
invisible, not empty:list_foldersomits 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=Trueon SELECT andBODY.PEEKon
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_SUBJECTSand 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 toPUBLIC_BASE_URL+MCP_PATHand 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.commail 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-11 14:39:05 -05:00 | 74 commits to main since this releaseTwo 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_time→list_free_timeadd_attendees→update_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_attendeesnow 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
released this
2026-08-11 14:27:15 -05:00 | 76 commits to main since this releaseAdding 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 fromWRITABLE_CALENDARSrather 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=CLIENTdoes not suppress delivery. There is no quiet mode, so
INVITES_ENABLEDis the entire boundary and no live test of this path can avoid mailing
a disposable alias.- The
ORGANIZERround 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. A201means
stored, not sent. - The organiser address is resolved from the account, never from config. An
ORGANIZERiCloud does not recognise is worse than an error: it keeps theATTENDEE
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-ACTIONrenders as "no reply yet" rather than passing
through, because a model summarising the raw token files it next toDECLINEDas 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.SEQUENCEis 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-setSCHEDULE-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 indocs/operations.md.Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
v0.9.4
Stablereleased this
2026-08-10 22:31:01 -05:00 | 84 commits to main since this releaseHardening 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_HOSTSdefaults to thePUBLIC_BASE_URLhost 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 whenPUBLIC_BASE_URLis 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 againstHostwithout one. -
line-length = 100is now a rule. It had been set under[tool.ruff]with nothing
selectingE501and the formatter never run, so it read as enforced while nine lines
exceeded it.E501is selected and those lines are wrapped. A setting that looks like a
rule and is not is worse than no setting. -
deploy/README.mdno longer contradicts the release procedure. It said "on a
release, bumpimage:there", which was true before the stack moved to:latestand
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: DENYandReferrer-Policy: no-referreron the browser-facing OAuth
routes./consentis 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 aReferer.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_reqrequires alimit_req_zonein nginx's http
context, and everything indeploy/nginx/calendar.confis pasted into NPM's Advanced
tab, which lands inside aserverblock. Addinglimit_reqthere alone makes
nginx -tfail 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_eventdecoded the sameevent_idup 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v0.9.3
Stablereleased this
2026-08-10 22:15:35 -05:00 | 85 commits to main since this releaseSecret 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
everyICS_FEEDSentry 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 everyFeedUnavailablemessage go through the same
reduction, since those reach the boot log through the preflight check'sdetail. -
Feed decompression is bounded.
MAX_FEED_BYTESwas enforced against the compressed
body only, and_decodethen handed up to 5 MB togzip.decompresswith 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_TOKENis passed viaenv:rather than interpolated into the step's
script text, matching the rule the workflow already stated forGITHUB_TOKENand
applied nowhere else.
Added
-
pip-auditruns ingate. Clean at the time of writing. Deliberately advisory
rather than blocking:imagehasneeds: gateand 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 tolinux/arm64/v8on the Pi runners. -
Upper bounds on the four version-sensitive dependencies —
fastmcp,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'sexpand=
becameserver_expand=and the old spelling is swallowed into**searchargs, which
presents as a read that quietly stops expanding. -
secrets/and*.secretexcluded from the Docker build context. No secret reaches
the image today, because the Dockerfile copies onlypyproject.tomlandmcp_gateway/.
The exposure was entirely future-tense: oneCOPY . .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_ENABLEDnow gates attendees
alone. Probed against the live account, read-only: iCloud answers afree-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, returning207on them, and granting
read-free-busy. That is Apple declining the operation for an app-specific password,
the same shape as the 0.8.1412. Recorded indocs/plan.mdso nobody rediscovers it.find_free_timeis unaffected: it computes availability from events this server reads
itself, which is now the only way availability will be computed here.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v0.9.2
Stablereleased this
2026-08-10 21:07:03 -05:00 | 88 commits to main since this releaseThe 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 ofdocs/audit.md.Added
-
truncatedandevents_foundin 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. Solist_eventsover a busy
month returnedcount: 500and a clean envelope, and the model reported that as the
user's whole month. A request that failed, reporting success.FetchResultnow 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_timewording is deliberately harder thanlist_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_calendarsalready makes about unknown calendar names. Both tools log
truncated_fromtoo, so a shortcounthas 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/callbackrather than Claude's (the
redirect_uri_mismatchin 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_eventskeys 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 insideEVENT_CACHE_TTL, both indices hit
whichever was written second: one calendar's events returned twice, the other's gone,
nothing inunavailable. 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_whendecided a value
was date-only withlen(text) == 10, which is true of2026-08-07and false of
20260807— a spellingdatetime.fromisoformataccepts 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 forYYYY-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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v0.9.1
Stablereleased this
2026-08-10 20:20:00 -05:00 | 89 commits to main since this releaseTwo 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_idcould name a path, not just a filename.calendar/eventid.pyargues
at length that the handle does not need signing, because a forged one names a calendar
andWRITABLE_CALENDARSis 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.icswas joined onto the
allowlisted collection URL verbatim, and caldav'sURL.canonical()does not remove dot
segments, so the traversal went on the wire for iCloud to normalise. Reachable for both
update_eventanddelete_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%252fstill 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
attests/test_client.pyresolved 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.
IdentityAllowlistimplemented
on_call_tooland 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_SUBJECTSexists — could complete the flow, initialize a session and read the
serverinstructions, 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_toolis kept as a second check behind it, so a change in how FastMCP
dispatches middleware cannot silently reopen the write surface.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-