ofw-mcp 2.6.7 → 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 +524 -161
- package/dist/config.js +25 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +117 -24
- package/dist/tools/attachments.js +61 -0
- package/dist/tools/freshness.js +147 -0
- package/dist/tools/messages.js +297 -36
- 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)",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
39021
|
-
|
|
39022
|
-
|
|
39023
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39136
|
-
|
|
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 {
|
|
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
|
|
@@ -39263,56 +39468,95 @@ function staleDraftPayload(input) {
|
|
|
39263
39468
|
};
|
|
39264
39469
|
}
|
|
39265
39470
|
|
|
39266
|
-
// src/
|
|
39267
|
-
import {
|
|
39268
|
-
import {
|
|
39269
|
-
|
|
39270
|
-
|
|
39271
|
-
|
|
39272
|
-
|
|
39273
|
-
|
|
39274
|
-
|
|
39275
|
-
|
|
39276
|
-
|
|
39277
|
-
|
|
39278
|
-
|
|
39279
|
-
|
|
39280
|
-
|
|
39281
|
-
|
|
39282
|
-
|
|
39283
|
-
|
|
39284
|
-
|
|
39285
|
-
|
|
39286
|
-
|
|
39287
|
-
|
|
39288
|
-
|
|
39289
|
-
|
|
39290
|
-
|
|
39291
|
-
|
|
39292
|
-
|
|
39293
|
-
|
|
39294
|
-
|
|
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");
|
|
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";
|
|
39305
39500
|
}
|
|
39306
|
-
|
|
39307
|
-
|
|
39308
|
-
|
|
39309
|
-
|
|
39310
|
-
|
|
39311
|
-
|
|
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);
|
|
39312
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
|
+
};
|
|
39313
39557
|
|
|
39314
39558
|
// src/tools/messages.ts
|
|
39315
|
-
import { basename, join as join5 } from "node:path";
|
|
39559
|
+
import { basename as basename2, join as join5 } from "node:path";
|
|
39316
39560
|
var DateSchema = external_exports.looseObject({ dateTime: external_exports.string() });
|
|
39317
39561
|
var SentDetailSchema = external_exports.looseObject({
|
|
39318
39562
|
subject: external_exports.string().optional(),
|
|
@@ -39342,6 +39586,21 @@ var MessageDetailSchema = external_exports.looseObject({
|
|
|
39342
39586
|
folder: external_exports.looseObject({ id: external_exports.number() }).optional()
|
|
39343
39587
|
});
|
|
39344
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;
|
|
39345
39604
|
var UploadedFileSchema = external_exports.looseObject({
|
|
39346
39605
|
fileId: external_exports.number(),
|
|
39347
39606
|
fileName: external_exports.string().optional(),
|
|
@@ -39357,16 +39616,23 @@ function listDataHintsAtFiles(listData) {
|
|
|
39357
39616
|
if (Array.isArray(ld.files)) return ld.files.length > 0;
|
|
39358
39617
|
return false;
|
|
39359
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
|
+
}
|
|
39360
39625
|
function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
39361
39626
|
const writeMode = getWriteMode();
|
|
39362
39627
|
const allowSend = writeMode === "all";
|
|
39363
39628
|
const allowDrafts = writeMode !== "none";
|
|
39364
39629
|
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.",
|
|
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.",
|
|
39366
39631
|
annotations: { readOnlyHint: true }
|
|
39367
39632
|
}, async () => {
|
|
39368
39633
|
const data = await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true");
|
|
39369
|
-
|
|
39634
|
+
const freshness = await buildFreshness(cacheProvider(), { source: "live", folders: [] });
|
|
39635
|
+
return jsonResponse({ folders: data, freshness });
|
|
39370
39636
|
});
|
|
39371
39637
|
server.registerTool("ofw_list_messages", {
|
|
39372
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.",
|
|
@@ -39390,14 +39656,22 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39390
39656
|
else {
|
|
39391
39657
|
return jsonResponse({
|
|
39392
39658
|
messages: [],
|
|
39393
|
-
|
|
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.'
|
|
39394
39664
|
});
|
|
39395
39665
|
}
|
|
39396
39666
|
const cache = cacheProvider();
|
|
39397
39667
|
const filter = { folder, since: args.since, until: args.until, q: args.q };
|
|
39398
39668
|
const total = await cache.countMessages(filter);
|
|
39399
39669
|
const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
|
|
39400
|
-
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 };
|
|
39401
39675
|
if (total === 0) {
|
|
39402
39676
|
payload.note = "No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.";
|
|
39403
39677
|
} else if (page * size < total) {
|
|
@@ -39416,6 +39690,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39416
39690
|
const cache = cacheProvider();
|
|
39417
39691
|
const draftRow = await cache.getDraft(id);
|
|
39418
39692
|
if (draftRow !== null) {
|
|
39693
|
+
const { freshness: freshness2, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
|
|
39419
39694
|
return jsonResponse({
|
|
39420
39695
|
id: draftRow.id,
|
|
39421
39696
|
folder: "drafts",
|
|
@@ -39435,7 +39710,12 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39435
39710
|
// Concurrency token — pass as expectedRevision to ofw_save_draft /
|
|
39436
39711
|
// ofw_delete_draft to assert you are editing THIS version.
|
|
39437
39712
|
revision: draftRevision(draftRow),
|
|
39438
|
-
cacheStatus
|
|
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
|
|
39439
39719
|
});
|
|
39440
39720
|
}
|
|
39441
39721
|
const cached2 = await cache.getMessage(id);
|
|
@@ -39473,7 +39753,8 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39473
39753
|
} catch {
|
|
39474
39754
|
}
|
|
39475
39755
|
}
|
|
39476
|
-
|
|
39756
|
+
const freshness2 = await buildFreshness(cache, { source: "cache", folders: [row2.folder] });
|
|
39757
|
+
return jsonResponse({ ...withReadState(row2), attachments: attachments2, freshness: freshness2 });
|
|
39477
39758
|
}
|
|
39478
39759
|
const detail = parseLenient(
|
|
39479
39760
|
MessageDetailSchema,
|
|
@@ -39505,7 +39786,8 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39505
39786
|
await fetchAttachmentMetaForMessage(client2, detail.id, detail.files, cache);
|
|
39506
39787
|
}
|
|
39507
39788
|
const attachments = await cache.listAttachmentsForMessage(detail.id);
|
|
39508
|
-
|
|
39789
|
+
const freshness = await buildFreshness(cache, { source: "live", folders: [folder] });
|
|
39790
|
+
return jsonResponse({ ...withReadState(row), attachments, freshness });
|
|
39509
39791
|
});
|
|
39510
39792
|
if (allowSend) server.registerTool("ofw_send_message", {
|
|
39511
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.",
|
|
@@ -39690,15 +39972,25 @@ ${JSON.stringify(
|
|
|
39690
39972
|
const page = args.page ?? 1;
|
|
39691
39973
|
const size = args.size ?? 50;
|
|
39692
39974
|
const cache = cacheProvider();
|
|
39693
|
-
const cacheStatus = await
|
|
39975
|
+
const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
|
|
39694
39976
|
const rows = await cache.listDrafts({ page, size });
|
|
39695
|
-
const drafts = rows.map((d) => ({
|
|
39977
|
+
const drafts = rows.map((d) => ({
|
|
39978
|
+
...d,
|
|
39979
|
+
revision: draftRevision(d),
|
|
39980
|
+
cacheStatus,
|
|
39981
|
+
serverConfirmed,
|
|
39982
|
+
asOf: freshness.asOf
|
|
39983
|
+
}));
|
|
39696
39984
|
if (drafts.length === 0) {
|
|
39697
|
-
return jsonResponse({
|
|
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
|
+
});
|
|
39698
39990
|
}
|
|
39699
|
-
const payload = { drafts };
|
|
39700
|
-
if (
|
|
39701
|
-
payload.note = '
|
|
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.';
|
|
39702
39994
|
}
|
|
39703
39995
|
return jsonResponse(payload);
|
|
39704
39996
|
});
|
|
@@ -39781,7 +40073,7 @@ ${JSON.stringify(
|
|
|
39781
40073
|
}
|
|
39782
40074
|
}
|
|
39783
40075
|
}
|
|
39784
|
-
const responseObj = persisted !== null ? { ...persisted, revision: newRevision, cacheStatus: "fresh" } : raw;
|
|
40076
|
+
const responseObj = persisted !== null ? { ...persisted, revision: newRevision, cacheStatus: "fresh", serverConfirmed: true } : raw;
|
|
39785
40077
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
|
|
39786
40078
|
const notes = [forceNote, rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join("\n\n");
|
|
39787
40079
|
return textResponse(notes ? `${notes}
|
|
@@ -39823,9 +40115,15 @@ ${text}` : text);
|
|
|
39823
40115
|
}, async (args) => {
|
|
39824
40116
|
const page = args.page ?? 1;
|
|
39825
40117
|
const size = args.size ?? 50;
|
|
39826
|
-
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"] });
|
|
39827
40121
|
if (sent.length === 0) {
|
|
39828
|
-
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
|
+
});
|
|
39829
40127
|
}
|
|
39830
40128
|
const unread = [];
|
|
39831
40129
|
for (const msg of sent) {
|
|
@@ -39835,9 +40133,13 @@ ${text}` : text);
|
|
|
39835
40133
|
}
|
|
39836
40134
|
}
|
|
39837
40135
|
if (unread.length === 0) {
|
|
39838
|
-
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
|
+
});
|
|
39839
40141
|
}
|
|
39840
|
-
return jsonResponse(unread);
|
|
40142
|
+
return jsonResponse({ unread, freshness });
|
|
39841
40143
|
});
|
|
39842
40144
|
if (allowDrafts) server.registerTool("ofw_upload_attachment", {
|
|
39843
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.`,
|
|
@@ -39881,18 +40183,20 @@ ${text}` : text);
|
|
|
39881
40183
|
});
|
|
39882
40184
|
});
|
|
39883
40185
|
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
|
|
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).',
|
|
39885
40187
|
annotations: { readOnlyHint: false },
|
|
39886
40188
|
inputSchema: {
|
|
39887
40189
|
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 (
|
|
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
|
|
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(),
|
|
39890
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()
|
|
39891
40193
|
}
|
|
39892
40194
|
}, async (args) => {
|
|
39893
40195
|
const fileId = args.fileId;
|
|
39894
40196
|
const cache = cacheProvider();
|
|
39895
|
-
const
|
|
40197
|
+
const requestedInline = args.inline ?? getDefaultInlineAttachments();
|
|
40198
|
+
const inline = requestedInline || !attachmentIO.supportsDisk;
|
|
40199
|
+
const forcedInline = inline && !requestedInline;
|
|
39896
40200
|
let cached2 = await cache.getAttachment(fileId);
|
|
39897
40201
|
if (!cached2) {
|
|
39898
40202
|
await fetchAttachmentMeta(client2, fileId, 0, cache);
|
|
@@ -39901,36 +40205,39 @@ ${text}` : text);
|
|
|
39901
40205
|
}
|
|
39902
40206
|
if (inline) {
|
|
39903
40207
|
let bytes = null;
|
|
39904
|
-
let
|
|
39905
|
-
let
|
|
40208
|
+
let headerMime = cached2.mimeType;
|
|
40209
|
+
let fileName2 = cached2.fileName;
|
|
39906
40210
|
if (cached2.downloadedPath) {
|
|
39907
40211
|
bytes = attachmentIO.readDownloaded(cached2.downloadedPath);
|
|
39908
40212
|
}
|
|
39909
40213
|
if (bytes === null) {
|
|
39910
40214
|
const response2 = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
|
|
39911
40215
|
bytes = response2.body;
|
|
39912
|
-
|
|
39913
|
-
|
|
40216
|
+
headerMime = response2.contentType ?? cached2.mimeType;
|
|
40217
|
+
fileName2 = response2.suggestedFileName ?? cached2.fileName;
|
|
39914
40218
|
}
|
|
40219
|
+
const mimeType = resolveDownloadMime(bytes, headerMime, fileName2);
|
|
39915
40220
|
const base643 = bytes.toString("base64");
|
|
39916
|
-
const
|
|
40221
|
+
const meta3 = {
|
|
39917
40222
|
fileId,
|
|
39918
|
-
fileName,
|
|
40223
|
+
fileName: fileName2,
|
|
39919
40224
|
mimeType,
|
|
39920
40225
|
sizeBytes: bytes.length,
|
|
39921
40226
|
mode: "inline"
|
|
39922
|
-
}
|
|
39923
|
-
if (
|
|
40227
|
+
};
|
|
40228
|
+
if (forcedInline) meta3.forcedInline = true;
|
|
40229
|
+
const metaBlock = { type: "text", text: JSON.stringify(meta3, null, 2) };
|
|
40230
|
+
if (isHostRenderableImage(mimeType)) {
|
|
39924
40231
|
return { content: [metaBlock, { type: "image", data: base643, mimeType }] };
|
|
39925
40232
|
}
|
|
39926
40233
|
return { content: [metaBlock, { type: "resource", resource: {
|
|
39927
|
-
uri: `ofw://attachment/${fileId}/${encodeURIComponent(
|
|
40234
|
+
uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName2)}`,
|
|
39928
40235
|
mimeType,
|
|
39929
40236
|
blob: base643
|
|
39930
40237
|
} }] };
|
|
39931
40238
|
}
|
|
39932
40239
|
let dest;
|
|
39933
|
-
const safeName =
|
|
40240
|
+
const safeName = basename2(cached2.fileName);
|
|
39934
40241
|
if (args.saveTo) {
|
|
39935
40242
|
const isDirArg = args.saveTo.endsWith("/") || args.saveTo.endsWith("\\");
|
|
39936
40243
|
const abs = expandPath2(args.saveTo);
|
|
@@ -39940,9 +40247,12 @@ ${text}` : text);
|
|
|
39940
40247
|
}
|
|
39941
40248
|
if (!args.force && cached2.downloadedPath === dest) {
|
|
39942
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.
|
|
39943
40253
|
fileId,
|
|
39944
40254
|
path: dest,
|
|
39945
|
-
mimeType: cached2.mimeType,
|
|
40255
|
+
mimeType: resolveDownloadMime(Buffer.alloc(0), cached2.mimeType, cached2.fileName),
|
|
39946
40256
|
sizeBytes: cached2.sizeBytes,
|
|
39947
40257
|
fileName: cached2.fileName,
|
|
39948
40258
|
note: "already downloaded"
|
|
@@ -39951,31 +40261,137 @@ ${text}` : text);
|
|
|
39951
40261
|
const response = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
|
|
39952
40262
|
attachmentIO.writeDownload(dest, response.body);
|
|
39953
40263
|
await cache.markAttachmentDownloaded(fileId, dest);
|
|
40264
|
+
const fileName = response.suggestedFileName ?? cached2.fileName;
|
|
39954
40265
|
return jsonResponse({
|
|
39955
40266
|
fileId,
|
|
39956
40267
|
path: dest,
|
|
39957
|
-
mimeType: response.contentType ?? cached2.mimeType,
|
|
40268
|
+
mimeType: resolveDownloadMime(response.body, response.contentType ?? cached2.mimeType, fileName),
|
|
39958
40269
|
sizeBytes: response.body.length,
|
|
39959
|
-
fileName
|
|
40270
|
+
fileName
|
|
39960
40271
|
});
|
|
39961
40272
|
});
|
|
39962
40273
|
server.registerTool("ofw_sync_messages", {
|
|
39963
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).",
|
|
39964
40275
|
annotations: { readOnlyHint: false },
|
|
39965
40276
|
inputSchema: {
|
|
39966
|
-
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(),
|
|
39967
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(),
|
|
39968
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(),
|
|
39969
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()
|
|
39970
40281
|
}
|
|
39971
40282
|
}, async (args) => {
|
|
40283
|
+
const cache = cacheProvider();
|
|
39972
40284
|
const result = await syncAll(client2, {
|
|
39973
40285
|
folders: args.folders,
|
|
39974
40286
|
fetchUnreadBodies: args.fetchUnreadBodies,
|
|
39975
40287
|
deep: args.deep,
|
|
39976
40288
|
maxRequests: args.maxRequests ?? getSyncMaxRequests()
|
|
39977
|
-
},
|
|
39978
|
-
|
|
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);
|
|
39979
40395
|
});
|
|
39980
40396
|
}
|
|
39981
40397
|
async function deleteOFWMessages(client2, ids) {
|
|
@@ -40210,8 +40626,8 @@ function registerJournalTools(server, client2) {
|
|
|
40210
40626
|
|
|
40211
40627
|
// src/cache/node.ts
|
|
40212
40628
|
import { DatabaseSync } from "node:sqlite";
|
|
40213
|
-
import { mkdirSync, chmodSync, existsSync } from "node:fs";
|
|
40214
|
-
import { dirname as
|
|
40629
|
+
import { mkdirSync as mkdirSync2, chmodSync, existsSync } from "node:fs";
|
|
40630
|
+
import { dirname as dirname3 } from "node:path";
|
|
40215
40631
|
|
|
40216
40632
|
// src/cache/store.ts
|
|
40217
40633
|
function rowFromDb(r) {
|
|
@@ -40705,7 +41121,7 @@ var NodeSqlDriver = class {
|
|
|
40705
41121
|
}
|
|
40706
41122
|
};
|
|
40707
41123
|
function enforceCachePermissions(dbPath) {
|
|
40708
|
-
chmodSync(
|
|
41124
|
+
chmodSync(dirname3(dbPath), 448);
|
|
40709
41125
|
chmodSync(dbPath, 384);
|
|
40710
41126
|
for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
40711
41127
|
if (existsSync(sibling)) chmodSync(sibling, 384);
|
|
@@ -40719,7 +41135,7 @@ var OFWCache = class _OFWCache extends LocalCacheStore {
|
|
|
40719
41135
|
db;
|
|
40720
41136
|
static open(path) {
|
|
40721
41137
|
const memory = path === ":memory:";
|
|
40722
|
-
if (!memory)
|
|
41138
|
+
if (!memory) mkdirSync2(dirname3(path), { recursive: true });
|
|
40723
41139
|
const db = new DatabaseSync(path);
|
|
40724
41140
|
if (!memory) enforceCachePermissions(path);
|
|
40725
41141
|
db.exec("PRAGMA journal_mode = WAL");
|
|
@@ -40733,59 +41149,6 @@ var OFWCache = class _OFWCache extends LocalCacheStore {
|
|
|
40733
41149
|
}
|
|
40734
41150
|
};
|
|
40735
41151
|
|
|
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
41152
|
// src/index.ts
|
|
40790
41153
|
var originalEmit = process.emit.bind(process);
|
|
40791
41154
|
process.emit = function(event, ...args) {
|
|
@@ -40802,7 +41165,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
|
|
|
40802
41165
|
var nodeAttachmentIO = new NodeAttachmentIO();
|
|
40803
41166
|
await runMcp({
|
|
40804
41167
|
name: "ofw",
|
|
40805
|
-
version: "2.
|
|
41168
|
+
version: "2.7.0",
|
|
40806
41169
|
// x-release-please-version
|
|
40807
41170
|
deps: client,
|
|
40808
41171
|
tools: [
|