ofw-mcp 2.6.7 → 2.7.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.
package/dist/bundle.js CHANGED
@@ -38407,7 +38407,7 @@ async function loginWithPassword(username, password) {
38407
38407
  // package.json
38408
38408
  var package_default = {
38409
38409
  name: "ofw-mcp",
38410
- version: "2.6.7",
38410
+ version: "2.7.1",
38411
38411
  license: "MIT",
38412
38412
  mcpName: "io.github.chrischall/ofw-mcp",
38413
38413
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
@@ -38449,13 +38449,13 @@ var package_default = {
38449
38449
  zod: "^4.4.3"
38450
38450
  },
38451
38451
  devDependencies: {
38452
- "@chrischall/mcp-connector": "^1.0.0",
38452
+ "@chrischall/mcp-connector": "^1.1.1",
38453
38453
  "@cloudflare/vitest-pool-workers": "^0.18.4",
38454
38454
  "@cloudflare/workers-oauth-provider": "^0.8.1",
38455
38455
  "@cloudflare/workers-types": "^5.20260708.1",
38456
38456
  "@types/node": "^26.0.0",
38457
38457
  "@vitest/coverage-v8": "^4.1.7",
38458
- agents: "^0.17.3",
38458
+ agents: "^0.19.0",
38459
38459
  esbuild: "^0.28.0",
38460
38460
  typescript: "^7.0.2",
38461
38461
  vitest: "^4.1.7",
@@ -38880,10 +38880,11 @@ async function walkPages(client2, folder, folderId, opts, store) {
38880
38880
  let page = opts.startPage;
38881
38881
  let newestId = null;
38882
38882
  let synced = 0;
38883
+ let pagesFetched = 0;
38883
38884
  const unread = [];
38884
38885
  while (true) {
38885
38886
  if (!budget.take()) {
38886
- return { synced, unread, newestId, done: false, nextPage: page };
38887
+ return { synced, unread, newestId, pagesFetched, done: false, nextPage: page };
38887
38888
  }
38888
38889
  const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
38889
38890
  const list = parseLenient(
@@ -38891,9 +38892,10 @@ async function walkPages(client2, folder, folderId, opts, store) {
38891
38892
  await client2.request("GET", path),
38892
38893
  { label: "ofw-mcp", context: `GET /pub/v3/messages?folders={${folder}}` }
38893
38894
  );
38895
+ pagesFetched++;
38894
38896
  const items = list.data ?? [];
38895
38897
  if (items.length === 0) {
38896
- return { synced, unread, newestId, done: true, nextPage: null };
38898
+ return { synced, unread, newestId, pagesFetched, done: true, nextPage: null };
38897
38899
  }
38898
38900
  const existingById = new Map(
38899
38901
  (await store.getMessages(items.map((it) => it.id))).map((row) => [row.id, row])
@@ -38972,10 +38974,10 @@ async function walkPages(client2, folder, folderId, opts, store) {
38972
38974
  }
38973
38975
  await store.upsertMessages(toUpsert);
38974
38976
  if (pageBudgetHit) {
38975
- return { synced, unread, newestId, done: false, nextPage: page };
38977
+ return { synced, unread, newestId, pagesFetched, done: false, nextPage: page };
38976
38978
  }
38977
38979
  if (opts.stopAtCachedPage && !pageHadNewItem) {
38978
- return { synced, unread, newestId, done: true, nextPage: page };
38980
+ return { synced, unread, newestId, pagesFetched, done: true, nextPage: page };
38979
38981
  }
38980
38982
  page++;
38981
38983
  }
@@ -38997,7 +38999,11 @@ async function syncMessageFolder(client2, folder, folderId, opts, store) {
38997
38999
  let resumePage;
38998
39000
  if (!fwd.done) {
38999
39001
  done = false;
39000
- resumePage = savedResume === null ? fwd.nextPage : Math.min(fwd.nextPage, savedResume);
39002
+ if (fwd.pagesFetched === 0) {
39003
+ resumePage = savedResume;
39004
+ } else {
39005
+ resumePage = savedResume === null ? fwd.nextPage : Math.min(fwd.nextPage, savedResume);
39006
+ }
39001
39007
  } else if (fwd.nextPage === null) {
39002
39008
  done = true;
39003
39009
  resumePage = null;
@@ -39017,12 +39023,10 @@ async function syncMessageFolder(client2, folder, folderId, opts, store) {
39017
39023
  done = bf.done;
39018
39024
  resumePage = bf.done ? null : bf.nextPage;
39019
39025
  }
39020
- await store.setSyncState(folder, {
39021
- lastSyncAt: (/* @__PURE__ */ new Date()).toISOString(),
39022
- newestId,
39023
- resumePage
39024
- });
39025
- return { synced, unread, done };
39026
+ const now = (/* @__PURE__ */ new Date()).toISOString();
39027
+ await store.setSyncState(folder, { lastSyncAt: now, newestId, resumePage });
39028
+ if (fwd.done) await markFolderVerified(store, folder, now);
39029
+ return { synced, unread, done, verified: fwd.done };
39026
39030
  }
39027
39031
  var DraftListItemSchema = external_exports.looseObject({
39028
39032
  id: external_exports.number(),
@@ -39040,10 +39044,27 @@ var DRAFTS_CACHE_STATUS_KEY = "drafts_cache_status";
39040
39044
  async function getDraftsCacheStatus(store) {
39041
39045
  return await store.getMeta(DRAFTS_CACHE_STATUS_KEY) === "fresh" ? "fresh" : "unverified";
39042
39046
  }
39047
+ async function setDraftsCacheStatus(store, status) {
39048
+ await store.setMeta(DRAFTS_CACHE_STATUS_KEY, status);
39049
+ }
39050
+ function folderVerifiedAtKey(folder) {
39051
+ return `folder_verified_at:${folder}`;
39052
+ }
39053
+ async function getFolderVerifiedAt(store, folder) {
39054
+ return await store.getMeta(folderVerifiedAtKey(folder)) ?? null;
39055
+ }
39056
+ async function markFolderVerified(store, folder, at = (/* @__PURE__ */ new Date()).toISOString()) {
39057
+ await store.setMeta(folderVerifiedAtKey(folder), at);
39058
+ }
39043
39059
  async function syncDrafts(client2, draftsFolderId, store, budget) {
39044
39060
  const b = budget ?? makeBudget(Number.POSITIVE_INFINITY);
39045
39061
  const defer = async () => {
39046
- await store.setMeta(DRAFTS_CACHE_STATUS_KEY, "unverified");
39062
+ await setDraftsCacheStatus(store, "unverified");
39063
+ await store.setSyncState("drafts", {
39064
+ lastSyncAt: (/* @__PURE__ */ new Date()).toISOString(),
39065
+ newestId: null,
39066
+ resumePage: null
39067
+ });
39047
39068
  return { synced: 0, done: false };
39048
39069
  };
39049
39070
  const items = [];
@@ -39096,7 +39117,10 @@ async function syncDrafts(client2, draftsFolderId, store, budget) {
39096
39117
  for (const id of await store.listDraftIds()) {
39097
39118
  if (!seenIds.has(id)) await store.deleteDraft(id);
39098
39119
  }
39099
- await store.setMeta(DRAFTS_CACHE_STATUS_KEY, "fresh");
39120
+ const now = (/* @__PURE__ */ new Date()).toISOString();
39121
+ await setDraftsCacheStatus(store, "fresh");
39122
+ await store.setSyncState("drafts", { lastSyncAt: now, newestId: null, resumePage: null });
39123
+ await markFolderVerified(store, "drafts", now);
39100
39124
  return { synced, done: true };
39101
39125
  }
39102
39126
  async function syncAll(client2, opts, store) {
@@ -39112,6 +39136,16 @@ async function syncAll(client2, opts, store) {
39112
39136
  let unreadInbox = [];
39113
39137
  let done = true;
39114
39138
  let draftsUnverified = false;
39139
+ const refreshed = [];
39140
+ const notRefreshed = [];
39141
+ const record2 = (folder, verified, count) => {
39142
+ if (verified) {
39143
+ synced[folder] = count;
39144
+ refreshed.push(folder);
39145
+ } else {
39146
+ notRefreshed.push(folder);
39147
+ }
39148
+ };
39115
39149
  for (const folder of folders) {
39116
39150
  if (folder === "inbox") {
39117
39151
  const r = await syncMessageFolder(client2, "inbox", ids.inbox, {
@@ -39119,7 +39153,7 @@ async function syncAll(client2, opts, store) {
39119
39153
  deep: opts.deep ?? false,
39120
39154
  budget
39121
39155
  }, store);
39122
- synced.inbox = r.synced;
39156
+ record2("inbox", r.verified, r.synced);
39123
39157
  unreadInbox = r.unread;
39124
39158
  if (!r.done) done = false;
39125
39159
  } else if (folder === "sent") {
@@ -39128,12 +39162,12 @@ async function syncAll(client2, opts, store) {
39128
39162
  deep: opts.deep ?? false,
39129
39163
  budget
39130
39164
  }, store);
39131
- synced.sent = r.synced;
39165
+ record2("sent", r.verified, r.synced);
39132
39166
  if (!r.done) done = false;
39133
39167
  } else if (folder === "drafts") {
39134
39168
  const r = await syncDrafts(client2, ids.drafts, store, budget);
39135
- if (r.done) synced.drafts = r.synced;
39136
- else {
39169
+ record2("drafts", r.done, r.synced);
39170
+ if (!r.done) {
39137
39171
  draftsUnverified = true;
39138
39172
  done = false;
39139
39173
  }
@@ -39146,11 +39180,182 @@ async function syncAll(client2, opts, store) {
39146
39180
  if (unreadInbox.length > 0) {
39147
39181
  notes.push(`${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them \u2014 this will mark them as read on OFW.`);
39148
39182
  }
39183
+ if (notRefreshed.length > 0) {
39184
+ notes.push(`NOT checked against OurFamilyWizard on this call: ${notRefreshed.join(", ")}. No count is reported for ${notRefreshed.length > 1 ? "those folders" : "that folder"} \u2014 absence of a count means "not looked at", not "no changes". Cached contents may be behind the server; call ofw_sync_messages again to finish, or ofw_check_freshness for a cheap live confirmation.`);
39185
+ }
39149
39186
  if (!done) {
39150
39187
  notes.push("Paused after the request budget to stay within the hosting limit; more pages remain \u2014 call ofw_sync_messages again with the same arguments to resume where it left off and continue the backfill.");
39151
39188
  }
39152
39189
  const note = notes.length > 0 ? notes.join("\n\n") : void 0;
39153
- return { synced, unreadInbox, done, ...note ? { note } : {} };
39190
+ return {
39191
+ synced,
39192
+ unreadInbox,
39193
+ done,
39194
+ syncComplete: done,
39195
+ refreshed,
39196
+ notRefreshed,
39197
+ ...note ? { note } : {}
39198
+ };
39199
+ }
39200
+
39201
+ // src/config.ts
39202
+ import { createHash } from "node:crypto";
39203
+ import { homedir as homedir3 } from "node:os";
39204
+ import { join as join4 } from "node:path";
39205
+ function readCacheIdentity() {
39206
+ return readEnvVar("OFW_CACHE_IDENTITY") ?? readEnvVar("OFW_USERNAME") ?? "_default";
39207
+ }
39208
+ function getCacheDir() {
39209
+ const override = process.env.OFW_CACHE_DIR;
39210
+ if (override && override.trim().length > 0) return override.trim();
39211
+ return join4(homedir3(), ".cache", "ofw-mcp");
39212
+ }
39213
+ function getCacheDbPath() {
39214
+ const identity = readCacheIdentity();
39215
+ const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
39216
+ return join4(getCacheDir(), `${hash2}.db`);
39217
+ }
39218
+ function getAttachmentsDir() {
39219
+ const override = process.env.OFW_ATTACHMENTS_DIR;
39220
+ if (override && override.trim().length > 0) return override.trim();
39221
+ return join4(homedir3(), "Downloads", "ofw-mcp");
39222
+ }
39223
+ function getWriteMode() {
39224
+ const raw = process.env.OFW_WRITE_MODE;
39225
+ if (typeof raw !== "string" || raw.trim().length === 0) return "all";
39226
+ const mode = raw.trim().toLowerCase();
39227
+ if (mode === "none" || mode === "drafts" || mode === "all") return mode;
39228
+ console.error(
39229
+ `[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
39230
+ );
39231
+ return "none";
39232
+ }
39233
+ function getCalendarWritesAllowed() {
39234
+ const mode = getWriteMode();
39235
+ if (mode === "all") return true;
39236
+ return mode === "drafts" && parseBoolEnv("OFW_CALENDAR_WRITES");
39237
+ }
39238
+ function getDefaultInlineAttachments() {
39239
+ return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
39240
+ }
39241
+ function getSyncMaxRequests() {
39242
+ const raw = readEnvVar("OFW_SYNC_MAX_REQUESTS");
39243
+ if (raw === void 0) return Number.POSITIVE_INFINITY;
39244
+ const n = Number(raw);
39245
+ if (!Number.isInteger(n) || n <= 0) return Number.POSITIVE_INFINITY;
39246
+ return n;
39247
+ }
39248
+ var DEFAULT_FRESHNESS_TTL_SECONDS = 300;
39249
+ function getFreshnessTtlSeconds() {
39250
+ const raw = readEnvVar("OFW_FRESHNESS_TTL_SECONDS");
39251
+ if (raw === void 0) return DEFAULT_FRESHNESS_TTL_SECONDS;
39252
+ const n = Number(raw);
39253
+ if (!Number.isInteger(n) || n <= 0) return DEFAULT_FRESHNESS_TTL_SECONDS;
39254
+ return n;
39255
+ }
39256
+
39257
+ // src/tools/freshness.ts
39258
+ var RANK = { fresh: 0, unverified: 1, stale: 2 };
39259
+ function worst(a, b) {
39260
+ return RANK[a] >= RANK[b] ? a : b;
39261
+ }
39262
+ function describeAge(seconds) {
39263
+ return seconds < 60 ? `${seconds} sec ago` : `${Math.round(seconds / 60)} min ago`;
39264
+ }
39265
+ async function buildFreshness(store, opts) {
39266
+ const now = opts.now ?? /* @__PURE__ */ new Date();
39267
+ const ttl = opts.ttlSeconds ?? getFreshnessTtlSeconds();
39268
+ const emptyScope = opts.source === "cache" && opts.folders.length === 0;
39269
+ let staleness = emptyScope ? "stale" : "fresh";
39270
+ let oldestVerifiedAt = null;
39271
+ let sawNeverVerified = false;
39272
+ let lastServerSyncAt = null;
39273
+ let historyComplete = true;
39274
+ let syncComplete = !emptyScope;
39275
+ const deferred = [];
39276
+ const backfilling = [];
39277
+ for (const folder of opts.folders) {
39278
+ const verifiedAt = await getFolderVerifiedAt(store, folder);
39279
+ const state = await store.getSyncState(folder);
39280
+ if (state !== null && (lastServerSyncAt === null || state.lastSyncAt > lastServerSyncAt)) {
39281
+ lastServerSyncAt = state.lastSyncAt;
39282
+ }
39283
+ if (state !== null && state.resumePage !== null) {
39284
+ historyComplete = false;
39285
+ syncComplete = false;
39286
+ backfilling.push(folder);
39287
+ }
39288
+ if (verifiedAt === null) {
39289
+ sawNeverVerified = true;
39290
+ staleness = worst(staleness, "stale");
39291
+ syncComplete = false;
39292
+ continue;
39293
+ }
39294
+ if (oldestVerifiedAt === null || verifiedAt < oldestVerifiedAt) oldestVerifiedAt = verifiedAt;
39295
+ if (state !== null && state.lastSyncAt > verifiedAt) {
39296
+ staleness = worst(staleness, "unverified");
39297
+ syncComplete = false;
39298
+ deferred.push(folder);
39299
+ continue;
39300
+ }
39301
+ const age = Math.max(0, Math.floor((now.getTime() - Date.parse(verifiedAt)) / 1e3));
39302
+ if (age > ttl) staleness = worst(staleness, "unverified");
39303
+ }
39304
+ if (opts.source === "live") {
39305
+ const asOf2 = now.toISOString();
39306
+ const block2 = {
39307
+ source: "live",
39308
+ asOf: asOf2,
39309
+ ageSeconds: 0,
39310
+ staleness: "fresh",
39311
+ lastServerSyncAt,
39312
+ syncComplete,
39313
+ historyComplete
39314
+ };
39315
+ const liveReasons = [];
39316
+ if (sawNeverVerified) {
39317
+ liveReasons.push("the surrounding cache has never been checked against OurFamilyWizard, so anything you did NOT fetch in this call is unverified");
39318
+ }
39319
+ if (backfilling.length > 0) {
39320
+ liveReasons.push(`older history is still being backfilled for ${backfilling.join(", ")}, so older messages may be missing from the cache`);
39321
+ }
39322
+ if (liveReasons.length > 0) {
39323
+ block2.warning = `Fetched live from OurFamilyWizard, so this data is current. Note that ${liveReasons.join("; ")}.`;
39324
+ }
39325
+ return block2;
39326
+ }
39327
+ const asOf = sawNeverVerified ? null : oldestVerifiedAt;
39328
+ const ageSeconds = asOf === null ? null : Math.max(0, Math.floor((now.getTime() - Date.parse(asOf)) / 1e3));
39329
+ const block = {
39330
+ source: "cache",
39331
+ asOf,
39332
+ ageSeconds,
39333
+ staleness,
39334
+ lastServerSyncAt,
39335
+ syncComplete,
39336
+ historyComplete
39337
+ };
39338
+ const reasons = [];
39339
+ if (emptyScope) {
39340
+ reasons.push("this result is backed by no synced folder at all, so nothing about it has been verified");
39341
+ }
39342
+ if (sawNeverVerified) {
39343
+ reasons.push("this data has never been checked against OurFamilyWizard");
39344
+ }
39345
+ if (deferred.length > 0) {
39346
+ reasons.push(`the last sync did not finish checking ${deferred.join(", ")}`);
39347
+ }
39348
+ if (asOf !== null && ageSeconds !== null && ageSeconds > ttl) {
39349
+ reasons.push(`that is past the ${ttl}s freshness threshold`);
39350
+ }
39351
+ if (backfilling.length > 0) {
39352
+ reasons.push(`older history is still being backfilled for ${backfilling.join(", ")}`);
39353
+ }
39354
+ if (reasons.length > 0) {
39355
+ const served = asOf === null ? "Served from cache that was never verified against OurFamilyWizard" : `Served from cache last verified ${describeAge(ageSeconds)}`;
39356
+ block.warning = `${served}; ${reasons.join("; ")}. Re-read before asserting current state \u2014 call ofw_check_freshness for a cheap live confirmation, or ofw_sync_messages to refresh.`;
39357
+ }
39358
+ return block;
39154
39359
  }
39155
39360
 
39156
39361
  // src/tools/draft-freshness.ts
@@ -39203,6 +39408,10 @@ async function fetchServerDraft(client2, id) {
39203
39408
  recipients: mapRecipients(detail.recipients)
39204
39409
  };
39205
39410
  }
39411
+ var SUBSTANTIVE_FIELDS = ["subject", "body", "recipients"];
39412
+ function substantiveChanges(changed) {
39413
+ return changed.filter((f) => SUBSTANTIVE_FIELDS.includes(f));
39414
+ }
39206
39415
  function diffFields(a, b) {
39207
39416
  const changed = [];
39208
39417
  if (a.subject !== b.subject) changed.push("subject");
@@ -39226,6 +39435,17 @@ function checkDraftFreshness(input) {
39226
39435
  if (expectedRevision === actual) {
39227
39436
  return { verdict: "FRESH", reason: "expectedRevision matches the live server draft.", changedFields: [] };
39228
39437
  }
39438
+ if (cached2 !== null && draftRevision(cached2) === expectedRevision) {
39439
+ const changedFields2 = diffFields(server, cached2);
39440
+ if (substantiveChanges(changedFields2).length === 0) {
39441
+ return {
39442
+ verdict: "FRESH",
39443
+ reason: `Only connector-authored metadata (${changedFields2.join(", ")}) changed since you read the draft; its subject, body and recipients are unchanged, so this is not a conflict.`,
39444
+ changedFields: changedFields2,
39445
+ metadataOnly: true
39446
+ };
39447
+ }
39448
+ }
39229
39449
  return {
39230
39450
  verdict: "STALE",
39231
39451
  reason: `expectedRevision ${expectedRevision} does not match the live server draft (${actual}) \u2014 it changed after you read it.`,
@@ -39243,6 +39463,14 @@ function checkDraftFreshness(input) {
39243
39463
  if (changedFields.length === 0) {
39244
39464
  return { verdict: "FRESH", reason: "The cached draft matches the live server draft.", changedFields: [] };
39245
39465
  }
39466
+ if (substantiveChanges(changedFields).length === 0) {
39467
+ return {
39468
+ verdict: "FRESH",
39469
+ reason: `Only connector-authored metadata (${changedFields.join(", ")}) differs from the cached copy; subject, body and recipients match, so this is not a conflict.`,
39470
+ changedFields,
39471
+ metadataOnly: true
39472
+ };
39473
+ }
39246
39474
  return {
39247
39475
  verdict: "STALE",
39248
39476
  reason: `The draft on OurFamilyWizard differs from the cached copy (${changedFields.join(", ")}) \u2014 it was edited outside this tool.`,
@@ -39263,56 +39491,95 @@ function staleDraftPayload(input) {
39263
39491
  };
39264
39492
  }
39265
39493
 
39266
- // src/config.ts
39267
- import { createHash } from "node:crypto";
39268
- import { homedir as homedir3 } from "node:os";
39269
- import { join as join4 } from "node:path";
39270
- function readCacheIdentity() {
39271
- return readEnvVar("OFW_CACHE_IDENTITY") ?? readEnvVar("OFW_USERNAME") ?? "_default";
39272
- }
39273
- function getCacheDir() {
39274
- const override = process.env.OFW_CACHE_DIR;
39275
- if (override && override.trim().length > 0) return override.trim();
39276
- return join4(homedir3(), ".cache", "ofw-mcp");
39277
- }
39278
- function getCacheDbPath() {
39279
- const identity = readCacheIdentity();
39280
- const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
39281
- return join4(getCacheDir(), `${hash2}.db`);
39282
- }
39283
- function getAttachmentsDir() {
39284
- const override = process.env.OFW_ATTACHMENTS_DIR;
39285
- if (override && override.trim().length > 0) return override.trim();
39286
- return join4(homedir3(), "Downloads", "ofw-mcp");
39287
- }
39288
- function getWriteMode() {
39289
- const raw = process.env.OFW_WRITE_MODE;
39290
- if (typeof raw !== "string" || raw.trim().length === 0) return "all";
39291
- const mode = raw.trim().toLowerCase();
39292
- if (mode === "none" || mode === "drafts" || mode === "all") return mode;
39293
- console.error(
39294
- `[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
39295
- );
39296
- return "none";
39297
- }
39298
- function getCalendarWritesAllowed() {
39299
- const mode = getWriteMode();
39300
- if (mode === "all") return true;
39301
- return mode === "drafts" && parseBoolEnv("OFW_CALENDAR_WRITES");
39302
- }
39303
- function getDefaultInlineAttachments() {
39304
- return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
39494
+ // src/tools/attachments.ts
39495
+ import { readFileSync, statSync, mkdirSync, writeFileSync } from "node:fs";
39496
+ import { basename, dirname as dirname2, extname } from "node:path";
39497
+ var MIME_BY_EXT = {
39498
+ ".pdf": "application/pdf",
39499
+ ".png": "image/png",
39500
+ ".jpg": "image/jpeg",
39501
+ ".jpeg": "image/jpeg",
39502
+ ".gif": "image/gif",
39503
+ ".webp": "image/webp",
39504
+ ".heic": "image/heic",
39505
+ ".txt": "text/plain",
39506
+ ".md": "text/markdown",
39507
+ ".csv": "text/csv",
39508
+ ".html": "text/html",
39509
+ ".htm": "text/html",
39510
+ ".json": "application/json",
39511
+ ".xml": "application/xml",
39512
+ ".doc": "application/msword",
39513
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
39514
+ ".xls": "application/vnd.ms-excel",
39515
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
39516
+ ".ppt": "application/vnd.ms-powerpoint",
39517
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
39518
+ ".zip": "application/zip",
39519
+ ".ics": "text/calendar"
39520
+ };
39521
+ function mimeFromName(name) {
39522
+ return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
39305
39523
  }
39306
- function getSyncMaxRequests() {
39307
- const raw = readEnvVar("OFW_SYNC_MAX_REQUESTS");
39308
- if (raw === void 0) return Number.POSITIVE_INFINITY;
39309
- const n = Number(raw);
39310
- if (!Number.isInteger(n) || n <= 0) return Number.POSITIVE_INFINITY;
39311
- return n;
39524
+ var OCTET_STREAM = "application/octet-stream";
39525
+ var HOST_RENDERABLE_IMAGE_MIMES = /* @__PURE__ */ new Set([
39526
+ "image/png",
39527
+ "image/jpeg",
39528
+ "image/gif",
39529
+ "image/webp"
39530
+ ]);
39531
+ function normalizeMimeType(raw) {
39532
+ if (!raw) return OCTET_STREAM;
39533
+ const bare = raw.split(";", 1)[0].trim().toLowerCase();
39534
+ return bare || OCTET_STREAM;
39535
+ }
39536
+ var PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
39537
+ var JPEG_MAGIC = Buffer.from([255, 216, 255]);
39538
+ function sniffImageMime(bytes) {
39539
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(PNG_MAGIC)) return "image/png";
39540
+ if (bytes.length >= 3 && bytes.subarray(0, 3).equals(JPEG_MAGIC)) return "image/jpeg";
39541
+ if (bytes.length >= 6 && bytes.toString("ascii", 0, 4) === "GIF8") return "image/gif";
39542
+ if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") {
39543
+ return "image/webp";
39544
+ }
39545
+ return null;
39546
+ }
39547
+ function resolveDownloadMime(bytes, headerMime, fileName) {
39548
+ const sniffed = sniffImageMime(bytes);
39549
+ if (sniffed) return sniffed;
39550
+ const fromHeader = normalizeMimeType(headerMime);
39551
+ if (fromHeader !== OCTET_STREAM) return fromHeader;
39552
+ return mimeFromName(fileName);
39553
+ }
39554
+ function isHostRenderableImage(mime) {
39555
+ return HOST_RENDERABLE_IMAGE_MIMES.has(mime);
39312
39556
  }
39557
+ var NodeAttachmentIO = class {
39558
+ supportsDisk = true;
39559
+ async resolveUpload(path) {
39560
+ const abs = expandPath(path);
39561
+ const stat = statSync(abs);
39562
+ if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
39563
+ const fileName = basename(abs);
39564
+ const mimeType = mimeFromName(fileName);
39565
+ const blob = await fileBlob(abs, { type: mimeType });
39566
+ return { blob, fileName, mimeType, sizeBytes: stat.size };
39567
+ }
39568
+ readDownloaded(path) {
39569
+ try {
39570
+ return readFileSync(path);
39571
+ } catch {
39572
+ return null;
39573
+ }
39574
+ }
39575
+ writeDownload(dest, bytes) {
39576
+ mkdirSync(dirname2(dest), { recursive: true });
39577
+ writeFileSync(dest, bytes);
39578
+ }
39579
+ };
39313
39580
 
39314
39581
  // src/tools/messages.ts
39315
- import { basename, join as join5 } from "node:path";
39582
+ import { basename as basename2, join as join5 } from "node:path";
39316
39583
  var DateSchema = external_exports.looseObject({ dateTime: external_exports.string() });
39317
39584
  var SentDetailSchema = external_exports.looseObject({
39318
39585
  subject: external_exports.string().optional(),
@@ -39326,7 +39593,9 @@ var SavedDraftDetailSchema = external_exports.looseObject({
39326
39593
  body: external_exports.string().optional(),
39327
39594
  date: DateSchema.optional(),
39328
39595
  replyToId: external_exports.number().nullable().optional(),
39329
- recipients: external_exports.array(ApiRecipientSchema).optional()
39596
+ recipients: external_exports.array(ApiRecipientSchema).optional(),
39597
+ // Read to audit whether requested myFileIDs actually attached (Defect 3).
39598
+ files: external_exports.array(external_exports.number()).optional()
39330
39599
  });
39331
39600
  var MessageDetailSchema = external_exports.looseObject({
39332
39601
  id: external_exports.number(),
@@ -39342,6 +39611,21 @@ var MessageDetailSchema = external_exports.looseObject({
39342
39611
  folder: external_exports.looseObject({ id: external_exports.number() }).optional()
39343
39612
  });
39344
39613
  var DetailFilesSchema = external_exports.looseObject({ files: external_exports.array(external_exports.number()).optional() });
39614
+ var FolderCountsSchema = external_exports.looseObject({
39615
+ systemFolders: external_exports.array(external_exports.looseObject({
39616
+ id: external_exports.string(),
39617
+ folderType: external_exports.string(),
39618
+ totalCount: external_exports.number().optional(),
39619
+ messageCount: external_exports.number().optional(),
39620
+ count: external_exports.number().optional()
39621
+ })).optional()
39622
+ });
39623
+ var FOLDER_TYPE = {
39624
+ inbox: "INBOX",
39625
+ sent: "SENT_MESSAGES",
39626
+ drafts: "DRAFTS"
39627
+ };
39628
+ var MAX_FRESHNESS_IDS = 25;
39345
39629
  var UploadedFileSchema = external_exports.looseObject({
39346
39630
  fileId: external_exports.number(),
39347
39631
  fileName: external_exports.string().optional(),
@@ -39357,16 +39641,23 @@ function listDataHintsAtFiles(listData) {
39357
39641
  if (Array.isArray(ld.files)) return ld.files.length > 0;
39358
39642
  return false;
39359
39643
  }
39644
+ async function draftsFreshness(cache) {
39645
+ const freshness = await buildFreshness(cache, { source: "cache", folders: ["drafts"] });
39646
+ const completed = await getDraftsCacheStatus(cache);
39647
+ const cacheStatus = completed === "fresh" && freshness.staleness === "fresh" ? "fresh" : "unverified";
39648
+ return { freshness, serverConfirmed: cacheStatus === "fresh", cacheStatus };
39649
+ }
39360
39650
  function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39361
39651
  const writeMode = getWriteMode();
39362
39652
  const allowSend = writeMode === "all";
39363
39653
  const allowDrafts = writeMode !== "none";
39364
39654
  server.registerTool("ofw_list_message_folders", {
39365
- description: "List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.",
39655
+ description: "List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Fetched LIVE from OFW, so the counts are current. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.",
39366
39656
  annotations: { readOnlyHint: true }
39367
39657
  }, async () => {
39368
39658
  const data = await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true");
39369
- return jsonResponse(data);
39659
+ const freshness = await buildFreshness(cacheProvider(), { source: "live", folders: [] });
39660
+ return jsonResponse({ folders: data, freshness });
39370
39661
  });
39371
39662
  server.registerTool("ofw_list_messages", {
39372
39663
  description: "List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages \u2014 the cache may have 1000+ messages. Call ofw_sync_messages first if the cache is empty or stale.",
@@ -39390,14 +39681,22 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39390
39681
  else {
39391
39682
  return jsonResponse({
39392
39683
  messages: [],
39393
- note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.'
39684
+ freshness: await buildFreshness(cacheProvider(), {
39685
+ source: "cache",
39686
+ folders: ["inbox", "sent"]
39687
+ }),
39688
+ note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache. No lookup was performed \u2014 this empty result says nothing about what is in the cache.'
39394
39689
  });
39395
39690
  }
39396
39691
  const cache = cacheProvider();
39397
39692
  const filter = { folder, since: args.since, until: args.until, q: args.q };
39398
39693
  const total = await cache.countMessages(filter);
39399
39694
  const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
39400
- const payload = { messages, total, page, size };
39695
+ const freshness = await buildFreshness(cache, {
39696
+ source: "cache",
39697
+ folders: folder === void 0 ? ["inbox", "sent"] : [folder]
39698
+ });
39699
+ const payload = { messages, total, page, size, freshness };
39401
39700
  if (total === 0) {
39402
39701
  payload.note = "No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.";
39403
39702
  } else if (page * size < total) {
@@ -39416,6 +39715,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39416
39715
  const cache = cacheProvider();
39417
39716
  const draftRow = await cache.getDraft(id);
39418
39717
  if (draftRow !== null) {
39718
+ const { freshness: freshness2, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
39419
39719
  return jsonResponse({
39420
39720
  id: draftRow.id,
39421
39721
  folder: "drafts",
@@ -39435,7 +39735,12 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39435
39735
  // Concurrency token — pass as expectedRevision to ofw_save_draft /
39436
39736
  // ofw_delete_draft to assert you are editing THIS version.
39437
39737
  revision: draftRevision(draftRow),
39438
- cacheStatus: await getDraftsCacheStatus(cache)
39738
+ cacheStatus,
39739
+ // False = this draft's existence and unsent status are remembered from
39740
+ // a cache, not confirmed on OFW. Call ofw_check_freshness before
39741
+ // stating either as current fact.
39742
+ serverConfirmed,
39743
+ freshness: freshness2
39439
39744
  });
39440
39745
  }
39441
39746
  const cached2 = await cache.getMessage(id);
@@ -39473,7 +39778,8 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39473
39778
  } catch {
39474
39779
  }
39475
39780
  }
39476
- return jsonResponse({ ...withReadState(row2), attachments: attachments2 });
39781
+ const freshness2 = await buildFreshness(cache, { source: "cache", folders: [row2.folder] });
39782
+ return jsonResponse({ ...withReadState(row2), attachments: attachments2, freshness: freshness2 });
39477
39783
  }
39478
39784
  const detail = parseLenient(
39479
39785
  MessageDetailSchema,
@@ -39505,7 +39811,8 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39505
39811
  await fetchAttachmentMetaForMessage(client2, detail.id, detail.files, cache);
39506
39812
  }
39507
39813
  const attachments = await cache.listAttachmentsForMessage(detail.id);
39508
- return jsonResponse({ ...withReadState(row), attachments });
39814
+ const freshness = await buildFreshness(cache, { source: "live", folders: [folder] });
39815
+ return jsonResponse({ ...withReadState(row), attachments, freshness });
39509
39816
  });
39510
39817
  if (allowSend) server.registerTool("ofw_send_message", {
39511
39818
  description: "Send a message via OurFamilyWizard. To send an existing draft, pass messageId \u2014 subject/body/recipientIds become optional overrides (missing fields default to the draft's cached values) and the draft is deleted after sending. To send a fresh message, supply subject/body/recipientIds directly. draftId is the legacy spelling of messageId and works the same way. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.",
@@ -39653,7 +39960,10 @@ ${text}` : text);
39653
39960
  };
39654
39961
  }
39655
39962
  const verdict = checkDraftFreshness({ server: server2, cached: cached2, expectedRevision });
39656
- if (verdict.verdict === "FRESH") return { ok: true, note: null };
39963
+ if (verdict.verdict === "FRESH") {
39964
+ const note = verdict.metadataOnly ? `NOTE: draft ${draftId} was treated as current for this ${action}. Since you read it, OurFamilyWizard normalized connector-authored metadata (${verdict.changedFields.join(", ")}); the subject, body and recipients are unchanged, so this is not a conflict.` : null;
39965
+ return { ok: true, note };
39966
+ }
39657
39967
  if (force) {
39658
39968
  console.error(`[ofw-mcp] WARNING: force:true overrode a ${verdict.verdict} verdict on draft ${draftId} (${action}). ${verdict.reason}`);
39659
39969
  const echoed = server2 === null ? "The draft no longer existed on OurFamilyWizard." : `The server version that was overwritten is preserved below under "overwrittenServerDraft".`;
@@ -39690,20 +40000,30 @@ ${JSON.stringify(
39690
40000
  const page = args.page ?? 1;
39691
40001
  const size = args.size ?? 50;
39692
40002
  const cache = cacheProvider();
39693
- const cacheStatus = await getDraftsCacheStatus(cache);
40003
+ const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
39694
40004
  const rows = await cache.listDrafts({ page, size });
39695
- const drafts = rows.map((d) => ({ ...d, revision: draftRevision(d), cacheStatus }));
40005
+ const drafts = rows.map((d) => ({
40006
+ ...d,
40007
+ revision: draftRevision(d),
40008
+ cacheStatus,
40009
+ serverConfirmed,
40010
+ asOf: freshness.asOf
40011
+ }));
39696
40012
  if (drafts.length === 0) {
39697
- return jsonResponse({ drafts: [], note: "Cache empty. Call ofw_sync_messages to populate." });
40013
+ return jsonResponse({
40014
+ drafts: [],
40015
+ freshness,
40016
+ note: "No drafts in the local cache. That is NOT proof there are no drafts on OurFamilyWizard \u2014 call ofw_sync_messages to populate, or ofw_check_freshness to confirm."
40017
+ });
39698
40018
  }
39699
- const payload = { drafts };
39700
- if (cacheStatus !== "fresh") {
39701
- payload.note = 'cacheStatus "unverified": the last ofw_sync_messages did not finish checking the drafts folder against OurFamilyWizard, so these bodies may be behind the server (drafts edited in the OFW web app do not bump any timestamp). Run ofw_sync_messages again before relying on them. Writes are guarded regardless \u2014 ofw_save_draft and ofw_delete_draft re-check the server and refuse a stale overwrite.';
40019
+ const payload = { drafts, freshness };
40020
+ if (!serverConfirmed) {
40021
+ payload.note = 'serverConfirmed:false \u2014 these drafts are remembered from the local cache, NOT confirmed to still exist unsent on OurFamilyWizard right now, and their bodies may be behind the server. Do not state that a draft "is still sitting unsent" on this basis; drafts edited or deleted in the OFW web app bump no timestamp, so the cache cannot detect it on its own. Call ofw_check_freshness (cheap, live) or ofw_sync_messages first. Writes are guarded regardless \u2014 ofw_save_draft and ofw_delete_draft re-check the server and refuse a stale overwrite.';
39702
40022
  }
39703
40023
  return jsonResponse(payload);
39704
40024
  });
39705
40025
  if (allowDrafts) server.registerTool("ofw_save_draft", {
39706
- description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft \u2014 note that under the hood this creates a NEW draft and deletes the old one (OFW's update-in-place endpoint silently no-ops while echoing the posted body, so we don't use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if it changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). The refusal returns the current server body under serverBody \u2014 merge your edit into it and retry with expectedRevision.",
40026
+ description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft \u2014 note that under the hood this creates a NEW draft and deletes the old one (OFW's update-in-place endpoint silently no-ops while echoing the posted body, so we don't use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response that also lists which fields (subject/body/recipients/replyToId/attachments) were carried over. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state, and the returned `revision` reflects that authoritative state (so it will match on your next edit). FIELD PRESERVATION: the response echoes the effective threading (replyToId/inReplyTo) and, whenever OFW did not carry over a requested replyToId, recipient or attachment, a `warnings[]` entry naming what was dropped \u2014 never a silent null. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if its subject/body/recipients changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). A pure replyToId normalization by OFW is NOT treated as a conflict. The refusal returns the current server body under serverBody \u2014 merge your edit into it and retry with expectedRevision.",
39707
40027
  annotations: { readOnlyHint: false },
39708
40028
  inputSchema: {
39709
40029
  subject: external_exports.string().describe("Message subject"),
@@ -39758,32 +40078,67 @@ ${JSON.stringify(
39758
40078
  let replaceNote = null;
39759
40079
  let verifyNote = null;
39760
40080
  let newRevision = null;
40081
+ const warnings = [];
39761
40082
  if (newId !== null) {
39762
40083
  verifyNote = verifyWriteLanded("draft", { subject: args.subject, body: args.body }, detail);
40084
+ const effectiveReplyTo = detail.replyToId ?? null;
40085
+ const storedRecipients = mapRecipients(detail.recipients);
39763
40086
  persisted = {
39764
40087
  id: newId,
39765
40088
  subject: detail.subject ?? args.subject,
39766
40089
  body: detail.body ?? "",
39767
- recipients: mapRecipients(detail.recipients),
39768
- replyToId: detail.replyToId ?? resolvedReplyTo,
40090
+ recipients: storedRecipients,
40091
+ replyToId: effectiveReplyTo,
39769
40092
  modifiedAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
39770
40093
  listData: detail
39771
40094
  };
39772
40095
  await cache.upsertDraft(persisted);
39773
40096
  newRevision = draftRevision(persisted);
40097
+ if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
40098
+ const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : "";
40099
+ warnings.push(
40100
+ `replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo === null ? "null" : effectiveReplyTo} \u2014 OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped. If threading matters, verify on ourfamilywizard.com.`
40101
+ );
40102
+ }
40103
+ if (args.recipientIds !== void 0 && Array.isArray(detail.recipients)) {
40104
+ const requested = [...new Set(args.recipientIds)].sort((a, b) => a - b);
40105
+ const stored = [...new Set(storedRecipients.map((r) => r.userId))].sort((a, b) => a - b);
40106
+ if (requested.join(",") !== stored.join(",")) {
40107
+ warnings.push(
40108
+ `recipientIds were requested as [${requested.join(", ")}] but the saved draft has [${stored.join(", ")}]. Verify the recipients on ourfamilywizard.com.`
40109
+ );
40110
+ }
40111
+ }
40112
+ if (myFileIDs.length > 0 && Array.isArray(detail.files)) {
40113
+ const storedFiles = new Set(detail.files);
40114
+ const missing = myFileIDs.filter((id) => !storedFiles.has(id));
40115
+ if (missing.length > 0) {
40116
+ warnings.push(
40117
+ `Attachment fileId(s) ${missing.join(", ")} were requested in myFileIDs but are not attached to the saved draft. Re-upload or re-attach if needed.`
40118
+ );
40119
+ }
40120
+ }
39774
40121
  if (args.messageId !== void 0 && args.messageId !== newId) {
39775
40122
  try {
39776
40123
  await deleteOFWMessages(client2, [args.messageId]);
39777
40124
  await cache.deleteDraft(args.messageId);
39778
- replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.)`;
40125
+ replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.) Fields carried over to the new draft: subject, body, recipients (${persisted.recipients.length}), replyToId (${persisted.replyToId === null ? "none" : persisted.replyToId}), attachments (${myFileIDs.length}).${warnings.length > 0 ? " See warnings above for any field OurFamilyWizard did not carry over." : ""}`;
39779
40126
  } catch (e) {
39780
40127
  replaceNote = `WARNING: New draft ${newId} was created successfully, but the old draft ${args.messageId} could NOT be deleted: ${e.message}. BOTH drafts now exist on OurFamilyWizard and nothing was lost. Verify ${newId} reads correctly, then remove ${args.messageId} with ofw_delete_draft.`;
39781
40128
  }
39782
40129
  }
39783
40130
  }
39784
- const responseObj = persisted !== null ? { ...persisted, revision: newRevision, cacheStatus: "fresh" } : raw;
40131
+ const responseObj = persisted !== null ? {
40132
+ ...persisted,
40133
+ inReplyTo: persisted.replyToId,
40134
+ revision: newRevision,
40135
+ cacheStatus: "fresh",
40136
+ serverConfirmed: true,
40137
+ ...warnings.length > 0 ? { warnings } : {}
40138
+ } : raw;
39785
40139
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
39786
- const notes = [forceNote, rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join("\n\n");
40140
+ const warnNote = warnings.length > 0 ? `WARNING: ${warnings.join("\n\n")}` : null;
40141
+ const notes = [forceNote, rewriteNote, verifyNote, warnNote, replaceNote].filter((n) => n !== null).join("\n\n");
39787
40142
  return textResponse(notes ? `${notes}
39788
40143
 
39789
40144
  ${text}` : text);
@@ -39823,9 +40178,15 @@ ${text}` : text);
39823
40178
  }, async (args) => {
39824
40179
  const page = args.page ?? 1;
39825
40180
  const size = args.size ?? 50;
39826
- const sent = await cacheProvider().listMessages({ folder: "sent", page, size });
40181
+ const cache = cacheProvider();
40182
+ const sent = await cache.listMessages({ folder: "sent", page, size });
40183
+ const freshness = await buildFreshness(cache, { source: "cache", folders: ["sent"] });
39827
40184
  if (sent.length === 0) {
39828
- return jsonResponse({ note: "Sent cache is empty. Call ofw_sync_messages to populate." });
40185
+ return jsonResponse({
40186
+ unread: [],
40187
+ freshness,
40188
+ note: "Sent cache is empty. Call ofw_sync_messages to populate. An empty cache is NOT evidence that no sent messages exist."
40189
+ });
39829
40190
  }
39830
40191
  const unread = [];
39831
40192
  for (const msg of sent) {
@@ -39835,9 +40196,13 @@ ${text}` : text);
39835
40196
  }
39836
40197
  }
39837
40198
  if (unread.length === 0) {
39838
- return jsonResponse({ message: "All scanned sent messages have been read." });
40199
+ return jsonResponse({
40200
+ unread: [],
40201
+ freshness,
40202
+ message: "All scanned sent messages had been read as of the timestamp in `freshness.asOf`. A recipient may have read a message since without the cache hearing about it."
40203
+ });
39839
40204
  }
39840
- return jsonResponse(unread);
40205
+ return jsonResponse({ unread, freshness });
39841
40206
  });
39842
40207
  if (allowDrafts) server.registerTool("ofw_upload_attachment", {
39843
40208
  description: `Upload a local file to OurFamilyWizard's "My Files" so it can be attached to a message. Returns the fileId \u2014 pass that to ofw_send_message or ofw_save_draft in myFileIDs to attach it. The file is uploaded as PRIVATE (visible only to you) by default; pass shareClass:"SHARED" to share with co-parents directly via the My Files area.`,
@@ -39881,18 +40246,20 @@ ${text}` : text);
39881
40246
  });
39882
40247
  });
39883
40248
  server.registerTool("ofw_download_attachment", {
39884
- description: 'Download an OFW message attachment by fileId. By default, bytes are saved to disk (~/Downloads/ofw-mcp/) and the response carries the absolute path, mime type, and size for the caller to read back. Pass inline:true to skip disk entirely and return the bytes as MCP content blocks \u2014 images come back as ImageContent (the model sees them directly); other files come back as an EmbeddedResource blob. Use inline for small files where you want the model to read content immediately and the host is sandboxed; use disk for large files or when you want a persistent local copy. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var (set to "true" to make inline the default). fileId comes from attachments[].fileId on ofw_get_message. Override disk destination with OFW_ATTACHMENTS_DIR or saveTo. Re-downloading to the same path is a no-op (disk mode only).',
40249
+ description: 'Download an OFW message attachment by fileId. By default, bytes are saved to disk (~/Downloads/ofw-mcp/) and the response carries the absolute path, mime type, and size for the caller to read back. Pass inline:true to skip disk entirely and return the bytes as MCP content blocks \u2014 host-renderable images (PNG/JPEG/GIF/WEBP) come back as ImageContent (the model sees them directly); every other file comes back as an EmbeddedResource blob carrying the bytes. Reported mime types are always normalized to a bare media type (no charset/name parameters). Use inline for small files where you want the model to read content immediately and the host is sandboxed; use disk for large files or when you want a persistent local copy. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var (set to "true" to make inline the default). On a hosted deployment with no filesystem, disk mode is unavailable, so inline is forced (the response is marked forcedInline:true) rather than failing. fileId comes from attachments[].fileId on ofw_get_message. Override disk destination with OFW_ATTACHMENTS_DIR or saveTo. Re-downloading to the same path is a no-op (disk mode only).',
39885
40250
  annotations: { readOnlyHint: false },
39886
40251
  inputSchema: {
39887
40252
  fileId: external_exports.number().describe("Attachment file id (from ofw_get_message \u2192 attachments[].fileId)"),
39888
- inline: external_exports.boolean().describe("If true, return bytes inline as MCP content (image for image/*, embedded resource blob otherwise) and skip the disk write. If false, write to disk and return the path. If omitted, falls back to the OFW_INLINE_ATTACHMENTS env var (default: false = disk).").optional(),
39889
- saveTo: external_exports.string().describe("Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/Downloads/ofw-mcp/<fileId>-<filename>. Ignored when inline:true.").optional(),
40253
+ inline: external_exports.boolean().describe("If true, return bytes inline as MCP content (ImageContent for host-renderable images, embedded resource blob otherwise) and skip the disk write. If false, write to disk and return the path \u2014 except on a hosted deployment with no filesystem, where inline is forced (forcedInline:true) so the bytes are still returned. If omitted, falls back to the OFW_INLINE_ATTACHMENTS env var (default: false = disk).").optional(),
40254
+ saveTo: external_exports.string().describe("Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/Downloads/ofw-mcp/<fileId>-<filename>. Ignored when inline is in effect.").optional(),
39890
40255
  force: external_exports.boolean().describe("Re-download even if already on disk. Default false. Ignored when inline:true (inline always fetches fresh bytes, or reuses an on-disk copy if present).").optional()
39891
40256
  }
39892
40257
  }, async (args) => {
39893
40258
  const fileId = args.fileId;
39894
40259
  const cache = cacheProvider();
39895
- const inline = args.inline ?? getDefaultInlineAttachments();
40260
+ const requestedInline = args.inline ?? getDefaultInlineAttachments();
40261
+ const inline = requestedInline || !attachmentIO.supportsDisk;
40262
+ const forcedInline = inline && !requestedInline;
39896
40263
  let cached2 = await cache.getAttachment(fileId);
39897
40264
  if (!cached2) {
39898
40265
  await fetchAttachmentMeta(client2, fileId, 0, cache);
@@ -39901,36 +40268,39 @@ ${text}` : text);
39901
40268
  }
39902
40269
  if (inline) {
39903
40270
  let bytes = null;
39904
- let mimeType = cached2.mimeType;
39905
- let fileName = cached2.fileName;
40271
+ let headerMime = cached2.mimeType;
40272
+ let fileName2 = cached2.fileName;
39906
40273
  if (cached2.downloadedPath) {
39907
40274
  bytes = attachmentIO.readDownloaded(cached2.downloadedPath);
39908
40275
  }
39909
40276
  if (bytes === null) {
39910
40277
  const response2 = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
39911
40278
  bytes = response2.body;
39912
- mimeType = response2.contentType ?? cached2.mimeType;
39913
- fileName = response2.suggestedFileName ?? cached2.fileName;
40279
+ headerMime = response2.contentType ?? cached2.mimeType;
40280
+ fileName2 = response2.suggestedFileName ?? cached2.fileName;
39914
40281
  }
40282
+ const mimeType = resolveDownloadMime(bytes, headerMime, fileName2);
39915
40283
  const base643 = bytes.toString("base64");
39916
- const metaBlock = { type: "text", text: JSON.stringify({
40284
+ const meta3 = {
39917
40285
  fileId,
39918
- fileName,
40286
+ fileName: fileName2,
39919
40287
  mimeType,
39920
40288
  sizeBytes: bytes.length,
39921
40289
  mode: "inline"
39922
- }, null, 2) };
39923
- if (mimeType.startsWith("image/")) {
40290
+ };
40291
+ if (forcedInline) meta3.forcedInline = true;
40292
+ const metaBlock = { type: "text", text: JSON.stringify(meta3, null, 2) };
40293
+ if (isHostRenderableImage(mimeType)) {
39924
40294
  return { content: [metaBlock, { type: "image", data: base643, mimeType }] };
39925
40295
  }
39926
40296
  return { content: [metaBlock, { type: "resource", resource: {
39927
- uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName)}`,
40297
+ uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName2)}`,
39928
40298
  mimeType,
39929
40299
  blob: base643
39930
40300
  } }] };
39931
40301
  }
39932
40302
  let dest;
39933
- const safeName = basename(cached2.fileName);
40303
+ const safeName = basename2(cached2.fileName);
39934
40304
  if (args.saveTo) {
39935
40305
  const isDirArg = args.saveTo.endsWith("/") || args.saveTo.endsWith("\\");
39936
40306
  const abs = expandPath2(args.saveTo);
@@ -39940,9 +40310,12 @@ ${text}` : text);
39940
40310
  }
39941
40311
  if (!args.force && cached2.downloadedPath === dest) {
39942
40312
  return jsonResponse({
40313
+ // No bytes on hand for the no-op case: normalize the cached/extension
40314
+ // MIME (empty buffer sniffs nothing) so a stored `image/png;charset=…`
40315
+ // still reports bare.
39943
40316
  fileId,
39944
40317
  path: dest,
39945
- mimeType: cached2.mimeType,
40318
+ mimeType: resolveDownloadMime(Buffer.alloc(0), cached2.mimeType, cached2.fileName),
39946
40319
  sizeBytes: cached2.sizeBytes,
39947
40320
  fileName: cached2.fileName,
39948
40321
  note: "already downloaded"
@@ -39951,31 +40324,137 @@ ${text}` : text);
39951
40324
  const response = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
39952
40325
  attachmentIO.writeDownload(dest, response.body);
39953
40326
  await cache.markAttachmentDownloaded(fileId, dest);
40327
+ const fileName = response.suggestedFileName ?? cached2.fileName;
39954
40328
  return jsonResponse({
39955
40329
  fileId,
39956
40330
  path: dest,
39957
- mimeType: response.contentType ?? cached2.mimeType,
40331
+ mimeType: resolveDownloadMime(response.body, response.contentType ?? cached2.mimeType, fileName),
39958
40332
  sizeBytes: response.body.length,
39959
- fileName: response.suggestedFileName ?? cached2.fileName
40333
+ fileName
39960
40334
  });
39961
40335
  });
39962
40336
  server.registerTool("ofw_sync_messages", {
39963
40337
  description: "Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. EVERY call re-checks the newest page first, so new messages are picked up promptly even while an old-history backfill is still running; only then does it spend what is left of its budget advancing that backfill. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps). Sync is BOUNDED and RESUMABLE: on hosted deployments a per-call OFW-request budget (env OFW_SYNC_MAX_REQUESTS, or the maxRequests argument) caps how far one call walks; when the budget is hit the response reports done:false with a note \u2014 call again with the SAME arguments to resume. done:false means older history is still being backfilled; it does NOT mean recent messages are missing. Local installs are unbounded by default (done is always true).",
39964
40338
  annotations: { readOnlyHint: false },
39965
40339
  inputSchema: {
39966
- folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).describe("Folders to sync (default: all three)").optional(),
40340
+ folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).min(1).describe("Folders to sync (default: all three). Must be non-empty if given \u2014 an empty list would sync nothing while reporting success.").optional(),
39967
40341
  fetchUnreadBodies: external_exports.boolean().describe("If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.").optional(),
39968
40342
  deep: external_exports.boolean().describe("If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.").optional(),
39969
40343
  maxRequests: external_exports.number().int().min(1).describe("Maximum OFW requests this single call may make before pausing. When hit, the response reports done:false \u2014 call again with the same arguments to continue. Omit to use the server default (OFW_SYNC_MAX_REQUESTS, or unbounded on local installs).").optional()
39970
40344
  }
39971
40345
  }, async (args) => {
40346
+ const cache = cacheProvider();
39972
40347
  const result = await syncAll(client2, {
39973
40348
  folders: args.folders,
39974
40349
  fetchUnreadBodies: args.fetchUnreadBodies,
39975
40350
  deep: args.deep,
39976
40351
  maxRequests: args.maxRequests ?? getSyncMaxRequests()
39977
- }, cacheProvider());
39978
- return jsonResponse(result);
40352
+ }, cache);
40353
+ const freshness = await buildFreshness(cache, {
40354
+ source: "cache",
40355
+ folders: args.folders ?? ["inbox", "sent", "drafts"]
40356
+ });
40357
+ return jsonResponse({ ...result, freshness });
40358
+ });
40359
+ server.registerTool("ofw_check_freshness", {
40360
+ description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state \u2014 especially "draft X is still sitting unsent" \u2014 when a read returned serverConfirmed:false or freshness.staleness other than "fresh". Costs one OFW request for the folder check plus one per messageId. For each folder it returns the live server count next to the cached count; for each id, whether it still exists on OFW and whether its content matches the cache (compared by content revision, because OFW draft timestamps do NOT change when a draft is edited in the web app). Does not fetch bodies into the cache, does not touch attachments, and does not depend on sync state.',
40361
+ annotations: { readOnlyHint: true },
40362
+ inputSchema: {
40363
+ folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).min(1).describe("Folders to compare cached vs live counts for. Defaults to all three when messageIds is not given. Must be non-empty if given.").optional(),
40364
+ messageIds: external_exports.array(external_exports.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}). By default only ids present in the drafts cache are probed \u2014 see allowMarkRead.`).optional(),
40365
+ allowMarkRead: external_exports.boolean().describe("Default false. Probing an id that is NOT a cached draft requires fetching its detail, which marks an unread inbox message as READ on OurFamilyWizard \u2014 an irreversible change to the record. Such ids are skipped unless you set this to true.").optional()
40366
+ }
40367
+ }, async (args) => {
40368
+ const cache = cacheProvider();
40369
+ const allowMarkRead = args.allowMarkRead ?? false;
40370
+ const requestedIds = args.messageIds ?? [];
40371
+ const ids = requestedIds.slice(0, MAX_FRESHNESS_IDS);
40372
+ const wantFolders = args.folders ?? (requestedIds.length > 0 ? [] : ["inbox", "sent", "drafts"]);
40373
+ let requestsUsed = 0;
40374
+ const folders = [];
40375
+ if (wantFolders.length > 0) {
40376
+ requestsUsed++;
40377
+ const data = parseLenient(
40378
+ FolderCountsSchema,
40379
+ await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true"),
40380
+ { label: "ofw-mcp", context: "GET /pub/v1/messageFolders (ofw_check_freshness)" }
40381
+ );
40382
+ const sys = data.systemFolders ?? [];
40383
+ for (const folder of wantFolders) {
40384
+ const entry = sys.find((x) => x.folderType === FOLDER_TYPE[folder]);
40385
+ const serverCount = entry?.totalCount ?? entry?.messageCount ?? entry?.count ?? null;
40386
+ const cachedCount = folder === "drafts" ? (await cache.listDraftIds()).length : await cache.countMessages({ folder });
40387
+ const state = await cache.getSyncState(folder);
40388
+ const historyComplete = state !== null && state.resumePage === null;
40389
+ const inSync = serverCount === null || !historyComplete ? null : serverCount === cachedCount;
40390
+ folders.push({
40391
+ folder,
40392
+ existsOnServer: entry !== void 0,
40393
+ serverCount,
40394
+ cachedCount,
40395
+ historyComplete,
40396
+ lastVerifiedAt: await getFolderVerifiedAt(cache, folder),
40397
+ inSync,
40398
+ ...inSync === null ? { note: serverCount === null ? "OFW did not report a count for this folder, so cached-vs-server cannot be compared. Use the per-id check instead." : "Older history is still being backfilled, so a lower cachedCount is expected and does not indicate drift." } : {}
40399
+ });
40400
+ }
40401
+ }
40402
+ const items = [];
40403
+ for (const id of ids) {
40404
+ const cachedDraft = await cache.getDraft(id);
40405
+ if (cachedDraft === null && !allowMarkRead) {
40406
+ items.push({
40407
+ id,
40408
+ skipped: true,
40409
+ reason: "NOT_A_CACHED_DRAFT",
40410
+ note: "Not in the drafts cache. Verifying it requires fetching its detail from OFW, which would mark an unread inbox message as READ on OurFamilyWizard. Pass allowMarkRead:true if that is acceptable."
40411
+ });
40412
+ continue;
40413
+ }
40414
+ requestsUsed++;
40415
+ try {
40416
+ const server2 = await fetchServerDraft(client2, id);
40417
+ const cacheRevision = cachedDraft === null ? null : draftRevision(cachedDraft);
40418
+ if (server2 === null) {
40419
+ items.push({
40420
+ id,
40421
+ existsOnServer: false,
40422
+ inSync: false,
40423
+ cacheRevision,
40424
+ serverRevision: null,
40425
+ note: cachedDraft === null ? "Not found on OurFamilyWizard." : "This draft is in the local cache but NO LONGER EXISTS on OurFamilyWizard \u2014 it was sent or deleted elsewhere. Do not describe it as still unsent."
40426
+ });
40427
+ continue;
40428
+ }
40429
+ const serverRevision = draftRevision(server2);
40430
+ items.push({
40431
+ id,
40432
+ existsOnServer: true,
40433
+ cacheRevision,
40434
+ serverRevision,
40435
+ inSync: cacheRevision !== null && cacheRevision === serverRevision,
40436
+ ...cacheRevision === null ? { note: "Exists on OurFamilyWizard but is not in the local cache." } : cacheRevision !== serverRevision ? { note: "Content differs from the cache \u2014 it was edited on OurFamilyWizard since the last sync. Run ofw_sync_messages before reading or writing it." } : {}
40437
+ });
40438
+ } catch (e) {
40439
+ items.push({
40440
+ id,
40441
+ error: "FRESHNESS_CHECK_FAILED",
40442
+ message: e.message,
40443
+ inSync: null,
40444
+ note: "The freshness check itself failed, so nothing is confirmed either way."
40445
+ });
40446
+ }
40447
+ }
40448
+ const payload = {
40449
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
40450
+ requestsUsed,
40451
+ ...folders.length > 0 ? { folders } : {},
40452
+ ...items.length > 0 ? { items } : {}
40453
+ };
40454
+ if (requestedIds.length > ids.length) {
40455
+ payload.note = `Only the first ${MAX_FRESHNESS_IDS} of ${requestedIds.length} messageIds were checked (per-call cap). The remaining ${requestedIds.length - ids.length} were NOT verified \u2014 call again with the rest.`;
40456
+ }
40457
+ return jsonResponse(payload);
39979
40458
  });
39980
40459
  }
39981
40460
  async function deleteOFWMessages(client2, ids) {
@@ -40210,8 +40689,8 @@ function registerJournalTools(server, client2) {
40210
40689
 
40211
40690
  // src/cache/node.ts
40212
40691
  import { DatabaseSync } from "node:sqlite";
40213
- import { mkdirSync, chmodSync, existsSync } from "node:fs";
40214
- import { dirname as dirname2 } from "node:path";
40692
+ import { mkdirSync as mkdirSync2, chmodSync, existsSync } from "node:fs";
40693
+ import { dirname as dirname3 } from "node:path";
40215
40694
 
40216
40695
  // src/cache/store.ts
40217
40696
  function rowFromDb(r) {
@@ -40705,7 +41184,7 @@ var NodeSqlDriver = class {
40705
41184
  }
40706
41185
  };
40707
41186
  function enforceCachePermissions(dbPath) {
40708
- chmodSync(dirname2(dbPath), 448);
41187
+ chmodSync(dirname3(dbPath), 448);
40709
41188
  chmodSync(dbPath, 384);
40710
41189
  for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
40711
41190
  if (existsSync(sibling)) chmodSync(sibling, 384);
@@ -40719,7 +41198,7 @@ var OFWCache = class _OFWCache extends LocalCacheStore {
40719
41198
  db;
40720
41199
  static open(path) {
40721
41200
  const memory = path === ":memory:";
40722
- if (!memory) mkdirSync(dirname2(path), { recursive: true });
41201
+ if (!memory) mkdirSync2(dirname3(path), { recursive: true });
40723
41202
  const db = new DatabaseSync(path);
40724
41203
  if (!memory) enforceCachePermissions(path);
40725
41204
  db.exec("PRAGMA journal_mode = WAL");
@@ -40733,59 +41212,6 @@ var OFWCache = class _OFWCache extends LocalCacheStore {
40733
41212
  }
40734
41213
  };
40735
41214
 
40736
- // src/tools/attachments.ts
40737
- import { readFileSync, statSync, mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
40738
- import { basename as basename2, dirname as dirname3, extname } from "node:path";
40739
- var MIME_BY_EXT = {
40740
- ".pdf": "application/pdf",
40741
- ".png": "image/png",
40742
- ".jpg": "image/jpeg",
40743
- ".jpeg": "image/jpeg",
40744
- ".gif": "image/gif",
40745
- ".webp": "image/webp",
40746
- ".heic": "image/heic",
40747
- ".txt": "text/plain",
40748
- ".md": "text/markdown",
40749
- ".csv": "text/csv",
40750
- ".html": "text/html",
40751
- ".htm": "text/html",
40752
- ".json": "application/json",
40753
- ".xml": "application/xml",
40754
- ".doc": "application/msword",
40755
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
40756
- ".xls": "application/vnd.ms-excel",
40757
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
40758
- ".ppt": "application/vnd.ms-powerpoint",
40759
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
40760
- ".zip": "application/zip",
40761
- ".ics": "text/calendar"
40762
- };
40763
- function mimeFromName(name) {
40764
- return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
40765
- }
40766
- var NodeAttachmentIO = class {
40767
- async resolveUpload(path) {
40768
- const abs = expandPath(path);
40769
- const stat = statSync(abs);
40770
- if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
40771
- const fileName = basename2(abs);
40772
- const mimeType = mimeFromName(fileName);
40773
- const blob = await fileBlob(abs, { type: mimeType });
40774
- return { blob, fileName, mimeType, sizeBytes: stat.size };
40775
- }
40776
- readDownloaded(path) {
40777
- try {
40778
- return readFileSync(path);
40779
- } catch {
40780
- return null;
40781
- }
40782
- }
40783
- writeDownload(dest, bytes) {
40784
- mkdirSync2(dirname3(dest), { recursive: true });
40785
- writeFileSync(dest, bytes);
40786
- }
40787
- };
40788
-
40789
41215
  // src/index.ts
40790
41216
  var originalEmit = process.emit.bind(process);
40791
41217
  process.emit = function(event, ...args) {
@@ -40802,7 +41228,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
40802
41228
  var nodeAttachmentIO = new NodeAttachmentIO();
40803
41229
  await runMcp({
40804
41230
  name: "ofw",
40805
- version: "2.6.7",
41231
+ version: "2.7.1",
40806
41232
  // x-release-please-version
40807
41233
  deps: client,
40808
41234
  tools: [