@mgcrea/mcp-apple-calendar 0.0.0-bootstrap → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { AppBusyError as CalendarBusyError, AppNotRunningError as CalendarNotRunningError, AppleAutomationError, AppleAutomationError as AppleCalendarError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, PreconditionError, SchemaDriftError, columnsOf, confirmArg, createOsascriptRunner, describeStore, escapeLike, fingerprintSchema, limitArg, ok, openReadOnly, parseBool, parseConfig, parseIntOpt, parseList, readPackageIdentity, trimmed, withBusyRetry, wrap } from "@mgcrea/mcp-apple-core";
1
+ import { AppBusyError as CalendarBusyError, AppNotRunningError as CalendarNotRunningError, AppleAutomationError, AppleAutomationError as AppleCalendarError, BaseConfigSchema, CORE_DATA_EPOCH_OFFSET, IndexUnavailableError, PreconditionError, SchemaDriftError, columnsOf, confirmArg, createOsascriptRunner, describeStore, escapeLike, fingerprintSchema, limitArg, ok, openReadOnly, parseBool, parseConfig, parseIntOpt, parseList, promptArg, readPackageIdentity, registerSurfaceResources, registerWorkflowPrompt, requiredPromptArg, trimmed, withBusyRetry, wrap } from "@mgcrea/mcp-apple-core";
2
2
  import { readdirSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
@@ -12,8 +12,8 @@ const pkg = readPackageIdentity(new URL("../package.json", import.meta.url), {
12
12
  const BUILD_INFO = {
13
13
  name: pkg.name,
14
14
  version: pkg.version,
15
- gitCommit: "df06e7f",
16
- gitCommitDate: "2026-08-22T16:20:23+02:00"
15
+ gitCommit: "312147f",
16
+ gitCommitDate: "2026-08-27T16:06:37+02:00"
17
17
  };
18
18
  //#endregion
19
19
  //#region src/client/errors.ts
@@ -79,6 +79,187 @@ var CalendarNotWritableError = class extends AppleAutomationError {
79
79
  }
80
80
  };
81
81
  //#endregion
82
+ //#region src/client/availability.ts
83
+ /**
84
+ * Free-time arithmetic: turning a set of events into the times nothing is on.
85
+ *
86
+ * ## Why this is its own module, and why it is pure
87
+ *
88
+ * `recurrence.ts` set the precedent — the piece whose correctness rests on a
89
+ * measurement lives apart from the SQL. This is the mirror image. Nothing here
90
+ * rests on a measurement at all; it is interval arithmetic and calendar-day
91
+ * iteration, and it is separated for the opposite reason: it is the only part
92
+ * of an availability answer that can be tested without a store, and the part
93
+ * where an off-by-one books a meeting on top of another meeting.
94
+ *
95
+ * ## The rule that governs every decision below
96
+ *
97
+ * A gap reported here is an assertion that NOTHING is on the calendar then.
98
+ * `docs/calendar.md` already names why that is dangerous: "a short list of
99
+ * events is indistinguishable from a free afternoon". Every other read tool on
100
+ * this surface can afford to return a short list and flag it. This one cannot —
101
+ * shortening the busy set does not shorten the answer, it INVENTS free time.
102
+ * So the caller of this module must hand it a complete busy set or refuse, and
103
+ * everything here assumes that contract has already been checked.
104
+ *
105
+ * ## Local wall clock, deliberately
106
+ *
107
+ * Working hours are a human's, not UTC's: "09:00 to 18:00" means those numbers
108
+ * on the office wall, on both sides of a daylight-saving change. So day
109
+ * boundaries are built with local date components — the same choice, for the
110
+ * same reason, as `at()` in `dates.ts` — and a day that loses an hour simply
111
+ * has one fewer hour in it, which is what actually happened.
112
+ */
113
+ const WEEKDAY_KEYS = [
114
+ "sun",
115
+ "mon",
116
+ "tue",
117
+ "wed",
118
+ "thu",
119
+ "fri",
120
+ "sat"
121
+ ];
122
+ /** Monday to Friday, the default working week. */
123
+ const DEFAULT_WEEKDAYS = [
124
+ "mon",
125
+ "tue",
126
+ "wed",
127
+ "thu",
128
+ "fri"
129
+ ];
130
+ const CLOCK$1 = /^(\d{1,2}):(\d{2})$/;
131
+ const pad$2 = (n) => String(n).padStart(2, "0");
132
+ const parseClock = (field, raw) => {
133
+ const text = String(raw ?? "").trim();
134
+ const m = CLOCK$1.exec(text);
135
+ if (!m) throw new InvalidDateError(field, text, "expected a time of day like \"09:00\"");
136
+ const hours = Number(m[1]);
137
+ const minutes = Number(m[2]);
138
+ if (hours > 24 || minutes > 59) throw new InvalidDateError(field, text, `${hours}:${m[2]} is not a time of day`);
139
+ if (hours === 24 && minutes !== 0) throw new InvalidDateError(field, text, "24:00 is the latest time of day, so \"24:30\" is not one");
140
+ return {
141
+ hours,
142
+ minutes
143
+ };
144
+ };
145
+ const weekdaySet = (keys) => new Set(keys.map((k) => WEEKDAY_KEYS.indexOf(k)).filter((i) => i !== -1));
146
+ const startOfLocalDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
147
+ /** Same wall-clock time, n days later. Calendar arithmetic, so DST-safe. */
148
+ const addDays$1 = (d, n) => {
149
+ const out = new Date(d.getTime());
150
+ out.setDate(out.getDate() + n);
151
+ return out;
152
+ };
153
+ /**
154
+ * A time of day on a given local date.
155
+ *
156
+ * `new Date(y, m, d, 24, 0)` rolls to the next midnight on purpose, which is
157
+ * what makes `dayEnd: "24:00"` mean "to the end of this day" rather than an
158
+ * error case the caller has to special-case.
159
+ */
160
+ const atClock = (day, c) => new Date(day.getFullYear(), day.getMonth(), day.getDate(), c.hours, c.minutes, 0, 0).getTime();
161
+ /**
162
+ * Overlapping and touching intervals collapsed into the fewest that cover the
163
+ * same time, in order.
164
+ *
165
+ * Touching counts as overlapping (`<=`, not `<`): two meetings that end and
166
+ * begin on the same minute leave no gap, and emitting a zero-length one would
167
+ * put "you are free from 10:00 to 10:00" in front of a model.
168
+ */
169
+ const mergeIntervals = (input) => {
170
+ const sorted = input.filter((i) => i.to > i.from).toSorted((a, b) => a.from - b.from);
171
+ const out = [];
172
+ for (const cur of sorted) {
173
+ const last = out.at(-1);
174
+ if (last && cur.from <= last.to) {
175
+ if (cur.to > last.to) last.to = cur.to;
176
+ continue;
177
+ }
178
+ out.push({
179
+ from: cur.from,
180
+ to: cur.to
181
+ });
182
+ }
183
+ return out;
184
+ };
185
+ /**
186
+ * What is left of `window` once every busy interval is taken out of it.
187
+ *
188
+ * `busy` MUST already be merged and sorted — pass it through `mergeIntervals`
189
+ * first. Taking unsorted input here would silently under-subtract and hand back
190
+ * time that is booked, which is the one error this module exists to prevent, so
191
+ * the precondition is stated rather than defended against: a defensive re-sort
192
+ * would hide the caller's mistake instead of making it impossible.
193
+ */
194
+ const subtractBusy = (window, busy) => {
195
+ const out = [];
196
+ let cursor = window.from;
197
+ for (const b of busy) {
198
+ if (b.to <= cursor) continue;
199
+ if (b.from >= window.to) break;
200
+ if (b.from > cursor) out.push({
201
+ from: cursor,
202
+ to: b.from
203
+ });
204
+ cursor = b.to;
205
+ if (cursor >= window.to) return out;
206
+ }
207
+ if (cursor < window.to) out.push({
208
+ from: cursor,
209
+ to: window.to
210
+ });
211
+ return out;
212
+ };
213
+ /**
214
+ * The working windows inside `[from, to]`, one per admitted weekday.
215
+ *
216
+ * A `dayEnd` at or before `dayStart` yields no window for that day rather than
217
+ * a negative one. Overnight working hours are not expressible, and saying so
218
+ * with an empty result is better than wrapping around midnight and reporting
219
+ * free time on a day the caller never asked about.
220
+ */
221
+ const dayWindows = (opts) => {
222
+ const out = [];
223
+ const last = opts.to.getTime();
224
+ for (let day = startOfLocalDay(opts.from); day.getTime() <= last; day = addDays$1(day, 1)) {
225
+ if (!opts.weekdays.has(day.getDay())) continue;
226
+ const open = atClock(day, opts.dayStart);
227
+ const close = atClock(day, opts.dayEnd);
228
+ if (close <= open) continue;
229
+ const from = Math.max(open, opts.from.getTime());
230
+ const to = Math.min(close, last);
231
+ if (to > from) out.push({
232
+ from,
233
+ to
234
+ });
235
+ }
236
+ return out;
237
+ };
238
+ /**
239
+ * Round an instant up to the next granularity boundary of its own hour.
240
+ *
241
+ * Anchored to the top of the hour rather than to the window, so a slot lands on
242
+ * 09:15 and not on 09:07 because that is when the previous meeting happened to
243
+ * end. Callers restrict granularity to a divisor of 60, which is what keeps the
244
+ * per-hour restart invisible.
245
+ */
246
+ const alignUp = (ms, granularityMinutes) => {
247
+ const d = new Date(ms);
248
+ const topOfHour = new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours(), 0, 0, 0).getTime();
249
+ const step = granularityMinutes * 6e4;
250
+ const past = ms - topOfHour;
251
+ return topOfHour + Math.ceil(past / step) * step;
252
+ };
253
+ /** Local midnight at the start of the day an instant falls on. */
254
+ const startOfLocalDayMs = (ms) => startOfLocalDay(new Date(ms)).getTime();
255
+ /** The following local midnight. Calendar arithmetic, so a DST day is 23h or 25h. */
256
+ const nextLocalDayMs = (ms) => addDays$1(startOfLocalDay(new Date(ms)), 1).getTime();
257
+ /** `2026-08-24`, in local components — the day a slot falls on. */
258
+ const localDay = (ms) => {
259
+ const d = new Date(ms);
260
+ return `${d.getFullYear()}-${pad$2(d.getMonth() + 1)}-${pad$2(d.getDate())}`;
261
+ };
262
+ //#endregion
82
263
  //#region src/client/dates.ts
83
264
  /**
84
265
  * Date handling for the Calendar tools.
@@ -1464,6 +1645,29 @@ const openStore = (path, mode, logger) => {
1464
1645
  */
1465
1646
  const STATUS_CANCELLED = 3;
1466
1647
  const PARTICIPANT_DECLINED = 3;
1648
+ /**
1649
+ * `EKEventAvailability.free`, on the same inference as the two above — and held
1650
+ * to a stricter standard, because the direction it can fail in is worse.
1651
+ *
1652
+ * Reading this number wrongly makes a busy event look free, which is the one
1653
+ * error `findAvailability` exists to avoid. So unlike `status`, it is not
1654
+ * applied by default at all: `respectFreeMarking` is opt-in, the raw value
1655
+ * travels on every busy block, and until someone measures the column against a
1656
+ * live store every event blocks time regardless of how it is marked.
1657
+ */
1658
+ const AVAILABILITY_FREE = 1;
1659
+ /**
1660
+ * How many events `findAvailability` will read from one leg before refusing.
1661
+ *
1662
+ * `listEvents` over-fetches `limit * 4 + 50` and caps at 5,000 because it only
1663
+ * needs enough rows to fill a page. This tool needs EVERY row in the window, so
1664
+ * the number here is not a page size — it is the point past which the tool
1665
+ * stops claiming to know what is on the calendar. Sized against the measured
1666
+ * store in `docs/calendar.md` (1,350 items, 1,946 cached occurrences across
1667
+ * four years), so a year-long window on an ordinary calendar sits far under it
1668
+ * and a runaway one is caught rather than answered.
1669
+ */
1670
+ const BUSY_SCAN_BOUND = 5e3;
1467
1671
  var AppleCalendarClient = class {
1468
1672
  config;
1469
1673
  runner;
@@ -1706,6 +1910,212 @@ var AppleCalendarClient = class {
1706
1910
  coverage: null
1707
1911
  };
1708
1912
  }
1913
+ /**
1914
+ * When nothing is on the calendar, for long enough to hold a meeting.
1915
+ *
1916
+ * ## Why this is not list_events with arithmetic bolted on
1917
+ *
1918
+ * Every other read here returns what it found and flags what it missed, and
1919
+ * a caller reads the flag or does not. This one INVERTS the events: what it
1920
+ * returns is the complement of what it read, so anything the read missed
1921
+ * comes back as free time. A page limit, an unexpanded weekly meeting, an
1922
+ * event past the coverage edge — each of those shortens a `list_events`
1923
+ * result harmlessly and each of them, here, invents a slot that is already
1924
+ * booked. `docs/calendar.md` names the failure directly: "a short list of
1925
+ * events is indistinguishable from a free afternoon."
1926
+ *
1927
+ * So the busy set is either complete or the answer is withheld. Three checks
1928
+ * enforce that, and each returns a refusal rather than a short list:
1929
+ *
1930
+ * 1. the scan bound, so a saturated leg never passes for a quiet week
1931
+ * 2. the expansion, because an unexpanded series is invisible on every
1932
+ * date but one
1933
+ * 3. the coverage edge, past which repeating events simply are not there
1934
+ *
1935
+ * The third clips the window rather than refusing outright, because the part
1936
+ * inside the edge is genuinely answerable — but it says what it cut.
1937
+ */
1938
+ findAvailability(args) {
1939
+ const store = this.#require();
1940
+ const now = this.#now();
1941
+ const range = parseRange({
1942
+ from: args.from,
1943
+ to: args.to,
1944
+ defaultRangeDays: this.config.defaultRangeDays,
1945
+ maxRangeDays: this.config.maxRangeDays
1946
+ }, now);
1947
+ const dayStart = parseClock("dayStart", args.dayStart);
1948
+ const dayEnd = parseClock("dayEnd", args.dayEnd);
1949
+ const weekdayKeys = args.weekdays?.length ? args.weekdays : DEFAULT_WEEKDAYS;
1950
+ const weekdays = weekdaySet(weekdayKeys);
1951
+ const calendarUuids = this.#calendarUuids(store, args.calendar);
1952
+ const fromApple = this.#toApple(range.from, store);
1953
+ const toApple = this.#toApple(range.to, store);
1954
+ const q = {
1955
+ fromApple,
1956
+ toApple,
1957
+ limit: BUSY_SCAN_BOUND,
1958
+ ...calendarUuids ? { calendarUuids } : {}
1959
+ };
1960
+ const items = store.rangeItems(q);
1961
+ const occurrences = store.rangeOccurrences(q);
1962
+ if (items.length >= BUSY_SCAN_BOUND || occurrences.length >= BUSY_SCAN_BOUND) return {
1963
+ degraded: true,
1964
+ capability: "busy-set",
1965
+ reason: `The window holds at least ${BUSY_SCAN_BOUND} events, which is the bound on what this tool reads. Nothing was computed, so this is not "you have no free time" and not "you are entirely free" — it is no answer at all.`,
1966
+ hint: "Ask for a shorter window, or scope it to the calendars that matter with `calendar`. apple_calendar_list_events will still page through the range as it is."
1967
+ };
1968
+ const merged = mergeRange({
1969
+ items,
1970
+ occurrences,
1971
+ coverage: store.coverage(),
1972
+ hasOccurrenceCache: store.caps.hasOccurrenceCache,
1973
+ fromApple,
1974
+ toApple,
1975
+ limit: items.length + occurrences.length,
1976
+ epochOffset: store.caps.epochOffset
1977
+ });
1978
+ if (merged.expansion === "unavailable") return {
1979
+ degraded: true,
1980
+ capability: "occurrence-expansion",
1981
+ reason: "Repeating events are not expanded on this store, so a weekly meeting exists on the date its series begins and nowhere else. Every gap computed from that would be wrong " + `in the same direction — free where it is booked. ${merged.expansionReason ?? ""}`.trim(),
1982
+ hint: "Read the schedule directly with apple_calendar_list_events, which reports the same limitation rather than hiding it behind an answer."
1983
+ };
1984
+ let windowFrom = range.from;
1985
+ let windowTo = range.to;
1986
+ let truncated;
1987
+ const cov = merged.coverage;
1988
+ if (cov) {
1989
+ const covFrom = /* @__PURE__ */ new Date((cov.fromApple + store.caps.epochOffset) * 1e3);
1990
+ const covTo = /* @__PURE__ */ new Date((cov.toApple + store.caps.epochOffset) * 1e3);
1991
+ const reason = "the requested window runs past the range this store has expanded, where repeating events are missing entirely; it was cut back rather than reporting that time as free";
1992
+ if (windowTo.getTime() > covTo.getTime()) {
1993
+ truncated = {
1994
+ reason,
1995
+ requestedTo: toLocalIso(range.to)
1996
+ };
1997
+ windowTo = covTo;
1998
+ }
1999
+ if (windowFrom.getTime() < covFrom.getTime()) {
2000
+ truncated = {
2001
+ ...truncated ?? { reason },
2002
+ requestedFrom: toLocalIso(range.from)
2003
+ };
2004
+ windowFrom = covFrom;
2005
+ }
2006
+ if (truncated && windowTo.getTime() <= windowFrom.getTime()) return {
2007
+ degraded: true,
2008
+ capability: "occurrence-coverage",
2009
+ reason: "The whole requested window lies outside the range this store has expanded, so every gap in it would be invented rather than found.",
2010
+ hint: `The expansion reaches ${toLocalIso(covFrom)} to ${toLocalIso(covTo)}. Ask inside that, or use apple_calendar_list_events, which reports single events correctly at any distance.`
2011
+ };
2012
+ }
2013
+ const startedAtNow = windowFrom.getTime() < now.getTime();
2014
+ if (startedAtNow) windowFrom = now;
2015
+ const shell = {
2016
+ durationMinutes: args.durationMinutes,
2017
+ window: {
2018
+ from: toLocalIso(windowFrom),
2019
+ to: toLocalIso(windowTo),
2020
+ clamped: range.clamped,
2021
+ startedAtNow
2022
+ },
2023
+ workingHours: {
2024
+ dayStart: args.dayStart,
2025
+ dayEnd: args.dayEnd,
2026
+ weekdays: weekdayKeys,
2027
+ granularityMinutes: args.granularityMinutes,
2028
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
2029
+ },
2030
+ expansion: merged.expansion,
2031
+ coverage: cov ? {
2032
+ from: this.#fromApple(cov.fromApple, store),
2033
+ to: this.#fromApple(cov.toApple, store),
2034
+ rows: cov.rows
2035
+ } : null,
2036
+ ...truncated ? { truncated } : {}
2037
+ };
2038
+ if (windowTo.getTime() <= windowFrom.getTime()) return {
2039
+ ...shell,
2040
+ slots: [],
2041
+ busy: {
2042
+ blocking: 0,
2043
+ intervals: 0
2044
+ },
2045
+ allDayEvents: [],
2046
+ note: "The window holds no future time — every moment in it has already passed. Nothing was checked, so this is not a report that you are busy."
2047
+ };
2048
+ const declined = args.includeDeclined ?? this.config.includeDeclined;
2049
+ const cancelled = args.includeCancelled ?? this.config.includeCancelled;
2050
+ const busy = [];
2051
+ const allDayEvents = [];
2052
+ let blocking = 0;
2053
+ for (const row of merged.rows) {
2054
+ if (row.startApple === null) continue;
2055
+ if (!this.#visible(row, {
2056
+ declined,
2057
+ cancelled
2058
+ })) continue;
2059
+ const startMs = (row.startApple + store.caps.epochOffset) * 1e3;
2060
+ if (row.allDay) {
2061
+ const dayOpen = startOfLocalDayMs(startMs);
2062
+ const dayShut = nextLocalDayMs(startMs);
2063
+ if (dayOpen < windowTo.getTime() && dayShut > windowFrom.getTime()) {
2064
+ const rendered = renderInstant(row.startApple, row.startTz, true, store.caps.epochOffset);
2065
+ allDayEvents.push({
2066
+ day: rendered?.allDay ? rendered.day : localDay(startMs),
2067
+ summary: row.summary,
2068
+ calendar: row.calendarTitle
2069
+ });
2070
+ }
2071
+ if (!args.allDayBusy) continue;
2072
+ const endMs = row.endApple === null ? dayShut : (row.endApple + store.caps.epochOffset) * 1e3;
2073
+ busy.push({
2074
+ from: dayOpen,
2075
+ to: Math.max(dayShut, startOfLocalDayMs(endMs))
2076
+ });
2077
+ blocking += 1;
2078
+ continue;
2079
+ }
2080
+ if (args.respectFreeMarking && row.availability === AVAILABILITY_FREE) continue;
2081
+ const endMs = row.endApple === null ? startMs : (row.endApple + store.caps.epochOffset) * 1e3;
2082
+ if (endMs <= startMs) continue;
2083
+ busy.push({
2084
+ from: startMs,
2085
+ to: endMs
2086
+ });
2087
+ blocking += 1;
2088
+ }
2089
+ const blocks = mergeIntervals(busy);
2090
+ const needMs = args.durationMinutes * 6e4;
2091
+ const slots = [];
2092
+ outer: for (const w of dayWindows({
2093
+ from: windowFrom,
2094
+ to: windowTo,
2095
+ dayStart,
2096
+ dayEnd,
2097
+ weekdays
2098
+ })) for (const gap of subtractBusy(w, blocks)) {
2099
+ const start = alignUp(gap.from, args.granularityMinutes);
2100
+ if (gap.to - start < needMs) continue;
2101
+ slots.push({
2102
+ start: toLocalIso(new Date(start)),
2103
+ end: toLocalIso(new Date(gap.to)),
2104
+ day: localDay(start),
2105
+ minutes: Math.round((gap.to - start) / 6e4)
2106
+ });
2107
+ if (slots.length >= args.limit) break outer;
2108
+ }
2109
+ return {
2110
+ ...shell,
2111
+ slots,
2112
+ busy: {
2113
+ blocking,
2114
+ intervals: blocks.length
2115
+ },
2116
+ allDayEvents
2117
+ };
2118
+ }
1709
2119
  getEvent(ref) {
1710
2120
  const store = this.#require();
1711
2121
  const decoded = decodeRef(ref);
@@ -1971,6 +2381,15 @@ var AppleCalendarClient = class {
1971
2381
  };
1972
2382
  //#endregion
1973
2383
  //#region src/config.ts
2384
+ const CLOCK = /^\d{1,2}:\d{2}$/;
2385
+ /**
2386
+ * `Mon`, `MONDAY` and `mon` all mean the same day.
2387
+ *
2388
+ * Normalised here rather than in the schema so the schema's error message stays
2389
+ * the useful one — it lists the seven keys — instead of rejecting a spelling
2390
+ * nobody would think was wrong.
2391
+ */
2392
+ const parseWorkdays = (raw) => raw?.map((d) => d.trim().toLowerCase().slice(0, 3));
1974
2393
  /**
1975
2394
  * Configuration is environment-only — this server holds no secret at all, its
1976
2395
  * access is the macOS permission the user granted.
@@ -2014,6 +2433,17 @@ const ConfigSchema = BaseConfigSchema.extend({
2014
2433
  defaultRangeDays: z.number().int().min(1).max(366).default(7),
2015
2434
  /** Hard clamp, so one query cannot ask for a decade. */
2016
2435
  maxRangeDays: z.number().int().min(1).max(3660).default(366),
2436
+ /**
2437
+ * The working day `find_availability` assumes when the caller names no hours.
2438
+ *
2439
+ * Local wall clock, both of them: working hours are a person's, so they hold
2440
+ * their numbers across a daylight-saving change rather than drifting an hour.
2441
+ * `24:00` is a legal end and means midnight at the close of that day.
2442
+ */
2443
+ workdayStart: z.string().regex(CLOCK, "expected a time of day like \"09:00\"").default("09:00"),
2444
+ workdayEnd: z.string().regex(CLOCK, "expected a time of day like \"18:00\"").default("18:00"),
2445
+ /** The days that working day applies to. */
2446
+ workdays: z.array(z.enum(WEEKDAY_KEYS)).min(1).default([...DEFAULT_WEEKDAYS]),
2017
2447
  /** Length of a created event when the caller gives neither an end nor a duration. */
2018
2448
  defaultEventDurationMinutes: z.number().int().min(1).max(1440).default(60),
2019
2449
  /** Whether events the user declined are included when the caller does not say. */
@@ -2041,6 +2471,7 @@ const ConfigSchema = BaseConfigSchema.extend({
2041
2471
  */
2042
2472
  const loadConfig = (env = process.env) => parseConfig(ConfigSchema, {
2043
2473
  allowWrites: parseBool(env.APPLE_CALENDAR_ALLOW_WRITES),
2474
+ exposePrompts: parseBool(env.APPLE_CALENDAR_EXPOSE_PROMPTS),
2044
2475
  debug: parseBool(env.APPLE_CALENDAR_DEBUG),
2045
2476
  accounts: parseList(env.APPLE_CALENDAR_ACCOUNTS),
2046
2477
  calendars: parseList(env.APPLE_CALENDAR_CALENDARS),
@@ -2050,6 +2481,9 @@ const loadConfig = (env = process.env) => parseConfig(ConfigSchema, {
2050
2481
  defaultRangeDays: parseIntOpt(env.APPLE_CALENDAR_DEFAULT_RANGE_DAYS),
2051
2482
  maxRangeDays: parseIntOpt(env.APPLE_CALENDAR_MAX_RANGE_DAYS),
2052
2483
  defaultEventDurationMinutes: parseIntOpt(env.APPLE_CALENDAR_DEFAULT_EVENT_DURATION_MINUTES),
2484
+ workdayStart: trimmed(env.APPLE_CALENDAR_WORKDAY_START),
2485
+ workdayEnd: trimmed(env.APPLE_CALENDAR_WORKDAY_END),
2486
+ workdays: parseWorkdays(parseList(env.APPLE_CALENDAR_WORKDAYS)),
2053
2487
  includeDeclined: parseBool(env.APPLE_CALENDAR_INCLUDE_DECLINED),
2054
2488
  includeCancelled: parseBool(env.APPLE_CALENDAR_INCLUDE_CANCELLED),
2055
2489
  osascriptPath: trimmed(env.APPLE_CALENDAR_OSASCRIPT_PATH),
@@ -2058,6 +2492,126 @@ const loadConfig = (env = process.env) => parseConfig(ConfigSchema, {
2058
2492
  timeZone: trimmed(env.APPLE_CALENDAR_TIMEZONE)
2059
2493
  });
2060
2494
  //#endregion
2495
+ //#region src/guide.ts
2496
+ /**
2497
+ * The Calendar operating manual, served as `cupertino://calendar/guide` and
2498
+ * embedded ahead of every Calendar prompt. Static by design — see the note in
2499
+ * the Mail guide.
2500
+ */
2501
+ const CALENDAR_GUIDE = `# Apple Calendar — how to drive this server
2502
+
2503
+ ## Refs are opaque, and they name an occurrence
2504
+
2505
+ Events come back with a \`ref\` like \`c1:<calendar>/<occurrence>/<uid>\`. Pass it
2506
+ back verbatim. The part that matters: a ref names **one occurrence**, not the
2507
+ series. Reading it reports that occurrence's times; acting on it acts on that
2508
+ occurrence. Do not assume changing one changes the repeat.
2509
+
2510
+ ## Which tool, under which constraint
2511
+
2512
+ - **"When am I free", "find me 45 minutes", "what does Thursday look like"** is
2513
+ \`apple_calendar_find_availability\`. Reach for it instead of listing events and
2514
+ looking for gaps by hand — that is where an occurrence of a repeating meeting
2515
+ gets missed. It works in working hours (09:00–18:00 on weekdays by default),
2516
+ in this machine's timezone, and never offers time already past.
2517
+ - **What is on** is \`apple_calendar_list_events\` over a bounded range.
2518
+ - **Finding a specific event** is \`apple_calendar_search_events\`.
2519
+ - **Calendar names** must match what Calendar calls them — read the inventory
2520
+ resource first. A mistyped name matches nothing rather than erroring.
2521
+
2522
+ ## Empty is booked; degraded is unknown
2523
+
2524
+ On availability these are two different answers and must never be reported the
2525
+ same way. An empty \`slots\` list means **the time is booked**. \`degraded: true\`
2526
+ means the question could not be answered — too many events to read, a store
2527
+ that does not expand repeats, or a window past the expansion range — and the
2528
+ right reply is "I could not check", never "you are free".
2529
+
2530
+ All-day events do not block time by default, because an all-day event is as
2531
+ often a birthday as a holiday. They come back in \`allDayEvents\`: read that
2532
+ before booking over one rather than turning \`allDayBusy\` on blindly. Declined
2533
+ and cancelled events never block.
2534
+
2535
+ ## Writes, and the thing this server will not do
2536
+
2537
+ Mutating tools exist only when writes are enabled. If you cannot see
2538
+ \`apple_calendar_create_event\`, writes are off.
2539
+
2540
+ **There is no \`attendees\` parameter, anywhere, on purpose.** Adding an attendee
2541
+ sends mail to a person. This server creates events on calendars; it does not
2542
+ invite anyone. If the user wants someone invited, create the event and tell them
2543
+ to invite from Calendar — do not silently create an event they believe was sent.
2544
+
2545
+ Read-only calendars are refused rather than silently skipped.
2546
+ `;
2547
+ //#endregion
2548
+ //#region src/prompts.ts
2549
+ const CTX = {
2550
+ surface: "calendar",
2551
+ guide: CALENDAR_GUIDE
2552
+ };
2553
+ /**
2554
+ * Calendar's workflow prompts.
2555
+ *
2556
+ * The scheduling one carries the constraint that costs the most when it is
2557
+ * missed: this server can create an event but cannot invite anyone to it, so a
2558
+ * model that treats "schedule a meeting with Ana" as done has told the user
2559
+ * something false about a message that was never sent.
2560
+ */
2561
+ const registerPrompts = (server, allowWrites) => {
2562
+ registerWorkflowPrompt(server, CTX, {
2563
+ name: "apple_calendar_whats_my_day",
2564
+ title: "What my day looks like",
2565
+ description: "Read back a day or a range as something you can act on — what is fixed, what is movable, and where the real free time is. Read-only.",
2566
+ argsSchema: {
2567
+ when: promptArg("Which day or range, e.g. \"today\", \"tomorrow\", \"next week\". Defaults to today."),
2568
+ calendar: promptArg("Restrict to one calendar, exactly as Calendar spells it.")
2569
+ },
2570
+ build: ({ when, calendar }) => `Tell me what ${when ?? "today"} looks like${calendar ? `, on the ${calendar} calendar` : ""}.
2571
+
2572
+ 1. \`apple_calendar_list_events\` over that range${calendar ? " for that calendar" : ""}. Bound the range explicitly — do not list open-endedly and trim afterwards.
2573
+ 2. Then \`apple_calendar_find_availability\` over the same range, so you can say
2574
+ where the usable gaps are rather than leaving the user to subtract meetings
2575
+ from a day in their head. Report each gap's real length.
2576
+ 3. Call out anything in \`allDayEvents\` — a holiday or someone's day off changes
2577
+ what the rest of the day means, and it does not block time by default.
2578
+ 4. Flag the pressure points: back-to-back blocks with no gap, anything starting
2579
+ before or ending after normal hours, and any two events that overlap.
2580
+
2581
+ If a call comes back \`degraded: true\`, say which part of the range you could not
2582
+ read. An empty result means booked; degraded means unknown, and they must not be
2583
+ reported the same way.`
2584
+ });
2585
+ if (!allowWrites) return;
2586
+ registerWorkflowPrompt(server, CTX, {
2587
+ name: "apple_calendar_schedule",
2588
+ title: "Schedule something",
2589
+ description: "Find a time that genuinely works and put an event on the calendar. Requires writes. Note that this cannot invite anyone — it creates the event only.",
2590
+ argsSchema: {
2591
+ what: requiredPromptArg("What the event is, e.g. \"45 minutes with Ana about pricing\"."),
2592
+ when: promptArg("Constraint on when, e.g. \"next week\", \"Thursday afternoon\"."),
2593
+ calendar: promptArg("Which calendar to create it on, exactly as Calendar spells it.")
2594
+ },
2595
+ build: ({ what, when, calendar }) => `Schedule: ${what}${when ? `\n\nWhen: ${when}` : ""}
2596
+
2597
+ 1. Work out the duration from the description; ask if it is genuinely unclear
2598
+ rather than defaulting to an hour.
2599
+ 2. \`apple_calendar_find_availability\` over ${when ? `the window implied by "${when}"` : "the coming week"}. Do not list events and pick a gap yourself — a repeating meeting's
2600
+ occurrence is exactly what that misses.
2601
+ 3. Offer the user two or three real options before creating anything, with the
2602
+ full length of each gap so they can see whether it is tight. If availability
2603
+ comes back \`degraded: true\`, stop and say you could not check — do not offer
2604
+ a slot you could not verify.
2605
+ 4. On their choice, \`apple_calendar_create_event\`${calendar ? ` on the ${calendar} calendar` : " on a calendar from the inventory, not an invented name"}.
2606
+
2607
+ **Then be explicit about what did not happen: nobody was invited.** This server
2608
+ has no \`attendees\` parameter on any tool — adding one would send mail, and it
2609
+ deliberately cannot. If someone else needs to be at this, the event is on the
2610
+ calendar and the invitation still has to be sent from Calendar by hand. Say so
2611
+ plainly; do not let "scheduled with Ana" stand when Ana was never told.`
2612
+ });
2613
+ };
2614
+ //#endregion
2061
2615
  //#region src/tools/util.ts
2062
2616
  const eventRefArg = z.string().min(1).describe("An opaque event ref from a list or search result (looks like \"c1:<calendar>/<occurrence>/<uid>\"). Do not construct one by hand.");
2063
2617
  const calendarArg = z.string().optional().describe("A calendar name or uid, e.g. \"Work\". Use apple_calendar_list_calendars to see what exists.");
@@ -2073,6 +2627,107 @@ const toArg = z.string().optional().describe("End of the window. A bare day runs
2073
2627
  const includeDeclinedArg = z.boolean().optional().describe("Include events you have declined. Off by default; they are still on the calendar.");
2074
2628
  const includeCancelledArg = z.boolean().optional().describe("Include events an organiser cancelled but that are still in the store. Off by default.");
2075
2629
  //#endregion
2630
+ //#region src/tools/diagnostics.ts
2631
+ /**
2632
+ * Build the report.
2633
+ *
2634
+ * Split out of the tool registration so the `cupertino://calendar/diagnostics`
2635
+ * resource can serve the same bytes. Two renderings of one probe: duplicated,
2636
+ * the resource and the tool would drift, and the disagreement would surface as
2637
+ * "the diagnostics lied" — the one thing this file must never do.
2638
+ */
2639
+ const buildDiagnostics = async (client, ctx) => {
2640
+ const lanes = client.lanes();
2641
+ const located = client.locate();
2642
+ const store = client.index();
2643
+ /**
2644
+ * Three-valued, and the middle value is the useful one.
2645
+ *
2646
+ * Calendar's store has a constant filename, so `stat` answers "is it
2647
+ * there" even when `access(2)` is denied. That lets this separate "the
2648
+ * grant is missing" from "Calendar was never set up on this account",
2649
+ * which Reminders cannot do — its filename carries a generated UUID, so
2650
+ * without the grant there is no path to test at all.
2651
+ */
2652
+ const fullDiskAccess = located.readable ? "granted" : located.exists ? "denied (found the store, cannot read it)" : "unknown (no store file at the expected path)";
2653
+ return {
2654
+ server: { lanes },
2655
+ permissions: {
2656
+ fullDiskAccess,
2657
+ automation: ctx.allowWrites ? "needed for writes only. Reads never send an Apple Event, so a read-only setup prompts for nothing." : "not needed — writes are off, and reads never send an Apple Event. Turning writes on will prompt for Automation the first time one runs.",
2658
+ ...located.readable ? {} : { howToGrant: [
2659
+ "System Settings > Privacy & Security > Full Disk Access",
2660
+ "Add the app that launches this server (Terminal, iTerm, VS Code, Claude...), then restart it.",
2661
+ "Granting it to Calendar.app does nothing — the reader needs the permission, not Calendar."
2662
+ ] }
2663
+ },
2664
+ store: {
2665
+ containerPath: located.containerPath,
2666
+ path: located.storePath,
2667
+ containerListable: located.containerListable,
2668
+ extrasPresent: located.extrasPresent,
2669
+ candidates: located.candidates.length,
2670
+ exists: located.exists,
2671
+ readable: located.readable,
2672
+ sizeBytes: located.size,
2673
+ walPresent: located.walPresent,
2674
+ walSizeBytes: located.walSizeBytes,
2675
+ fingerprint: lanes.storeFingerprint,
2676
+ reason: located.reason
2677
+ },
2678
+ capabilities: store ? {
2679
+ hasOccurrenceCache: store.caps.hasOccurrenceCache,
2680
+ hasOccurrenceDays: store.caps.hasOccurrenceDays,
2681
+ hasRecurrence: store.caps.hasRecurrence,
2682
+ hasExceptionDates: store.caps.hasExceptionDates,
2683
+ hasLocation: store.caps.hasLocation,
2684
+ hasAttachments: store.caps.hasAttachments,
2685
+ hasParticipants: store.caps.hasParticipants,
2686
+ hasAlarms: store.caps.hasAlarms,
2687
+ itemColumns: store.caps.itemColumns.size,
2688
+ calendarColumns: store.caps.calendarColumns.size
2689
+ } : null,
2690
+ settings: {
2691
+ allowWrites: ctx.allowWrites,
2692
+ exposePrompts: client.config.exposePrompts,
2693
+ accountAllowlist: client.config.accounts,
2694
+ calendarAllowlist: client.config.calendars,
2695
+ defaultCalendar: client.config.defaultCalendar ?? null,
2696
+ defaultRangeDays: client.config.defaultRangeDays,
2697
+ maxRangeDays: client.config.maxRangeDays,
2698
+ includeDeclined: client.config.includeDeclined,
2699
+ includeCancelled: client.config.includeCancelled,
2700
+ indexMode: client.config.indexMode,
2701
+ maxResults: client.config.maxResults,
2702
+ timeZone: client.config.timeZone ?? null
2703
+ },
2704
+ caveats: [
2705
+ "Writes go through Apple Events and are real side effects: on a shared, CalDAV or Exchange calendar a created event syncs within seconds and other people see it. There is no draft state and no undo.",
2706
+ "Single occurrences of a repeating event can be neither edited nor deleted through this server, and that is a limit of Calendar's scripting interface rather than a choice: there is no way to detach one occurrence, and the excluded-dates property reads back a 1903 sentinel and throws on assignment (measured, macOS 26.6). Both are refused rather than applied to the whole series. Calendar.app can still do them.",
2707
+ "Attendees cannot be set, because that emails a person. There is no \"all future occurrences\" either, which would need two writes with no transaction between them.",
2708
+ "Calendar reads through the file lane only. Apple Events was measured at 3.4s for a single 90-day range query, with the cost falling per round trip rather than per event, so there is no slower-but-working fallback to offer when Full Disk Access is missing. Reads need the grant.",
2709
+ "Repeating events are expanded from OccurrenceCache, which was measured to reach about two years either side of today on the probed store. That is an edge: a range running past it returns fewer repeating events than exist, so every list_events result carries `coverage`, and sets `truncated` rather than returning a short list silently.",
2710
+ "The `status` and `invitationStatus` numbers are EventKit's documented constants, and this store is assumed to mirror them — likely, but not measured. That is why cancelled and declined events are only hidden when you ask, and why the raw value is on every result.",
2711
+ "Writes will go through Apple Events, targeting com.apple.iCal. Note the bundle id is iCal, not Calendar, which is the one place this surface differs from the other three."
2712
+ ]
2713
+ };
2714
+ };
2715
+ /**
2716
+ * One tool that answers "why is this not working".
2717
+ *
2718
+ * Unlike the Reminders equivalent this probes no Apple Event before reading:
2719
+ * Calendar has no Apple Events read lane to probe (`docs/distribution.md`), so
2720
+ * firing one here would trigger the Automation prompt for a capability the
2721
+ * server does not yet have.
2722
+ */
2723
+ const registerDiagnosticsTools = (server, client, ctx) => {
2724
+ server.registerTool("apple_calendar_diagnostics", {
2725
+ description: "Report which lanes are live, which macOS permissions are granted, and what each missing one is blocking. Start here when a tool fails or reports nothing — it names the exact System Settings pane to open.",
2726
+ inputSchema: {},
2727
+ annotations: { readOnlyHint: true }
2728
+ }, async () => wrap(() => buildDiagnostics(client, ctx)).then((r) => r ?? ok({})));
2729
+ };
2730
+ //#endregion
2076
2731
  //#region src/tools/actions.ts
2077
2732
  /**
2078
2733
  * The mutating tools.
@@ -2128,6 +2783,49 @@ const registerActionTools = (server, client) => {
2128
2783
  }, async ({ refs }) => wrap(() => client.deleteEvents(refs)));
2129
2784
  };
2130
2785
  //#endregion
2786
+ //#region src/tools/availability.ts
2787
+ /**
2788
+ * The one read on this surface that answers a question instead of returning
2789
+ * rows, and the only one whose result is the COMPLEMENT of what it read.
2790
+ *
2791
+ * It lives apart from `events.ts` for that reason. The three tools there share
2792
+ * a failure mode — return fewer events than exist, flag it, and the caller is
2793
+ * merely under-informed. This one turns the same shortfall into a positive
2794
+ * claim that a time is free, so its refusal paths are load-bearing rather than
2795
+ * defensive, and keeping them next to a `list_events` that legitimately pages
2796
+ * would invite someone to make the two consistent.
2797
+ */
2798
+ const registerAvailabilityTools = (server, client) => {
2799
+ server.registerTool("apple_calendar_find_availability", {
2800
+ description: "Find the times nothing is on the calendar, long enough to hold a meeting of a given length. This is the tool for \"when am I free\", \"find me 45 minutes next week\" or \"what does Thursday look like\" — reach for it instead of listing events and looking for gaps yourself, which is where an occurrence of a repeating meeting gets missed. Working hours default to 09:00-18:00 on weekdays, in THIS MACHINE'S timezone, and only time inside them is offered. Slots start on a 15-minute boundary and each one reports how long the whole gap is, which is usually longer than you asked for. Time already past today is never offered. It refuses rather than guesses: if the window holds too many events to read, if this store does not expand repeating events, or if the window runs past the range the expansion covers, you get `degraded: true` and a reason instead of free time that is not free. An empty `slots` list means BOOKED; `degraded` means UNKNOWN. Declined and cancelled events do not block time; all-day events do not either, but they are listed in `allDayEvents` so you can see the holiday you are about to book over.",
2801
+ inputSchema: {
2802
+ durationMinutes: z.number().int().min(1).max(1440).describe("How long the meeting needs to be. A gap shorter than this is not returned."),
2803
+ from: z.string().optional().describe("Start of the window. ISO-8601 \"2026-08-21\" or \"2026-08-21T09:00\", or \"today\", \"tomorrow\", \"next monday\", \"+2d\". Defaults to today. A start already in the past is pulled forward to now."),
2804
+ to: toArg,
2805
+ calendar: calendarArg,
2806
+ dayStart: z.string().optional().describe("Earliest time of day to offer, local wall clock, e.g. \"09:00\". Defaults to APPLE_CALENDAR_WORKDAY_START (09:00)."),
2807
+ dayEnd: z.string().optional().describe("Latest time of day to offer, e.g. \"18:00\" — or \"24:00\" for the end of the day. Defaults to APPLE_CALENDAR_WORKDAY_END (18:00)."),
2808
+ weekdays: z.array(z.enum(WEEKDAY_KEYS)).min(1).optional().describe("Which days to offer, e.g. [\"mon\",\"tue\",\"wed\",\"thu\",\"fri\"]. Defaults to the configured working week. Pass all seven to include the weekend."),
2809
+ granularityMinutes: z.number().int().min(1).max(60).refine((n) => 60 % n === 0, { message: "must divide 60 evenly — 5, 10, 15, 20, 30 or 60" }).optional().describe("Slot starts are rounded up to this boundary. Default 15."),
2810
+ allDayBusy: z.boolean().optional().describe("Let an all-day event block its whole day. Off by default, because an all-day event is as often a birthday as a holiday, and blocking on \"Ana's birthday\" would hide a working day. They are reported in `allDayEvents` either way — read that before booking rather than turning this on blindly."),
2811
+ respectFreeMarking: z.boolean().optional().describe("Skip events the calendar marks as \"free\" rather than \"busy\". Off by default: the stored constant is inferred from EventKit and has not been measured against a real store, and reading it wrongly would offer a slot that is booked. Every busy block reports its raw value so you can check before turning this on."),
2812
+ includeDeclined: includeDeclinedArg,
2813
+ includeCancelled: includeCancelledArg,
2814
+ limit: limitArg
2815
+ },
2816
+ annotations: { readOnlyHint: true }
2817
+ }, async (args) => wrap(async () => client.findAvailability({
2818
+ ...args,
2819
+ dayStart: args.dayStart ?? client.config.workdayStart,
2820
+ dayEnd: args.dayEnd ?? client.config.workdayEnd,
2821
+ weekdays: args.weekdays ?? client.config.workdays,
2822
+ granularityMinutes: args.granularityMinutes ?? 15,
2823
+ allDayBusy: args.allDayBusy ?? false,
2824
+ respectFreeMarking: args.respectFreeMarking ?? false,
2825
+ limit: args.limit ?? Math.min(25, client.config.maxResults)
2826
+ })));
2827
+ };
2828
+ //#endregion
2131
2829
  //#region src/tools/calendars.ts
2132
2830
  /**
2133
2831
  * NOTE ON `async` BELOW: core's `wrap` is typed `() => Promise<T>` because every
@@ -2148,97 +2846,6 @@ const registerCalendarTools = (server, client) => {
2148
2846
  }, async () => wrap(async () => client.accounts()));
2149
2847
  };
2150
2848
  //#endregion
2151
- //#region src/tools/diagnostics.ts
2152
- /**
2153
- * One tool that answers "why is this not working".
2154
- *
2155
- * Unlike the Reminders equivalent this probes no Apple Event before reading:
2156
- * Calendar has no Apple Events read lane to probe (`docs/distribution.md`), so
2157
- * firing one here would trigger the Automation prompt for a capability the
2158
- * server does not yet have.
2159
- */
2160
- const registerDiagnosticsTools = (server, client, ctx) => {
2161
- server.registerTool("apple_calendar_diagnostics", {
2162
- description: "Report which lanes are live, which macOS permissions are granted, and what each missing one is blocking. Start here when a tool fails or reports nothing — it names the exact System Settings pane to open.",
2163
- inputSchema: {},
2164
- annotations: { readOnlyHint: true }
2165
- }, async () => wrap(async () => {
2166
- const lanes = client.lanes();
2167
- const located = client.locate();
2168
- const store = client.index();
2169
- /**
2170
- * Three-valued, and the middle value is the useful one.
2171
- *
2172
- * Calendar's store has a constant filename, so `stat` answers "is it
2173
- * there" even when `access(2)` is denied. That lets this separate "the
2174
- * grant is missing" from "Calendar was never set up on this account",
2175
- * which Reminders cannot do — its filename carries a generated UUID, so
2176
- * without the grant there is no path to test at all.
2177
- */
2178
- const fullDiskAccess = located.readable ? "granted" : located.exists ? "denied (found the store, cannot read it)" : "unknown (no store file at the expected path)";
2179
- return {
2180
- server: { lanes },
2181
- permissions: {
2182
- fullDiskAccess,
2183
- automation: ctx.allowWrites ? "needed for writes only. Reads never send an Apple Event, so a read-only setup prompts for nothing." : "not needed — writes are off, and reads never send an Apple Event. Turning writes on will prompt for Automation the first time one runs.",
2184
- ...located.readable ? {} : { howToGrant: [
2185
- "System Settings > Privacy & Security > Full Disk Access",
2186
- "Add the app that launches this server (Terminal, iTerm, VS Code, Claude...), then restart it.",
2187
- "Granting it to Calendar.app does nothing — the reader needs the permission, not Calendar."
2188
- ] }
2189
- },
2190
- store: {
2191
- containerPath: located.containerPath,
2192
- path: located.storePath,
2193
- containerListable: located.containerListable,
2194
- extrasPresent: located.extrasPresent,
2195
- candidates: located.candidates.length,
2196
- exists: located.exists,
2197
- readable: located.readable,
2198
- sizeBytes: located.size,
2199
- walPresent: located.walPresent,
2200
- walSizeBytes: located.walSizeBytes,
2201
- fingerprint: lanes.storeFingerprint,
2202
- reason: located.reason
2203
- },
2204
- capabilities: store ? {
2205
- hasOccurrenceCache: store.caps.hasOccurrenceCache,
2206
- hasOccurrenceDays: store.caps.hasOccurrenceDays,
2207
- hasRecurrence: store.caps.hasRecurrence,
2208
- hasExceptionDates: store.caps.hasExceptionDates,
2209
- hasLocation: store.caps.hasLocation,
2210
- hasAttachments: store.caps.hasAttachments,
2211
- hasParticipants: store.caps.hasParticipants,
2212
- hasAlarms: store.caps.hasAlarms,
2213
- itemColumns: store.caps.itemColumns.size,
2214
- calendarColumns: store.caps.calendarColumns.size
2215
- } : null,
2216
- settings: {
2217
- allowWrites: ctx.allowWrites,
2218
- accountAllowlist: client.config.accounts,
2219
- calendarAllowlist: client.config.calendars,
2220
- defaultCalendar: client.config.defaultCalendar ?? null,
2221
- defaultRangeDays: client.config.defaultRangeDays,
2222
- maxRangeDays: client.config.maxRangeDays,
2223
- includeDeclined: client.config.includeDeclined,
2224
- includeCancelled: client.config.includeCancelled,
2225
- indexMode: client.config.indexMode,
2226
- maxResults: client.config.maxResults,
2227
- timeZone: client.config.timeZone ?? null
2228
- },
2229
- caveats: [
2230
- "Writes go through Apple Events and are real side effects: on a shared, CalDAV or Exchange calendar a created event syncs within seconds and other people see it. There is no draft state and no undo.",
2231
- "Single occurrences of a repeating event can be neither edited nor deleted through this server, and that is a limit of Calendar's scripting interface rather than a choice: there is no way to detach one occurrence, and the excluded-dates property reads back a 1903 sentinel and throws on assignment (measured, macOS 26.6). Both are refused rather than applied to the whole series. Calendar.app can still do them.",
2232
- "Attendees cannot be set, because that emails a person. There is no \"all future occurrences\" either, which would need two writes with no transaction between them.",
2233
- "Calendar reads through the file lane only. Apple Events was measured at 3.4s for a single 90-day range query, with the cost falling per round trip rather than per event, so there is no slower-but-working fallback to offer when Full Disk Access is missing. Reads need the grant.",
2234
- "Repeating events are expanded from OccurrenceCache, which was measured to reach about two years either side of today on the probed store. That is an edge: a range running past it returns fewer repeating events than exist, so every list_events result carries `coverage`, and sets `truncated` rather than returning a short list silently.",
2235
- "The `status` and `invitationStatus` numbers are EventKit's documented constants, and this store is assumed to mirror them — likely, but not measured. That is why cancelled and declined events are only hidden when you ask, and why the raw value is on every result.",
2236
- "Writes will go through Apple Events, targeting com.apple.iCal. Note the bundle id is iCal, not Calendar, which is the one place this surface differs from the other three."
2237
- ]
2238
- };
2239
- }).then((r) => r ?? ok({})));
2240
- };
2241
- //#endregion
2242
2849
  //#region src/tools/events.ts
2243
2850
  /** Declared once and spread into both tools, so the two cannot drift apart. */
2244
2851
  const filterSchema = {
@@ -2300,6 +2907,7 @@ const registerTools = (server, client, ctx) => {
2300
2907
  registerDiagnosticsTools(server, client, ctx);
2301
2908
  registerCalendarTools(server, client);
2302
2909
  registerEventTools(server, client);
2910
+ registerAvailabilityTools(server, client);
2303
2911
  if (!ctx.allowWrites) return;
2304
2912
  registerActionTools(server, client);
2305
2913
  };
@@ -2326,6 +2934,22 @@ const createServer = (opts) => {
2326
2934
  ...opts.now ? { now: opts.now } : {}
2327
2935
  });
2328
2936
  registerTools(server, client, { allowWrites: config.allowWrites });
2937
+ if (config.exposePrompts) {
2938
+ registerPrompts(server, config.allowWrites);
2939
+ registerSurfaceResources(server, {
2940
+ surface: "calendar",
2941
+ displayName: "Calendar",
2942
+ guide: CALENDAR_GUIDE,
2943
+ diagnostics: () => buildDiagnostics(client, { allowWrites: config.allowWrites }),
2944
+ inventory: {
2945
+ describes: "accounts and calendars",
2946
+ read: async () => ({
2947
+ accounts: await client.accounts(),
2948
+ calendars: await client.calendars()
2949
+ })
2950
+ }
2951
+ });
2952
+ }
2329
2953
  return {
2330
2954
  server,
2331
2955
  client
@@ -2334,4 +2958,4 @@ const createServer = (opts) => {
2334
2958
  //#endregion
2335
2959
  export { BUILD_INFO as A, CALENDAR_SURFACE as C, CalendarNotWritableError as D, CalendarNotRunningError as E, EventNotFoundError as O, CALENDAR_BUNDLE_ID as S, CalendarNotFoundError as T, STORE_FILENAME as _, loadConfig as a, locateStore as b, introspect as c, decodeRef as d, encodeRef as f, GROUP_CONTAINER as g, EXTRAS_FILENAME as h, registerTools as i, InvalidDateError as k, openStore as l, uuidOf as m, SERVER_VERSION as n, AppleCalendarClient as o, seriesRefOf as p, createServer as r, CalendarStore as s, SERVER_NAME as t, REF_VERSION as u, defaultContainerPath as v, CalendarBusyError as w, AppleCalendarError as x, defaultStorePath as y };
2336
2960
 
2337
- //# sourceMappingURL=server-B2HtiXLF.js.map
2961
+ //# sourceMappingURL=server-DoSfe1y7.js.map