ofw-mcp 2.4.4 → 2.5.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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "OurFamilyWizard tools for Claude Code",
9
- "version": "2.4.4"
9
+ "version": "2.5.0"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -14,7 +14,7 @@
14
14
  "displayName": "OurFamilyWizard",
15
15
  "source": "./",
16
16
  "description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
17
- "version": "2.4.4",
17
+ "version": "2.5.0",
18
18
  "author": {
19
19
  "name": "Chris Chall"
20
20
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ofw",
3
3
  "displayName": "OurFamilyWizard",
4
- "version": "2.4.4",
4
+ "version": "2.5.0",
5
5
  "description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
6
6
  "author": {
7
7
  "name": "Chris Chall"
package/README.md CHANGED
@@ -144,9 +144,9 @@ Read-only tools run automatically. Write tools ask for your confirmation first.
144
144
  | `ofw_delete_draft` | Delete a draft | Confirm | `drafts` |
145
145
  | `ofw_upload_attachment` | Upload a local file to My Files; returns a fileId to attach via `ofw_send_message`/`ofw_save_draft` | Auto | `drafts` |
146
146
  | `ofw_list_events` | Calendar events in a date range | Auto | any |
147
- | `ofw_create_event` | Create a calendar event | Confirm | `all` |
148
- | `ofw_update_event` | Update a calendar event | Confirm | `all` |
149
- | `ofw_delete_event` | Delete a calendar event | Confirm | `all` |
147
+ | `ofw_create_event` | Create a calendar event | Confirm | `all` (or `drafts` + `OFW_CALENDAR_WRITES`) |
148
+ | `ofw_update_event` | Update a calendar event | Confirm | `all` (or `drafts` + `OFW_CALENDAR_WRITES`) |
149
+ | `ofw_delete_event` | Delete a calendar event | Confirm | `all` (or `drafts` + `OFW_CALENDAR_WRITES`) |
150
150
  | `ofw_get_expense_totals` | Expense summary totals | Auto | any |
151
151
  | `ofw_list_expenses` | Expense history | Auto | any |
152
152
  | `ofw_create_expense` | Log a new expense | Confirm | `all` |
@@ -165,6 +165,10 @@ The "Confirm" permission above is a *hint* to the MCP host — a host configured
165
165
 
166
166
  Unrecognized values fail closed to `none`, with a warning on stderr — a typo never silently grants write access.
167
167
 
168
+ #### Calendar opt-in (`OFW_CALENDAR_WRITES`)
169
+
170
+ Calendar events sit between the two message tiers: they have no draft stage (a created event is immediately visible on the shared record), but unlike a sent message they are reversible — an event can be edited or deleted afterward. If you run in `drafts` mode but are comfortable with direct calendar writes, set `OFW_CALENDAR_WRITES=true` to additionally register `ofw_create_event`, `ofw_update_event`, and `ofw_delete_event`. The flag is redundant in `all` mode and never overrides `none`.
171
+
168
172
  ## Troubleshooting
169
173
 
170
174
  **"0 messages"** — Claude may have read the notification counts rather than the actual messages. Ask explicitly: *"List the messages in my OFW inbox"* or *"Use ofw_list_message_folders then ofw_list_messages"*.
package/dist/bundle.js CHANGED
@@ -34931,6 +34931,7 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34931
34931
  "capture_request_header",
34932
34932
  "capture_redirect",
34933
34933
  "read_indexed_db",
34934
+ "read_dom",
34934
34935
  "download"
34935
34936
  ]);
34936
34937
 
@@ -35232,6 +35233,44 @@ function assertIndexedDbScopesArray(value, label) {
35232
35233
  }
35233
35234
  }
35234
35235
  }
35236
+ var DOM_SELECTOR_RE = /^[^-]{1,512}$/;
35237
+ var DOM_ATTRIBUTE_RE = /^[A-Za-z_:][A-Za-z0-9_:.\-]{0,127}$/;
35238
+ function assertDomSelectorsArray(value, label) {
35239
+ if (!Array.isArray(value)) {
35240
+ throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
35241
+ }
35242
+ const seen = /* @__PURE__ */ new Set();
35243
+ for (let i = 0; i < value.length; i++) {
35244
+ const entry = value[i];
35245
+ assertObject(entry, `${label}[${i}]`);
35246
+ if (entry.name === void 0) {
35247
+ throw new ProtocolError(`${label}[${i}].name: missing`);
35248
+ }
35249
+ if (entry.selector === void 0) {
35250
+ throw new ProtocolError(`${label}[${i}].selector: missing`);
35251
+ }
35252
+ if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
35253
+ throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
35254
+ }
35255
+ if (typeof entry.selector !== "string" || !DOM_SELECTOR_RE.test(entry.selector)) {
35256
+ throw new ProtocolError(`${label}[${i}].selector: invalid ${JSON.stringify(entry.selector)}`);
35257
+ }
35258
+ if (entry.attribute !== void 0) {
35259
+ if (typeof entry.attribute !== "string" || !DOM_ATTRIBUTE_RE.test(entry.attribute)) {
35260
+ throw new ProtocolError(`${label}[${i}].attribute: invalid ${JSON.stringify(entry.attribute)}`);
35261
+ }
35262
+ }
35263
+ if (seen.has(entry.name)) {
35264
+ throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
35265
+ }
35266
+ seen.add(entry.name);
35267
+ for (const k of Object.keys(entry)) {
35268
+ if (k !== "name" && k !== "selector" && k !== "attribute") {
35269
+ throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
35270
+ }
35271
+ }
35272
+ }
35273
+ }
35235
35274
  function validateFrame(raw) {
35236
35275
  assertObject(raw, "frame");
35237
35276
  const t = raw.type;
@@ -35307,6 +35346,9 @@ function validateHello(raw) {
35307
35346
  if (raw.sessionStoragePointers !== void 0) {
35308
35347
  assertStoragePointersArray(raw.sessionStoragePointers, "hello.sessionStoragePointers", raw.sessionStorageKeys);
35309
35348
  }
35349
+ if (raw.domSelectors !== void 0) {
35350
+ assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
35351
+ }
35310
35352
  assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
35311
35353
  assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
35312
35354
  assertBase64(raw.sessionNonce, "hello.sessionNonce");
@@ -35541,6 +35583,21 @@ function validateInnerRequest(raw) {
35541
35583
  }
35542
35584
  return raw;
35543
35585
  }
35586
+ if (raw.op === "read_dom") {
35587
+ assertObject(raw.init, "inner.init");
35588
+ if (raw.init.origin === void 0)
35589
+ throw new ProtocolError("inner.init.origin: missing");
35590
+ if (raw.init.names === void 0)
35591
+ throw new ProtocolError("inner.init.names: missing");
35592
+ assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
35593
+ assertNonEmptyKeyArray(raw.init.names, "inner.init.names");
35594
+ for (const k of Object.keys(raw.init)) {
35595
+ if (k !== "origin" && k !== "names") {
35596
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on read_dom`);
35597
+ }
35598
+ }
35599
+ return raw;
35600
+ }
35544
35601
  if (raw.op === "download") {
35545
35602
  assertObject(raw.init, "inner.init");
35546
35603
  if (raw.init.url === void 0) {
@@ -35566,7 +35623,7 @@ function validateInnerRequest(raw) {
35566
35623
  }
35567
35624
  return raw;
35568
35625
  }
35569
- throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "download"; got ${JSON.stringify(raw.op)}`);
35626
+ throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download"; got ${JSON.stringify(raw.op)}`);
35570
35627
  }
35571
35628
  function assertNonEmptyKeyArray(value, label) {
35572
35629
  if (!Array.isArray(value)) {
@@ -35651,6 +35708,13 @@ function validateInnerResponse(raw) {
35651
35708
  assertObject(raw.values, "inner.values");
35652
35709
  return raw;
35653
35710
  }
35711
+ if (op === "read_dom") {
35712
+ if (raw.values === void 0) {
35713
+ throw new ProtocolError("inner.values: missing on read_dom response");
35714
+ }
35715
+ assertStringMap(raw.values, "inner.values");
35716
+ return raw;
35717
+ }
35654
35718
  if (op === "download") {
35655
35719
  assertObject(raw.value, "inner.value");
35656
35720
  assertString(raw.value.path, "inner.value.path");
@@ -35990,6 +36054,13 @@ async function buildServerHello(opts) {
35990
36054
  jsonPointer: d.jsonPointer
35991
36055
  }));
35992
36056
  }
36057
+ if (opts.domSelectors && opts.domSelectors.length > 0) {
36058
+ hello.domSelectors = opts.domSelectors.map((d) => ({
36059
+ name: d.name,
36060
+ selector: d.selector,
36061
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36062
+ }));
36063
+ }
35993
36064
  return hello;
35994
36065
  }
35995
36066
 
@@ -36079,7 +36150,8 @@ async function startHost(opts) {
36079
36150
  captureHeaders: opts.ownCaptureHeaders,
36080
36151
  indexedDbScopes: opts.ownIndexedDbScopes,
36081
36152
  localStoragePointers: opts.ownLocalStoragePointers,
36082
- sessionStoragePointers: opts.ownSessionStoragePointers
36153
+ sessionStoragePointers: opts.ownSessionStoragePointers,
36154
+ domSelectors: opts.ownDomSelectors
36083
36155
  });
36084
36156
  const ownSessionNonce = fromB64(ownHello.sessionNonce);
36085
36157
  let extensionWs = null;
@@ -36314,6 +36386,7 @@ async function startPeer(opts) {
36314
36386
  sessionStorageKeys: opts.sessionStorageKeys,
36315
36387
  captureHeaders: opts.captureHeaders,
36316
36388
  indexedDbScopes: opts.indexedDbScopes,
36389
+ domSelectors: opts.domSelectors,
36317
36390
  localStoragePointers: opts.localStoragePointers,
36318
36391
  sessionStoragePointers: opts.sessionStoragePointers
36319
36392
  });
@@ -36721,6 +36794,11 @@ var FetchproxyServer = class {
36721
36794
  key: d.key,
36722
36795
  jsonPointer: d.jsonPointer
36723
36796
  })),
36797
+ domSelectors: (opts.domSelectors ?? []).map((d) => ({
36798
+ name: d.name,
36799
+ selector: d.selector,
36800
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36801
+ })),
36724
36802
  // 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
36725
36803
  // adapter was about to set these to the same numbers anyway; the
36726
36804
  // back-door is `0` (explicit opt-out) if a caller genuinely wants
@@ -36841,6 +36919,7 @@ var FetchproxyServer = class {
36841
36919
  ownIndexedDbScopes: this.opts.indexedDbScopes,
36842
36920
  ownLocalStoragePointers: this.opts.localStoragePointers,
36843
36921
  ownSessionStoragePointers: this.opts.sessionStoragePointers,
36922
+ ownDomSelectors: this.opts.domSelectors,
36844
36923
  onPairCode: this.opts.onPairCode
36845
36924
  });
36846
36925
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
@@ -36868,7 +36947,8 @@ var FetchproxyServer = class {
36868
36947
  captureHeaders: this.opts.captureHeaders,
36869
36948
  indexedDbScopes: this.opts.indexedDbScopes,
36870
36949
  localStoragePointers: this.opts.localStoragePointers,
36871
- sessionStoragePointers: this.opts.sessionStoragePointers
36950
+ sessionStoragePointers: this.opts.sessionStoragePointers,
36951
+ domSelectors: this.opts.domSelectors
36872
36952
  });
36873
36953
  this.peerHandle.onInner((inner) => this.onInner(inner));
36874
36954
  this.peerHandle.onRenegotiate(() => {
@@ -37843,6 +37923,46 @@ var FetchproxyServer = class {
37843
37923
  await this.sendInnerFrame(inner);
37844
37924
  return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
37845
37925
  }
37926
+ /**
37927
+ * 1.4.0+: read declared DOM values from the user's signed-in tab.
37928
+ * Requires `'read_dom'` in capabilities AND every requested `name` to
37929
+ * match a declared `domSelectors` entry. The extension reads each
37930
+ * declared selector from the matched tab's DOM (isolated-world
37931
+ * `querySelector`, value or attribute) — no page-JS execution.
37932
+ *
37933
+ * Returns a `Record<string, string>` of `name → value`, with names
37934
+ * whose element (or attribute) was absent omitted. Throws
37935
+ * `FetchproxyProtocolError` on bridge failures and a plain `Error` on
37936
+ * developer mistakes (undeclared capability, undeclared name).
37937
+ */
37938
+ async readDom(opts) {
37939
+ if (!this.opts.capabilities.includes("read_dom")) {
37940
+ throw new Error('FetchproxyServer.readDom(): MCP did not declare "read_dom" in capabilities');
37941
+ }
37942
+ await this.ensureConnected();
37943
+ this.throwIfPendingPair();
37944
+ if (!Array.isArray(opts.names) || opts.names.length === 0) {
37945
+ throw new Error("FetchproxyServer.readDom: opts.names must be a non-empty array");
37946
+ }
37947
+ this.assertScopeSubset(opts.names, this.opts.domSelectors.map((d) => d.name), "domSelectors");
37948
+ if (opts.subdomain !== void 0)
37949
+ assertSubdomainLabel(opts.subdomain);
37950
+ const baseDomain = this.resolveBaseDomain(opts.domain);
37951
+ const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
37952
+ const origin = `https://${host}`;
37953
+ const id = this.nextRequestId++;
37954
+ const inner = {
37955
+ type: "request",
37956
+ id,
37957
+ op: "read_dom",
37958
+ init: { origin, names: [...opts.names] }
37959
+ };
37960
+ const pending = new Promise((resolve2, reject) => {
37961
+ this.pendingStorage.set(id, { resolve: resolve2, reject });
37962
+ });
37963
+ await this.sendInnerFrame(inner);
37964
+ return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
37965
+ }
37846
37966
  assertScopeSubset(requested, declared, label) {
37847
37967
  const undeclared = undeclaredKeys(requested, declared);
37848
37968
  if (undeclared.length > 0) {
@@ -37914,7 +38034,7 @@ var FetchproxyServer = class {
37914
38034
  if (storageCb) {
37915
38035
  this.pendingStorage.delete(inner.id);
37916
38036
  if (inner.ok) {
37917
- if ((inner.op === "read_local_storage" || inner.op === "read_session_storage") && inner.values) {
38037
+ if ((inner.op === "read_local_storage" || inner.op === "read_session_storage" || inner.op === "read_dom") && inner.values) {
37918
38038
  storageCb.resolve({ ...inner.values });
37919
38039
  } else {
37920
38040
  storageCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on storage awaiter`));
@@ -38285,7 +38405,7 @@ async function loginWithPassword(username, password) {
38285
38405
  // package.json
38286
38406
  var package_default = {
38287
38407
  name: "ofw-mcp",
38288
- version: "2.4.4",
38408
+ version: "2.5.0",
38289
38409
  license: "MIT",
38290
38410
  mcpName: "io.github.chrischall/ofw-mcp",
38291
38411
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
@@ -38317,7 +38437,7 @@ var package_default = {
38317
38437
  "test:watch": "vitest"
38318
38438
  },
38319
38439
  dependencies: {
38320
- "@chrischall/mcp-utils": "^0.12.0",
38440
+ "@chrischall/mcp-utils": "^0.13.0",
38321
38441
  "@fetchproxy/bootstrap": "^1.3.0",
38322
38442
  "@modelcontextprotocol/sdk": "^1.29.0",
38323
38443
  dotenv: "^17.4.2",
@@ -38327,7 +38447,7 @@ var package_default = {
38327
38447
  "@types/node": "^26.0.0",
38328
38448
  "@vitest/coverage-v8": "^4.1.7",
38329
38449
  esbuild: "^0.28.0",
38330
- typescript: "^6.0.3",
38450
+ typescript: "^7.0.2",
38331
38451
  vitest: "^4.1.7"
38332
38452
  }
38333
38453
  };
@@ -38553,11 +38673,14 @@ var ApiRecipientSchema = external_exports.looseObject({
38553
38673
  viewed: external_exports.looseObject({ dateTime: external_exports.string() }).nullable().optional()
38554
38674
  });
38555
38675
  function mapRecipients(items) {
38556
- return (items ?? []).map((r) => ({
38557
- userId: r.user?.id ?? 0,
38558
- name: r.user?.name ?? "",
38559
- viewedAt: r.viewed?.dateTime ?? null
38560
- }));
38676
+ return (items ?? []).map((r) => {
38677
+ const dt = r.viewed?.dateTime;
38678
+ const viewedAt = typeof dt === "string" && !dt.startsWith("1970-01-01") ? dt : null;
38679
+ return { userId: r.user?.id ?? 0, name: r.user?.name ?? "", viewedAt };
38680
+ });
38681
+ }
38682
+ function hasRealView(recipients) {
38683
+ return recipients.some((r) => r.viewedAt !== null && !r.viewedAt.startsWith("1970-01-01"));
38561
38684
  }
38562
38685
  var expandPath2 = expandPath;
38563
38686
  function verifyWriteLanded(kind, sent, persisted) {
@@ -38646,6 +38769,11 @@ function getWriteMode() {
38646
38769
  );
38647
38770
  return "none";
38648
38771
  }
38772
+ function getCalendarWritesAllowed() {
38773
+ const mode = getWriteMode();
38774
+ if (mode === "all") return true;
38775
+ return mode === "drafts" && parseBoolEnv("OFW_CALENDAR_WRITES");
38776
+ }
38649
38777
  function getDefaultInlineAttachments() {
38650
38778
  return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
38651
38779
  }
@@ -39055,7 +39183,10 @@ var ListItemSchema = external_exports.looseObject({
39055
39183
  var ListResponseSchema = external_exports.looseObject({ data: external_exports.array(ListItemSchema).optional() });
39056
39184
  var DetailResponseSchema = external_exports.looseObject({
39057
39185
  body: external_exports.string().optional(),
39058
- files: external_exports.array(external_exports.number()).optional()
39186
+ files: external_exports.array(external_exports.number()).optional(),
39187
+ // The detail endpoint carries the REAL recipient view timestamps (the list
39188
+ // endpoint only has an epoch placeholder) — used by the view-status refresh.
39189
+ recipients: external_exports.array(ApiRecipientSchema).optional()
39059
39190
  });
39060
39191
  async function syncMessageFolder(client2, folder, folderId, opts) {
39061
39192
  let page = 1;
@@ -39075,7 +39206,18 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
39075
39206
  for (const item of items) {
39076
39207
  if (newestId === null || item.id > newestId) newestId = item.id;
39077
39208
  const existing = getMessage(item.id);
39078
- if (existing) continue;
39209
+ if (existing) {
39210
+ if (folder === "sent" && item.showNeverViewed === false && !hasRealView(existing.recipients)) {
39211
+ const detail = parseLenient(
39212
+ DetailResponseSchema,
39213
+ await client2.request("GET", `/pub/v3/messages/${item.id}`),
39214
+ { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (view-status refresh)" }
39215
+ );
39216
+ upsertMessage({ ...existing, recipients: mapRecipients(detail.recipients), listData: item });
39217
+ synced++;
39218
+ }
39219
+ continue;
39220
+ }
39079
39221
  pageHadNewItem = true;
39080
39222
  const isInboxUnread = folder === "inbox" && item.showNeverViewed === true;
39081
39223
  const shouldFetchBody = !isInboxUnread || opts.fetchUnreadBodies;
@@ -39362,8 +39504,26 @@ function registerMessageTools(server, client2) {
39362
39504
  }
39363
39505
  const cached2 = getMessage(id);
39364
39506
  if (cached2 && cached2.body !== null) {
39507
+ let row2 = cached2;
39508
+ if (cached2.folder === "sent" && !hasRealView(cached2.recipients)) {
39509
+ try {
39510
+ const detail2 = parseLenient(
39511
+ MessageDetailSchema,
39512
+ await client2.request("GET", `/pub/v3/messages/${id}`),
39513
+ { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (view-status refresh)" }
39514
+ );
39515
+ const recipients = mapRecipients(detail2.recipients);
39516
+ row2 = {
39517
+ ...cached2,
39518
+ recipients,
39519
+ listData: { ...cached2.listData, showNeverViewed: !hasRealView(recipients) }
39520
+ };
39521
+ upsertMessage(row2);
39522
+ } catch {
39523
+ }
39524
+ }
39365
39525
  let attachments2 = listAttachmentsForMessage(id);
39366
- if (attachments2.length === 0 && listDataHintsAtFiles(cached2.listData)) {
39526
+ if (attachments2.length === 0 && listDataHintsAtFiles(row2.listData)) {
39367
39527
  try {
39368
39528
  const detail2 = parseLenient(
39369
39529
  DetailFilesSchema,
@@ -39377,7 +39537,7 @@ function registerMessageTools(server, client2) {
39377
39537
  } catch {
39378
39538
  }
39379
39539
  }
39380
- return jsonResponse({ ...cached2, attachments: attachments2 });
39540
+ return jsonResponse({ ...row2, attachments: attachments2 });
39381
39541
  }
39382
39542
  const detail = parseLenient(
39383
39543
  MessageDetailSchema,
@@ -39793,8 +39953,87 @@ async function deleteOFWMessages(client2, ids) {
39793
39953
  }
39794
39954
 
39795
39955
  // src/tools/calendar.ts
39956
+ var ofwDate = external_exports.looseObject({ dateTime: external_exports.string() });
39957
+ var userRef = external_exports.looseObject({ userId: external_exports.number() });
39958
+ var eventDetailSchema = external_exports.looseObject({
39959
+ eventRecurrenceId: external_exports.number(),
39960
+ title: external_exports.string(),
39961
+ allDay: external_exports.boolean(),
39962
+ publicFlag: external_exports.boolean(),
39963
+ startDate: ofwDate,
39964
+ endDate: ofwDate,
39965
+ location: external_exports.string().nullish(),
39966
+ notes: external_exports.string().nullish(),
39967
+ reminderMinutes: external_exports.number().nullish(),
39968
+ children: external_exports.array(userRef).nullish(),
39969
+ eventParent: userRef.nullish(),
39970
+ dropOffParent: userRef.nullish(),
39971
+ pickUpParent: userRef.nullish()
39972
+ });
39973
+ var eventWriteFields = {
39974
+ startDate: external_exports.string().describe("Start date YYYY-MM-DD"),
39975
+ endDate: external_exports.string().describe("End date YYYY-MM-DD (default: startDate)").optional(),
39976
+ startTime: external_exports.string().describe("Start time HH:mm, 24-hour (required unless allDay)").optional(),
39977
+ endTime: external_exports.string().describe("End time HH:mm, 24-hour (required unless allDay)").optional(),
39978
+ allDay: external_exports.boolean().optional(),
39979
+ privateEvent: external_exports.boolean().describe("true = visible only to you; default false = shared with co-parent").optional(),
39980
+ location: external_exports.string().optional(),
39981
+ notes: external_exports.string().optional(),
39982
+ reminderMinutes: external_exports.number().int().min(0).optional(),
39983
+ children: external_exports.array(external_exports.number()).describe("Child userIds to tag (see ofw_get_profile)").optional(),
39984
+ eventParentId: external_exports.number().describe("userId of the parent the event is 'for'").optional(),
39985
+ dropOffParentId: external_exports.number().describe("userId of the drop-off parent").optional(),
39986
+ pickUpParentId: external_exports.number().describe("userId of the pick-up parent").optional()
39987
+ };
39988
+ function buildEventPayload(a) {
39989
+ const allDay = a.allDay ?? false;
39990
+ if (!allDay && (!a.startTime || !a.endTime)) {
39991
+ throw new Error("startTime and endTime (HH:mm) are required unless allDay is true");
39992
+ }
39993
+ const payload = {
39994
+ title: a.title,
39995
+ startDate: a.startDate,
39996
+ endDate: a.endDate ?? a.startDate,
39997
+ // The web form always sends times; for all-day events it uses 01:00/02:00
39998
+ // placeholders that OFW ignores.
39999
+ startTime: a.startTime ?? "01:00",
40000
+ endTime: a.endTime ?? "02:00",
40001
+ allDay,
40002
+ publicFlag: !(a.privateEvent ?? false)
40003
+ };
40004
+ if (a.location) payload.location = a.location;
40005
+ if (a.notes) payload.notes = a.notes;
40006
+ if (a.reminderMinutes !== void 0) payload.reminderMinutes = String(a.reminderMinutes);
40007
+ if (a.children !== void 0) payload.children = a.children;
40008
+ if (a.eventParentId !== void 0) payload.eventParentId = String(a.eventParentId);
40009
+ if (a.dropOffParentId !== void 0) payload.dropOffParentId = String(a.dropOffParentId);
40010
+ if (a.pickUpParentId !== void 0) payload.pickUpParentId = String(a.pickUpParentId);
40011
+ return payload;
40012
+ }
40013
+ function detailToWriteArgs(d) {
40014
+ const [startDate, startClock] = d.startDate.dateTime.split("T");
40015
+ const [endDate, endClock] = d.endDate.dateTime.split("T");
40016
+ return {
40017
+ title: d.title,
40018
+ startDate,
40019
+ endDate,
40020
+ startTime: (startClock ?? "01:00:00").slice(0, 5),
40021
+ endTime: (endClock ?? "02:00:00").slice(0, 5),
40022
+ allDay: d.allDay,
40023
+ privateEvent: !d.publicFlag,
40024
+ location: d.location ?? void 0,
40025
+ notes: d.notes ?? void 0,
40026
+ reminderMinutes: d.reminderMinutes ?? void 0,
40027
+ // Untagged (nullish or empty) → undefined, so the merged PUT omits the
40028
+ // field (omission preserves; only a CALLER-supplied [] should clear).
40029
+ children: d.children?.length ? d.children.map((c) => c.userId) : void 0,
40030
+ eventParentId: d.eventParent?.userId,
40031
+ dropOffParentId: d.dropOffParent?.userId,
40032
+ pickUpParentId: d.pickUpParent?.userId
40033
+ };
40034
+ }
39796
40035
  function registerCalendarTools(server, client2) {
39797
- const allowWrites = getWriteMode() === "all";
40036
+ const allowWrites = getCalendarWritesAllowed();
39798
40037
  server.registerTool("ofw_list_events", {
39799
40038
  description: "List OurFamilyWizard calendar events in a date range",
39800
40039
  annotations: { readOnlyHint: true },
@@ -39812,51 +40051,62 @@ function registerCalendarTools(server, client2) {
39812
40051
  return jsonResponse(data);
39813
40052
  });
39814
40053
  if (allowWrites) server.registerTool("ofw_create_event", {
39815
- description: "Create a calendar event in OurFamilyWizard",
40054
+ description: "Create a calendar event in OurFamilyWizard. Unless privateEvent is true, the event is immediately visible to the co-parent \u2014 there is no draft stage.",
39816
40055
  annotations: { destructiveHint: false },
39817
40056
  inputSchema: {
39818
40057
  title: external_exports.string(),
39819
- startDate: external_exports.string().describe("ISO datetime string"),
39820
- endDate: external_exports.string().describe("ISO datetime string"),
39821
- allDay: external_exports.boolean().optional(),
39822
- location: external_exports.string().optional(),
39823
- reminder: external_exports.string().describe('Reminder setting (e.g. "1 hour before")').optional(),
39824
- privateEvent: external_exports.boolean().optional(),
39825
- eventFor: external_exports.string().describe("neither | parent1 | parent2").optional(),
39826
- dropOffParent: external_exports.string().optional(),
39827
- pickUpParent: external_exports.string().optional(),
39828
- children: external_exports.array(external_exports.number()).describe("Array of child IDs").optional()
40058
+ ...eventWriteFields
39829
40059
  }
39830
40060
  }, async (args) => {
39831
- const data = await client2.request("POST", "/pub/v1/calendar/events", args);
39832
- return jsonResponse(data);
40061
+ const raw = await client2.request("POST", "/pub/v3/events", buildEventPayload(args));
40062
+ const event = parseLenient(eventDetailSchema, raw, { label: "ofw-mcp", context: "POST /pub/v3/events", mode: "strict" });
40063
+ return jsonResponse({
40064
+ note: `Event created. Use eventRecurrenceId ${event.eventRecurrenceId} as eventId for ofw_update_event/ofw_delete_event.`,
40065
+ event
40066
+ });
39833
40067
  });
39834
40068
  if (allowWrites) server.registerTool("ofw_update_event", {
39835
- description: "Update an existing OurFamilyWizard calendar event",
40069
+ description: "Update an existing OurFamilyWizard calendar event. Fetches the event, applies the given changes, and writes the merged result back (OFW has no partial update).",
39836
40070
  annotations: { destructiveHint: true },
39837
40071
  inputSchema: {
39838
- eventId: external_exports.string(),
40072
+ eventId: external_exports.string().describe("Event id \u2014 the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event"),
39839
40073
  title: external_exports.string().optional(),
39840
- startDate: external_exports.string().optional(),
39841
- endDate: external_exports.string().optional(),
39842
- allDay: external_exports.boolean().optional(),
39843
- location: external_exports.string().optional(),
39844
- reminder: external_exports.string().optional(),
39845
- privateEvent: external_exports.boolean().optional()
40074
+ startDate: eventWriteFields.startDate.optional(),
40075
+ endDate: eventWriteFields.endDate,
40076
+ startTime: eventWriteFields.startTime,
40077
+ endTime: eventWriteFields.endTime,
40078
+ allDay: eventWriteFields.allDay,
40079
+ privateEvent: eventWriteFields.privateEvent,
40080
+ location: eventWriteFields.location,
40081
+ notes: eventWriteFields.notes,
40082
+ reminderMinutes: eventWriteFields.reminderMinutes,
40083
+ children: external_exports.array(external_exports.number()).describe("Child userIds to tag; pass [] to remove all child tags (omit to keep current tags)").optional(),
40084
+ eventParentId: eventWriteFields.eventParentId,
40085
+ dropOffParentId: eventWriteFields.dropOffParentId,
40086
+ pickUpParentId: eventWriteFields.pickUpParentId
39846
40087
  }
39847
40088
  }, async (args) => {
39848
- const { eventId, ...updateData } = args;
39849
- const data = await client2.request("PUT", `/pub/v1/calendar/events/${encodeURIComponent(eventId)}`, updateData);
39850
- return jsonResponse(data);
40089
+ const { eventId, ...changes } = args;
40090
+ const id = encodeURIComponent(eventId);
40091
+ const rawDetail = await client2.request("GET", `/pub/v3/events/${id}`);
40092
+ const current = parseLenient(eventDetailSchema, rawDetail, { label: "ofw-mcp", context: `GET /pub/v3/events/${eventId}`, mode: "strict" });
40093
+ const defined = Object.fromEntries(Object.entries(changes).filter(([, v]) => v !== void 0));
40094
+ const merged = { ...detailToWriteArgs(current), ...defined };
40095
+ await client2.request("PUT", `/pub/v3/events/${id}`, buildEventPayload(merged));
40096
+ const rawAfter = await client2.request("GET", `/pub/v3/events/${id}`);
40097
+ const event = parseLenient(eventDetailSchema, rawAfter, { label: "ofw-mcp", context: `GET /pub/v3/events/${eventId} (post-update)`, mode: "strict" });
40098
+ return jsonResponse({ note: "Event updated; returning re-fetched event state.", event });
39851
40099
  });
39852
40100
  if (allowWrites) server.registerTool("ofw_delete_event", {
39853
40101
  description: "Delete an OurFamilyWizard calendar event",
39854
40102
  annotations: { destructiveHint: true },
39855
40103
  inputSchema: {
39856
- eventId: external_exports.string().describe("Event ID to delete")
40104
+ eventId: external_exports.string().describe("Event id \u2014 the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event"),
40105
+ includeFuture: external_exports.boolean().describe("For repeating events: also delete future occurrences (default false)").optional()
39857
40106
  }
39858
40107
  }, async (args) => {
39859
- await client2.request("DELETE", `/pub/v1/calendar/events/${encodeURIComponent(args.eventId)}`);
40108
+ const includeFuture = args.includeFuture ?? false;
40109
+ await client2.request("DELETE", `/pub/v3/events/${encodeURIComponent(args.eventId)}?includeFuture=${includeFuture}`);
39860
40110
  return textResponse(`Event ${args.eventId} deleted`);
39861
40111
  });
39862
40112
  }
@@ -39939,7 +40189,7 @@ process.emit = function(event, ...args) {
39939
40189
  };
39940
40190
  await runMcp({
39941
40191
  name: "ofw",
39942
- version: "2.4.4",
40192
+ version: "2.5.0",
39943
40193
  // x-release-please-version
39944
40194
  deps: client,
39945
40195
  tools: [
package/dist/config.js CHANGED
@@ -61,6 +61,27 @@ export function getWriteMode() {
61
61
  console.error(`[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" — failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`);
62
62
  return 'none';
63
63
  }
64
+ /**
65
+ * Calendar-write opt-in for 'drafts' deployments.
66
+ *
67
+ * Messages have a draft stage (the human sends from the web UI), so 'drafts'
68
+ * mode keeps a human between model output and the court-visible record.
69
+ * Calendar events have no draft stage — but unlike a sent message they are
70
+ * fully reversible (editable and deletable), so a drafts-mode user may accept
71
+ * direct calendar writes without accepting sends. Setting
72
+ * OFW_CALENDAR_WRITES=true registers the calendar write tools
73
+ * (ofw_create_event, ofw_update_event, ofw_delete_event) alongside the
74
+ * draft-level message writes.
75
+ *
76
+ * The flag never overrides 'none': that mode is the hard read-only guarantee,
77
+ * including the fail-closed result of an unrecognized OFW_WRITE_MODE.
78
+ */
79
+ export function getCalendarWritesAllowed() {
80
+ const mode = getWriteMode();
81
+ if (mode === 'all')
82
+ return true;
83
+ return mode === 'drafts' && parseBoolEnv('OFW_CALENDAR_WRITES');
84
+ }
64
85
  // Default for ofw_download_attachment's `inline` arg when the caller doesn't
65
86
  // pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
66
87
  // MCP content blocks by default (skipping disk) — useful on sandboxed MCP
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@ import { registerJournalTools } from './tools/journal.js';
24
24
  // always succeeds before any credential check runs.
25
25
  await runMcp({
26
26
  name: 'ofw',
27
- version: '2.4.4', // x-release-please-version
27
+ version: '2.5.0', // x-release-please-version
28
28
  deps: client,
29
29
  tools: [
30
30
  registerUserTools,