ofw-mcp 2.6.6 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -1
- package/dist/bundle.js +720 -127
- package/dist/config.js +25 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +161 -23
- package/dist/tools/_shared.js +32 -13
- package/dist/tools/attachments.js +61 -0
- package/dist/tools/draft-freshness.js +166 -0
- package/dist/tools/freshness.js +147 -0
- package/dist/tools/messages.js +410 -40
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +16 -1
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.
|
|
38410
|
+
version: "2.7.0",
|
|
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)",
|
|
@@ -38692,6 +38692,9 @@ var client = new OFWClient();
|
|
|
38692
38692
|
// src/tools/_shared.ts
|
|
38693
38693
|
var jsonResponse = textResult;
|
|
38694
38694
|
var textResponse = rawTextResult;
|
|
38695
|
+
function jsonErrorResponse(data) {
|
|
38696
|
+
return { ...textResult(data), isError: true };
|
|
38697
|
+
}
|
|
38695
38698
|
var ApiRecipientSchema = external_exports.looseObject({
|
|
38696
38699
|
// Live OFW payloads key the recipient's id as `userId` (verified against a
|
|
38697
38700
|
// real /pub/v3/messages record: `recipients[].user.userId === 3039201`). An
|
|
@@ -38721,15 +38724,15 @@ function scrapeSaysRead(listData) {
|
|
|
38721
38724
|
const ld = listData;
|
|
38722
38725
|
return ld.read === true || ld.showNeverViewed === false;
|
|
38723
38726
|
}
|
|
38724
|
-
function deriveRead(row
|
|
38727
|
+
function deriveRead(row) {
|
|
38728
|
+
const viewedByAnyone = row.recipients.some((r) => r.viewedAt !== null);
|
|
38725
38729
|
if (row.folder === "inbox") {
|
|
38726
|
-
|
|
38727
|
-
return viewed || row.fetchedBodyAt !== null || scrapeSaysRead(row.listData);
|
|
38730
|
+
return viewedByAnyone || row.fetchedBodyAt !== null || scrapeSaysRead(row.listData);
|
|
38728
38731
|
}
|
|
38729
|
-
return
|
|
38732
|
+
return viewedByAnyone || scrapeSaysRead(row.listData);
|
|
38730
38733
|
}
|
|
38731
|
-
function withReadState(row
|
|
38732
|
-
const read = deriveRead(row
|
|
38734
|
+
function withReadState(row) {
|
|
38735
|
+
const read = deriveRead(row);
|
|
38733
38736
|
const listData = typeof row.listData === "object" && row.listData !== null ? { ...row.listData, read, showNeverViewed: !read } : row.listData;
|
|
38734
38737
|
return { ...row, read, listData };
|
|
38735
38738
|
}
|
|
@@ -38877,10 +38880,11 @@ async function walkPages(client2, folder, folderId, opts, store) {
|
|
|
38877
38880
|
let page = opts.startPage;
|
|
38878
38881
|
let newestId = null;
|
|
38879
38882
|
let synced = 0;
|
|
38883
|
+
let pagesFetched = 0;
|
|
38880
38884
|
const unread = [];
|
|
38881
38885
|
while (true) {
|
|
38882
38886
|
if (!budget.take()) {
|
|
38883
|
-
return { synced, unread, newestId, done: false, nextPage: page };
|
|
38887
|
+
return { synced, unread, newestId, pagesFetched, done: false, nextPage: page };
|
|
38884
38888
|
}
|
|
38885
38889
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
38886
38890
|
const list = parseLenient(
|
|
@@ -38888,9 +38892,10 @@ async function walkPages(client2, folder, folderId, opts, store) {
|
|
|
38888
38892
|
await client2.request("GET", path),
|
|
38889
38893
|
{ label: "ofw-mcp", context: `GET /pub/v3/messages?folders={${folder}}` }
|
|
38890
38894
|
);
|
|
38895
|
+
pagesFetched++;
|
|
38891
38896
|
const items = list.data ?? [];
|
|
38892
38897
|
if (items.length === 0) {
|
|
38893
|
-
return { synced, unread, newestId, done: true, nextPage: null };
|
|
38898
|
+
return { synced, unread, newestId, pagesFetched, done: true, nextPage: null };
|
|
38894
38899
|
}
|
|
38895
38900
|
const existingById = new Map(
|
|
38896
38901
|
(await store.getMessages(items.map((it) => it.id))).map((row) => [row.id, row])
|
|
@@ -38969,10 +38974,10 @@ async function walkPages(client2, folder, folderId, opts, store) {
|
|
|
38969
38974
|
}
|
|
38970
38975
|
await store.upsertMessages(toUpsert);
|
|
38971
38976
|
if (pageBudgetHit) {
|
|
38972
|
-
return { synced, unread, newestId, done: false, nextPage: page };
|
|
38977
|
+
return { synced, unread, newestId, pagesFetched, done: false, nextPage: page };
|
|
38973
38978
|
}
|
|
38974
38979
|
if (opts.stopAtCachedPage && !pageHadNewItem) {
|
|
38975
|
-
return { synced, unread, newestId, done: true, nextPage: page };
|
|
38980
|
+
return { synced, unread, newestId, pagesFetched, done: true, nextPage: page };
|
|
38976
38981
|
}
|
|
38977
38982
|
page++;
|
|
38978
38983
|
}
|
|
@@ -38994,7 +38999,11 @@ async function syncMessageFolder(client2, folder, folderId, opts, store) {
|
|
|
38994
38999
|
let resumePage;
|
|
38995
39000
|
if (!fwd.done) {
|
|
38996
39001
|
done = false;
|
|
38997
|
-
|
|
39002
|
+
if (fwd.pagesFetched === 0) {
|
|
39003
|
+
resumePage = savedResume;
|
|
39004
|
+
} else {
|
|
39005
|
+
resumePage = savedResume === null ? fwd.nextPage : Math.min(fwd.nextPage, savedResume);
|
|
39006
|
+
}
|
|
38998
39007
|
} else if (fwd.nextPage === null) {
|
|
38999
39008
|
done = true;
|
|
39000
39009
|
resumePage = null;
|
|
@@ -39014,12 +39023,10 @@ async function syncMessageFolder(client2, folder, folderId, opts, store) {
|
|
|
39014
39023
|
done = bf.done;
|
|
39015
39024
|
resumePage = bf.done ? null : bf.nextPage;
|
|
39016
39025
|
}
|
|
39017
|
-
|
|
39018
|
-
|
|
39019
|
-
|
|
39020
|
-
|
|
39021
|
-
});
|
|
39022
|
-
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 };
|
|
39023
39030
|
}
|
|
39024
39031
|
var DraftListItemSchema = external_exports.looseObject({
|
|
39025
39032
|
id: external_exports.number(),
|
|
@@ -39033,12 +39040,37 @@ var DraftDetailSchema = external_exports.looseObject({
|
|
|
39033
39040
|
body: external_exports.string().optional(),
|
|
39034
39041
|
subject: external_exports.string().optional()
|
|
39035
39042
|
});
|
|
39043
|
+
var DRAFTS_CACHE_STATUS_KEY = "drafts_cache_status";
|
|
39044
|
+
async function getDraftsCacheStatus(store) {
|
|
39045
|
+
return await store.getMeta(DRAFTS_CACHE_STATUS_KEY) === "fresh" ? "fresh" : "unverified";
|
|
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
|
+
}
|
|
39036
39059
|
async function syncDrafts(client2, draftsFolderId, store, budget) {
|
|
39037
39060
|
const b = budget ?? makeBudget(Number.POSITIVE_INFINITY);
|
|
39061
|
+
const defer = async () => {
|
|
39062
|
+
await setDraftsCacheStatus(store, "unverified");
|
|
39063
|
+
await store.setSyncState("drafts", {
|
|
39064
|
+
lastSyncAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
39065
|
+
newestId: null,
|
|
39066
|
+
resumePage: null
|
|
39067
|
+
});
|
|
39068
|
+
return { synced: 0, done: false };
|
|
39069
|
+
};
|
|
39038
39070
|
const items = [];
|
|
39039
39071
|
let page = 1;
|
|
39040
39072
|
while (true) {
|
|
39041
|
-
if (!b.take()) return
|
|
39073
|
+
if (!b.take()) return defer();
|
|
39042
39074
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
39043
39075
|
const list = parseLenient(
|
|
39044
39076
|
DraftListResponseSchema,
|
|
@@ -39052,7 +39084,7 @@ async function syncDrafts(client2, draftsFolderId, store, budget) {
|
|
|
39052
39084
|
}
|
|
39053
39085
|
const rows = [];
|
|
39054
39086
|
for (const item of items) {
|
|
39055
|
-
if (!b.take()) return
|
|
39087
|
+
if (!b.take()) return defer();
|
|
39056
39088
|
const detail = parseLenient(
|
|
39057
39089
|
DraftDetailSchema,
|
|
39058
39090
|
await client2.request("GET", `/pub/v3/messages/${item.id}`),
|
|
@@ -39085,16 +39117,35 @@ async function syncDrafts(client2, draftsFolderId, store, budget) {
|
|
|
39085
39117
|
for (const id of await store.listDraftIds()) {
|
|
39086
39118
|
if (!seenIds.has(id)) await store.deleteDraft(id);
|
|
39087
39119
|
}
|
|
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);
|
|
39088
39124
|
return { synced, done: true };
|
|
39089
39125
|
}
|
|
39090
39126
|
async function syncAll(client2, opts, store) {
|
|
39091
|
-
const
|
|
39127
|
+
const requested = opts.folders ?? ["inbox", "sent", "drafts"];
|
|
39128
|
+
const folders = [
|
|
39129
|
+
...requested.filter((f) => f === "drafts"),
|
|
39130
|
+
...requested.filter((f) => f !== "drafts")
|
|
39131
|
+
];
|
|
39092
39132
|
const budget = makeBudget(opts.maxRequests ?? Number.POSITIVE_INFINITY);
|
|
39093
39133
|
budget.take();
|
|
39094
39134
|
const ids = await resolveFolderIds(client2, store);
|
|
39095
39135
|
const synced = {};
|
|
39096
39136
|
let unreadInbox = [];
|
|
39097
39137
|
let done = true;
|
|
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
|
+
};
|
|
39098
39149
|
for (const folder of folders) {
|
|
39099
39150
|
if (folder === "inbox") {
|
|
39100
39151
|
const r = await syncMessageFolder(client2, "inbox", ids.inbox, {
|
|
@@ -39102,7 +39153,7 @@ async function syncAll(client2, opts, store) {
|
|
|
39102
39153
|
deep: opts.deep ?? false,
|
|
39103
39154
|
budget
|
|
39104
39155
|
}, store);
|
|
39105
|
-
|
|
39156
|
+
record2("inbox", r.verified, r.synced);
|
|
39106
39157
|
unreadInbox = r.unread;
|
|
39107
39158
|
if (!r.done) done = false;
|
|
39108
39159
|
} else if (folder === "sent") {
|
|
@@ -39111,23 +39162,40 @@ async function syncAll(client2, opts, store) {
|
|
|
39111
39162
|
deep: opts.deep ?? false,
|
|
39112
39163
|
budget
|
|
39113
39164
|
}, store);
|
|
39114
|
-
|
|
39165
|
+
record2("sent", r.verified, r.synced);
|
|
39115
39166
|
if (!r.done) done = false;
|
|
39116
39167
|
} else if (folder === "drafts") {
|
|
39117
39168
|
const r = await syncDrafts(client2, ids.drafts, store, budget);
|
|
39118
|
-
|
|
39119
|
-
if (!r.done)
|
|
39169
|
+
record2("drafts", r.done, r.synced);
|
|
39170
|
+
if (!r.done) {
|
|
39171
|
+
draftsUnverified = true;
|
|
39172
|
+
done = false;
|
|
39173
|
+
}
|
|
39120
39174
|
}
|
|
39121
39175
|
}
|
|
39122
39176
|
const notes = [];
|
|
39177
|
+
if (draftsUnverified) {
|
|
39178
|
+
notes.push('The drafts folder was NOT checked against OurFamilyWizard on this call (the request budget ran out first), so no drafts count is reported and the cached drafts are marked "unverified". Cached draft bodies may be behind the server. Call ofw_sync_messages again \u2014 or ofw_sync_messages with folders:["drafts"] \u2014 before editing or deleting a draft.');
|
|
39179
|
+
}
|
|
39123
39180
|
if (unreadInbox.length > 0) {
|
|
39124
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.`);
|
|
39125
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
|
+
}
|
|
39126
39186
|
if (!done) {
|
|
39127
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.");
|
|
39128
39188
|
}
|
|
39129
39189
|
const note = notes.length > 0 ? notes.join("\n\n") : void 0;
|
|
39130
|
-
return {
|
|
39190
|
+
return {
|
|
39191
|
+
synced,
|
|
39192
|
+
unreadInbox,
|
|
39193
|
+
done,
|
|
39194
|
+
syncComplete: done,
|
|
39195
|
+
refreshed,
|
|
39196
|
+
notRefreshed,
|
|
39197
|
+
...note ? { note } : {}
|
|
39198
|
+
};
|
|
39131
39199
|
}
|
|
39132
39200
|
|
|
39133
39201
|
// src/config.ts
|
|
@@ -39177,9 +39245,318 @@ function getSyncMaxRequests() {
|
|
|
39177
39245
|
if (!Number.isInteger(n) || n <= 0) return Number.POSITIVE_INFINITY;
|
|
39178
39246
|
return n;
|
|
39179
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;
|
|
39359
|
+
}
|
|
39360
|
+
|
|
39361
|
+
// src/tools/draft-freshness.ts
|
|
39362
|
+
var DraftFreshnessError = class extends Error {
|
|
39363
|
+
};
|
|
39364
|
+
var FNV_OFFSET = 0xcbf29ce484222325n;
|
|
39365
|
+
var FNV_PRIME = 0x100000001b3n;
|
|
39366
|
+
var MASK64 = 0xffffffffffffffffn;
|
|
39367
|
+
function fnv1a64(s) {
|
|
39368
|
+
let h = FNV_OFFSET;
|
|
39369
|
+
for (let i = 0; i < s.length; i++) {
|
|
39370
|
+
h = (h ^ BigInt(s.charCodeAt(i))) * FNV_PRIME & MASK64;
|
|
39371
|
+
}
|
|
39372
|
+
return h.toString(16).padStart(16, "0");
|
|
39373
|
+
}
|
|
39374
|
+
function draftRevision(d) {
|
|
39375
|
+
const ids = [...new Set(d.recipients.map((r) => r.userId))].sort((a, b) => a - b);
|
|
39376
|
+
const parts = [d.subject, d.body, String(d.replyToId ?? ""), ids.join(",")];
|
|
39377
|
+
return `r1:${fnv1a64(parts.map((p) => `${p.length}:${p}`).join("|"))}`;
|
|
39378
|
+
}
|
|
39379
|
+
var ServerDraftSchema = external_exports.looseObject({
|
|
39380
|
+
subject: external_exports.string().optional(),
|
|
39381
|
+
body: external_exports.string().optional(),
|
|
39382
|
+
replyToId: external_exports.number().nullable().optional(),
|
|
39383
|
+
recipients: external_exports.array(ApiRecipientSchema).optional()
|
|
39384
|
+
});
|
|
39385
|
+
function isNotFound(e) {
|
|
39386
|
+
return e instanceof Error && /OFW API error: 404\b/.test(e.message);
|
|
39387
|
+
}
|
|
39388
|
+
async function fetchServerDraft(client2, id) {
|
|
39389
|
+
let raw;
|
|
39390
|
+
try {
|
|
39391
|
+
raw = await client2.request("GET", `/pub/v3/messages/${id}`);
|
|
39392
|
+
} catch (e) {
|
|
39393
|
+
if (isNotFound(e)) return null;
|
|
39394
|
+
throw new DraftFreshnessError(
|
|
39395
|
+
`could not read the current state of draft ${id} from OurFamilyWizard: ${e.message}`
|
|
39396
|
+
);
|
|
39397
|
+
}
|
|
39398
|
+
if (raw === null || raw === void 0) return null;
|
|
39399
|
+
const detail = parseLenient(ServerDraftSchema, raw, {
|
|
39400
|
+
label: "ofw-mcp",
|
|
39401
|
+
context: "GET /pub/v3/messages/{id} (draft freshness check)",
|
|
39402
|
+
mode: "strict"
|
|
39403
|
+
});
|
|
39404
|
+
return {
|
|
39405
|
+
subject: detail.subject ?? "",
|
|
39406
|
+
body: detail.body ?? "",
|
|
39407
|
+
replyToId: detail.replyToId ?? null,
|
|
39408
|
+
recipients: mapRecipients(detail.recipients)
|
|
39409
|
+
};
|
|
39410
|
+
}
|
|
39411
|
+
function diffFields(a, b) {
|
|
39412
|
+
const changed = [];
|
|
39413
|
+
if (a.subject !== b.subject) changed.push("subject");
|
|
39414
|
+
if (a.body !== b.body) changed.push("body");
|
|
39415
|
+
if (a.replyToId !== b.replyToId) changed.push("replyToId");
|
|
39416
|
+
const ids = (d) => [...new Set(d.recipients.map((r) => r.userId))].sort((x, y) => x - y).join(",");
|
|
39417
|
+
if (ids(a) !== ids(b)) changed.push("recipients");
|
|
39418
|
+
return changed;
|
|
39419
|
+
}
|
|
39420
|
+
function checkDraftFreshness(input) {
|
|
39421
|
+
const { server, cached: cached2, expectedRevision } = input;
|
|
39422
|
+
if (server === null) {
|
|
39423
|
+
return {
|
|
39424
|
+
verdict: "MISSING",
|
|
39425
|
+
reason: "The draft no longer exists on OurFamilyWizard \u2014 it may have been sent or deleted elsewhere.",
|
|
39426
|
+
changedFields: []
|
|
39427
|
+
};
|
|
39428
|
+
}
|
|
39429
|
+
if (expectedRevision !== void 0) {
|
|
39430
|
+
const actual = draftRevision(server);
|
|
39431
|
+
if (expectedRevision === actual) {
|
|
39432
|
+
return { verdict: "FRESH", reason: "expectedRevision matches the live server draft.", changedFields: [] };
|
|
39433
|
+
}
|
|
39434
|
+
return {
|
|
39435
|
+
verdict: "STALE",
|
|
39436
|
+
reason: `expectedRevision ${expectedRevision} does not match the live server draft (${actual}) \u2014 it changed after you read it.`,
|
|
39437
|
+
changedFields: cached2 === null ? [] : diffFields(server, cached2)
|
|
39438
|
+
};
|
|
39439
|
+
}
|
|
39440
|
+
if (cached2 === null) {
|
|
39441
|
+
return {
|
|
39442
|
+
verdict: "STALE",
|
|
39443
|
+
reason: "This draft is not in the local cache, so there is no base to confirm the edit against.",
|
|
39444
|
+
changedFields: []
|
|
39445
|
+
};
|
|
39446
|
+
}
|
|
39447
|
+
const changedFields = diffFields(server, cached2);
|
|
39448
|
+
if (changedFields.length === 0) {
|
|
39449
|
+
return { verdict: "FRESH", reason: "The cached draft matches the live server draft.", changedFields: [] };
|
|
39450
|
+
}
|
|
39451
|
+
return {
|
|
39452
|
+
verdict: "STALE",
|
|
39453
|
+
reason: `The draft on OurFamilyWizard differs from the cached copy (${changedFields.join(", ")}) \u2014 it was edited outside this tool.`,
|
|
39454
|
+
changedFields
|
|
39455
|
+
};
|
|
39456
|
+
}
|
|
39457
|
+
function staleDraftPayload(input) {
|
|
39458
|
+
const { error: error51, draftId, verdict, server, cached: cached2 } = input;
|
|
39459
|
+
return {
|
|
39460
|
+
error: error51,
|
|
39461
|
+
draftId,
|
|
39462
|
+
verdict: verdict.verdict,
|
|
39463
|
+
reason: verdict.reason,
|
|
39464
|
+
...verdict.changedFields.length > 0 ? { changedFields: verdict.changedFields } : {},
|
|
39465
|
+
...server !== null ? { serverBody: server.body, serverSubject: server.subject, serverRevision: draftRevision(server) } : {},
|
|
39466
|
+
...cached2 !== null ? { cachedBody: cached2.body } : {},
|
|
39467
|
+
recovery: server === null ? "The draft is gone from OurFamilyWizard. Nothing was changed. If you still want this content saved, call ofw_save_draft WITHOUT messageId to create a new draft." : "Nothing was changed. Merge your edit into serverBody above, then retry with expectedRevision set to serverRevision. Pass force:true only if you intend to discard the server copy shown here."
|
|
39468
|
+
};
|
|
39469
|
+
}
|
|
39470
|
+
|
|
39471
|
+
// src/tools/attachments.ts
|
|
39472
|
+
import { readFileSync, statSync, mkdirSync, writeFileSync } from "node:fs";
|
|
39473
|
+
import { basename, dirname as dirname2, extname } from "node:path";
|
|
39474
|
+
var MIME_BY_EXT = {
|
|
39475
|
+
".pdf": "application/pdf",
|
|
39476
|
+
".png": "image/png",
|
|
39477
|
+
".jpg": "image/jpeg",
|
|
39478
|
+
".jpeg": "image/jpeg",
|
|
39479
|
+
".gif": "image/gif",
|
|
39480
|
+
".webp": "image/webp",
|
|
39481
|
+
".heic": "image/heic",
|
|
39482
|
+
".txt": "text/plain",
|
|
39483
|
+
".md": "text/markdown",
|
|
39484
|
+
".csv": "text/csv",
|
|
39485
|
+
".html": "text/html",
|
|
39486
|
+
".htm": "text/html",
|
|
39487
|
+
".json": "application/json",
|
|
39488
|
+
".xml": "application/xml",
|
|
39489
|
+
".doc": "application/msword",
|
|
39490
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
39491
|
+
".xls": "application/vnd.ms-excel",
|
|
39492
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
39493
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
39494
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
39495
|
+
".zip": "application/zip",
|
|
39496
|
+
".ics": "text/calendar"
|
|
39497
|
+
};
|
|
39498
|
+
function mimeFromName(name) {
|
|
39499
|
+
return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
|
|
39500
|
+
}
|
|
39501
|
+
var OCTET_STREAM = "application/octet-stream";
|
|
39502
|
+
var HOST_RENDERABLE_IMAGE_MIMES = /* @__PURE__ */ new Set([
|
|
39503
|
+
"image/png",
|
|
39504
|
+
"image/jpeg",
|
|
39505
|
+
"image/gif",
|
|
39506
|
+
"image/webp"
|
|
39507
|
+
]);
|
|
39508
|
+
function normalizeMimeType(raw) {
|
|
39509
|
+
if (!raw) return OCTET_STREAM;
|
|
39510
|
+
const bare = raw.split(";", 1)[0].trim().toLowerCase();
|
|
39511
|
+
return bare || OCTET_STREAM;
|
|
39512
|
+
}
|
|
39513
|
+
var PNG_MAGIC = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
39514
|
+
var JPEG_MAGIC = Buffer.from([255, 216, 255]);
|
|
39515
|
+
function sniffImageMime(bytes) {
|
|
39516
|
+
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(PNG_MAGIC)) return "image/png";
|
|
39517
|
+
if (bytes.length >= 3 && bytes.subarray(0, 3).equals(JPEG_MAGIC)) return "image/jpeg";
|
|
39518
|
+
if (bytes.length >= 6 && bytes.toString("ascii", 0, 4) === "GIF8") return "image/gif";
|
|
39519
|
+
if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") {
|
|
39520
|
+
return "image/webp";
|
|
39521
|
+
}
|
|
39522
|
+
return null;
|
|
39523
|
+
}
|
|
39524
|
+
function resolveDownloadMime(bytes, headerMime, fileName) {
|
|
39525
|
+
const sniffed = sniffImageMime(bytes);
|
|
39526
|
+
if (sniffed) return sniffed;
|
|
39527
|
+
const fromHeader = normalizeMimeType(headerMime);
|
|
39528
|
+
if (fromHeader !== OCTET_STREAM) return fromHeader;
|
|
39529
|
+
return mimeFromName(fileName);
|
|
39530
|
+
}
|
|
39531
|
+
function isHostRenderableImage(mime) {
|
|
39532
|
+
return HOST_RENDERABLE_IMAGE_MIMES.has(mime);
|
|
39533
|
+
}
|
|
39534
|
+
var NodeAttachmentIO = class {
|
|
39535
|
+
supportsDisk = true;
|
|
39536
|
+
async resolveUpload(path) {
|
|
39537
|
+
const abs = expandPath(path);
|
|
39538
|
+
const stat = statSync(abs);
|
|
39539
|
+
if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
|
|
39540
|
+
const fileName = basename(abs);
|
|
39541
|
+
const mimeType = mimeFromName(fileName);
|
|
39542
|
+
const blob = await fileBlob(abs, { type: mimeType });
|
|
39543
|
+
return { blob, fileName, mimeType, sizeBytes: stat.size };
|
|
39544
|
+
}
|
|
39545
|
+
readDownloaded(path) {
|
|
39546
|
+
try {
|
|
39547
|
+
return readFileSync(path);
|
|
39548
|
+
} catch {
|
|
39549
|
+
return null;
|
|
39550
|
+
}
|
|
39551
|
+
}
|
|
39552
|
+
writeDownload(dest, bytes) {
|
|
39553
|
+
mkdirSync(dirname2(dest), { recursive: true });
|
|
39554
|
+
writeFileSync(dest, bytes);
|
|
39555
|
+
}
|
|
39556
|
+
};
|
|
39180
39557
|
|
|
39181
39558
|
// src/tools/messages.ts
|
|
39182
|
-
import { basename, join as join5 } from "node:path";
|
|
39559
|
+
import { basename as basename2, join as join5 } from "node:path";
|
|
39183
39560
|
var DateSchema = external_exports.looseObject({ dateTime: external_exports.string() });
|
|
39184
39561
|
var SentDetailSchema = external_exports.looseObject({
|
|
39185
39562
|
subject: external_exports.string().optional(),
|
|
@@ -39209,6 +39586,21 @@ var MessageDetailSchema = external_exports.looseObject({
|
|
|
39209
39586
|
folder: external_exports.looseObject({ id: external_exports.number() }).optional()
|
|
39210
39587
|
});
|
|
39211
39588
|
var DetailFilesSchema = external_exports.looseObject({ files: external_exports.array(external_exports.number()).optional() });
|
|
39589
|
+
var FolderCountsSchema = external_exports.looseObject({
|
|
39590
|
+
systemFolders: external_exports.array(external_exports.looseObject({
|
|
39591
|
+
id: external_exports.string(),
|
|
39592
|
+
folderType: external_exports.string(),
|
|
39593
|
+
totalCount: external_exports.number().optional(),
|
|
39594
|
+
messageCount: external_exports.number().optional(),
|
|
39595
|
+
count: external_exports.number().optional()
|
|
39596
|
+
})).optional()
|
|
39597
|
+
});
|
|
39598
|
+
var FOLDER_TYPE = {
|
|
39599
|
+
inbox: "INBOX",
|
|
39600
|
+
sent: "SENT_MESSAGES",
|
|
39601
|
+
drafts: "DRAFTS"
|
|
39602
|
+
};
|
|
39603
|
+
var MAX_FRESHNESS_IDS = 25;
|
|
39212
39604
|
var UploadedFileSchema = external_exports.looseObject({
|
|
39213
39605
|
fileId: external_exports.number(),
|
|
39214
39606
|
fileName: external_exports.string().optional(),
|
|
@@ -39224,16 +39616,23 @@ function listDataHintsAtFiles(listData) {
|
|
|
39224
39616
|
if (Array.isArray(ld.files)) return ld.files.length > 0;
|
|
39225
39617
|
return false;
|
|
39226
39618
|
}
|
|
39619
|
+
async function draftsFreshness(cache) {
|
|
39620
|
+
const freshness = await buildFreshness(cache, { source: "cache", folders: ["drafts"] });
|
|
39621
|
+
const completed = await getDraftsCacheStatus(cache);
|
|
39622
|
+
const cacheStatus = completed === "fresh" && freshness.staleness === "fresh" ? "fresh" : "unverified";
|
|
39623
|
+
return { freshness, serverConfirmed: cacheStatus === "fresh", cacheStatus };
|
|
39624
|
+
}
|
|
39227
39625
|
function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
39228
39626
|
const writeMode = getWriteMode();
|
|
39229
39627
|
const allowSend = writeMode === "all";
|
|
39230
39628
|
const allowDrafts = writeMode !== "none";
|
|
39231
39629
|
server.registerTool("ofw_list_message_folders", {
|
|
39232
|
-
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.",
|
|
39630
|
+
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.",
|
|
39233
39631
|
annotations: { readOnlyHint: true }
|
|
39234
39632
|
}, async () => {
|
|
39235
39633
|
const data = await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true");
|
|
39236
|
-
|
|
39634
|
+
const freshness = await buildFreshness(cacheProvider(), { source: "live", folders: [] });
|
|
39635
|
+
return jsonResponse({ folders: data, freshness });
|
|
39237
39636
|
});
|
|
39238
39637
|
server.registerTool("ofw_list_messages", {
|
|
39239
39638
|
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.",
|
|
@@ -39257,14 +39656,22 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39257
39656
|
else {
|
|
39258
39657
|
return jsonResponse({
|
|
39259
39658
|
messages: [],
|
|
39260
|
-
|
|
39659
|
+
freshness: await buildFreshness(cacheProvider(), {
|
|
39660
|
+
source: "cache",
|
|
39661
|
+
folders: ["inbox", "sent"]
|
|
39662
|
+
}),
|
|
39663
|
+
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.'
|
|
39261
39664
|
});
|
|
39262
39665
|
}
|
|
39263
39666
|
const cache = cacheProvider();
|
|
39264
39667
|
const filter = { folder, since: args.since, until: args.until, q: args.q };
|
|
39265
39668
|
const total = await cache.countMessages(filter);
|
|
39266
39669
|
const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
|
|
39267
|
-
const
|
|
39670
|
+
const freshness = await buildFreshness(cache, {
|
|
39671
|
+
source: "cache",
|
|
39672
|
+
folders: folder === void 0 ? ["inbox", "sent"] : [folder]
|
|
39673
|
+
});
|
|
39674
|
+
const payload = { messages, total, page, size, freshness };
|
|
39268
39675
|
if (total === 0) {
|
|
39269
39676
|
payload.note = "No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.";
|
|
39270
39677
|
} else if (page * size < total) {
|
|
@@ -39283,6 +39690,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39283
39690
|
const cache = cacheProvider();
|
|
39284
39691
|
const draftRow = await cache.getDraft(id);
|
|
39285
39692
|
if (draftRow !== null) {
|
|
39693
|
+
const { freshness: freshness2, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
|
|
39286
39694
|
return jsonResponse({
|
|
39287
39695
|
id: draftRow.id,
|
|
39288
39696
|
folder: "drafts",
|
|
@@ -39298,7 +39706,16 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39298
39706
|
replyToId: draftRow.replyToId,
|
|
39299
39707
|
chainRootId: null,
|
|
39300
39708
|
listData: draftRow.listData,
|
|
39301
|
-
attachments: []
|
|
39709
|
+
attachments: [],
|
|
39710
|
+
// Concurrency token — pass as expectedRevision to ofw_save_draft /
|
|
39711
|
+
// ofw_delete_draft to assert you are editing THIS version.
|
|
39712
|
+
revision: draftRevision(draftRow),
|
|
39713
|
+
cacheStatus,
|
|
39714
|
+
// False = this draft's existence and unsent status are remembered from
|
|
39715
|
+
// a cache, not confirmed on OFW. Call ofw_check_freshness before
|
|
39716
|
+
// stating either as current fact.
|
|
39717
|
+
serverConfirmed,
|
|
39718
|
+
freshness: freshness2
|
|
39302
39719
|
});
|
|
39303
39720
|
}
|
|
39304
39721
|
const cached2 = await cache.getMessage(id);
|
|
@@ -39336,7 +39753,8 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39336
39753
|
} catch {
|
|
39337
39754
|
}
|
|
39338
39755
|
}
|
|
39339
|
-
|
|
39756
|
+
const freshness2 = await buildFreshness(cache, { source: "cache", folders: [row2.folder] });
|
|
39757
|
+
return jsonResponse({ ...withReadState(row2), attachments: attachments2, freshness: freshness2 });
|
|
39340
39758
|
}
|
|
39341
39759
|
const detail = parseLenient(
|
|
39342
39760
|
MessageDetailSchema,
|
|
@@ -39368,7 +39786,8 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39368
39786
|
await fetchAttachmentMetaForMessage(client2, detail.id, detail.files, cache);
|
|
39369
39787
|
}
|
|
39370
39788
|
const attachments = await cache.listAttachmentsForMessage(detail.id);
|
|
39371
|
-
|
|
39789
|
+
const freshness = await buildFreshness(cache, { source: "live", folders: [folder] });
|
|
39790
|
+
return jsonResponse({ ...withReadState(row), attachments, freshness });
|
|
39372
39791
|
});
|
|
39373
39792
|
if (allowSend) server.registerTool("ofw_send_message", {
|
|
39374
39793
|
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.",
|
|
@@ -39488,6 +39907,60 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39488
39907
|
|
|
39489
39908
|
${text}` : text);
|
|
39490
39909
|
});
|
|
39910
|
+
async function guardDestructiveDraftOp(input) {
|
|
39911
|
+
const { cache, draftId, expectedRevision, force, action } = input;
|
|
39912
|
+
const cachedRow = await cache.getDraft(draftId);
|
|
39913
|
+
const cached2 = cachedRow === null ? null : {
|
|
39914
|
+
subject: cachedRow.subject,
|
|
39915
|
+
body: cachedRow.body,
|
|
39916
|
+
recipients: cachedRow.recipients,
|
|
39917
|
+
replyToId: cachedRow.replyToId
|
|
39918
|
+
};
|
|
39919
|
+
let server2;
|
|
39920
|
+
try {
|
|
39921
|
+
server2 = await fetchServerDraft(client2, draftId);
|
|
39922
|
+
} catch (e) {
|
|
39923
|
+
const reason = e.message;
|
|
39924
|
+
if (force) {
|
|
39925
|
+
return { ok: true, note: `WARNING: force:true \u2014 proceeded with ${action} on draft ${draftId} even though its current state could not be read from OurFamilyWizard (${reason}). Any newer server-side version was destroyed and is NOT recoverable from this response.` };
|
|
39926
|
+
}
|
|
39927
|
+
return {
|
|
39928
|
+
ok: false,
|
|
39929
|
+
response: jsonErrorResponse({
|
|
39930
|
+
error: "FRESHNESS_CHECK_FAILED",
|
|
39931
|
+
draftId,
|
|
39932
|
+
reason,
|
|
39933
|
+
recovery: "Nothing was changed. This is usually transient \u2014 retry. If it persists, verify the draft on ourfamilywizard.com. Pass force:true only if you accept overwriting a version you have not seen."
|
|
39934
|
+
})
|
|
39935
|
+
};
|
|
39936
|
+
}
|
|
39937
|
+
const verdict = checkDraftFreshness({ server: server2, cached: cached2, expectedRevision });
|
|
39938
|
+
if (verdict.verdict === "FRESH") return { ok: true, note: null };
|
|
39939
|
+
if (force) {
|
|
39940
|
+
console.error(`[ofw-mcp] WARNING: force:true overrode a ${verdict.verdict} verdict on draft ${draftId} (${action}). ${verdict.reason}`);
|
|
39941
|
+
const echoed = server2 === null ? "The draft no longer existed on OurFamilyWizard." : `The server version that was overwritten is preserved below under "overwrittenServerDraft".`;
|
|
39942
|
+
return {
|
|
39943
|
+
ok: true,
|
|
39944
|
+
note: `WARNING: force:true overrode a ${verdict.verdict} freshness verdict on draft ${draftId}. ${verdict.reason} ${echoed}
|
|
39945
|
+
|
|
39946
|
+
${JSON.stringify(
|
|
39947
|
+
{ overwrittenServerDraft: server2 === null ? null : { ...server2, revision: draftRevision(server2) } },
|
|
39948
|
+
null,
|
|
39949
|
+
2
|
|
39950
|
+
)}`
|
|
39951
|
+
};
|
|
39952
|
+
}
|
|
39953
|
+
return {
|
|
39954
|
+
ok: false,
|
|
39955
|
+
response: jsonErrorResponse(staleDraftPayload({
|
|
39956
|
+
error: verdict.verdict === "MISSING" ? "MISSING_DRAFT" : "STALE_DRAFT",
|
|
39957
|
+
draftId,
|
|
39958
|
+
verdict,
|
|
39959
|
+
server: server2,
|
|
39960
|
+
cached: cached2
|
|
39961
|
+
}))
|
|
39962
|
+
};
|
|
39963
|
+
}
|
|
39491
39964
|
server.registerTool("ofw_list_drafts", {
|
|
39492
39965
|
description: "List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.",
|
|
39493
39966
|
annotations: { readOnlyHint: true },
|
|
@@ -39498,12 +39971,31 @@ ${text}` : text);
|
|
|
39498
39971
|
}, async (args) => {
|
|
39499
39972
|
const page = args.page ?? 1;
|
|
39500
39973
|
const size = args.size ?? 50;
|
|
39501
|
-
const
|
|
39502
|
-
const
|
|
39974
|
+
const cache = cacheProvider();
|
|
39975
|
+
const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
|
|
39976
|
+
const rows = await cache.listDrafts({ page, size });
|
|
39977
|
+
const drafts = rows.map((d) => ({
|
|
39978
|
+
...d,
|
|
39979
|
+
revision: draftRevision(d),
|
|
39980
|
+
cacheStatus,
|
|
39981
|
+
serverConfirmed,
|
|
39982
|
+
asOf: freshness.asOf
|
|
39983
|
+
}));
|
|
39984
|
+
if (drafts.length === 0) {
|
|
39985
|
+
return jsonResponse({
|
|
39986
|
+
drafts: [],
|
|
39987
|
+
freshness,
|
|
39988
|
+
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."
|
|
39989
|
+
});
|
|
39990
|
+
}
|
|
39991
|
+
const payload = { drafts, freshness };
|
|
39992
|
+
if (!serverConfirmed) {
|
|
39993
|
+
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.';
|
|
39994
|
+
}
|
|
39503
39995
|
return jsonResponse(payload);
|
|
39504
39996
|
});
|
|
39505
39997
|
if (allowDrafts) server.registerTool("ofw_save_draft", {
|
|
39506
|
-
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.",
|
|
39998
|
+
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.",
|
|
39507
39999
|
annotations: { readOnlyHint: false },
|
|
39508
40000
|
inputSchema: {
|
|
39509
40001
|
subject: external_exports.string().describe("Message subject"),
|
|
@@ -39511,10 +40003,24 @@ ${text}` : text);
|
|
|
39511
40003
|
recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (optional for drafts)").optional(),
|
|
39512
40004
|
messageId: external_exports.number().describe("ID of an existing draft to replace (the new draft will have a new id; the old is deleted)").optional(),
|
|
39513
40005
|
replyToId: external_exports.number().describe("ID of the message this draft replies to").optional(),
|
|
39514
|
-
myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment)").optional()
|
|
40006
|
+
myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment)").optional(),
|
|
40007
|
+
expectedRevision: external_exports.string().describe('With messageId: the `revision` you got from ofw_list_drafts/ofw_get_message for that draft. Asserts you are replacing THAT version. If the draft changed on OFW since, the write is refused and the current server body is returned. Omit and the tool compares the server against the local cache instead \u2014 omitting never means "overwrite anyway".').optional(),
|
|
40008
|
+
force: external_exports.boolean().describe("Default false. Overwrite even when the draft changed on OurFamilyWizard since you read it. The discarded server version is echoed back in the response. Only use after showing the user the conflict.").optional()
|
|
39515
40009
|
}
|
|
39516
40010
|
}, async (args) => {
|
|
39517
40011
|
const cache = cacheProvider();
|
|
40012
|
+
let forceNote = null;
|
|
40013
|
+
if (args.messageId !== void 0) {
|
|
40014
|
+
const guard = await guardDestructiveDraftOp({
|
|
40015
|
+
cache,
|
|
40016
|
+
draftId: args.messageId,
|
|
40017
|
+
expectedRevision: args.expectedRevision,
|
|
40018
|
+
force: args.force ?? false,
|
|
40019
|
+
action: "replace"
|
|
40020
|
+
});
|
|
40021
|
+
if (!guard.ok) return guard.response;
|
|
40022
|
+
forceNote = guard.note;
|
|
40023
|
+
}
|
|
39518
40024
|
const requestedReplyTo = args.replyToId ?? null;
|
|
39519
40025
|
let resolvedReplyTo = requestedReplyTo;
|
|
39520
40026
|
let rewriteNote = null;
|
|
@@ -39543,6 +40049,7 @@ ${text}` : text);
|
|
|
39543
40049
|
let persisted = null;
|
|
39544
40050
|
let replaceNote = null;
|
|
39545
40051
|
let verifyNote = null;
|
|
40052
|
+
let newRevision = null;
|
|
39546
40053
|
if (newId !== null) {
|
|
39547
40054
|
verifyNote = verifyWriteLanded("draft", { subject: args.subject, body: args.body }, detail);
|
|
39548
40055
|
persisted = {
|
|
@@ -39555,33 +40062,48 @@ ${text}` : text);
|
|
|
39555
40062
|
listData: detail
|
|
39556
40063
|
};
|
|
39557
40064
|
await cache.upsertDraft(persisted);
|
|
40065
|
+
newRevision = draftRevision(persisted);
|
|
39558
40066
|
if (args.messageId !== void 0 && args.messageId !== newId) {
|
|
39559
40067
|
try {
|
|
39560
40068
|
await deleteOFWMessages(client2, [args.messageId]);
|
|
39561
40069
|
await cache.deleteDraft(args.messageId);
|
|
39562
40070
|
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.)`;
|
|
39563
40071
|
} catch (e) {
|
|
39564
|
-
replaceNote = `WARNING: New draft ${newId} created successfully, but
|
|
40072
|
+
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.`;
|
|
39565
40073
|
}
|
|
39566
40074
|
}
|
|
39567
40075
|
}
|
|
39568
|
-
const responseObj = persisted
|
|
40076
|
+
const responseObj = persisted !== null ? { ...persisted, revision: newRevision, cacheStatus: "fresh", serverConfirmed: true } : raw;
|
|
39569
40077
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
|
|
39570
|
-
const notes = [rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join("\n\n");
|
|
40078
|
+
const notes = [forceNote, rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join("\n\n");
|
|
39571
40079
|
return textResponse(notes ? `${notes}
|
|
39572
40080
|
|
|
39573
40081
|
${text}` : text);
|
|
39574
40082
|
});
|
|
39575
40083
|
if (allowDrafts) server.registerTool("ofw_delete_draft", {
|
|
39576
|
-
description: "Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.",
|
|
40084
|
+
description: "Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache. Before deleting, the draft is re-read from OFW and the delete is REFUSED if it changed since you last read it (the current server body is returned so nothing is lost) \u2014 pass expectedRevision to assert which version you mean, or force:true to delete regardless.",
|
|
39577
40085
|
annotations: { destructiveHint: true },
|
|
39578
40086
|
inputSchema: {
|
|
39579
|
-
messageId: external_exports.number().describe("Draft message ID to delete")
|
|
40087
|
+
messageId: external_exports.number().describe("Draft message ID to delete"),
|
|
40088
|
+
expectedRevision: external_exports.string().describe("The `revision` you got from ofw_list_drafts/ofw_get_message. Asserts you are deleting THAT version; if the draft changed on OFW since, the delete is refused and the current server body returned.").optional(),
|
|
40089
|
+
force: external_exports.boolean().describe("Default false. Delete even if the draft changed on OurFamilyWizard since you read it. The discarded server version is echoed back in the response.").optional()
|
|
39580
40090
|
}
|
|
39581
40091
|
}, async (args) => {
|
|
40092
|
+
const cache = cacheProvider();
|
|
40093
|
+
const guard = await guardDestructiveDraftOp({
|
|
40094
|
+
cache,
|
|
40095
|
+
draftId: args.messageId,
|
|
40096
|
+
expectedRevision: args.expectedRevision,
|
|
40097
|
+
force: args.force ?? false,
|
|
40098
|
+
action: "delete"
|
|
40099
|
+
});
|
|
40100
|
+
if (!guard.ok) return guard.response;
|
|
39582
40101
|
const data = await deleteOFWMessages(client2, [args.messageId]);
|
|
39583
|
-
await
|
|
39584
|
-
|
|
40102
|
+
await cache.deleteDraft(args.messageId);
|
|
40103
|
+
const text = data ? JSON.stringify(data, null, 2) : "Draft deleted.";
|
|
40104
|
+
return textResponse(guard.note ? `${guard.note}
|
|
40105
|
+
|
|
40106
|
+
${text}` : text);
|
|
39585
40107
|
});
|
|
39586
40108
|
server.registerTool("ofw_get_unread_sent", {
|
|
39587
40109
|
description: "List sent messages that have not been read by one or more recipients. Reads from local cache; call ofw_sync_messages first if cache is stale.",
|
|
@@ -39593,9 +40115,15 @@ ${text}` : text);
|
|
|
39593
40115
|
}, async (args) => {
|
|
39594
40116
|
const page = args.page ?? 1;
|
|
39595
40117
|
const size = args.size ?? 50;
|
|
39596
|
-
const
|
|
40118
|
+
const cache = cacheProvider();
|
|
40119
|
+
const sent = await cache.listMessages({ folder: "sent", page, size });
|
|
40120
|
+
const freshness = await buildFreshness(cache, { source: "cache", folders: ["sent"] });
|
|
39597
40121
|
if (sent.length === 0) {
|
|
39598
|
-
return jsonResponse({
|
|
40122
|
+
return jsonResponse({
|
|
40123
|
+
unread: [],
|
|
40124
|
+
freshness,
|
|
40125
|
+
note: "Sent cache is empty. Call ofw_sync_messages to populate. An empty cache is NOT evidence that no sent messages exist."
|
|
40126
|
+
});
|
|
39599
40127
|
}
|
|
39600
40128
|
const unread = [];
|
|
39601
40129
|
for (const msg of sent) {
|
|
@@ -39605,9 +40133,13 @@ ${text}` : text);
|
|
|
39605
40133
|
}
|
|
39606
40134
|
}
|
|
39607
40135
|
if (unread.length === 0) {
|
|
39608
|
-
return jsonResponse({
|
|
40136
|
+
return jsonResponse({
|
|
40137
|
+
unread: [],
|
|
40138
|
+
freshness,
|
|
40139
|
+
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."
|
|
40140
|
+
});
|
|
39609
40141
|
}
|
|
39610
|
-
return jsonResponse(unread);
|
|
40142
|
+
return jsonResponse({ unread, freshness });
|
|
39611
40143
|
});
|
|
39612
40144
|
if (allowDrafts) server.registerTool("ofw_upload_attachment", {
|
|
39613
40145
|
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.`,
|
|
@@ -39651,18 +40183,20 @@ ${text}` : text);
|
|
|
39651
40183
|
});
|
|
39652
40184
|
});
|
|
39653
40185
|
server.registerTool("ofw_download_attachment", {
|
|
39654
|
-
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
|
|
40186
|
+
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).',
|
|
39655
40187
|
annotations: { readOnlyHint: false },
|
|
39656
40188
|
inputSchema: {
|
|
39657
40189
|
fileId: external_exports.number().describe("Attachment file id (from ofw_get_message \u2192 attachments[].fileId)"),
|
|
39658
|
-
inline: external_exports.boolean().describe("If true, return bytes inline as MCP content (
|
|
39659
|
-
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
|
|
40190
|
+
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(),
|
|
40191
|
+
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(),
|
|
39660
40192
|
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()
|
|
39661
40193
|
}
|
|
39662
40194
|
}, async (args) => {
|
|
39663
40195
|
const fileId = args.fileId;
|
|
39664
40196
|
const cache = cacheProvider();
|
|
39665
|
-
const
|
|
40197
|
+
const requestedInline = args.inline ?? getDefaultInlineAttachments();
|
|
40198
|
+
const inline = requestedInline || !attachmentIO.supportsDisk;
|
|
40199
|
+
const forcedInline = inline && !requestedInline;
|
|
39666
40200
|
let cached2 = await cache.getAttachment(fileId);
|
|
39667
40201
|
if (!cached2) {
|
|
39668
40202
|
await fetchAttachmentMeta(client2, fileId, 0, cache);
|
|
@@ -39671,36 +40205,39 @@ ${text}` : text);
|
|
|
39671
40205
|
}
|
|
39672
40206
|
if (inline) {
|
|
39673
40207
|
let bytes = null;
|
|
39674
|
-
let
|
|
39675
|
-
let
|
|
40208
|
+
let headerMime = cached2.mimeType;
|
|
40209
|
+
let fileName2 = cached2.fileName;
|
|
39676
40210
|
if (cached2.downloadedPath) {
|
|
39677
40211
|
bytes = attachmentIO.readDownloaded(cached2.downloadedPath);
|
|
39678
40212
|
}
|
|
39679
40213
|
if (bytes === null) {
|
|
39680
40214
|
const response2 = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
|
|
39681
40215
|
bytes = response2.body;
|
|
39682
|
-
|
|
39683
|
-
|
|
40216
|
+
headerMime = response2.contentType ?? cached2.mimeType;
|
|
40217
|
+
fileName2 = response2.suggestedFileName ?? cached2.fileName;
|
|
39684
40218
|
}
|
|
40219
|
+
const mimeType = resolveDownloadMime(bytes, headerMime, fileName2);
|
|
39685
40220
|
const base643 = bytes.toString("base64");
|
|
39686
|
-
const
|
|
40221
|
+
const meta3 = {
|
|
39687
40222
|
fileId,
|
|
39688
|
-
fileName,
|
|
40223
|
+
fileName: fileName2,
|
|
39689
40224
|
mimeType,
|
|
39690
40225
|
sizeBytes: bytes.length,
|
|
39691
40226
|
mode: "inline"
|
|
39692
|
-
}
|
|
39693
|
-
if (
|
|
40227
|
+
};
|
|
40228
|
+
if (forcedInline) meta3.forcedInline = true;
|
|
40229
|
+
const metaBlock = { type: "text", text: JSON.stringify(meta3, null, 2) };
|
|
40230
|
+
if (isHostRenderableImage(mimeType)) {
|
|
39694
40231
|
return { content: [metaBlock, { type: "image", data: base643, mimeType }] };
|
|
39695
40232
|
}
|
|
39696
40233
|
return { content: [metaBlock, { type: "resource", resource: {
|
|
39697
|
-
uri: `ofw://attachment/${fileId}/${encodeURIComponent(
|
|
40234
|
+
uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName2)}`,
|
|
39698
40235
|
mimeType,
|
|
39699
40236
|
blob: base643
|
|
39700
40237
|
} }] };
|
|
39701
40238
|
}
|
|
39702
40239
|
let dest;
|
|
39703
|
-
const safeName =
|
|
40240
|
+
const safeName = basename2(cached2.fileName);
|
|
39704
40241
|
if (args.saveTo) {
|
|
39705
40242
|
const isDirArg = args.saveTo.endsWith("/") || args.saveTo.endsWith("\\");
|
|
39706
40243
|
const abs = expandPath2(args.saveTo);
|
|
@@ -39710,9 +40247,12 @@ ${text}` : text);
|
|
|
39710
40247
|
}
|
|
39711
40248
|
if (!args.force && cached2.downloadedPath === dest) {
|
|
39712
40249
|
return jsonResponse({
|
|
40250
|
+
// No bytes on hand for the no-op case: normalize the cached/extension
|
|
40251
|
+
// MIME (empty buffer sniffs nothing) so a stored `image/png;charset=…`
|
|
40252
|
+
// still reports bare.
|
|
39713
40253
|
fileId,
|
|
39714
40254
|
path: dest,
|
|
39715
|
-
mimeType: cached2.mimeType,
|
|
40255
|
+
mimeType: resolveDownloadMime(Buffer.alloc(0), cached2.mimeType, cached2.fileName),
|
|
39716
40256
|
sizeBytes: cached2.sizeBytes,
|
|
39717
40257
|
fileName: cached2.fileName,
|
|
39718
40258
|
note: "already downloaded"
|
|
@@ -39721,31 +40261,137 @@ ${text}` : text);
|
|
|
39721
40261
|
const response = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
|
|
39722
40262
|
attachmentIO.writeDownload(dest, response.body);
|
|
39723
40263
|
await cache.markAttachmentDownloaded(fileId, dest);
|
|
40264
|
+
const fileName = response.suggestedFileName ?? cached2.fileName;
|
|
39724
40265
|
return jsonResponse({
|
|
39725
40266
|
fileId,
|
|
39726
40267
|
path: dest,
|
|
39727
|
-
mimeType: response.contentType ?? cached2.mimeType,
|
|
40268
|
+
mimeType: resolveDownloadMime(response.body, response.contentType ?? cached2.mimeType, fileName),
|
|
39728
40269
|
sizeBytes: response.body.length,
|
|
39729
|
-
fileName
|
|
40270
|
+
fileName
|
|
39730
40271
|
});
|
|
39731
40272
|
});
|
|
39732
40273
|
server.registerTool("ofw_sync_messages", {
|
|
39733
40274
|
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).",
|
|
39734
40275
|
annotations: { readOnlyHint: false },
|
|
39735
40276
|
inputSchema: {
|
|
39736
|
-
folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).describe("Folders to sync (default: all three)").optional(),
|
|
40277
|
+
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(),
|
|
39737
40278
|
fetchUnreadBodies: external_exports.boolean().describe("If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.").optional(),
|
|
39738
40279
|
deep: external_exports.boolean().describe("If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.").optional(),
|
|
39739
40280
|
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()
|
|
39740
40281
|
}
|
|
39741
40282
|
}, async (args) => {
|
|
40283
|
+
const cache = cacheProvider();
|
|
39742
40284
|
const result = await syncAll(client2, {
|
|
39743
40285
|
folders: args.folders,
|
|
39744
40286
|
fetchUnreadBodies: args.fetchUnreadBodies,
|
|
39745
40287
|
deep: args.deep,
|
|
39746
40288
|
maxRequests: args.maxRequests ?? getSyncMaxRequests()
|
|
39747
|
-
},
|
|
39748
|
-
|
|
40289
|
+
}, cache);
|
|
40290
|
+
const freshness = await buildFreshness(cache, {
|
|
40291
|
+
source: "cache",
|
|
40292
|
+
folders: args.folders ?? ["inbox", "sent", "drafts"]
|
|
40293
|
+
});
|
|
40294
|
+
return jsonResponse({ ...result, freshness });
|
|
40295
|
+
});
|
|
40296
|
+
server.registerTool("ofw_check_freshness", {
|
|
40297
|
+
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.',
|
|
40298
|
+
annotations: { readOnlyHint: true },
|
|
40299
|
+
inputSchema: {
|
|
40300
|
+
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(),
|
|
40301
|
+
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(),
|
|
40302
|
+
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()
|
|
40303
|
+
}
|
|
40304
|
+
}, async (args) => {
|
|
40305
|
+
const cache = cacheProvider();
|
|
40306
|
+
const allowMarkRead = args.allowMarkRead ?? false;
|
|
40307
|
+
const requestedIds = args.messageIds ?? [];
|
|
40308
|
+
const ids = requestedIds.slice(0, MAX_FRESHNESS_IDS);
|
|
40309
|
+
const wantFolders = args.folders ?? (requestedIds.length > 0 ? [] : ["inbox", "sent", "drafts"]);
|
|
40310
|
+
let requestsUsed = 0;
|
|
40311
|
+
const folders = [];
|
|
40312
|
+
if (wantFolders.length > 0) {
|
|
40313
|
+
requestsUsed++;
|
|
40314
|
+
const data = parseLenient(
|
|
40315
|
+
FolderCountsSchema,
|
|
40316
|
+
await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true"),
|
|
40317
|
+
{ label: "ofw-mcp", context: "GET /pub/v1/messageFolders (ofw_check_freshness)" }
|
|
40318
|
+
);
|
|
40319
|
+
const sys = data.systemFolders ?? [];
|
|
40320
|
+
for (const folder of wantFolders) {
|
|
40321
|
+
const entry = sys.find((x) => x.folderType === FOLDER_TYPE[folder]);
|
|
40322
|
+
const serverCount = entry?.totalCount ?? entry?.messageCount ?? entry?.count ?? null;
|
|
40323
|
+
const cachedCount = folder === "drafts" ? (await cache.listDraftIds()).length : await cache.countMessages({ folder });
|
|
40324
|
+
const state = await cache.getSyncState(folder);
|
|
40325
|
+
const historyComplete = state !== null && state.resumePage === null;
|
|
40326
|
+
const inSync = serverCount === null || !historyComplete ? null : serverCount === cachedCount;
|
|
40327
|
+
folders.push({
|
|
40328
|
+
folder,
|
|
40329
|
+
existsOnServer: entry !== void 0,
|
|
40330
|
+
serverCount,
|
|
40331
|
+
cachedCount,
|
|
40332
|
+
historyComplete,
|
|
40333
|
+
lastVerifiedAt: await getFolderVerifiedAt(cache, folder),
|
|
40334
|
+
inSync,
|
|
40335
|
+
...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." } : {}
|
|
40336
|
+
});
|
|
40337
|
+
}
|
|
40338
|
+
}
|
|
40339
|
+
const items = [];
|
|
40340
|
+
for (const id of ids) {
|
|
40341
|
+
const cachedDraft = await cache.getDraft(id);
|
|
40342
|
+
if (cachedDraft === null && !allowMarkRead) {
|
|
40343
|
+
items.push({
|
|
40344
|
+
id,
|
|
40345
|
+
skipped: true,
|
|
40346
|
+
reason: "NOT_A_CACHED_DRAFT",
|
|
40347
|
+
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."
|
|
40348
|
+
});
|
|
40349
|
+
continue;
|
|
40350
|
+
}
|
|
40351
|
+
requestsUsed++;
|
|
40352
|
+
try {
|
|
40353
|
+
const server2 = await fetchServerDraft(client2, id);
|
|
40354
|
+
const cacheRevision = cachedDraft === null ? null : draftRevision(cachedDraft);
|
|
40355
|
+
if (server2 === null) {
|
|
40356
|
+
items.push({
|
|
40357
|
+
id,
|
|
40358
|
+
existsOnServer: false,
|
|
40359
|
+
inSync: false,
|
|
40360
|
+
cacheRevision,
|
|
40361
|
+
serverRevision: null,
|
|
40362
|
+
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."
|
|
40363
|
+
});
|
|
40364
|
+
continue;
|
|
40365
|
+
}
|
|
40366
|
+
const serverRevision = draftRevision(server2);
|
|
40367
|
+
items.push({
|
|
40368
|
+
id,
|
|
40369
|
+
existsOnServer: true,
|
|
40370
|
+
cacheRevision,
|
|
40371
|
+
serverRevision,
|
|
40372
|
+
inSync: cacheRevision !== null && cacheRevision === serverRevision,
|
|
40373
|
+
...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." } : {}
|
|
40374
|
+
});
|
|
40375
|
+
} catch (e) {
|
|
40376
|
+
items.push({
|
|
40377
|
+
id,
|
|
40378
|
+
error: "FRESHNESS_CHECK_FAILED",
|
|
40379
|
+
message: e.message,
|
|
40380
|
+
inSync: null,
|
|
40381
|
+
note: "The freshness check itself failed, so nothing is confirmed either way."
|
|
40382
|
+
});
|
|
40383
|
+
}
|
|
40384
|
+
}
|
|
40385
|
+
const payload = {
|
|
40386
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
40387
|
+
requestsUsed,
|
|
40388
|
+
...folders.length > 0 ? { folders } : {},
|
|
40389
|
+
...items.length > 0 ? { items } : {}
|
|
40390
|
+
};
|
|
40391
|
+
if (requestedIds.length > ids.length) {
|
|
40392
|
+
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.`;
|
|
40393
|
+
}
|
|
40394
|
+
return jsonResponse(payload);
|
|
39749
40395
|
});
|
|
39750
40396
|
}
|
|
39751
40397
|
async function deleteOFWMessages(client2, ids) {
|
|
@@ -39980,8 +40626,8 @@ function registerJournalTools(server, client2) {
|
|
|
39980
40626
|
|
|
39981
40627
|
// src/cache/node.ts
|
|
39982
40628
|
import { DatabaseSync } from "node:sqlite";
|
|
39983
|
-
import { mkdirSync, chmodSync, existsSync } from "node:fs";
|
|
39984
|
-
import { dirname as
|
|
40629
|
+
import { mkdirSync as mkdirSync2, chmodSync, existsSync } from "node:fs";
|
|
40630
|
+
import { dirname as dirname3 } from "node:path";
|
|
39985
40631
|
|
|
39986
40632
|
// src/cache/store.ts
|
|
39987
40633
|
function rowFromDb(r) {
|
|
@@ -40475,7 +41121,7 @@ var NodeSqlDriver = class {
|
|
|
40475
41121
|
}
|
|
40476
41122
|
};
|
|
40477
41123
|
function enforceCachePermissions(dbPath) {
|
|
40478
|
-
chmodSync(
|
|
41124
|
+
chmodSync(dirname3(dbPath), 448);
|
|
40479
41125
|
chmodSync(dbPath, 384);
|
|
40480
41126
|
for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
40481
41127
|
if (existsSync(sibling)) chmodSync(sibling, 384);
|
|
@@ -40489,7 +41135,7 @@ var OFWCache = class _OFWCache extends LocalCacheStore {
|
|
|
40489
41135
|
db;
|
|
40490
41136
|
static open(path) {
|
|
40491
41137
|
const memory = path === ":memory:";
|
|
40492
|
-
if (!memory)
|
|
41138
|
+
if (!memory) mkdirSync2(dirname3(path), { recursive: true });
|
|
40493
41139
|
const db = new DatabaseSync(path);
|
|
40494
41140
|
if (!memory) enforceCachePermissions(path);
|
|
40495
41141
|
db.exec("PRAGMA journal_mode = WAL");
|
|
@@ -40503,59 +41149,6 @@ var OFWCache = class _OFWCache extends LocalCacheStore {
|
|
|
40503
41149
|
}
|
|
40504
41150
|
};
|
|
40505
41151
|
|
|
40506
|
-
// src/tools/attachments.ts
|
|
40507
|
-
import { readFileSync, statSync, mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
|
|
40508
|
-
import { basename as basename2, dirname as dirname3, extname } from "node:path";
|
|
40509
|
-
var MIME_BY_EXT = {
|
|
40510
|
-
".pdf": "application/pdf",
|
|
40511
|
-
".png": "image/png",
|
|
40512
|
-
".jpg": "image/jpeg",
|
|
40513
|
-
".jpeg": "image/jpeg",
|
|
40514
|
-
".gif": "image/gif",
|
|
40515
|
-
".webp": "image/webp",
|
|
40516
|
-
".heic": "image/heic",
|
|
40517
|
-
".txt": "text/plain",
|
|
40518
|
-
".md": "text/markdown",
|
|
40519
|
-
".csv": "text/csv",
|
|
40520
|
-
".html": "text/html",
|
|
40521
|
-
".htm": "text/html",
|
|
40522
|
-
".json": "application/json",
|
|
40523
|
-
".xml": "application/xml",
|
|
40524
|
-
".doc": "application/msword",
|
|
40525
|
-
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
40526
|
-
".xls": "application/vnd.ms-excel",
|
|
40527
|
-
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
40528
|
-
".ppt": "application/vnd.ms-powerpoint",
|
|
40529
|
-
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
40530
|
-
".zip": "application/zip",
|
|
40531
|
-
".ics": "text/calendar"
|
|
40532
|
-
};
|
|
40533
|
-
function mimeFromName(name) {
|
|
40534
|
-
return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
|
|
40535
|
-
}
|
|
40536
|
-
var NodeAttachmentIO = class {
|
|
40537
|
-
async resolveUpload(path) {
|
|
40538
|
-
const abs = expandPath(path);
|
|
40539
|
-
const stat = statSync(abs);
|
|
40540
|
-
if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
|
|
40541
|
-
const fileName = basename2(abs);
|
|
40542
|
-
const mimeType = mimeFromName(fileName);
|
|
40543
|
-
const blob = await fileBlob(abs, { type: mimeType });
|
|
40544
|
-
return { blob, fileName, mimeType, sizeBytes: stat.size };
|
|
40545
|
-
}
|
|
40546
|
-
readDownloaded(path) {
|
|
40547
|
-
try {
|
|
40548
|
-
return readFileSync(path);
|
|
40549
|
-
} catch {
|
|
40550
|
-
return null;
|
|
40551
|
-
}
|
|
40552
|
-
}
|
|
40553
|
-
writeDownload(dest, bytes) {
|
|
40554
|
-
mkdirSync2(dirname3(dest), { recursive: true });
|
|
40555
|
-
writeFileSync(dest, bytes);
|
|
40556
|
-
}
|
|
40557
|
-
};
|
|
40558
|
-
|
|
40559
41152
|
// src/index.ts
|
|
40560
41153
|
var originalEmit = process.emit.bind(process);
|
|
40561
41154
|
process.emit = function(event, ...args) {
|
|
@@ -40572,7 +41165,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
|
|
|
40572
41165
|
var nodeAttachmentIO = new NodeAttachmentIO();
|
|
40573
41166
|
await runMcp({
|
|
40574
41167
|
name: "ofw",
|
|
40575
|
-
version: "2.
|
|
41168
|
+
version: "2.7.0",
|
|
40576
41169
|
// x-release-please-version
|
|
40577
41170
|
deps: client,
|
|
40578
41171
|
tools: [
|