All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
#191 — undo of a recurring-series deletion no longer fails with 1010, and a failed undo no longer consumes the entry.
-
Fixed —
EventSnapshotstored the original event'sEKRecurrenceRuleobjects by reference; after the series was deleted the references went stale, and re-attaching them during undo made the restore save fail (EKCADErrorDomain 1010, #186 on-device). Rules are now captured as value snapshots (RecurrenceRuleSnapshot) and rebuilt as fresh objects on restore (Refs #191). -
Fixed —
undo/redopopped the record BEFORE executing it: a failed execution silently consumed the entry, so a retry undid the wrong, older operation. A failed undo/redo now restores the record to its stack so the user can fix the environment and retry. #190 —all_day+timezoneis now rejected instead of silently degrading. -
Fixed — pairing
all_day: truewith atimezoneused to silently strip the all-day flag (EventKit keeps all-day events as floating calendar days; setting a timezone afterwards converts them to timed events), shifting occurrence days across the dateline and silently defeatingexcluded_occurrence_dates(#186 on-device finding). The pairing is now rejected with an explicit error at the handler layer (create_event / create_events_batch / update_event) plus defense-in-depth guards in the manager (including the resolved-state case where an existing all-day event is updated with a timezone;clear_timezonestays legal) (Refs #190).
#184 — a non-object recurrence value is now rejected instead of silently dropped.
- Fixed —
"recurrence": "daily"(string instead of object) used to be silently ignored across all four recurrence-parsing tools, creating a NON-recurring event while the response looked successful. Present-but-wrong-type now throws an explicit error with the correct shape (#101 F2 discipline; closes the last same-family gap after v1.16.0 fixedend_date/occurrence_count/excluded_occurrence_dates) (Refs #184).
#180 — new archive-event plugin skill.
- Added — archive an event from a narrative source (meeting notice, announcement, mail thread): correction selection by sender identity + parsable time, update-vs-create keyed on the original notice's Message-ID (
.claude/.ical/state/archives.jsonprimary index), mandatory estimate labelling + source citation in notes, three-tier calendar choice (.claude/.ical/config.yaml→ derivation → ask), same-day deadline surfacing.quick-eventdescription sharpened to mark the boundary (Refs #180).
#185 — batch and series deletions now record undo entries.
- Fixed —
delete_events_batchrecords one.batchundo entry covering every event actually removed (partial failure includes only the successes); a singleundorestores them all.deleteEventSeriesrecords a.deleteEvententry with the master snapshot (recurrence rules preserved, so undo rebuilds the whole series). The span:"all" batch path aggregates through a newdeleteEventSeriesBatch, staying one undo unit instead of N entries. Previously neither path recorded anything — batch/series deletions were invisible toundoandundo_history, inconsistent with singledelete_event(Refs #185).
#182 — create_event / create_events_batch recurrence now supports excluded_occurrence_dates.
- Added — an optional
excluded_occurrence_datesstring array inside therecurrenceobject ofcreate_eventand eachcreate_events_batchitem: skip specific dates in a recurring series at creation time (Refs #182). Same date grammar asoccurrence_date(date-only values interpreted in the event timezone), max 100 entries, duplicates rejected, and the series' first occurrence cannot be excluded (it anchors rollback, duplicate detection, and the returned event ID). EventKit has no EXDATE primitive, so the implementation creates the series, resolves ALL excluded dates to their occurrences (pass 1), then removes them (pass 2, spanthis); any failure removes the entire new series via compensating delete (best-effort all-or-nothing — a rollback failure is itself reported with the residual state, never silent). If the rollback itself fails, the error reports the master event ID + the exclusions already applied. Oneundoremoves the whole series including exclusions. - Added — responses include
excluded_occurrence_dates(normalizedyyyy-MM-ddin the event timezone) +exclusion_count;create_events_batchreports both per-item. - Changed — idempotent retry: duplicate detection (same title + start ±30s) with every requested date already absent from the existing recurring series →
skipped; a requested exclusion date still present on the existing series → conflict error. Known limitation: extra exclusions present on the existing series but not in the request are NOT detected. - Changed —
update_eventandcreate_reminderexplicitly reject the field.
#175 — startup banner now detects the "versioned Claude Code host + ungranted EventKit" combination.
- Added — a new drift-detector signal (
#122family): when the MCP server starts under a Claude Code versioned host binary (~/.local/share/claude/versions/<v>— the path #170 documents as rotating on every auto-update, staling the host-side TCC grant) AND EventKit is not fully granted in the current attribution context (either Calendar or Reminders ungranted counts — a Reminders-only breakage was doubly silent under Calendar-only gating, verify DA-1), the banner explains the rotation and points at the actionable fix (toggle the newest version-number entry in System Settings, or trigger a tool call to re-prompt; troubleshoot-tcc skill for the full checklist). The parent-chain capture (ParentChainSource, #169) is spent only on the ungranted path — granted users pay zero added subprocess cost (spy-asserted); capture failure degrades to a visibleversioned-host check skippedreason per the #122 advisory contract. - Changed — when this signal fires, the
#163"grant via--setup" banner line is suppressed:--setupgrants CheICalMCP-as-foreground-app's own attribution identity, which is the wrong fix when the HOST's rotated grant is the problem — the two lines offered contradictory remediation (verify DA-2).
#173 — parent-chain diagnostics polish (follow-up bundle from the #169 verify).
- Changed — the
--print-tcc-pathparent-chain walk now makes every early stop visible: hop-cap and cycle stops append a synthetic marker hop ((chain truncated after 15 hops)/(cycle detected), carrying the pid the walk stopped at) instead of ending silently;psrows with an emptycommkeep their pid→ppid linkage as(unknown)instead of severing the chain one hop early;psruns with-wwso long bundle paths are never width-clamped; non-UTF-8psoutput and non-zero exits now surface as(parent chain unavailable: …)reasons (with stderr's first line attached) instead of a silently empty chain. The NOTE wording no longer equates the parent chain with macOS's responsible process (it is an approximation), and Claude Desktop users are routed to the sqlite3 TCC query (this shell-invoked chain can never show the Desktop MCP context). 9 new tests including real-subprocess fixture tests for the decode/exit failure paths (LiveParentChainSourceTests).
#169 — --print-tcc-path now prints its execution context (parent process chain) + a context-dependence warning.
- Added — the
--print-tcc-pathdiagnostic output ends with a new "Execution context (parent process chain)" section: the binary's own pid/path marked(this binary), then every ancestor up to launchd (pid 1), captured via a singleps -A -o pid=,ppid=,comm=snapshot walked in-memory (cycle-guarded, hop-capped at 15 — a real Claude Code session chain already spends 10 hops). ANOTE:warning follows, stating that the EventKit authorization status shown above reflects the CURRENT execution context (the responsible process), not an absolute property of the binary (#168) — to diagnose a specific host, run the command from within that host's environment.psfailure/timeout degrades to a visible(parent chain unavailable: <reason>)line; the rest of the output is unaffected. - Internals — new
ParentChainSourceseam (LiveParentChainSourceviaSubprocessRunner, 500 ms budget) + pureParentChainWalker(parse/walk) +ParentChainFormatter(display, per the #117 extraction precedent);--helptext mentions the new section. 14 new unit tests (ParentChainWalkTests,ParentChainFormatterTests).
#168 — troubleshoot-tcc diagnostic now covers the host-app (responsible-process) TCC layer, not just the binary's own grant.
- Fixed —
troubleshoot-tccpreviously inspected only theCheICalMCPbinary's own EventKitauthorizationStatus, missing macOS TCC's responsible-process layer. A user hitCalendar access deniedwhile the System Settings list showed a second entry — a bare version string (e.g.2.1.202= the Claude Code versioned binary at~/.local/share/claude/versions/<version>) whose toggle also mattered. Thetroubleshoot-tccskill,/check-tcccommand,mcpb/README.md, andplugin/CLAUDE.mdnow document the two-layer authorization model, the context-dependence of--print-tcc-path, a full "which toggles to flip" checklist (Claude Code vs Claude Desktop), and a toggle-and-observe verification procedure. - Docs / skill-layer only — no binary or tool-surface changes; the binary is byte-identical to v1.14.1. Sister issues #169 (
--print-tcc-pathshould print execution context) and #170 (Claude Code updates rotate the versioned binary path, staling the host-layer grant) filed for follow-up.
Metadata correction — tool-count consistency across published surfaces.
- Fixed —
server.jsondescriptionread "24 tools" andPROMOTION.mdread "20 tools", while the server actually exposes 29 tools (the authoritative count:mcpb/manifest.jsontools[]has 29 entries, guarded byManifestParityTests, andlong_descriptionalready said 29). Corrected the registry-facingserver.jsondescription,docs/COMPETITIVE_ANALYSIS.md("24 tools → 29 tools", "nearly 2x → over 2x"), and all sixPROMOTION.mdmentions to 29. Historical tool counts in this CHANGELOG and the README version-history table (e.g. "28 tools" at v1.6.0) are left as accurate records of past versions. - No code or tool-surface changes — the binary is functionally identical to v1.14.0. This release exists solely to publish the corrected metadata: MCP Registry versions are immutable, so a metadata fix to the already-published
io.github.PsychQuant/che-ical-mcpv1.14.0 requires cutting a new version. - 454 tests, 0 failures (unchanged).
#166 — Claude Desktop 1.18286.0 silently dropped the entire che-ical server. CONFIRMED root cause: a literal & in the manifest display_name.
- Fixed (#166) — the cure:
mcpb/manifest.jsondisplay_namewas"macOS Calendar & Reminders". The literal&(ampersand) makes Claude Desktop 1.18286.0's tool-injection layer silently drop the whole 29-tool server from every conversation — the transport handshake +tools/listcomplete (Desktop receives the full list) but no tool is ever injected, and nothing surfaces in any log (drift is above the MCP-protocol layer). Claude Code (which does not run this injection layer) worked end-to-end throughout. Changeddisplay_nameto"macOS Calendar and Reminders". Empirically confirmed by single-variable intervention on the exact failing Desktop install (2026-07-03): with the 29-tool binary and manifest otherwise byte-identical, removing only the&flipped the server from dropped → injecting real EventKit calendar data. This also explains the regression timeline exactly —display_namecarried the&long before the 2026-07-02 Desktop update; the update changed how the injection layer handles it. - Fixed (#166) — hygiene (NOT the cause): also aligned
serverInfo.nameto the kebab manifest id via a newAppVersion.mcpServerName = "che-ical-mcp"(repointedServerserverInfo;AppVersion.namestaysCheICalMCPas the on-disk binary/product name for--version/--help/ argv0). TheserverInfo.name≠ manifest-id mismatch was the earlier leading hypothesis (H6) but was empirically refuted — fixing the name and swapping the binary in did NOT restore injection; only the&removal did. The alignment is retained because a server'sserverInfo.namematching its manifest id is a baseline MCP expectation regardless. - Added (#166):
ManifestParityTests.testDisplayNameHasNoXMLMetacharacters— the primary regression guard: failsswift testifdisplay_nameever contains&/</>again (&confirmed-breaking;</>guarded as same-class defense-in-depth). PlustestServerInfoNameMatchesManifestNameguardingserverInfo.name↔ manifestnameparity. 431 tests, 0 failures. - Umbrella note (#166): a cross-che-mcps sweep of all 15
mcpb/manifest.jsonfiles found one other server carrying the same&landmine — che-duckdb-mcp ("DuckDB Documentation & Database"). It is not Desktop-installed → not currently user-impacting, but would drop identically if installed under Desktop 1.18286.0. Tracked as a sibling follow-up. (The earlierserverInfo.namesweep — che-apple-notes / che-xcode / che-contacts — is now moot for the Desktop-drop symptom, since that mismatch is refuted as the cause; those alignments remain worthwhile hygiene.)
#154 sister-concern batch — TCC diagnostics + docs (#157 / #158 / #155) + swift-nio bump (#159).
- Added (#155) — TCC drift detector's third
DriftSignal,.csreqMismatch, catching the #154 silent-denial class: a TCC row pins a code requirement (csreq) the running binary no longer satisfies, so macOS denies at access time whileauthorizationStatus,--print-tcc-path, the startup banner, and System Settings all report green.TCCDatabaseSourcenow reads thecsreqBLOB viahex(csreq)intoTCCEntry.csreqHex; a new injectableCodeSignatureSourceseam runsSecCodeCheckValidityof the running binary againstSecRequirementCreateWithData(csreq)plusSecCodeCopySigningInformationentitlement introspection (the first Security-framework self-validation in the repo). The detector emits only onerrSecCSReqFailed; any other/undecidable OSStatus becomes a skip reason, never a false signal. The banner surfaces atccutil reset <svc> … && … --setupremediation. Design note: emits on csreq-mismatch alone (annotating entitlement state) rather than gating on "mismatch AND empty entitlements" — gating would false-negative a signed binary with a stale pinned row. Caveat: the.mismatchbranch is unit-tested via a fake only — the realSecCodeCheckValiditypredicate can't be validated on a healthy/granted host (the drift state isn't locally reproducible); Live-impl correctness is proven only on an affected machine. - Fixed (#158) — the non-interactive
.mcpbdenial message no longer leads with--setupfor the #154 dead-end signature. When the TCC status was already.deniedat the gate (sorequestFullAccesswas skipped and a bare--setupcan't re-prompt — Terminal--setupreads the lying.fullAccesslegacy row and no-ops), the message now names the real blocker (anthropics/claude-code#63032) and the paths that actually work: Claude Code plugin install,tccutil reset <svc> …+ re-grant,.icsimport. First-run (.notDetermined).mcpbdenials keep the--setuplead. Threaded adeniedByStatusflag from the.denied/.restrictedgate case; extracted the message construction into the pure, injectableEventKitManager.accessDeniedMessage(...)so every branch is unit-testable. - Fixed (#157) — README's macOS shields.io badge still read
13.0+after #119 raised the floor to14.0; bumped the badge to match the body (lines 242/560). - Changed (#159) — bumped
swift-nio2.96.0 → 2.101.0 (Dependabot; clears one advisory). CI green. - 454 tests, 0 failures (+23 across #158 and #155).
#164 — SwiftUI SetupWindow for interactive --setup (follow-up to #163).
- Added (#164): interactive
--setupnow presents a SwiftUI SetupWindow inside the foregroundNSApplication(from #163) instead of firing the requests window-less. The window shows live Calendar / Reminders status, per-entity Grant buttons that callrequestFullAccess*directly (the system dialog), the resolved absolute path of the binary being authorized (the buried.mcpbbinary — with Copy / Open System Settings actions), and flips to "✅ Ready" the instant access is granted (1.5s live re-check). Mirrors che-apple-mail-mcp'sSetupWindow(che-apple-mail-mcp#213), but richer because EventKit has a request API (FDA does not). Non-interactive / non-AppKit paths stay headless; the stdio MCP path never enters the window runloop. - Refactored (#164): extracted
SetupEntityState+SetupModel(injectableAuthorizationStatusSourceprobe) so the window's status mapping, grant handling (incl. sanitized error), live refresh, and timer idempotency are unit-tested without anEKEventStoreor SwiftUI render.SetupRunner.runInteractive()now delegates toSetupWindow.run().
#165 — Calendar denied through Claude Desktop: isNonInteractive misfired on TERM == nil.
- Fixed (#165): the MCP-server access gate detected "non-interactive" via
env["TERM"] == nil, which is true for a process spawned by a GUI app (Claude Desktop spawns the stdio server with no controlling TTY). The gate then fast-failed on.notDeterminedwithout ever callingrequestFullAccess, so the first-grant Calendar dialog never appeared through Claude Desktop and the tool call returnedaccess denied (non-interactive session detected).NonInteractiveDetection.isNonInteractivenow uses an injectable GUI-session signal (production:CGSessionCopyCurrentDictionary() != nil, a window-server/Aqua session) instead ofTERM: a GUI-app-spawned MCP server (no TTY but in the Aqua session) is correctly treated as interactive, so the gate callsrequestFullAccessand the dialog can present. #131 (CI hang) stays protected by theCIclause; #143 (--setuphuman withCI=1) still gets the dialog; launchd daemon / no-GUI-session still fast-fails. Verified via theNonInteractiveDetectionmatrix tests. - 429 tests, 0 failures.
#163 — foreground --setup so the Calendar TCC dialog actually presents, + binary-specific --setup remediation in denial messages and the startup banner.
- Fixed (#163): interactive
--setupran itsrequestFullAccesscalls from a bare CLI async context, which on macOS 14+/26 has no foreground-app context or running run loop to pump EventKit's system modal — so the first request (Calendar) silently returned denied with no dialog while a later one (Reminders) sometimes slipped through (the Calendar-denied / Reminders-granted asymmetry users hit). Interactive--setupnow runs inside a foregroundNSApplication(setActivationPolicy(.regular)+ delegate +app.run()) viaSetupRunner, mirroring che-apple-mail-mcp'sSetupWindow.run()(che-apple-mail-mcp#213) — no SwiftUI window, EventKit presents its own modal; we only need the foreground context. The non-interactive path stays headless (status-only, never enters the run loop). - Added (#163): permission-denied tool responses and the startup banner now surface the resolved absolute path of the running binary plus a copy-pasteable
"<path>" --setupcommand (viaEventKitManager.setupCommandHint/resolvedSetupCommandHint, control-char sanitized). For the buried Claude Desktop.mcpbbinary this is the actionable way to grant THIS binary's TCC permission; the.mcpbdenial message now leads with--setup(foreground dialog) and keeps the plugin-install path as the fallback. - Refactored (#163): extracted
SetupEntityOutcome+SetupRunner.evaluateEntity(status:nonInteractive:request:)— a pure, injectable seam so every setup branch (already-granted / granted / denied / skip-would-block / write-only / sanitized-error) is unit-testable without anEKEventStoreor stdout capture.
#160 — create_event start < end validation (symmetric with update_event).
- Fixed (#160):
createEventhad no time-range guard whileupdateEventrejectedend <= startfor timed events. The asymmetry letcreate_eventpersist inverted / zero-duration timed events thatupdate_eventrejects. Extracted a shared staticEventKitManager.validateTimeRange(start:end:isAllDay:)— throwsinvalidTimeRangewhenstart >= end && !isAllDay, all-day events exempt — called from bothcreateEvent(before save) andupdateEvent(replacing the inline guard; original error message preserved via ahintparameter). NewTimeValidationTestscases exercise the shared guard directly. 405 tests, 0 failures. 6-AI / multi-reviewer verified.
#154 — TCC healing re-prompt unblocked (personal-information entitlements).
- Fixed (#154): long-lived installs upgraded from the pre-v1.7.1 ad-hoc era could hit silent, permanent Calendar (or Reminders) denial on macOS 26.5: the TCC row stays pinned to the old build's cdhashes (csreq match fails at access time) while the healing re-prompt is policy-blocked because the hardened-runtime binary shipped no entitlements — and every status-API-based diagnostic (
authorizationStatus(for:)per-call gate,--print-tcc-path, the v1.10.0 startup banner, System Settings) reports green.Entitlements.plistnow shipscom.apple.security.personal-information.calendars+.reminders(both unrestricted; reverses the #58 item 5 decision, which held on 26.4.1 but Apple has since tightened). First launch of the fixed build is finally allowed to re-prompt; approving rewrites the row keyed to the Developer ID requirement, healing it for all future upgrades. NewEntitlementsPlistTestspins both keys in CI. - Added (#154):
scripts/sign-and-notarize.shstep 2.5 release gate — verifies both personal-information entitlements on the signed binary (fail-fast before the 1-15 min notarization wait), catching a wrong/staleENTITLEMENTSpath that source-level tests cannot see.
Cluster #131 + #143 + #144 — non-interactive EventKit access hardening. PR #142 (#131), PR #145 (#143 #144). 6-AI verified.
- Fixed (#131): GHA CI test hang root-caused —
AuthorizationGate.ensureAccessnow fast-fails.notDeterminedin non-interactive sessions (SSH / launchd / CI) instead of blocking forever on a TCC dialog that can never appear. Removed the-DCI_BUILDcompile-exclusion + theskipIfCI()guards soDispatchRoundTripTestsand the binary-spawnTCCDriftDetectorBannerTestsnow run on CI (GHA green, all banner tests execute). - Fixed (#143):
--setupno longer hangs in non-interactive sessions — it now checksEKEventStore.authorizationStatusbefore calling the blockingrequestFullAccess. On.notDeterminedin a non-interactive session it prints remediation and skips (exits non-zero) rather than blocking; an already-granted binary still reports success. New puresetupAccessDecision(status:isNonInteractive:)helper + decision-table tests.--setupuses a narrower non-interactive check (TERM/ppid) that deliberately excludes theCIenv var, so a human in Terminal withCI=1still gets the dialog. - Changed (#144): renamed
isLaunchd→isNonInteractive(theAuthorizationGateparam +EventKitError.accessDeniedcase label were namedisLaunchdbut fedisNonInteractiveSession= launchd ∪ no-TTY ∪ CI) acrossAuthorizationStatusSource+EventKitManager+ tests, and generalized the launchd-specific remediation wording ("restart the launchd job" → "restart the non-interactive job (launchd service, CI runner, etc.)") in both the launchd-only and SSH+non-interactive branches.
Verify follow-ups (#146 / #147 / #149 / #150) — test + escape hardening. PR #148 (#146 #147), PR #151 (#149 #150). 6-AI verified.
- Fixed (#146 / #147):
--setuperror output now routes throughEventKitErrorSanitizer.escapeForStderr(consistency with other stderr-boundary callers);testIsNonInteractiveDetectionmade robust to theCIenv var (was env-fragile underCI=1). - Changed (#149): extracted a pure injectable
NonInteractiveDetection.isNonInteractive(env:ppid:includeCI:)helper. Both the MCP server gate (includeCI: true) and--setup(includeCI: false, preserving the #143 CI carve-out) delegate to it, so the predicate is matrix-testable(TERM, CI, ppid) × includeCIvia an independent oracle instead of inline process-global reads. - Changed (#150):
EventKitErrorSanitizer.escapeForStderrnow also escapes the C1 control band (\x80..\x9F), closing the 8-bit CSI (\xC2\x9B) terminal-hijack form; printable scalars start at\xA0, so the C1 range is control-only.
Cluster: 16 verify follow-ups from #108 (TCC has*Access refactor) and #122 (TCC drift detector banner) — one PR (#135), 4 commits, 367 tests pass (was 348).
- Deploy floor raised to macOS 14.0 (Sonoma) (#119):
Package.swift.macOS(.v13)→.macOS(.v14). Removes 5#available(macOS 14.0, *)branches acrossmain.swift+AuthorizationStatusSource.swiftplus the@availableclass-level annotation onAuthorizationGateTests. macOS 13 (Ventura) users will hit a hard SDK-version mismatch rather than a silent dyld failure —mcpb/manifest.jsonnow declarescompatibility.runtimes.macos = "14.0"to surface this at install time. Rationale: macOS 14 (Sonoma) is ~2.5 years old as of this release, EventKit's per-call gate work (#108 Phase 2) is built on macOS 14 APIs, and maintaining dead pre-14 branches outweighed any negligible Calendar/Reminders MCP user share on macOS 13.
TCCStatusFormatterenum + 6 unit tests (#117): extracts the inline--print-tcc-pathstatus formatter into a unit-testableTCCStatusFormatter.describecovering all 5EKAuthorizationStatuscases +@unknown defaultraw-value escape hatch. Closes the formatting-regression-only-caught-by-manual-smoke-test gap.BinaryPathResolverenum + 7 unit tests (#121, #128, #129): unifies argv[0] resolution across--print-tcc-path, the startup banner, and--self-update. Usesrealpath(3)to walk multi-level symlink chains (closes #121 — single-hopdestinationOfSymbolicLinkleft intermediate paths visible) and exposesresolveWithPATHFallbackfor bare-argv[0]$PATHwalk used by--self-update.SubprocessRunnerhelper (#126): consolidatessqlite3+pssubprocess execution fromLiveTCCDatabaseSourceandLiveProcessInventorySourcewith hard-capDispatchSourceTimertimeout (500ms default). Hung child processes (TCC.db locked / sandbox edge case) surface as explicitfailureReason: "sqlite3 timed out after 500ms"instead of blocking MCP server startup.EventKitError.unsupportedEntityType(rawValue: UInt)case (#118):LiveAuthorizationStatusSource.requestFullAccess@unknown defaultnow throws this typed error instead of returningfalse(which got misattributed downstream as user-denied). Future Apple-addedEKEntityTypecases surface as a build-version mismatch rather than a phantom denial.EventKitManager.forTesting(probe:)DEBUG factory (#115): test-only construction path with explicit AuthorizationStatusSource injection. NewEventKitManagerForTestingTestsexercises it so the seam is provably wired, not dead code.- Direct unit tests for
LiveTCCDatabaseSource+LiveProcessInventorySource(#124): 13 tests covering missing binaries, missing TCC.db, corrupt-db exit-status surfacing, synthetic-db happy path with realsqlite3seed, tiny-timeout race tolerance, exact-basename match positive + negative cases, deep-path basename extraction. Closes the CI coverage gap where Live impls were only exercised through the GHA-flaky binary-spawn integration tests.
AuthorizationGate.ensureAccessnow acceptsisSSH/isLaunchddefaulted params (#113):EventKitManager.ensureCalendarAccess/ensureReminderAccesspassSelf.isSSHSession+Self.isNonInteractiveSessionthrough to the gate. Restores the SSH-context and launchd-context-specific workaround text inEventKitError.accessDeniedthat the #108 Phase 2 refactor had hardcoded tofalse.EventKitManager.initnowfileprivate(#115): singleton invariant (EventKitManager.shared) no longer relies on convention. Production code must use.shared; tests use the newforTesting(probe:)factory.AuthorizationStatusSourceprotocol dropsSendableconstraint (#116, Option A from issue):EKEventStoreis non-Sendable; the protocol constraint forced@unchecked Sendableworkarounds onLiveAuthorizationStatusSourceandMockAuthorizationStatusSource. Actor isolation inEventKitManager(the sole owner) provides the safety guarantee instead.@preconcurrency import EventKitsuppresses the framework-side warning. Mock loses@unchecked Sendablecleanly.ProcessInventoryParser.parseRowexact-basename match (#125): comparison switched fromcommPath.contains(processNameSubstring)toURL(commPath).lastPathComponent == processName. Eliminates false positives like/path/CheICalMCP-helperand/tmp/CheICalMCPLegacy.bakfrom the banner's stale-process count. Known limitation: versioned binaries (e.g.CheICalMCP-1.10.0) are not matched — recommend opening a follow-up if package-manager distribution adopts that naming.MockAuthorizationStatusSourcegains explicit fresh-instance pattern comment (#120): documents why the unsynchronizedrequestCallCountis safe (single-test ownership, never shared) so future copy-paste of the mock pattern doesn't propagate a hidden race.testBannerAppearsInDefaultMCPServerModeaddsXCTAssertLessThan(elapsed, 1.5)latency budget (#127): encodes the Plan tier #122 target (200ms target, 1.5s assertion bound for local-host noise tolerance) so banner-emission regressions are caught in CI rather than discovered through user reports.
mcpb/README.mdpost-install / upgrade narrative rewritten (#114): drops the disproved "cdhash invalidation breaks TCC grants on each release" hypothesis; reframes the silent-failure mode as the in-processhas*Accesscache anti-pattern (fixed structurally in v1.9.0 via #108 Phase 2). Errata header inCHANGELOG [1.8.1]entry preserves the original wrong narrative for audit. Inline troubleshooting paragraph at line 115 also corrected — previously contradicted the lead paragraph.CLAUDE.mdgains 'Startup Banner CLI Skip List' section (#130): documents the--setupskip decision (Option B from issue). Strategy Lock Acceptance Criterion #7 (--setup runs drift check at start) was silently retracted by the Plan tier; this commit makes the retraction explicit with the user-flow rationale (--setupis the remediation path, not a diagnostic surface). Future revisits must write a follow-up issue rather than flip the skip list silently.README.mdupdated to reflect macOS 14.0 floor + current v1.10.0 version (lines 237, 540, 544).
- TCC drift detector + startup banner (#122,
Sources/CheICalMCP/EventKit/TCCDriftDetector.swift+TCCDatabaseSource.swift+ProcessInventorySource.swift): emits a single-shot stderr banner at MCP-server-mode startup with version, binary path, PID, and any drift signals detected. Two signals: (1) TCC.db path mismatch — TCC has a grant for CheICalMCP but recorded against a different binary path than the running one (typical when~/bin/CheICalMCPand the.mcpbinstall path co-exist); the running binary will get.notDeterminedeven though "CheICalMCP" appears in System Settings. (2) Stale running processes — long-lived CheICalMCP processes started before the on-disk binary mtime hold cached auth state from older code (root cause confirmed during #122 reproduction: 37 stale processes from pre-v1.8.0 on the issuer's host). Banner is advisory and non-blocking; failed reads (sqlite3 unavailable, ps blocked, TCC.db locked) surface as skip reasons rather than aborting startup. Opt-out viaCHE_ICAL_MCP_NO_BANNER=<any non-empty>for CI / automation. Skip applies to--version/--help/--setup/--print-tcc-path/--self-update/--clipaths. - Drift detection test seams (#122): two narrow
<Domain>Sourceprotocols (TCCDatabaseSource,ProcessInventorySource) following the CLAUDE.md Test Seam Convention. Live implementations shell out to/usr/bin/sqlite3 -readonlyand/bin/ps -Arespectively, both returning skip reasons on failure rather than throwing. Puredetect()+formatBanner()make the drift logic unit-testable without subprocess spawn or TCC.db access. 18 new tests (TCCDriftDetectorTests.swift13 unit cases +TCCDriftDetectorBannerTests.swift5 subprocess-integration cases) cover happy / skip / mismatch / stale / banner-format invariants. Integration tests use a copy-binary-to-temp-path pattern to verify alternate-path scenarios (proxy for.mcpbinstall path) without machine-specific TCC fixtures.
- Pipe-deadlock bug in subprocess helpers (#122):
Process.waitUntilExit()was called before draining stdout/stderr pipes;ps -Aoutput regularly exceeds the 64KB OS pipe buffer, blocking the child on write and deadlocking the parent's wait. BothLiveTCCDatabaseSourceandLiveProcessInventorySourcenow read pipes viareadDataToEndOfFile()first, then wait — child can drain naturally on stdout close. Surfaced during banner smoke-testing of #122; the unit-test suite never exercised live subprocess execution so the bug wasn't caught byTCCDriftDetectorTests. - Parent pipe write-end fd leak in subprocess helpers (#122 verify round 3.3): The R1 fix above established read-before-wait order but missed the second half of the POSIX pipe rule:
read(2)only returns EOF when every write-end fd closes, and Foundation'sProcessretains parent-side write-end handles afterrun()until thePipeis deallocated. BothLiveTCCDatabaseSourceandLiveProcessInventorySourcenowclose(2)the parent'sstdout.fileHandleForWritingandstderr.fileHandleForWritingimmediately afterprocess.run(), ensuringreadDataToEndOfFileactually returns once the child exits. Local macOS 26 (Tahoe) appears to schedule fd cleanup aggressively enough to mask this; GHA macos-15-arm64 (Sequoia) blocks indefinitely without the explicit close.
- CWE-117 stderr-injection defense in TCC drift banner (#122 verify round 1, B1 — three-reviewer consensus):
formatBannerwas writingrunningBinaryPath(argv[0]-derived),recordedClient(TCC.db-sourced),bundleID, ISO dates, sample-PID list, and everyskipReason(embedding sqlite3/ps stderr + frameworklocalizedDescription) directly to stderr withoutEventKitErrorSanitizer.escapeForStderr. Hostile content paths real: TCC.db is writable with Full Disk Access, and a\r[banner] che-ical-mcp 99.99.99 — TCC OKinjection would forge a fake banner line in the operator's terminal — regressing the codebase-wide CWE-117 discipline (EventKitErrorSanitizer.swift:144-147,219,258-271). All interpolated values now pass throughescapeForStderrbefore stderr write. Two regression tests (testFormatBannerEscapesControlCharsInSkipReasonsandtestFormatBannerEscapesControlCharsInRecordedClient) assert raw\r/\ncannot forge banner lines.
- Per-service TCC path mismatch detection (#122 verify round 1, B2 — codex/logic/DA consensus):
TCCDriftDetector.detect()was checkingruntimeHasMatchonce globally across all TCC services. If Calendar grant pointed at/path/Aand Reminders grant pointed at/path/B, and the runtime was at/path/B, the Calendar mismatch was silently suppressed because Reminders matched. Now computed per-service: each service (kTCCServiceCalendar,kTCCServiceReminders, ...) emits its own mismatch signal independently. Bundle-ID-only entries (path-independent grants) continue to be filtered out before the per-service check. New teststestCalendarMismatchEmittedEvenWhenRemindersMatches+testBundleIDOnlyEntriesProduceNoMismatchlock the behavior. - POSIX single-quote escaping for actionable commands (#122 verify round 1, B3; refined round 2, R1): Banner-emitted
tccutil reset … && "PATH" --setupandpkill -f "PATH" …used double-quote shell escaping, which breaks on paths containing"/$/`/\\/'/newline/etc. Now uses POSIX single-quote escaping viaTCCDriftDetector.shellSingleQuote(_:):'/Users/test/O'\\''Hara/CheICalMCP'. Also addstccutilShortName(forService:)whitelist — for unknown TCC services (kTCCServiceContactsetc., orservice-column poisoning via TCC.db write), the banner emits a manual-remediation hint instead of a copy-pasteabletccutil reset kTCCServiceXxxline. Round-2 refinement (R1):shellSingleQuotealone preserves shell semantics but does not neutralise control chars on the stderr stream — a path with literal\ninside single quotes would still split the banner line and forge a fake[banner]line. The fix detects control chars upfront viapathHasControlChars(_:)and emits a safe-display hint (with path passed throughescapeForStderr) instead of a copy-paste command when control chars are present. Avoids the double-escape regression Codex flagged where wrappingshellSingleQuote(...)inescapeForStderrwould convert\in the'\''splice to\\and break POSIX shell parsing for paths with apostrophes. Six tests cover the helper, whitelist suppression, integration assertion, control-char detection, and the safe-display hint emission. - Removed dead
do/catchwrapper inemitStartupBanner(#122 verify round 1, F1): The catch block was unreachable — all internal calls usetry?(non-throwing) or are non-throwing by design. Comment claimed "future maintenance could add a throwing path" but currently misleads reviewers into thinking there's a defense in place. Restructured without the wrapper. - Deleted misleading "PID 0 sentinel" comment (#122 verify round 1, F2): Comment in
TCCDriftDetector.swiftclaimed PID 0 sentinel handling that the code never implemented (noif ownPID == 0branch). - Defensive guard against empty
commPathinpsparser (#122 verify round 1, F6): Pathologicalpsrows (zombie / kernel-thread with strippedcomm) could yield 6 validlstarttokens followed by an empty 7th. AddedcommPath.trimmingCharacters(in: .whitespaces).isEmptyguard before the substring filter. - Explicit
TimeZone.currentpin onLiveProcessInventorySource.lstartFormatter(#122 verify round 1, F8): Without explicit pin, formatter falls back to system default which is normallyTimeZone.currentbut can drift on DST transitions. Pinning prevents mismatches againstattrs[.modificationDate]Date comparison. - Static cached
TCCDriftDetector.iso8601Formatter(#122 verify round 1, F9): Banner emits once per startup so perf impact zero, but pattern is now consistent withLiveProcessInventorySource.lstartFormatterstatic caching. - CI: 8 tests compile-time excluded on GitHub Actions (#122 verify round 3, tracked in #131):
TCCDriftDetectorBannerTests(5 binary-spawn integration tests) andDispatchRoundTripTests(3 real-server dispatch tests) hang the GHAmacos-15-arm64runner for 20m with zero test output until the job timeout cancels. R6 verbose+PTY diagnostic (script -q /dev/null swift test --verbose) finally surfaced the actual hang point:DispatchRoundTripTests.testCleanupCompletedRemindersIsDispatchedinvokesexecuteToolCallon a realCheICalMCPServer, which routes to a handler that callsEventKitManager.shared— and on macOS 15 in a headless sandbox with no TCC grants, EventKit framework blocks indefinitely waiting for a TCC prompt rather than returning.deniedsynchronously like macOS 26 (Tahoe). Both test files now use#if !CI_BUILD...#endifguards activated by the workflow's-Xswiftc -DCI_BUILDflag. CI runs 330/330 tests; local dev runs 338/338 tests. Banner format invariants remain covered by the 13 mocked-source unit tests inTCCDriftDetectorTests.swift; the R3.3 production pipe-write-fd fix (above) stays as legitimate POSIX hygiene independent of which test happened to be the CI-hang trigger. Workflow also retains--verbose+ PTY instrumentation as a diagnostic safety net for future test additions. Removal criteria: when #131 is properly resolved (likely viaFakeEventKitManagerinjection on the dispatch tests + investigation of the banner test hang, which is a separate sub-cluster), drop both#if !CI_BUILDguards and the-DCI_BUILDworkflow flag together.
EventKit TCC access gate refactor (#108 Phase 2) — eliminates the hasCalendarAccess / hasReminderAccess actor-private cache anti-pattern in EventKitManager. Per-call EKEventStore.authorizationStatus(for:) cheap check now drives every tool call's access gate, aligning with Apple's documented pattern (TN3153 + authorizationStatus(for:) API guidance) and surfacing TCC state changes immediately as actionable accessDenied / insufficientAccess / unknownAuthState errors instead of being masked by stale cache. Bundles #109 --print-tcc-path diagnostic flag — prints binary path, bundle ID, current authorization status, and ready-to-paste tccutil reset / sqlite3 commands for users troubleshooting TCC issues post-install.
EventKitManagerTCC access gate refactored to per-call status check (#108 Phase 2, breaking internal API): replacedrequestCalendarAccess()/requestReminderAccess()(cached granted state inhasCalendarAccess/hasReminderAccessflags) withensureCalendarAccess()/ensureReminderAccess()(each tool call cheap-readsEKEventStore.authorizationStatus(for:)and dispatches viaAuthorizationGate.ensureAccess). Cache removed entirely — every tool call sees fresh TCC state. Apple-recommended pattern per TN3153 — by callingauthorizationStatus(for:)each time rather than caching, the app reflects user changes (System Settings toggle / TCC db reset / future macOS policy shifts) immediately. New constructor parameterinit(authorizationSource: AuthorizationStatusSource? = nil)defaults toLiveAuthorizationStatusSourceso production callers viaEventKitManager.sharedare unchanged; tests injectMockAuthorizationStatusSourceto exercise theAuthorizationGateswitch without real EventKit. No MCP tool surface impact — internal API only.
AuthorizationStatusSourceprotocol +LiveAuthorizationStatusSource(#108 Phase 2, NEWSources/CheICalMCP/EventKit/AuthorizationStatusSource.swift): narrow<Domain>Sourcetest seam protocol perCLAUDE.mdTest Seam Convention with two methods (authorizationStatus(for:),requestFullAccess(for:)). Production wiresLiveAuthorizationStatusSourcesharing theEventKitManager'sEKEventStoreinstance. TheAuthorizationGate.ensureAccessstatic helper implements the switch logic againstEKAuthorizationStatus—.fullAccessshort-circuits;.writeOnlythrows newinsufficientAccess;.denied/.restrictedthrows existingaccessDenied;.notDeterminedtriggers expensiverequestFullAccessand throws if denied after prompt;@unknown defaultthrows newunknownAuthState. Both macOS 14+ and pre-14 (legacy.authorized) branches handled explicitly.EventKitError.insufficientAccess(type:)(#108 Phase 2): new error case for macOS 14+.writeOnlypartial-access state. Read operations cannot silently fall back — user must manually upgrade to full access in System Settings. Error message includes step-by-step upgrade instructions.EventKitError.unknownAuthState(type:statusValue:)(#108 Phase 2): defensive new error case for@unknown defaultin the authorization-status switch, guarding against future EKAuthorizationStatus enum cases this build doesn't recognize. Error message instructs user to run--setupfrom Terminal,tccutil reset, or upgrade CheICalMCP.Tests/CheICalMCPTests/AuthorizationGateTests.swift(NEW) (#108 Phase 2): 7 pure unit tests covering eachEKAuthorizationStatusswitch branch —fullAccess(short-circuits without calling request),denied(throwsaccessDenied),restricted(throwsaccessDenied),writeOnly(throwsinsufficientAccess),notDeterminedwith granted result (calls request once and returns),notDeterminedwith denied result (calls request once then throws), plus a separate test assertingtypeNamepropagation into theaccessDeniederror. UsesMockAuthorizationStatusSourcethat records request-call count so silent-fail paths are explicitly asserted-against (assertrequestCallCount == 0for.denied/.restricted/.writeOnly).--print-tcc-pathdiagnostic flag (#109): bundled into v1.9.0 per Phase 2 sister-bundle decision. Prints binary path, bundle identifier, current EventKit authorization status (Calendar + Reminders, with macOS-14-aware status string formatting),tccutil resetsnippet (with bundle ID interpolated),sqlite3TCC.db query snippet, and System Settings paths. Designed for.mcpbinstalled users who need to locate the extracted binary path before running--setupfrom Terminal. Output exits before MCP server mode — purely diagnostic.
Errata (2026-05-25, #114): the original narrative for this release framed the silent-failure as "cdhash changes on each release invalidate TCC grants". The cdhash hypothesis was disproved during #108 Phase 1 smoke testing — TCC grants for a notarized Developer-ID-signed binary survived 5 consecutive binary swaps because TCC keys entries by designated requirement (csreq) which is stable across cdhash changes for the same signing identity. The actual root cause is the in-process
has*Accesscache anti-pattern fixed structurally in v1.9.0 (#108 Phase 2). The.mcpbREADME +--setupworkflow that shipped in 1.8.1 still helps for the first-install case (TCC entry truly absent), and the diagnostic instructions remain accurate; only the "Why this is needed" framing was wrong. Seemcpb/README.mdfor the corrected narrative.
Documentation-only release. Adds the post-install / upgrade TCC permission setup guide for .mcpb installation path (#108 Phase 1). Original (incorrect) framing kept here for audit; see errata above. Diagnoses the silent-failure mode where reinstalling .mcpb invalidates the existing TCC grant (cdhash changed — see errata) and the MCP subprocess context cannot surface a re-authorization dialog. Provides verified workaround (run --setup from Terminal once after install / upgrade) until the v1.9.0 structural fix (per-call authorizationStatus gate, Phase 2) ships.
.mcpbpost-install / upgrade TCC permission workflow documented (#108 Phase 1): newmcpb/README.mdcovers verify-current-state (TCC db SQL query), locate-extracted-binary (findsnippet), run---setup-from-Terminal, and troubleshooting (tccutil reset+ manual System Settings toggle). Closes the documentation gap that left.mcpbupgrade-path users facing silent calendar tool failures without an obvious remediation path. Phase 2 (v1.9.0) landed the structural fix that removes the actor-privatehasCalendarAccess/hasReminderAccesscaches so subsequent TCC state changes surface as immediateaccessDeniederrors instead of silent failures. (Per #114 errata: the actor-private cache was the actual root cause; the original "cdhash invalidation" framing here was a wrong hypothesis disproved by Phase 1 smoke.)
Wire-format consistency wave + response-shape parameters. Completes the #101 cluster (5 closed issues across 3 days) — event listing tools gain detail_level / fields / display_timezone / limit for LLM-friendly verbosity tuning, and all 5 list/search envelopes converge on top-level <entity>_count with pre-limit semantics. Two breaking wire-format changes (#102, #107) — MCP clients reading metadata.returned or result_count must update. Validator hardening (#101 F1-F3) closes a Int.max DoS trap and the type-coerce-bypass class on 2 more helpers. Runtime-anchored drift detection (#103) prevents formatEventDict ↔ validEventFields divergence.
- Event listing response-shape parameters (#101, originally PR #47 by @fabiocarvalho777, taken over with
Co-authored-byafter 6-AI verify FAIL): four optional parameters onlist_events,search_events, andlist_events_quick:detail_level(string,"summary"|"standard", default"standard"): preset response-verbosity tiers.summaryreturns 10 core fields (id/title/dates/timezone/is_all_day/calendar/location);standardreturns all fields. Cuts token usage substantially when LLM consumers don't need notes/url/recurrence/attendees.fields(string array): fine-grained field selection — overridesdetail_levelwhen both supplied. Unknown field names rejected withinvalidParameterlisting all available options. Non-array input or non-string elements throw with the offending index, not silently dropped (#28 R2-F1 type-coerce-bypass class).display_timezone(string, IANA Region/City orUTC): converts*_localtimestamp fields to specified zone. Strict membership check viaTimeZone.knownTimeZoneIdentifiersrejects abbreviations (PST/EST) and POSIX-style offsets (GMT+08:00) that have ambiguous DST semantics. Per-eventtimezonefield continues to report event's own zone.list_events_quickenvelopetimezoneechoes the requested zone instead of system tz so renders are internally consistent (M4).limit(integer): added tosearch_eventsandlist_events_quick(was already onlist_events). Loud-failure on type mismatch (per #25) — string"5", fractional5.5, etc. throw rather than silent-coerce. Bounds: must be> 0and≤ 10000(defense-in-depth against accidentally-massive responses).
- envelope count fields unified to top-level
<entity>_count,search_remindersgainslimitparameter (#107, breaking wire-format change):list_events.metadata.returned+list_reminders.metadata.returnedremoved; replaced with top-levelevent_count/reminder_countsemantically aligned to pre-limit total (taken fromtotalAfterFilterbefore any prefix truncation).search_remindersadds optionallimitparameter (max=10000 viarequireOptionalLimit, defense-in-depth) soreminder_countsemantic now exactly matchesevent_countacross all 5 list/search envelopes.metadatawrapper retained for query state info (total_in_range/total_after_filter/filter/sort/limit). Callers compute truncation via<entity>_count - len(events)per existingsearch_eventspattern. MCP clients readingmetadata.returnedneed to update. search_reminders.result_count→reminder_count(#102, breaking wire-format change): renames the response envelope field on the reminder side, mirroring #101 M1 on the event side. The reminder tool family now matches the event family's<entity>_countconvention. MCP clients hardcodingresult_countneed to update. (Note: post-#107, this entry's "metadata.returned keeps post-limit semantics" caveat no longer applies — all 5 envelopes now use top-level<entity>_countwith pre-limit semantic.)search_events.result_count→event_count(#101 M1, breaking wire-format change): renames the response envelope field across event-listing tools (list_events_quick.event_countwas already canonical). MCP clients hardcodingresult_countneed to update. (Note: post-#107,list_events.metadata.returnedis also removed in favor of top-levelevent_count.)InputValidation.parseDisplayTimezonestrictness (#101 B3): rejects Foundation-accepted abbreviations and POSIX offsets that varied semantics across hosts. Region/City IANA identifiers +UTCalias accepted; everything else rejected withinvalidParameterlisting examples. Adds determinism to*_localrendering at the cost of accepting a narrower input set.summarydetail_level description (#101 LO1): tool schema now lists all 10 emitted fields (was misleadingly described as "title, times, calendar, location only").InputValidation.validEventFieldsruntime-anchored drift detection (#101 M3, strengthened by #103): bidirectional drift test catches forgotten updates whenformatEventDict's emission set changes. Initially landed (#101 M3) as avalidEventFields↔formatEventDictKeysmirror-pair check (documentation-only contract, drift detection only when maintainer remembered to touchValidation.swift); strengthened in #103 to anchor against actual runtime emission viaEventFormattingSourcetest seam — fake event drivesformatEventDictthrough every conditional path, dict.keys becomes the source-of-truth compared bidirectionally withvalidEventFields. Manual mirror constant deleted.
Int.maxboundary trap closed (#101 F1, verify-fix from re-verify FAIL):requireOptionalIntandrequireIntIfPresentnow useInt(exactly: d)instead ofInt(d). Previously, a JSON payload{"limit": 9223372036854776000}(just aboveInt.max) decoded as.double, passed the bound checkd <= Double(Int.max)(a tautology —Double(Int.max)rounds UP to 2^63 becauseInt.max=2^63-1is not exactly representable asDouble), thenInt(d)trapped the MCP server process. The cap at 10000 did not help — the trap fired insiderequireOptionalIntBEFORErequireOptionalLimit's cap check. Root-caused by 3-reviewer convergence (Logic + Security + Codex). DoS class; closed at root.- Validator-contract uniformity for
detail_level+display_timezone(#101 F2, verify-fix): both helpers previously usedarguments[K]?.stringValuewhich returnsnilfor any non-string input, silently coercing to default. Same #28 R2-F1 type-coerce-bypass class as the B1/B2 fixes forlimit+fields. Now distinguish absent (return default/nil) from present-but-non-string (throwinvalidParameter). Restores the validator contractValidation.swift:4-8claims ("never silently drop or coerce"). UTCecho lossy fix (#101 F3, verify-fix):TimeZone(identifier: "UTC").identifierreturns"GMT"on Foundation/macOS (Foundation normalizesUTCtoGMTinternally). Previously the responsedisplay_timezoneecho + envelopetimezonefield showed"GMT"for a requested"UTC", lossy-by-spec. Helper signature changed fromdisplayTimezone: TimeZone?torequestedDisplayTimezone: String?; all 4 echo sites inServer.swiftnow read raw user input viaarguments["display_timezone"]?.stringValueso requested tokens round-trip verbatim.
Hardening + features wave following the v1.7.1 security baseline. This release lands the post-merge sanitizer-hardening cluster (#73 #74 #80 #85 #86 #94), the install / CI / distribution infrastructure cluster (#49 #50 #51 #98), zh-TW docs sync (#75 #90), and post-v1.7.1 polish (#46 #57 #58 #60). 30+ commits since v1.7.1, all with Refs #N IDD discipline and 6-AI parallel verify before merge.
scripts/build-mcpb.shstep renumbering (#57): seven script steps now use uniform[1/7]…[7/7]denominators instead of the mixed[0/5]/[0.5/5]/[N/4]/[3.5/4]pattern that PR #52 left behind when it inserted the sign + notarize step. Pure echo-string change; no behavior modification.- Redo error for
.createEventinterpolates event title (#46):EventKitManager.executeRedofor the.createEventcase now interpolates the original event title into the user-facing message ("Cannot redo creation of event '<title>' — please create it again manually"), using the same title-interpolation strategy as the sibling.createReminderarm. Also silences the SourceKit "immutable value 'title' was never used" warning onmain. Sources/CheICalMCP/Entitlements.plistdocumentation comment (#58 item 5): adds an XML comment explaining intentional emptiness — hardened runtime alone is the macOS 26 TCC trigger, EventKit is user-prompt-driven and requires no entitlement key for outside-MAS distribution. Prevents future maintainers from over-claiming entitlements.Makefile release-signed:cwd note (#60 item 1): adds a comment on therelease-signed:target noting it must be run from the repo root because it invokes./scripts/build-mcpb.shvia a relative path. Documents themake -f /abs/path/Makefile release-signededge case identified during PR #52 verify.
scripts/sign-and-notarize.shpre-flight checks (#59 items 1+3): adds two pre-flight checks —xcrunavailability (clearer error than mid-flowxcrun: error: unable to find utility "notarytool"when Xcode Command Line Tools are missing) and Mach-O sanity check on$BINARYviafile ... | grep -q "Mach-O"(catches fat-finger paths beforecodesignproduces a cryptic "unsupported file type" error). Items 2+4 from #59 (idempotency optimization, cross-reviewer disagreement log) are deferred — see PR #63 description for rationale.
scripts/sign-and-notarize.shpost-notarize spctl cross-check (#53): afterxcrun notarytool submit --waitreturns success, the new[4/5]step runsspctl -a -vvv -t install $BINARYand refuses to exit 0 unless the output matchessource=Notarized Developer ID. Catches the partial-state race where notarytool reports success but Apple's CDN hasn't propagated the verdict — without this check, the binary would be packed into the .mcpb in a "signed but Gatekeeper-rejects" state and only fail on user first launch. Step counter renumbered[1/4]…[3/4]→[1/5]…[5/5]to absorb the new cross-check.scripts/build-mcpb.shpre-pack signature integrity check (#53): whenever signing was requested ($SHOULD_SIGN=true— broader thanREQUIRE_CODESIGN, so direct./scripts/build-mcpb.shinvocations withDEVELOPER_IDset also benefit), the script now runs (a)codesign --verify --strictfor actual integrity, then (b)codesign -dv | grep -F "Authority=$DEVELOPER_ID"to confirm the EXACT identity the user asked for (not just "any Developer ID team"). Belt-and-suspenders againstsign-and-notarize.shexit-code loss in piped/CI invocations and against any post-sign tampering of the universal binary beforemcpb pack.make verify-release-readytarget (#48): new pre-flight that comparesAppVersion.currentagainstgit tag --sort=-creatordate | head -1usingsort -Vfor directional comparison. Three drift cases reported separately so the message is actionable — match (no bump needed), AHEAD (expected pre-release; tag when ready), BEHIND (downgrade alarm — DO NOT tag, investigate stale branch / bad merge first).release-signednow depends on this target so the warning surfaces every release-cut. Warning-only on drift; hard-fails ONLY whenVersion.swiftcannot be parsed at all (because no other release target can succeed in that case anyway —build-mcpb.shStep 0.5 also requires the same regex).
-
cleanup_completed_reminderstool (#21): single-call cleanup of all completed reminders, eliminating the external list-then-delete loop that daily-cleanup automations used to require.dry_run=truedefault surfaces the exact scope before deletion (matchesdelete_events_batchsafety pattern); optionalcalendar_name+calendar_sourcescope to a single list. Implementation composes existinglistReminders(completed:)+deleteRemindersBatchprimitives — noEventKitManagerchange. InheritsdeleteRemindersBatch's no-undo behavior (separate issue candidate to backfill).calendar_sourcealone (withoutcalendar_name) is rejected — EventKit cannot scope to "all lists on this account" and silently falling back to "all lists on all accounts" would be a destructive silent failure.limitparameter (default 1000) caps each invocation; response includesremainingso callers can re-invoke to drain large backlogs without multi-MB responses or long blocking delete loops.- Dry-run preview returns only
reminder_id(no titles/calendar names) — titles are attacker-controllable (malicious.icsinvites, shared-list collaborators) and must not bypass theUntrustedContentWrapperboundary. Pipe throughlist_reminders(wrapped) when human-readable titles are needed. - Response shape is stable across dry-run / execute-empty / execute-non-empty branches: every response includes
dry_run,total,deleted_count,deleted_ids,failures,remaining, and (when applicable)message. Dry-run reports zeros in thedeleted_*fields so parsers don't need branch-aware key handling. totalreflects the deduped count of distinct reminders the tool will act upon. iCloud shared-list aliasing can causelistRemindersto return the same reminder twice; the response fields use the post-dedupe count sototal == deleted_count + failures.count + remainingholds as the caller's arithmetic sanity check.calendar_nameandcalendar_sourcemust be strings or absent. Non-string JSON input (e.g.{"calendar_source": 123}) is rejected withinvalidParameterat the handler boundary — this prevents the type-coerce-to-nil bypass that would have silently widened cleanup to all accounts.rejectSourceWithoutNameadditionally rejects empty or whitespace-onlycalendar_nameso the guard's safety is explicit, not coincidental on downstreamfindCalendarsbehavior.
-
DispatchRoundTripTests(#21): guards against tool-name drift betweendefineTools()and theexecuteToolCalldispatch switch — catches the "compiles green, silently unroutable" class of bug that can't be caught by individual handler tests. -
ReminderCleanupTests(#21): pure-function unit tests for thecalendar_sourceguard and the dedupe invariant F2 relies on. -
cleanup_completed_remindersbinding mode (#28): new optionalreminder_ids: [String]parameter. When supplied, the handler operates on exactly those IDs instead of re-listing completed reminders from the filter — so dry-run's "I approve these IDs" is honored verbatim by the execute call. Filter parameters (calendar_name,calendar_source,limit) are ignored in binding mode. Response includes amodefield ("filter"or"binding") so callers can distinguish. Filter mode is unchanged (still re-derives the set for automation use).- Binding-mode execute path enforces the "only completed" invariant before deleting. A reminder un-completed in the Reminders app between dry-run and execute surfaces in
failures[]with"Reminder is no longer completed"instead of being silently deleted (matches the schema's explicit promise).
- Binding-mode execute path enforces the "only completed" invariant before deleting. A reminder un-completed in the Reminders app between dry-run and execute surfaces in
-
delete_events_batchuntrusted-content hardening (#27): dry-run preview entries no longer echoevent.titleorevent.calendar.title— both are attacker-controllable via malicious.icsinvites or shared-calendar collaborators, and the handler is excluded fromUntrustedContentWrapper.readTools. Mirrors the #21 F3 fix. Preview now returns onlyevent_id+ server-formatted dates; pipe throughlist_eventsfor human-readable titles. -
list_reminders/list_reminder_tags/search_remindersscope-invariant (#29): all three handlers now rejectcalendar_sourcesupplied withoutcalendar_name(consistent withcleanup_completed_remindersas of #21). Non-string JSON input on either filter key is also rejected (R2-F1 type-coerce defense applied cross-handler).search_reminderswas the same class bug, caught during #29 verification and folded into the same fix. -
ManifestParityTests(#30): new test guards drift betweenServer.defineTools()andmcpb/manifest.json. The #21 two-commit history —featcommit adding a tool to Swift,docscommit adding the manifest entry afterwards — demonstrated the failure mode. Now caught atswift testtime. -
EventKitManagingprotocol +FakeEventKitManager+CleanupHandlerTests(#31): protocol-oriented test harness for handler integration tests.EventKitManagingexposes the 2 methodshandleCleanupCompletedRemindersactually depends on (listCompletedReminderIdentifiers→[String]anddeleteRemindersBatch);CheICalMCPServer.init(reminderCleanupSource:)accepts an injection withEventKitManager.sharedas the default (production unchanged).FakeEventKitManageractor scripts return values and records invocations.CleanupHandlerTests(12 cases) pins #21 F1 guard ordering + integration-level type-coerce rejection (R2-F1), #21 F2 dedupe / R2-F2 arithmetic, #28 F1onlyCompletedwiring, #21 F4 limit cap, #21 F8 response shape stability, and the binding-vs-filter mode branching. Narrow scope per/spectra-discussconvergence: protocol only covers methods the cleanup handler uses; other 30+ EventKitManager methods stay direct-singleton and will be protocol-fied on demand.- Honest scope note: integration tests pin the handler's contract with the EventKit primitive, not the primitive's internals. The destructive
if onlyCompleted && !reminder.isCompletedguard insideEventKitManager.deleteRemindersBatchstill requires its own test (tracked as #33). Deleting that guard would ship green against #31's suite. #31's closing summary references this gap explicitly.
- Honest scope note: integration tests pin the handler's contract with the EventKit primitive, not the primitive's internals. The destructive
-
BatchDeleteFilter.shouldSkipUncompletedextraction + 4-row truth table tests (#33): closes the destructive-primitive test gap that #31 explicitly deferred. Theif onlyCompleted && !reminder.isCompletedguard atEventKitManager.swift:1383was previously reachable only through realEKEventStore(TCC required), leaving the #28 F1 contract untested in CI. NewBatchDeleteFilter.shouldSkipUncompleted(isCompleted:onlyCompleted:) -> Boolpure function carries the entire destructive rule; production code calls it instead of inline. NewBatchDeleteFilterTestspins all 4 truth-table rows. The wire-visible message string ("Reminder is no longer completed") is emitted byEventKitManagerafter the filter returns true; that contract is pinned byCleanupHandlerTestsintegration coverage, not duplicated here as a self-comparing constant test. 203 → 207 tests.
- #32 (landed in this Unreleased window — see Security below)
--self-updateSHA-256 verification before install (#98): closes the supply-chain gap that #49's verify (Codex Finding 1 HIGH) deferred —--self-updatenow downloads a.sha256companion file alongside the binary asset, computes the SHA-256 of the downloaded binary viaCC_SHA256streaming hash, and refuses install on mismatch. Defense against in-flight tampering / mirror compromise / corrupted download.scripts/build-mcpb.shnow writesmcpb/server/CheICalMCP.sha256post-signature so release-time hash matches what--self-updatewill compute on the user's machine. Companion file format accepts both bare-hex andshasum -a 256standard output (hash filename); BOM tolerated; first valid 64-hex token wins. Refuse-on-unparseable: if the release predates SHA-256 publication policy, install is rejected with explicit guidance to verify manually via codesign/spctl. 10 new tests pin parser + streaming-hash invariants (NIST FIPS 180-4 reference vectors for empty file +"abc"; deterministic-streaming check for 200 KB payload). Tests: 237 → 247 (+10). Codex Finding 1 Option B (Developer ID Team ID check viacodesign -dv) deliberately not included — separate enhancement if Team ID hardcoding is acceptable; current Option A defense covers transit + mirror compromise.
--self-updateCLI flag (#49): discoverable upgrade command for existing installs. Queries GitHub Releases API for the latest tag (tag_namefield), compares againstAppVersion.currentvia best-effort semver-ish parser, and (if newer) downloads the binary asset, makes it executable, and atomically replaces the current binary at its own path. Atomic-replace usesrm -f+mv(fresh inode, per #62 upgrade-trap fix — avoids macOS 26 stale code-signature SIGKILL on running MCP processes still holding the old inode). Closes the gap that plugin wrapper auto-download covers fresh-install only — existing v1.7.0 users couldn't auto-upgrade. Per #49 design discussion (4 options), chose Option 3 (explicit user-invoked, discoverable via--help+ README) over Option 1/2 auto-upgrade variants — auto would risk swapping binary mid-MCP-call. Network failure / parse / install errors all surface friendlyLocalizedError.errorDescriptionstrings (conform toTrustedErrorMessageso #41 carve-out keeps stderr clean). 11 new tests pinstripTagPrefix(leadingvremoval),isNewer(numeric semver-ish comparison: 1.7.10 > 1.7.9 numerically, prerelease lexicographic fallback), andmakeAssetDownloadURL(URL shape pinned). Tests: 226 → 237 (+11).make install-signedMakefile target (#50): Developer ID + hardened runtime signature WITHOUT notarization for fast maintainer dev iteration. Solves the macOS 26 TCC dogfood gap — ad-hoc signed binaries (make install) can't trigger Calendar / Reminders permission dialogs on macOS 26, breaking the maintainer's--setupflow verification. Trade-offs vsrelease-signed: signed-but-not-notarized → Gatekeeper online-checks on first launch (one-time stutter), suitable for maintainer dogfood / TCC flow verification on macOS 26, NOT for distribution to other users. Pre-condition:DEVELOPER_IDexported (NOTARY_PROFILEunused — no notarytool step)..github/workflows/test.yml— PR-time test gate (#51 Layer 1): minimal CI workflow that runsswift build+swift teston every push/PR againstmain. Catches "PR breaks the test suite" before reviewer manualswift test. Runner pinned tomacos-latest(EventKit is macOS-only). SPM.buildcache keyed onPackage.resolvedfor dependency-upgrade-aware invalidation. Concurrency group cancels in-progress runs on rapid push sequences. Layers 2 (lint workflows) + 3 (release automation with.p12cert in GH Secrets) deferred per #51 Strategy — see closing summary for trigger conditions.
writeFailureLog+ R3 inline-write thread-safety best-effort posture (#70): documented the actual concurrency property of the 11FileHandle.standardError.writesites — POSIXwrite(2)only guarantees atomicity for byte counts ≤ macOSPIPE_BUF(512 bytes, NOT 4096; that's Linux). Failure-line shape<handler>(<identifier>) failed: <safeRawLog>\nexceeds 512 bytes for any non-trivialNSError.localizedDescription(maxRawLogCharsis 1024 chars alone). Concurrentasyncfailing-tool-call races therefore CAN interleave on stderr; operators must NOT rely ontail -f stderr | parse-by-lineproducing perfectly framed records. Serial actor option (Option A —StderrLogger.shared) deferred — at this server's single-client concurrency profile, the contention window is narrow enough that touching every catch handler withawaitpropagation exceeds the observability value. Trigger to revisit Option A: multi-tenant deployment / SRE interleaving complaint / structured stderr becoming load-bearing /#66periodic-summary mechanism. See#70closing summary for full deferral rationale.
Tests/CheICalMCPTests/Helpers/StderrCaptureHarness.swiftextraction (#83): centralized thedup2/Pipestderr-capture pattern that #80 (CLIRunnerStderrTests) and #85/#86/#73/#74 (sanitizer cluster tests) had inlined separately. ProvideswithCapturedStderr { ... } -> (result, stderr)(full form, supports closure return + rethrows) andcapturedStderr(of:) -> String(convenience for() -> Void). The dup2-deadlock fix discovered during #80 (must restore stderr BEFORE closing pipe write end, otherwise FD 2's dup keeps the write end open andreadDataToEndOfFileblocks forever) is now in one place. MigratedCLIRunnerStderrTests.swift(171 → 124 LOC, -47) and 3 stderr-capture tests inEventKitErrorSanitizerTests.swiftto use the helper. NewHelpers/subdirectory exempt from*Tests.swiftnaming convention per CLAUDE.md addendum. Phase 3 of the diagnosis (R3deleteRemindersBatchand R8Server.swiftouter-catch site-level carve-out tests) deferred — require new EventKit fakes / subprocess harness, separate scope.
escapeForStderrcovers full C0 + DEL (#73): pre-fix only handled\\\n\r. ESC (\x1b) reached stderr verbatim — an attacker controllinglocalizedDescription(e.g. via event title interpolated by an EKError) could inject ANSI clear-screen + home-cursor sequences (\x1b[2J\x1b[H) that hijack the operator's terminal. NUL (\x00) had a similar gap (truncates C-string log readers liketailwriting to syslog). Replaced 3-charreplacingOccurrencesfast-path with scalar walk:< 0x20 || == 0x7F→\xHHlowercase hex; legacy\\/\n/\rinvariants preserved. Closes the residual that PR #65's outer-catch (#39) expanded surface to 11 stderr-write sites. Tests: 215 → 221 (+6 — ESC, NUL, BS, BEL, DEL, mid-range C0, ANSI clear-screen attack neutralization, Unicode passthrough, legacy backslash/LF/CR). C1 controls (\x80..\x9F) deferred — separate issue if alternate-form CSI hijacking becomes observed concern.sanitizeForInterpolationdefense-in-depth helper + 12-site retrofit (#74): newEventKitErrorSanitizer.sanitizeForInterpolation(_:)strips C0 + DEL (silent removal — empty replacement, no visible artifact) for user-controlled strings interpolated into response prose. Applied at everyexecuteUndo/executeRedotitle interpolation inEventKitManager.swift(12 sites total: 7 undo + 5 redo). Closes CWE-117 surface where an MCP host writing the wiremessagefield to a non-escaping log writer (e.g. syslog) could be tricked into forged log entries via a malicious title ("foo\n[ERROR] FORGED"). Wire is already safe (JSON-encoded byJSONSerialization); helper provides server-side defensive layer regardless of host behavior. Per cluster Plan, kept SPLIT fromescapeForStderr— different boundaries (response prose vs stderr operator log), different visibility intent (silent strip vs visible escape). Tests: 221 → 226 (+5). C1 explicitly NOT stripped per #74 scope.writeFailureLog1024-char cap onrawLog(#86): frameworkNSError.localizedDescriptionis theoretically unbounded. Pre-cap, batch handlers (e.g.Server.swift:1836/2188-2274/2432-2546,EventKitManager.swift:838) that fan out per malformed entry could amplify one oversize Apple error into MB-scale stderr volume. Cap fires beforeescapeForStderr(escape inflation can't expand the budget); suffix annotation…[truncated N chars]preserves the original size signal for operator debug. Tunable viaEventKitErrorSanitizer.maxRawLogChars. Closes the DoS-amplification residual surfaced by 6-AI verify of #80 (security F#3). Tests: 212 → 214 (+2: long-rawLog truncation pin + short-rawLog passthrough sanity).CLIError.invalidJSON(_)safety doc-comment (#85):CLIErrorconforms toTrustedErrorMessage, soerrorDescriptionstrings reach the wire (stdout JSON, escape-safe viaJSONSerialization) but bypassescapeForStderr. Today's call sites pass static literals only, but a future contributor passingerror.localizedDescriptionfrom a re-throw would route raw text through the carve-out unescaped — re-opening the CWE-117 window that #80 closed for the framework path. Doc-comment + grep audit becomes the future-contributor guard. Defense-in-depth (Option B over Option A enum-of-codes) per 6-AI verify of #80 (security F#2).- CLI runner stderr write delegated to
EventKitErrorSanitizer.writeFailureLog(#80):CLIRunner.run's catch was the 4thFileHandle.standardError.writesite that escaped the R3/R7/R8 cluster's hardening — no trusted-branch carve-out (DoS-amplification window viaCLIError, which conforms toTrustedErrorMessage) and noescapeForStderr(CWE-117 log-injection vector if a future macOS Calendar surface interpolates user-supplied event/reminder content with\n/\r). PR #79 verify surfaced the asymmetry; this issue closes it.run()'s catch now delegates towriteFailureLog(the canonical helper #72 established for R8), inheriting both invariants. To make the delegation unit-testable, the catch logic was extracted intoCLIRunner.handleRunError(_:toolName:)(noexit(1), returns to caller). Operator-facing stderr line shape changes fromCLIRunner failed: <log>toCLIRunner(<toolName>) failed: <log>—grep -rn "CLIRunner failed:"confirmed only the source line itself matched repo-wide before the change. NewCLIRunnerStderrTests.swiftis the first stderr-capture test in the repo (pipe +dup2(STDERR_FILENO, ...)pattern; #83 will track extracting a shared harness for cluster-wide coverage). 4 tests pin: TrustedErrorMessage carve-out for bothCLIErrorandToolError, control-char escape on untrustedNSError, nil-toolName fallback to<no-tool>. Tests: 208 → 212 (+4). - #37 PR re-review — Codex adversarial findings landed (commit
62d1031): three additional trust-contract / sanitizer holes caught by/codex:adversarial-reviewafter the 6-AI Round 1 verify. (1)updateCalendarandcopyEventpreviously builtEventKitError.calendarNotFound(identifier: "\(calendar.title) (read-only)")—calendar.titleis CalendarStore-sourced and could carry shared/subscribed calendar titles set by remote publishers. Fixed to echo the caller's ownidentifier/toCalendarNamefor the read-only refusal. (2)CLIRunner.runcatch was bypassing the new sanitizer entirely; rawerror.localizedDescriptionwas being embedded in the stdout JSONmessagefield. Extracted testable helperCLIRunner.formatErrorForCLI(_:)that routes throughEventKitErrorSanitizer.sanitizeForResponse(_:); CLI stdout now carries only sanitized codes, raw log goes to stderr. (3)deleteRemindersBatchcleanup catch was writing inline to stderr without applying the same control-char escape (\\n/\\r/\\\\) used bywriteFailureLog. PromotedEventKitErrorSanitizer.escapeForStderrfromprivatetointernal staticso thesanitize(_:)-bound R3 path can share the same escape without violating the spec's direct-binding requirement. Tests: 199 → 203 (+4: 1 read-only calendar regression + 3 CLI framework-error / trusted-passthrough pins). Round 2 verify is in-scope, no re-verify required per skill convention. - All non-cleanup batch handlers + outer
handleToolCallcatch sanitization (#37): extends #32's sanitizer to 11 dispatch sites total = 10writeFailureLogcallers (R9) + 1 outer-catch site usingsanitizeForResponsedirectly (R8). Originally 8 sites listed in the issue body; verify addedServer.swift:989outer-catch (DA F3) andfindSimilarEventswas caught via grep guard during apply. Newpublic protocol TrustedErrorMessageis an empty marker that author-controlled error types opt into to assert theirerrorDescriptionis hand-written and safe to forward verbatim —ToolError,EventKitError, andCLIErrorconform; framework Foundation errors (URLError,CocoaError,POSIXError) deliberately do not. NewEventKitErrorSanitizer.sanitizeForResponse(_:)type-dispatches: trusted → pass through; framework → existingsanitize(_:). NewEventKitErrorSanitizer.writeFailureLog(handler:identifier:error:)consolidates "sanitize + stderr + return code" into one call. Stderr writes escape\n/\rinhandler/identifier/rawLogto prevent log-injection from user-supplied event titles or IDs (#37 verify F2). The trust marker onEventKitError'scalendarNotFound/calendarNotFoundWithSource/multipleCalendarsFoundcases now suppresses theavailable:/sources:interpolation inerrorDescription(#37 verify F1) — those cases would otherwise echo remote-publisher-controlledEKCalendar.title/EKSource.titlestrings (same #21/#27 threat class) through the trust path. Sites converted:EventKitManager.swift:838(deleteEventsBatch),Server.swift:989(outerhandleToolCall, R8 path),Server.swift:1836(createRemindersBatch),Server.swift:2188/2208/2227(createEventsBatchparsers),Server.swift:2274(createEventsBatchsave),Server.swift:2318(findSimilarEvents),Server.swift:2432(moveEventsBatch),Server.swift:2474(deleteEventsBatchseries),Server.swift:2546(deleteEventsBatchdry-run preview). LOW-class wire output unchanged (ToolErrorparser messages appear verbatim); HIGH-class shifts from Apple text to stableeventkit_error_<N>codes; outer-catch wire shape ("Error: <text>") preserved (no breaking change). 184 → 199 tests (+15: 7 sanitizer/marker + 1 NSError-not-trusted + 1 negative-code + 2 writeFailureLog + 1 control-char-passthrough + 3 outer-catch incl. trust-vs-framework distinguisher + 3 EventKitError trust-contract pins). #32'sdeleteRemindersBatch:1387continues to usesanitize(_:)directly per spec R3, completely unaffected. cleanup_completed_remindersfailures[].errorsanitization (#32):EventKitManager.deleteRemindersBatchpreviously echoederror.localizedDescriptionfromeventStore.removedirectly intofailures[].error. Apple's currentNSErrortext doesn't interpolate reminder content (verified during #21 R2 security review), but that's a load-bearing assumption on Apple's internal implementation. NewEventKitErrorSanitizer.sanitize(_:)mapsError→(code, rawLog). Thecodefield — the only value forwarded to the MCP client — is derived only fromnsError.domain+nsError.code.magnitude(neveruserInfo, neverlocalizedDescription):EKErrorDomain→eventkit_error_<N>; otherNSError→error_<domain-slug>_<N>; bridged Swift errors →error_unknown. TherawLogfield ISlocalizedDescriptionverbatim, but writes only to stderr for operator debug — never to the response.code.magnitudeensures the spec value-domain regex[0-9]+is an invariant even when Foundation domains carry negative codes. No wire break:failures[].errorremains a string. Narrow scope (matches #31 pattern): only thedeleteRemindersBatchcatch path is converted; sibling leaks (other batch handlers + outerhandleToolCall:989catch for cleanup) are tracked in #37. Also benefitsdelete_reminders_batchsince both tools share this manager method.
formatJSONcrash on invalid JSON types (#22):JSONSerialization.data(withJSONObject:)raises an Objective-CNSInvalidArgumentExceptionon unsupported types (rawDate,NaN,Infinity, non-string dict keys) — an ObjC exception that Swifttry/catchcannot capture, crashing the process in release builds. The previouscatch { return "[]" }never actually handled this path. Now pre-checks withJSONSerialization.isValidJSONObject(_:)and throwsToolError.invalidParameter, whichhandleToolCallconverts into MCPisError: true. MovedformatJSON/actionResultto top-levelResponseFormatting.swiftto match the repo's utility-function pattern.findSimilarEventserror swallowing in batch create (#23):try? awaitinhandleCreateEventsBatchwas silently dropping EventKit errors during the similar-event hint lookup, so callers saw "no similar events" when the lookup had actually failed (mid-batch access revocation, predicate-breaking characters, actor reentrancy). Failures now log to stderr and surface inresponse["similar_events_errors"]so callers know the hint is incomplete; primary batch create/fail results unchanged.- Silent default-masking of numeric tool arguments (#25):
arguments["priority"]?.intValue ?? 0and similar patterns (interval,tolerance_minutes,limit) conflated "key absent" with "key present but unparseable". An LLM sendingpriority: "high"got0silently. NewInputValidation.requireIntIfPresenthelper separates the two — absent uses default, present must parse to an integer (whole-number doubles like5.0accepted for JSON-parser compatibility; strings and fractional doubles throw). 27-site audit; 5 call sites fixed, 22 boolean / string-enum defaults kept as legitimate "absent means default".
- Build-time version consistency check (#24):
scripts/build-mcpb.shnow fails fast ifAppVersion.current,Info.plistCFBundleVersion, andmcpb/manifest.jsonversiondrift apart.server.jsonis intentionally outside this check — it is an MCP Registry submission snapshot with an independent cadence (see README 'Release Process').
- README Release Process table (#24): documents the four version-carrying files, which are build-time coupled (three) vs independently updated (
server.json), eliminating the 'is this a bug?' confusion that prompted the issue.
- 18 new cases across
ResponseFormattingTests(11) andInputValidationTests(7 forrequireIntIfPresent). Total: 123 → 141.
- Upgrade trap fix (#62):
make installandscripts/build-mcpb.shnowrm -fthe destination binary before writing. Without this, copying over an existing~/bin/CheICalMCPwhile old MCP server processes are still running reuses the same inode — and the macOS kernel caches code-signature hashes per-inode, so the new binary fails to exec withload code signature error 2 / Taskgated Invalid SignatureSIGKILL. README user-facing install instructions also includerm -f ~/bin/CheICalMCPbeforecurl. Discovered during macOS 26 TCC verification of #44. - macOS 26 codesigning + notarization (#44): release binary is now signed with Developer ID Application + hardened runtime + notarized via
xcrun notarytool. macOS 26 tightened TCC such that ad-hoc signed binaries can no longer trigger Calendar / Reminders permission dialogs — Developer ID + hardened runtime + notarization is the only path that lets end users grant access without manually re-signing the binary themselves.- New
scripts/sign-and-notarize.shwraps the codesign + ditto-zip + notarytool submit flow with pre-flight checks (cert in keychain, notarytool keychain profile configured) and friendly error messages including the submission ID forxcrun notarytool logpost-mortem. - New
scripts/build-mcpb.shstep[3.5/4]calls the signing script automatically. Auto-skips whenDEVELOPER_IDenv var is unset OR cert isn't in the keychain (so contributors / CI / forks can build a working unsigned.mcpbfor testing without manually settingSKIP_CODESIGN=1). - New
Makefiletargetrelease-signedis the canonical release-cut command. Sources/CheICalMCP/Entitlements.plist(empty<dict/>) — minimal entitlements; hardened runtime alone is what macOS 26 requires for TCC. EventKit is user-prompt-driven (no entitlement key needed for outside-MAS distribution).- Known limitation: stapling skipped (raw Mach-O doesn't support
xcrun stapler staple); Gatekeeper online-checks notarization on first launch, requiring one-time network access. - README "Release Process" gains a "Signing & Notarization" subsection with prerequisites, per-release flow, end-to-end verification (
spctl -a -vvv -t install), and troubleshooting.
- New
- Input validation at MCP tool boundaries (#20):
create_event,update_event,create_reminder,update_reminder, and their batch counterparts now enforce length limits (title ≤ 255, notes ≤ 65535, location ≤ 1024) and a URL scheme allowlist (http / https only). Rejectsjavascript:,file:,data:, and other non-web schemes that could be rendered as clickable URIs by calendar clients. - Prompt-injection defense (#20): Responses from tools that echo externally-sourced content (
list_events,search_events,list_events_quick,check_conflicts,find_duplicate_events,list_reminders,search_reminders,list_reminder_tags) are wrapped with[UNTRUSTED CALENDAR DATA ...]markers over the MCP interface so consuming LLMs can distinguish data from instructions. CLI mode preserves pure JSON output. - Loud failures for LLM-malformed integer arrays:
recurrence.days_of_week,recurrence.days_of_month, andalarms_minutes_offsetsnow throwToolError.invalidParameteron out-of-range or non-integer values. Previously the code silently dropped invalid elements with no warning, leaving callers unaware their input was partially ignored. - Force-unwrap crash eliminated (originally from #20):
EKWeekday(rawValue:)!inEventKitManager.createRecurrenceRulereplaced with safecompactMap. With parse-boundary validation now in place, the safe-unwrap path is unreachable but retained for defense-in-depth.
Info.plistCFBundleVersion sync: plist version was stuck at1.4.1since before the v1.5.0 release. Bumped to matchAppVersion.current.
InputValidationTests(32 cases): URL scheme allowlist, length boundaries, Unicode grapheme semantics.UntrustedContentWrapperTests(10 cases): wrap format, allowlist membership (read tools included, write tools excluded).
- Attendee & organizer info (#17): Event responses now include
attendeesarray andorganizerobject (read-only from EventKit)- Each attendee: name, email, role, status, type, is_current_user
- Organizer: name, email, is_current_user
- Available in:
list_events,search_events,list_events_quick,check_conflicts - Omitted when event has no participants
- Refactored event dict construction: Extracted shared
formatEventDictmethod, eliminating 3 duplicated event-to-JSON closures (~60 lines removed) - New
ParticipantFormatting.swift: Participant utilities as testable free functions
ParticipantFormattingTests.swift: 7 tests covering email extraction, role/status/type mapping
--setupflag (#13): Pre-authorize TCC (Calendars/Reminders) permissions for launchd and automation environments- Non-interactive session detection: Detect launchd/SSH sessions via TERM, ppid, and environment variables; show targeted error messages with workaround instructions
--climode (#14): Invoke all 28 tools directly from command line without starting MCP server- Flag-based mode:
CheICalMCP --cli list_events --start_date 2026-04-01 --end_date 2026-04-07 - JSON stdin mode:
echo '{"start_date":"2026-04-01"}' | CheICalMCP --cli list_events - Smart type inference for bool/int/double/array parameters
- Flag-based mode:
- MCP Swift SDK 0.12.0: Updated for Swift 6.3 compatibility
- argv prioritized over stdin: Fixes isatty hang in non-interactive environments
- Non-interactive detection: Improved SSH and launchd error handling (#13)
- CLI arg parsing: Native JSON types for stdin, smart type inference for argv (#14)
- Per-event timezone (#12):
timezoneparameter oncreate_event,update_event, andcreate_events_batch; event output uses event's own timezone; naive datetimes parsed in event timezone - Clear due date (#9):
clear_due_dateparameter onupdate_reminder - Weekday validation (#5):
create_eventandupdate_eventvalidatestart_timeweekday againstdays_of_week - Undo/redo system (#8): 3 new tools —
undo,redo,undo_history - Recurring event fixes (#7): Occurrence-level delete and update with
occurrence_date
- Swift 6 build (#11): Updated build workflow and README for
make release - Tool count: 25 → 28
- SSH session detection: Detect SSH sessions via
SSH_CLIENT/SSH_CONNECTIONenvironment variables and show SSH-specific workaround instructions when calendar/reminder access is denied (#6) - SSH troubleshooting docs: Added SSH Access section to README (EN + zh-TW) with two workarounds: run locally first to trigger TCC dialog, or grant Full Disk Access to sshd
searched_rangemetadata insearch_eventsresponse: Returns the actual date range searched (start,end,is_default_range), enabling LLM consumers to verify coverage and self-correct when events are not foundsimilar_eventshints increate_events_batchresponse: Returns existing events with similar titles (by word match), helping LLMs reuse correct calendar names and avoid duplicatesfindSimilarEventsinternal method: New EventKitManager method for title-based fuzzy matching with deduplication
- Fixed default search range:
search_eventsnow defaults to ±2 years instead ofDate.distantPast/Date.distantFuture. Apple's EventKitpredicateForEventscan return incomplete results with extremely wide ranges, causing past events to be silently missed - Updated tool descriptions with LLM tips:
search_eventsandcreate_events_batchdescriptions now include guidance for LLM callers (default range info,searched_rangefield, similar events hints)
Improves search_events and create_events_batch for LLM reliability. Fixes a subtle EventKit bug where past events were silently missed, adds observability metadata, and provides deduplication hints. 25 tools (unchanged).
- Clarified tag documentation: Tags are MCP-level (stored as
#hashtagtext in notes), not native Reminders.app tags. Apple provides no public API for native tags. Updated tool descriptions, README, and CHANGELOG to reflect this accurately.
- Reminder tags (MCP-level):
create_reminder,update_reminder, andcreate_reminders_batchnow accept atagsparameter. Tags are stored as#hashtagtext in the reminder notes field, searchable and filterable through MCP tools. Note: These are MCP-managed tags, not native Reminders.app tags — Apple does not provide any public API (EventKit, AppleScript, or JXA) to create native Reminders tags programmatically list_reminder_tags: New tool to list all unique tags across reminders with usage counts- Tag filtering in
search_reminders: Newtagparameter to filter reminders by tag clear_tags:update_remindersupportsclear_tags: trueto remove all tags from a reminder- Tags in output:
list_remindersandsearch_remindersnow return atagsarray and show clean notes (without the tag line)
- Updated MCP Swift SDK dependency to 0.11.0
search_remindersnow accepts tag-only searches (without keywords)
1 new tool (24 → 25 total). Tags feature enables MCP-level categorization and filtering of reminders through #hashtag text in notes. Note: Apple provides no public API for native Reminders tags.
- Idempotent writes: All create operations (
create_event,create_events_batch,create_reminder,create_reminders_batch,create_calendar) now perform check-before-write to prevent duplicate data when AI agents retry failed requests - Duplicate detection at lowest layer: Idempotency checks implemented in
EventKitManager(data access layer), protecting all callers automatically - Idempotency keys: Events use
title + startDate + calendar, reminders usetitle + dueDate + list, calendars usetitle + entityType - Skipped status in responses: Batch operations now report
skippedcount and per-itemskipped: truefor duplicates find_duplicate_eventshandler: Exposed duplicate event detection as a standalone tool
No new tools (24 total). Major reliability improvement: all write operations are now idempotent, preventing duplicate data creation when agents retry due to network errors or response loss.
- Recurrence rules:
create_event,update_event,create_reminder, andcreate_events_batchnow accept arecurrenceparameter to create recurring events/reminders (daily, weekly, monthly, yearly with interval, end date, occurrence count, days of week/month) clear_recurrence:update_eventsupportsclear_recurrence: trueto remove recurrence rules from existing events- Structured locations:
create_event,update_event, andcreate_events_batchnow acceptstructured_locationwith coordinates (title, latitude, longitude, radius) for map-integrated event locations - Location triggers:
create_reminderandupdate_remindernow acceptlocation_triggerto set geofence-based reminders that fire on enter/leave clear_location_trigger:update_remindersupportsclear_location_trigger: trueto remove location-based alarms- Rich recurrence output:
list_events,search_events, andlist_events_quicknow return fullrecurrence_rulesdetails (frequency, interval, end date, days) instead of justis_recurring: true - Structured location output: Event responses now include
structured_locationwith coordinates when available - Location trigger output: Reminder responses now include
location_triggerdetails when geofence alarms are set
No new tools (24 total). Two major feature enhancements: recurring event/reminder creation (previously infrastructure-only, now fully exposed via MCP) and location-based triggers for both events and reminders.
list_eventsresponse format: Changed from plain array to{"events": [...], "metadata": {...}}list_remindersresponse format: Changed from plain array to{"reminders": [...], "metadata": {...}}
- Flexible date parsing: All date parameters now accept 4 formats:
- ISO8601 with timezone:
2026-02-06T14:00:00+08:00 - Datetime without timezone:
2026-02-06T14:00:00(uses system timezone) - Date only:
2026-02-06(00:00:00 system timezone) - Time only:
14:00(today at that time)
- ISO8601 with timezone:
- Fuzzy calendar matching: Calendar lookup now falls back to case-insensitive matching; error messages include all available calendars/lists
list_calendarssource_type: Each calendar now includes asource_typefield (Local/iCloud/Exchange/CalDAV/Subscribed/Birthdays)list_eventsfilter/sort/limit: New parametersfilter(all/past/future/all_day),sort(asc/desc),limitlist_remindersfilter/sort/limit: New parametersfilter(all/incomplete/completed/overdue),sort(due_date/creation_date/priority/title),limit; each reminder now includesis_overdueandcreation_datefieldsdelete_events_batchdate range mode: Can now delete by calendar + date range (not just by event IDs); includesdry_runmode (default: true) for safe preview before deletion- Unit tests: Added
FlexibleDateParsingTests.swift
Major quality-of-life improvements focused on developer experience. No new tools added (24 total), but significant enhancements to existing tools.
update_calendar: Rename a calendar or change its colorsearch_reminders: Search reminders by keyword(s) in title or notes, with AND/OR matching and completion status filtercreate_reminders_batch: Create multiple reminders in a single call with per-item success/failure trackingdelete_reminders_batch: Delete multiple reminders in a single call with detailed results
4 new tools added (20 → 24 total). This release rounds out Reminders support with search and batch operations, and adds calendar update functionality.
- Critical:
this_week/next_weekweek boundary calculation - Fixed an issue where week calculations depended on system locale, causing incorrect results for users with different cultural conventions for first day of week
- New
week_starts_onparameter forlist_events_quick- Supports international week definitions:system(default): Uses system locale settingsmonday: ISO 8601 standard (Europe, Asia)sunday: US, Japan conventionsaturday: Middle East convention
- Response now includes
week_starts_onfield showing the effective week start day used - Unit tests for week calculation with different firstWeekday settings
- Updated MCP Swift SDK dependency to 0.10.2 (strict concurrency improvements)
Previously, this_week and next_week used Calendar.current.firstWeekday without explicit control. This caused:
- Users expecting Monday-start weeks (ISO 8601) to get Sunday-start results on US-locale systems
- Inconsistent behavior depending on system locale
The fix allows explicit control while defaulting to system locale for backwards compatibility.
- Critical:
update_eventtime validation bug - Fixed an issue where updating onlystart_timewithoutend_timecould result in an invalid event state (startDate > endDate), causing the event to become unsearchable or invisible in the calendar - When only
start_timeis provided, the event's original duration is now automatically preserved - Added explicit validation to reject events where start time is not before end time (for non-all-day events)
- New error type
invalidTimeRangefor clearer error messages when time validation fails - Improved
update_eventtool description with clearer documentation about time handling - Added
all_dayparameter toupdate_eventtool for converting between timed and all-day events - Unit test framework with time validation tests
The bug occurred because startDate and endDate were updated independently. When moving an event from Jan 25 to Jan 31 with only start_time, the event would have:
startDate: Jan 31, 14:00endDate: Jan 25, 15:00 (unchanged from original)
This invalid state caused EventKit to handle the event incorrectly. The fix preserves the original event duration when only the start time changes.
- BREAKING:
calendar_nameis now required forcreate_event,create_events_batch, andcreate_reminder - Removed implicit default calendar behavior to prevent events being saved to unexpected calendars
- Improved error messages guide users to use
list_calendarsto see available options
Previously, if calendar_name was not specified, events/reminders would be saved to the system's default calendar. This caused confusion when users had multiple accounts (iCloud, Google, Exchange) and didn't know where their data went. Now the API explicitly requires specifying the target calendar.
- Tool annotations: Added MCP tool annotations for Anthropic Connectors Directory submission
- Auto-refresh mechanism: Improved event store refresh handling
- Enhanced batch tool descriptions: Clearer documentation for batch operations
calendar_sourceparameter: New optional parameter for disambiguating calendars with the same name across different sources (e.g., iCloud, Google, Exchange)- Added to 10 tools:
list_events,create_event,update_event,list_reminders,create_reminder,update_reminder,search_events,list_events_quick,check_conflicts,create_events_batch target_calendar_sourceparameter: Forcopy_eventandmove_events_batchtools- Improved error messages: When multiple calendars share the same name, the error now lists all available sources for disambiguation
- Refactored calendar lookup logic with new
findCalendar()andfindCalendars()helper methods - Clearer error handling for calendar-not-found scenarios
delete_events_batch: Delete multiple events at once, much more efficient than callingdelete_eventmultiple timesfind_duplicate_events: Find duplicate events across calendars before merging, matches by title (case-insensitive) and time (configurable tolerance)- Multi-keyword search:
search_eventsnow supports multiple keywords withmatch_modeparameter (anyfor OR,allfor AND) - PRIVACY.md: Added privacy policy document explaining data handling
- Improved permission error messages: When calendar/reminders access is denied, now provides clear instructions for granting permissions
- Enhanced search_events response: Now includes search metadata (keywords used, match mode, result count)
copy_event: Copy an event to another calendar, with optionaldelete_originalflag for move behaviormove_events_batch: Move multiple events to another calendar at once
search_events: Search events by keyword in title, notes, or locationlist_events_quick: Quick time range shortcuts (today, tomorrow, this_week, next_week, this_month, next_7_days, next_30_days)create_events_batch: Create multiple events at once with success/failure trackingcheck_conflicts: Check for overlapping events in a time range- Local timezone display: All date responses now include both UTC and local time
- Timezone field: All responses include the current timezone identifier
- Complete rewrite from Python to Swift
- Native EventKit integration (no AppleScript)
- Full Reminders support:
list_reminders,create_reminder,update_reminder,complete_reminder,delete_reminder - Calendar management:
create_calendar,delete_calendar - Event alarms/reminders support
- URL support for events
- Initial Python version
- Basic calendar event operations via AppleScript
list_calendars,list_events,create_event,update_event,delete_event
| Version | Total Tools | New Tools |
|---|---|---|
| 1.3.1 | 25 | Docs: clarified tags are MCP-level, not native Reminders.app tags |
| 1.3.0 | 25 | +1 (list_reminder_tags), MCP-level tags support in create/update/search/batch |
| 1.0.0 | 24 | Enhancement: flexible dates, fuzzy matching, filter/sort/limit, batch delete with dry_run |
| 0.9.0 | 24 | +4 (update_calendar, search_reminders, create_reminders_batch, delete_reminders_batch) |
| 0.6.0 | 20 | Enhancement: calendar_source parameter for disambiguation |
| 0.5.0 | 20 | +2 (delete_events_batch, find_duplicate_events) |
| 0.4.0 | 18 | +2 (copy_event, move_events_batch) |
| 0.3.0 | 16 | +4 (search_events, list_events_quick, create_events_batch, check_conflicts) |
| 0.2.0 | 12 | +7 (5 reminder tools, 2 calendar tools) |
| 0.1.0 | 5 | Initial release |