circal-mcp 0.1.0 → 0.2.0

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.
package/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # circal-mcp
2
2
 
3
3
  A stdio [MCP](https://modelcontextprotocol.io) server that reads and writes
4
- circal's own mirror file — the `.json` a browser tab keeps in sync on disk
5
- through the File System Access API (see `src/lib/mirror.ts` and
6
- `src/lib/mirrorLink.ts`). It imports circal's own domain layer
4
+ circal's own mirror file — the `.json` a browser tab or the macOS app keeps
5
+ in sync on disk, through the File System Access API on the web and the
6
+ shell's own bridge in the app (see `src/lib/mirror.ts`, `src/lib/mirrorLink.ts`
7
+ and `mac/main.swift`'s `MirrorFile`). It imports circal's own domain layer
7
8
  (`src/lib/events.ts`, `recur.ts`, `quickadd.ts`, `mutate.ts`, `backup.ts`, …),
8
9
  so an agent and the app can never disagree about what a legal event, an
9
10
  occurrence, or a recurrence rule is.
@@ -18,10 +19,11 @@ again, the same as any second writer would.
18
19
 
19
20
  ## Quick start
20
21
 
21
- A `FileSystemFileHandle` (what the browser hands circal when the mirror is
22
- turned on) never exposes the full path it came from: that is deliberate
23
- browser privacy design, so circal cannot print it for you. `--find` searches
24
- the places a mirror file usually lands and does that work instead.
22
+ A `FileSystemFileHandle` (what a browser hands circal when the mirror is turned on there) never
23
+ exposes the full path it came from: that is deliberate browser privacy design, so circal cannot print
24
+ it for you. The macOS app does not have this problem — it mints and adopts a mirror at a fixed path
25
+ on first launch, no gesture required so `--find` checks that path first, by name, then falls back
26
+ to the browser case and searches the places a mirror file usually lands.
25
27
 
26
28
  ```sh
27
29
  npx -y circal-mcp --find
@@ -32,6 +34,22 @@ zone, revision) plus a ready-to-paste config block. Paste that block into
32
34
  your client below, swap the path if it picked up the wrong file, then
33
35
  restart the client.
34
36
 
37
+ > **Use a locally built bundle, not `npx circal-mcp`.** The published
38
+ > `circal-mcp@0.1.0` predates several fields in `src/lib` (`free`,
39
+ > `remindLead`, `organizer` and the guest pair on an event; `backupNudge`,
40
+ > `backupInterval`, `deviceZone`, `stampedZones` in settings). Every write
41
+ > round-trips the whole document through its reader, so it silently drops
42
+ > those nine keys — measured, same fixture, two bundles. A current server
43
+ > refuses to write a file whose format is newer than its own, so this cannot
44
+ > happen unnoticed any more, but the published bundle is still stale.
45
+ >
46
+ > ```sh
47
+ > pnpm build:mcp # -> mcp/dist/circal-mcp.mjs
48
+ > ```
49
+ >
50
+ > Then use `node /absolute/path/to/circal/mcp/dist/circal-mcp.mjs` wherever
51
+ > the examples below say `npx -y circal-mcp`.
52
+
35
53
  ### Claude Desktop
36
54
 
37
55
  Add to `claude_desktop_config.json`:
@@ -81,9 +99,9 @@ Add to `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global):
81
99
  }
82
100
  ```
83
101
 
84
- No mirror file yet: turn it on from circal's Settings panel, then run
85
- `--find` again. Run `npx -y circal-mcp --help` for the full flag and
86
- environment variable list.
102
+ No mirror file yet: on the Mac app there is almost always one already (`--find` should have caught
103
+ it); in a browser, turn the mirror on from circal's Settings panel, then run `--find` again. Run
104
+ `npx -y circal-mcp --help` for the full flag and environment variable list.
87
105
 
88
106
  ## From source (contributors)
89
107
 
@@ -1460,7 +1460,7 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
1460
1460
 
1461
1461
  // mcp/file.ts
1462
1462
  import { randomBytes } from "node:crypto";
1463
- import { readFile, rename, writeFile } from "node:fs/promises";
1463
+ import { open, readFile, rename, rm } from "node:fs/promises";
1464
1464
  import { dirname, join } from "node:path";
1465
1465
 
1466
1466
  // src/lib/time.ts
@@ -2162,6 +2162,78 @@ function fuzzyScore(text, query) {
2162
2162
 
2163
2163
  // src/lib/events.ts
2164
2164
  var ICON_MAX = 16;
2165
+ var ICON_NAME_MAX = 48;
2166
+ function isIconName(icon) {
2167
+ return /^[a-z0-9-]+$/.test(icon);
2168
+ }
2169
+ function firstGrapheme(value) {
2170
+ const text = value.trim();
2171
+ if (!text) return "";
2172
+ if (typeof Intl.Segmenter === "function") {
2173
+ const [first] = new Intl.Segmenter(void 0, { granularity: "grapheme" }).segment(text);
2174
+ return first ? first.segment.slice(0, ICON_MAX) : "";
2175
+ }
2176
+ return [...text][0]?.slice(0, ICON_MAX) ?? "";
2177
+ }
2178
+ function readMark(value) {
2179
+ if (typeof value !== "string") return "";
2180
+ const mark = value.trim();
2181
+ if (mark === "") return "";
2182
+ if (isIconName(mark)) return mark.length <= ICON_NAME_MAX ? mark : "";
2183
+ return firstGrapheme(mark);
2184
+ }
2185
+ var REMIND_INHERIT = -1;
2186
+ var REMIND_SILENT = -2;
2187
+ var REMIND_LEAD_MAX = 40320;
2188
+ function readRemindLead(value) {
2189
+ if (value === REMIND_SILENT) return REMIND_SILENT;
2190
+ if (typeof value !== "number" || !Number.isInteger(value)) return REMIND_INHERIT;
2191
+ if (value < 0 || value > REMIND_LEAD_MAX) return REMIND_INHERIT;
2192
+ return value;
2193
+ }
2194
+ var PARTSTATS = ["needs-action", "accepted", "declined", "tentative"];
2195
+ var ATTENDEE_MAX = 32;
2196
+ var GUEST_NAME_MAX = 64;
2197
+ var GUEST_ADDRESS_MAX = 254;
2198
+ var ATTENDEE_COUNT_MAX = 9999;
2199
+ function readText(value, max) {
2200
+ return typeof value === "string" ? value.trim().slice(0, max) : "";
2201
+ }
2202
+ function readPartstat(value) {
2203
+ if (typeof value !== "string") return "needs-action";
2204
+ const lower = value.trim().toLowerCase();
2205
+ return PARTSTATS.includes(lower) ? lower : "needs-action";
2206
+ }
2207
+ function readPerson(value) {
2208
+ if (value === null || typeof value !== "object") return null;
2209
+ const record = value;
2210
+ const address = readText(record.address, GUEST_ADDRESS_MAX);
2211
+ if (!address) return null;
2212
+ return { name: readText(record.name, GUEST_NAME_MAX), address };
2213
+ }
2214
+ function readGuests(list, count) {
2215
+ const seen = /* @__PURE__ */ new Set();
2216
+ const people = [];
2217
+ if (Array.isArray(list)) {
2218
+ for (const entry of list) {
2219
+ const person = readPerson(entry);
2220
+ if (!person) continue;
2221
+ const key = person.address.toLowerCase();
2222
+ if (seen.has(key)) continue;
2223
+ seen.add(key);
2224
+ people.push({
2225
+ ...person,
2226
+ status: readPartstat(entry.status)
2227
+ });
2228
+ }
2229
+ }
2230
+ const attendees = people.slice(0, ATTENDEE_MAX);
2231
+ const claimed = typeof count === "number" && Number.isInteger(count) && count > 0 ? count : people.length;
2232
+ return {
2233
+ attendees,
2234
+ attendeeCount: Math.min(Math.max(claimed, people.length), ATTENDEE_COUNT_MAX)
2235
+ };
2236
+ }
2165
2237
  function overridesIn(events) {
2166
2238
  const out = /* @__PURE__ */ new Map();
2167
2239
  for (const event of events) {
@@ -2249,7 +2321,7 @@ function toggleDoneKey(keys, at) {
2249
2321
  }
2250
2322
  var DEFAULT_WAKING = { from: 8 * 60, to: 22 * 60 };
2251
2323
  function freeGaps(occurrences, minMinutes, from, to) {
2252
- const busy = occurrences.filter((o) => !o.event.allDay).map((o) => ({ start: Math.max(o.startMin, from), end: Math.min(o.endMin, to) })).filter((b) => b.end > b.start).sort((a, b) => a.start - b.start);
2324
+ const busy = occurrences.filter((o) => !o.event.allDay && !o.event.free).map((o) => ({ start: Math.max(o.startMin, from), end: Math.min(o.endMin, to) })).filter((b) => b.end > b.start).sort((a, b) => a.start - b.start);
2253
2325
  const gaps = [];
2254
2326
  let cursor = from;
2255
2327
  for (const block of busy) {
@@ -3098,6 +3170,13 @@ var DEFAULT_SETTINGS = {
3098
3170
  // a day here, not half of one, so an hour has exactly one place on it and
3099
3171
  // `13` is where `13` is. AM/PM is a preference, and it is one click away.
3100
3172
  clock: "24h",
3173
+ // Unknown until the first launch writes it, and written silently there: a
3174
+ // browser with nothing stored has no "before", and the question this field
3175
+ // exists to ask is only ever about a change.
3176
+ deviceZone: "",
3177
+ // Nothing stamped, which is every browser that has never travelled and
3178
+ // every one that answered "move".
3179
+ stampedZones: [],
3101
3180
  secondZone: "",
3102
3181
  showZoneName: true,
3103
3182
  showZoneOffset: true,
@@ -3178,6 +3257,14 @@ var DEFAULT_SETTINGS = {
3178
3257
  // Ten minutes. Long enough to walk somewhere, short enough that the thing
3179
3258
  // is still the next thing when it lands.
3180
3259
  remindLead: 10,
3260
+ // On. This is the one interruption in the app that defends against losing
3261
+ // everything rather than missing one thing, and the browsers it is aimed at
3262
+ // have no other warning — so unlike `remind` it earns the aggressive default.
3263
+ backupNudge: true,
3264
+ // Thirty days. A month is long enough that a working calendar has changed
3265
+ // enough to be worth saving again, short enough that a lost month is a loss
3266
+ // and not a catastrophe.
3267
+ backupInterval: 30,
3181
3268
  // An hour, which is what `DEFAULT_DURATION` has always been and what a
3182
3269
  // VEVENT with no DTEND is read as. This setting exists to be *changed* — a
3183
3270
  // day of thirty-minute meetings is a real working life — not to have a new
@@ -3231,7 +3318,19 @@ function draftEvent(day, startMin, endMin, calendarId, repeat = "never") {
3231
3318
  color: "",
3232
3319
  icon: "",
3233
3320
  task: false,
3234
- doneKeys: []
3321
+ doneKeys: [],
3322
+ // Busy: a gesture that named a time meant that time to be spoken for.
3323
+ free: false,
3324
+ // Inheriting, which is the only answer a gesture gives: the drag named a
3325
+ // time, not how much warning it wants about it.
3326
+ remindLead: REMIND_INHERIT,
3327
+ // Nobody, and no way to make it anybody: circal has no channel to send an
3328
+ // invitation on, so a draft has no guest list and the editor offers no
3329
+ // control that would make one. These fields only ever arrive from a file
3330
+ // somebody else wrote.
3331
+ organizer: null,
3332
+ attendees: [],
3333
+ attendeeCount: 0
3235
3334
  };
3236
3335
  }
3237
3336
 
@@ -3245,6 +3344,17 @@ function readSettings(value) {
3245
3344
  // version that has ever had the setting.
3246
3345
  clock: value.clock === "12h" || value.clock === "24h" ? value.clock : DEFAULT_SETTINGS.clock,
3247
3346
  secondZone: typeof value.secondZone === "string" ? value.secondZone : "",
3347
+ // The zone the browser that wrote this backup was in. Carried rather than
3348
+ // dropped, and deliberately not replaced with the reader's own: a Berlin
3349
+ // calendar restored in New York is exactly the journey the travel notice
3350
+ // exists to ask about, and the events in the file are floating. `sane()`
3351
+ // refuses a zone `Intl` cannot resolve; absent reads as a first run, which
3352
+ // asks nothing.
3353
+ deviceZone: typeof value.deviceZone === "string" ? value.deviceZone : "",
3354
+ // The zones that browser's "keep" answer stamped, so the row that offers
3355
+ // to take them off survives a restore with the events it is about. Strings
3356
+ // only here; `sane()` is what refuses one `Intl` cannot resolve.
3357
+ stampedZones: Array.isArray(value.stampedZones) ? value.stampedZones.filter((zone) => typeof zone === "string") : [],
3248
3358
  // The dial switches read absent-as-on: a backup written before they
3249
3359
  // existed came from a dial that was showing all of them.
3250
3360
  showZoneName: value.showZoneName !== false,
@@ -3309,6 +3419,12 @@ function readSettings(value) {
3309
3419
  moon: value.moon === true || typeof value.moonPhase === "string" && value.moonPhase !== "off",
3310
3420
  remind: value.remind === true,
3311
3421
  remindLead: typeof value.remindLead === "number" ? value.remindLead : DEFAULT_SETTINGS.remindLead,
3422
+ // Absent-as-on, like the dial switches: a backup written before the nudge
3423
+ // existed came from a build that could not have turned this data-safety
3424
+ // warning off, so it reads as the default rather than as a choice.
3425
+ backupNudge: value.backupNudge !== false,
3426
+ // Types only: `sane()` pins an interval off the list to the default.
3427
+ backupInterval: typeof value.backupInterval === "number" ? value.backupInterval : DEFAULT_SETTINGS.backupInterval,
3312
3428
  // What a new event starts as. `sane()` clamps the length; the calendar is
3313
3429
  // an id and is deliberately not checked against the backup's own
3314
3430
  // calendars — `defaultCalendarId` resolves it at the moment it is used,
@@ -3363,12 +3479,33 @@ function readEvent(value) {
3363
3479
  seriesId: typeof value.seriesId === "string" ? value.seriesId : "",
3364
3480
  seriesKey: typeof value.seriesKey === "string" ? value.seriesKey : "",
3365
3481
  color: typeof value.color === "string" ? value.color : "",
3366
- icon: typeof value.icon === "string" ? value.icon.slice(0, ICON_MAX) : "",
3482
+ // What a legal mark is lives in `events.ts`, next to the two vocabularies
3483
+ // it has to tell apart. This used to slice at one shared cap, which turned
3484
+ // ten of the 247 shipped names into strings matching no path.
3485
+ icon: readMark(value.icon),
3367
3486
  task,
3368
3487
  // The same instant-key check EXDATE gets, and the same reason: a key this
3369
3488
  // does not spell matches no occurrence, so it is a row of dead weight that
3370
3489
  // survives every future export.
3371
- doneKeys: readDoneKeys(value.doneKeys)
3490
+ doneKeys: readDoneKeys(value.doneKeys),
3491
+ // Anything other than a literal `true` is busy, which is what an event
3492
+ // exported before this field existed was. A truthy string in a
3493
+ // hand-edited blob must not quietly take a meeting off the day's total.
3494
+ free: value.free === true,
3495
+ // What a legal lead is lives in `remind.ts`, beside the three states it
3496
+ // has to distinguish: an unreadable one comes back inheriting, which is
3497
+ // what an event exported before this field existed already did.
3498
+ remindLead: readRemindLead(value.remindLead),
3499
+ // The one field on this model that arrives from a stranger with no natural
3500
+ // length, so this is where the caps are enforced rather than where they are
3501
+ // chosen: `readGuests` in `events.ts` owns the numbers, because a
3502
+ // subscription's ATTENDEE lines never pass through this file and the two
3503
+ // paths must not disagree about how big a guest list may be. The count
3504
+ // comes through so a restore prints the same "and 40 others" the import
3505
+ // did — clamped up to the list it labels, since a blob understating it
3506
+ // would print a negative remainder.
3507
+ organizer: readPerson(value.organizer),
3508
+ ...readGuests(value.attendees, value.attendeeCount)
3372
3509
  };
3373
3510
  }
3374
3511
  function readCalendar(value) {
@@ -3380,11 +3517,12 @@ function readCalendar(value) {
3380
3517
  id,
3381
3518
  name: typeof name === "string" && name ? name : id,
3382
3519
  color: typeof color === "string" && color ? color : "#8ecbff",
3383
- // Length is the whole check. An icon name this build does not carry
3384
- // renders as the dot rather than as nothing, so an unknown one is worth
3385
- // keeping — a backup restored into an older build and back again should
3386
- // not lose the mark — but an unbounded string here is a paste.
3387
- icon: typeof icon === "string" ? icon.slice(0, ICON_MAX) : "",
3520
+ // Same validator as an event's, same reason. An icon name this build does
3521
+ // not carry still renders as the dot rather than as nothing, so an unknown
3522
+ // one is worth keeping — a backup restored into an older build and back
3523
+ // again should not lose the mark — but an unbounded string is a paste, and
3524
+ // an over-long *name* is now dropped rather than cut into junk.
3525
+ icon: readMark(icon),
3388
3526
  visible: value.visible !== false,
3389
3527
  ...feedUrl ? { feed: { url: feedUrl, fetchedAt: 0, error: "" } } : {}
3390
3528
  };
@@ -3447,7 +3585,11 @@ function readMirrorFile(text) {
3447
3585
  // A plain `.json` backup has no zone. Absent means "written by something
3448
3586
  // that did not say", which the reader treats as agreeing with it — the
3449
3587
  // alternative is refusing to open every backup ever exported.
3450
- zone: typeof envelope.zone === "string" ? envelope.zone : ""
3588
+ zone: typeof envelope.zone === "string" ? envelope.zone : "",
3589
+ // Absent reads as this build's own, for the reason `zone` does: a plain
3590
+ // `.json` backup handed to the mirror is a case the shared format exists
3591
+ // to allow, and refusing every export ever taken would be the wrong trade.
3592
+ version: typeof envelope.version === "number" && Number.isFinite(envelope.version) ? envelope.version : MIRROR_VERSION
3451
3593
  };
3452
3594
  }
3453
3595
 
@@ -3474,9 +3616,30 @@ async function readMirror(path2) {
3474
3616
  return contents;
3475
3617
  }
3476
3618
  async function atomicWrite(path2, body) {
3477
- const tmp = join(dirname(path2), `.circal-mcp-${randomBytes(6).toString("hex")}.tmp`);
3478
- await writeFile(tmp, body, "utf8");
3479
- await rename(tmp, path2);
3619
+ const dir = dirname(path2);
3620
+ const tmp = join(dir, `.circal-mcp-${randomBytes(6).toString("hex")}.tmp`);
3621
+ try {
3622
+ const file = await open(tmp, "w");
3623
+ try {
3624
+ await file.writeFile(body, "utf8");
3625
+ await file.sync();
3626
+ } finally {
3627
+ await file.close();
3628
+ }
3629
+ await rename(tmp, path2);
3630
+ } catch (error) {
3631
+ await rm(tmp, { force: true });
3632
+ throw error;
3633
+ }
3634
+ try {
3635
+ const entry = await open(dir, "r");
3636
+ try {
3637
+ await entry.sync();
3638
+ } finally {
3639
+ await entry.close();
3640
+ }
3641
+ } catch {
3642
+ }
3480
3643
  }
3481
3644
  function zoneWarning(contents) {
3482
3645
  const here = localZone();
@@ -3491,6 +3654,12 @@ function assertZoneAllowsMutation(contents, env) {
3491
3654
  `Refusing to write: the file's zone (${contents.zone}) does not match this server's (${here}) \u2014 a write here could land on the wrong day there. Set CIRCAL_ALLOW_ZONE_MISMATCH=1 to write anyway.`
3492
3655
  );
3493
3656
  }
3657
+ function assertVersionAllowsMutation(contents) {
3658
+ if (contents.version <= MIRROR_VERSION) return;
3659
+ throw new RefusalError(
3660
+ `Refusing to write: the file's format is version ${contents.version} and this server understands ${MIRROR_VERSION}. Writing it would drop every field this build has no name for. Rebuild circal-mcp from the matching source (pnpm build:mcp) and retry.`
3661
+ );
3662
+ }
3494
3663
  async function loadForRead(ctx2) {
3495
3664
  const contents = await readMirror(ctx2.path);
3496
3665
  return { contents, warning: zoneWarning(contents) };
@@ -3498,6 +3667,7 @@ async function loadForRead(ctx2) {
3498
3667
  async function casWriteOnce(ctx2, mutate) {
3499
3668
  for (let attempt = 0; attempt < 2; attempt++) {
3500
3669
  const before = await readMirror(ctx2.path);
3670
+ assertVersionAllowsMutation(before);
3501
3671
  assertZoneAllowsMutation(before, ctx2.env);
3502
3672
  const { result, summary } = mutate(before);
3503
3673
  const after = await readMirror(ctx2.path);
@@ -3542,6 +3712,9 @@ function roots(home) {
3542
3712
  join2(home, "Sync")
3543
3713
  ];
3544
3714
  }
3715
+ function appDefault(home) {
3716
+ return join2(home, "Library", "Application Support", "circal", "calendar.json");
3717
+ }
3545
3718
  async function jsonFilesUnder(root) {
3546
3719
  const found = [];
3547
3720
  const top = await listDir(root);
@@ -3581,6 +3754,7 @@ async function asMirror(path2) {
3581
3754
  async function runFind(home = homedir()) {
3582
3755
  const searchRoots = roots(home);
3583
3756
  const candidates = /* @__PURE__ */ new Set();
3757
+ candidates.add(appDefault(home));
3584
3758
  for (const root of searchRoots) {
3585
3759
  for (const path2 of await jsonFilesUnder(root)) candidates.add(path2);
3586
3760
  }
@@ -3592,6 +3766,7 @@ async function runFind(home = homedir()) {
3592
3766
  if (hits.length === 0) {
3593
3767
  console.log("No circal mirror file found.\n");
3594
3768
  console.log("Searched these locations (their own files, and one level of subdirectories):");
3769
+ console.log(` ${appDefault(home)}`);
3595
3770
  for (const root of searchRoots) console.log(` ${root}`);
3596
3771
  console.log(
3597
3772
  "\nTurn the mirror on from circal's Settings panel to create a file, then run this again."
@@ -3836,7 +4011,20 @@ function buildEventRecord(input, calendars, settings) {
3836
4011
  color: input.color ?? "",
3837
4012
  icon: input.icon ?? "",
3838
4013
  task: span.task,
3839
- doneKeys: []
4014
+ doneKeys: [],
4015
+ // Busy. There is no tool argument for this yet, and an agent asked to put
4016
+ // an hour in the diary means the hour to be spoken for.
4017
+ free: false,
4018
+ // Inheriting, for the same reason: no tool argument names a lead, and an
4019
+ // event created with one of its own would be an agent quietly deciding
4020
+ // how much warning its user wants.
4021
+ remindLead: REMIND_INHERIT,
4022
+ // Nobody, for the reason there is no tool argument and never will be: an
4023
+ // agent has no channel to invite anyone on either, so a guest list it
4024
+ // assembled would be a list of people who were never asked.
4025
+ organizer: null,
4026
+ attendees: [],
4027
+ attendeeCount: 0
3840
4028
  });
3841
4029
  }
3842
4030
  function applyPatchToEvent(base, input, calendars, settings, anchorMs = base.start) {
@@ -3858,7 +4046,25 @@ function applyPatchToEvent(base, input, calendars, settings, anchorMs = base.sta
3858
4046
  color: input.color ?? base.color,
3859
4047
  icon: input.icon ?? base.icon,
3860
4048
  task: span.task,
3861
- doneKeys: base.doneKeys
4049
+ doneKeys: base.doneKeys,
4050
+ // Carried, like every other field a patch does not name: `validate` reads
4051
+ // the draft through `backup.readEvent`, which defaults an absent `free`
4052
+ // to busy — so leaving it out would quietly rebook a free event the
4053
+ // moment its title was edited.
4054
+ free: base.free,
4055
+ // Carried for exactly the reason above, and this is the field where it
4056
+ // costs most: an event the user silenced would start speaking again the
4057
+ // moment an assistant fixed a typo in its title, because `readEvent`
4058
+ // reads an absent lead as inheriting.
4059
+ remindLead: base.remindLead,
4060
+ // Carried, and this is the field where dropping it would be least
4061
+ // recoverable: the guest list is a record of a message somebody else sent,
4062
+ // and `readEvent` reads an absent one as no invitation at all — so an
4063
+ // assistant fixing a typo in the title would erase the only copy of who
4064
+ // had asked and who was coming.
4065
+ organizer: base.organizer,
4066
+ attendees: base.attendees,
4067
+ attendeeCount: base.attendeeCount
3862
4068
  });
3863
4069
  }
3864
4070
  function calendarName(calendars, id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "circal-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "MCP server for circal — read and write your calendar through its mirror file",
6
6
  "license": "MIT",