ofw-mcp 2.6.6 → 2.6.7
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/dist/bundle.js +255 -25
- package/dist/index.js +1 -1
- package/dist/sync.js +50 -5
- package/dist/tools/_shared.js +32 -13
- package/dist/tools/draft-freshness.js +166 -0
- package/dist/tools/messages.js +122 -13
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"metadata": {
|
|
8
8
|
"description": "OurFamilyWizard tools for Claude Code",
|
|
9
|
-
"version": "2.6.
|
|
9
|
+
"version": "2.6.7"
|
|
10
10
|
},
|
|
11
11
|
"plugins": [
|
|
12
12
|
{
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"displayName": "OurFamilyWizard",
|
|
15
15
|
"source": "./",
|
|
16
16
|
"description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
|
|
17
|
-
"version": "2.6.
|
|
17
|
+
"version": "2.6.7",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "Chris Chall"
|
|
20
20
|
},
|
package/dist/bundle.js
CHANGED
|
@@ -38407,7 +38407,7 @@ async function loginWithPassword(username, password) {
|
|
|
38407
38407
|
// package.json
|
|
38408
38408
|
var package_default = {
|
|
38409
38409
|
name: "ofw-mcp",
|
|
38410
|
-
version: "2.6.
|
|
38410
|
+
version: "2.6.7",
|
|
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
|
}
|
|
@@ -39033,12 +39036,20 @@ var DraftDetailSchema = external_exports.looseObject({
|
|
|
39033
39036
|
body: external_exports.string().optional(),
|
|
39034
39037
|
subject: external_exports.string().optional()
|
|
39035
39038
|
});
|
|
39039
|
+
var DRAFTS_CACHE_STATUS_KEY = "drafts_cache_status";
|
|
39040
|
+
async function getDraftsCacheStatus(store) {
|
|
39041
|
+
return await store.getMeta(DRAFTS_CACHE_STATUS_KEY) === "fresh" ? "fresh" : "unverified";
|
|
39042
|
+
}
|
|
39036
39043
|
async function syncDrafts(client2, draftsFolderId, store, budget) {
|
|
39037
39044
|
const b = budget ?? makeBudget(Number.POSITIVE_INFINITY);
|
|
39045
|
+
const defer = async () => {
|
|
39046
|
+
await store.setMeta(DRAFTS_CACHE_STATUS_KEY, "unverified");
|
|
39047
|
+
return { synced: 0, done: false };
|
|
39048
|
+
};
|
|
39038
39049
|
const items = [];
|
|
39039
39050
|
let page = 1;
|
|
39040
39051
|
while (true) {
|
|
39041
|
-
if (!b.take()) return
|
|
39052
|
+
if (!b.take()) return defer();
|
|
39042
39053
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
39043
39054
|
const list = parseLenient(
|
|
39044
39055
|
DraftListResponseSchema,
|
|
@@ -39052,7 +39063,7 @@ async function syncDrafts(client2, draftsFolderId, store, budget) {
|
|
|
39052
39063
|
}
|
|
39053
39064
|
const rows = [];
|
|
39054
39065
|
for (const item of items) {
|
|
39055
|
-
if (!b.take()) return
|
|
39066
|
+
if (!b.take()) return defer();
|
|
39056
39067
|
const detail = parseLenient(
|
|
39057
39068
|
DraftDetailSchema,
|
|
39058
39069
|
await client2.request("GET", `/pub/v3/messages/${item.id}`),
|
|
@@ -39085,16 +39096,22 @@ async function syncDrafts(client2, draftsFolderId, store, budget) {
|
|
|
39085
39096
|
for (const id of await store.listDraftIds()) {
|
|
39086
39097
|
if (!seenIds.has(id)) await store.deleteDraft(id);
|
|
39087
39098
|
}
|
|
39099
|
+
await store.setMeta(DRAFTS_CACHE_STATUS_KEY, "fresh");
|
|
39088
39100
|
return { synced, done: true };
|
|
39089
39101
|
}
|
|
39090
39102
|
async function syncAll(client2, opts, store) {
|
|
39091
|
-
const
|
|
39103
|
+
const requested = opts.folders ?? ["inbox", "sent", "drafts"];
|
|
39104
|
+
const folders = [
|
|
39105
|
+
...requested.filter((f) => f === "drafts"),
|
|
39106
|
+
...requested.filter((f) => f !== "drafts")
|
|
39107
|
+
];
|
|
39092
39108
|
const budget = makeBudget(opts.maxRequests ?? Number.POSITIVE_INFINITY);
|
|
39093
39109
|
budget.take();
|
|
39094
39110
|
const ids = await resolveFolderIds(client2, store);
|
|
39095
39111
|
const synced = {};
|
|
39096
39112
|
let unreadInbox = [];
|
|
39097
39113
|
let done = true;
|
|
39114
|
+
let draftsUnverified = false;
|
|
39098
39115
|
for (const folder of folders) {
|
|
39099
39116
|
if (folder === "inbox") {
|
|
39100
39117
|
const r = await syncMessageFolder(client2, "inbox", ids.inbox, {
|
|
@@ -39115,11 +39132,17 @@ async function syncAll(client2, opts, store) {
|
|
|
39115
39132
|
if (!r.done) done = false;
|
|
39116
39133
|
} else if (folder === "drafts") {
|
|
39117
39134
|
const r = await syncDrafts(client2, ids.drafts, store, budget);
|
|
39118
|
-
synced.drafts = r.synced;
|
|
39119
|
-
|
|
39135
|
+
if (r.done) synced.drafts = r.synced;
|
|
39136
|
+
else {
|
|
39137
|
+
draftsUnverified = true;
|
|
39138
|
+
done = false;
|
|
39139
|
+
}
|
|
39120
39140
|
}
|
|
39121
39141
|
}
|
|
39122
39142
|
const notes = [];
|
|
39143
|
+
if (draftsUnverified) {
|
|
39144
|
+
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.');
|
|
39145
|
+
}
|
|
39123
39146
|
if (unreadInbox.length > 0) {
|
|
39124
39147
|
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
39148
|
}
|
|
@@ -39130,6 +39153,116 @@ async function syncAll(client2, opts, store) {
|
|
|
39130
39153
|
return { synced, unreadInbox, done, ...note ? { note } : {} };
|
|
39131
39154
|
}
|
|
39132
39155
|
|
|
39156
|
+
// src/tools/draft-freshness.ts
|
|
39157
|
+
var DraftFreshnessError = class extends Error {
|
|
39158
|
+
};
|
|
39159
|
+
var FNV_OFFSET = 0xcbf29ce484222325n;
|
|
39160
|
+
var FNV_PRIME = 0x100000001b3n;
|
|
39161
|
+
var MASK64 = 0xffffffffffffffffn;
|
|
39162
|
+
function fnv1a64(s) {
|
|
39163
|
+
let h = FNV_OFFSET;
|
|
39164
|
+
for (let i = 0; i < s.length; i++) {
|
|
39165
|
+
h = (h ^ BigInt(s.charCodeAt(i))) * FNV_PRIME & MASK64;
|
|
39166
|
+
}
|
|
39167
|
+
return h.toString(16).padStart(16, "0");
|
|
39168
|
+
}
|
|
39169
|
+
function draftRevision(d) {
|
|
39170
|
+
const ids = [...new Set(d.recipients.map((r) => r.userId))].sort((a, b) => a - b);
|
|
39171
|
+
const parts = [d.subject, d.body, String(d.replyToId ?? ""), ids.join(",")];
|
|
39172
|
+
return `r1:${fnv1a64(parts.map((p) => `${p.length}:${p}`).join("|"))}`;
|
|
39173
|
+
}
|
|
39174
|
+
var ServerDraftSchema = external_exports.looseObject({
|
|
39175
|
+
subject: external_exports.string().optional(),
|
|
39176
|
+
body: external_exports.string().optional(),
|
|
39177
|
+
replyToId: external_exports.number().nullable().optional(),
|
|
39178
|
+
recipients: external_exports.array(ApiRecipientSchema).optional()
|
|
39179
|
+
});
|
|
39180
|
+
function isNotFound(e) {
|
|
39181
|
+
return e instanceof Error && /OFW API error: 404\b/.test(e.message);
|
|
39182
|
+
}
|
|
39183
|
+
async function fetchServerDraft(client2, id) {
|
|
39184
|
+
let raw;
|
|
39185
|
+
try {
|
|
39186
|
+
raw = await client2.request("GET", `/pub/v3/messages/${id}`);
|
|
39187
|
+
} catch (e) {
|
|
39188
|
+
if (isNotFound(e)) return null;
|
|
39189
|
+
throw new DraftFreshnessError(
|
|
39190
|
+
`could not read the current state of draft ${id} from OurFamilyWizard: ${e.message}`
|
|
39191
|
+
);
|
|
39192
|
+
}
|
|
39193
|
+
if (raw === null || raw === void 0) return null;
|
|
39194
|
+
const detail = parseLenient(ServerDraftSchema, raw, {
|
|
39195
|
+
label: "ofw-mcp",
|
|
39196
|
+
context: "GET /pub/v3/messages/{id} (draft freshness check)",
|
|
39197
|
+
mode: "strict"
|
|
39198
|
+
});
|
|
39199
|
+
return {
|
|
39200
|
+
subject: detail.subject ?? "",
|
|
39201
|
+
body: detail.body ?? "",
|
|
39202
|
+
replyToId: detail.replyToId ?? null,
|
|
39203
|
+
recipients: mapRecipients(detail.recipients)
|
|
39204
|
+
};
|
|
39205
|
+
}
|
|
39206
|
+
function diffFields(a, b) {
|
|
39207
|
+
const changed = [];
|
|
39208
|
+
if (a.subject !== b.subject) changed.push("subject");
|
|
39209
|
+
if (a.body !== b.body) changed.push("body");
|
|
39210
|
+
if (a.replyToId !== b.replyToId) changed.push("replyToId");
|
|
39211
|
+
const ids = (d) => [...new Set(d.recipients.map((r) => r.userId))].sort((x, y) => x - y).join(",");
|
|
39212
|
+
if (ids(a) !== ids(b)) changed.push("recipients");
|
|
39213
|
+
return changed;
|
|
39214
|
+
}
|
|
39215
|
+
function checkDraftFreshness(input) {
|
|
39216
|
+
const { server, cached: cached2, expectedRevision } = input;
|
|
39217
|
+
if (server === null) {
|
|
39218
|
+
return {
|
|
39219
|
+
verdict: "MISSING",
|
|
39220
|
+
reason: "The draft no longer exists on OurFamilyWizard \u2014 it may have been sent or deleted elsewhere.",
|
|
39221
|
+
changedFields: []
|
|
39222
|
+
};
|
|
39223
|
+
}
|
|
39224
|
+
if (expectedRevision !== void 0) {
|
|
39225
|
+
const actual = draftRevision(server);
|
|
39226
|
+
if (expectedRevision === actual) {
|
|
39227
|
+
return { verdict: "FRESH", reason: "expectedRevision matches the live server draft.", changedFields: [] };
|
|
39228
|
+
}
|
|
39229
|
+
return {
|
|
39230
|
+
verdict: "STALE",
|
|
39231
|
+
reason: `expectedRevision ${expectedRevision} does not match the live server draft (${actual}) \u2014 it changed after you read it.`,
|
|
39232
|
+
changedFields: cached2 === null ? [] : diffFields(server, cached2)
|
|
39233
|
+
};
|
|
39234
|
+
}
|
|
39235
|
+
if (cached2 === null) {
|
|
39236
|
+
return {
|
|
39237
|
+
verdict: "STALE",
|
|
39238
|
+
reason: "This draft is not in the local cache, so there is no base to confirm the edit against.",
|
|
39239
|
+
changedFields: []
|
|
39240
|
+
};
|
|
39241
|
+
}
|
|
39242
|
+
const changedFields = diffFields(server, cached2);
|
|
39243
|
+
if (changedFields.length === 0) {
|
|
39244
|
+
return { verdict: "FRESH", reason: "The cached draft matches the live server draft.", changedFields: [] };
|
|
39245
|
+
}
|
|
39246
|
+
return {
|
|
39247
|
+
verdict: "STALE",
|
|
39248
|
+
reason: `The draft on OurFamilyWizard differs from the cached copy (${changedFields.join(", ")}) \u2014 it was edited outside this tool.`,
|
|
39249
|
+
changedFields
|
|
39250
|
+
};
|
|
39251
|
+
}
|
|
39252
|
+
function staleDraftPayload(input) {
|
|
39253
|
+
const { error: error51, draftId, verdict, server, cached: cached2 } = input;
|
|
39254
|
+
return {
|
|
39255
|
+
error: error51,
|
|
39256
|
+
draftId,
|
|
39257
|
+
verdict: verdict.verdict,
|
|
39258
|
+
reason: verdict.reason,
|
|
39259
|
+
...verdict.changedFields.length > 0 ? { changedFields: verdict.changedFields } : {},
|
|
39260
|
+
...server !== null ? { serverBody: server.body, serverSubject: server.subject, serverRevision: draftRevision(server) } : {},
|
|
39261
|
+
...cached2 !== null ? { cachedBody: cached2.body } : {},
|
|
39262
|
+
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."
|
|
39263
|
+
};
|
|
39264
|
+
}
|
|
39265
|
+
|
|
39133
39266
|
// src/config.ts
|
|
39134
39267
|
import { createHash } from "node:crypto";
|
|
39135
39268
|
import { homedir as homedir3 } from "node:os";
|
|
@@ -39298,7 +39431,11 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39298
39431
|
replyToId: draftRow.replyToId,
|
|
39299
39432
|
chainRootId: null,
|
|
39300
39433
|
listData: draftRow.listData,
|
|
39301
|
-
attachments: []
|
|
39434
|
+
attachments: [],
|
|
39435
|
+
// Concurrency token — pass as expectedRevision to ofw_save_draft /
|
|
39436
|
+
// ofw_delete_draft to assert you are editing THIS version.
|
|
39437
|
+
revision: draftRevision(draftRow),
|
|
39438
|
+
cacheStatus: await getDraftsCacheStatus(cache)
|
|
39302
39439
|
});
|
|
39303
39440
|
}
|
|
39304
39441
|
const cached2 = await cache.getMessage(id);
|
|
@@ -39488,6 +39625,60 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
39488
39625
|
|
|
39489
39626
|
${text}` : text);
|
|
39490
39627
|
});
|
|
39628
|
+
async function guardDestructiveDraftOp(input) {
|
|
39629
|
+
const { cache, draftId, expectedRevision, force, action } = input;
|
|
39630
|
+
const cachedRow = await cache.getDraft(draftId);
|
|
39631
|
+
const cached2 = cachedRow === null ? null : {
|
|
39632
|
+
subject: cachedRow.subject,
|
|
39633
|
+
body: cachedRow.body,
|
|
39634
|
+
recipients: cachedRow.recipients,
|
|
39635
|
+
replyToId: cachedRow.replyToId
|
|
39636
|
+
};
|
|
39637
|
+
let server2;
|
|
39638
|
+
try {
|
|
39639
|
+
server2 = await fetchServerDraft(client2, draftId);
|
|
39640
|
+
} catch (e) {
|
|
39641
|
+
const reason = e.message;
|
|
39642
|
+
if (force) {
|
|
39643
|
+
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.` };
|
|
39644
|
+
}
|
|
39645
|
+
return {
|
|
39646
|
+
ok: false,
|
|
39647
|
+
response: jsonErrorResponse({
|
|
39648
|
+
error: "FRESHNESS_CHECK_FAILED",
|
|
39649
|
+
draftId,
|
|
39650
|
+
reason,
|
|
39651
|
+
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."
|
|
39652
|
+
})
|
|
39653
|
+
};
|
|
39654
|
+
}
|
|
39655
|
+
const verdict = checkDraftFreshness({ server: server2, cached: cached2, expectedRevision });
|
|
39656
|
+
if (verdict.verdict === "FRESH") return { ok: true, note: null };
|
|
39657
|
+
if (force) {
|
|
39658
|
+
console.error(`[ofw-mcp] WARNING: force:true overrode a ${verdict.verdict} verdict on draft ${draftId} (${action}). ${verdict.reason}`);
|
|
39659
|
+
const echoed = server2 === null ? "The draft no longer existed on OurFamilyWizard." : `The server version that was overwritten is preserved below under "overwrittenServerDraft".`;
|
|
39660
|
+
return {
|
|
39661
|
+
ok: true,
|
|
39662
|
+
note: `WARNING: force:true overrode a ${verdict.verdict} freshness verdict on draft ${draftId}. ${verdict.reason} ${echoed}
|
|
39663
|
+
|
|
39664
|
+
${JSON.stringify(
|
|
39665
|
+
{ overwrittenServerDraft: server2 === null ? null : { ...server2, revision: draftRevision(server2) } },
|
|
39666
|
+
null,
|
|
39667
|
+
2
|
|
39668
|
+
)}`
|
|
39669
|
+
};
|
|
39670
|
+
}
|
|
39671
|
+
return {
|
|
39672
|
+
ok: false,
|
|
39673
|
+
response: jsonErrorResponse(staleDraftPayload({
|
|
39674
|
+
error: verdict.verdict === "MISSING" ? "MISSING_DRAFT" : "STALE_DRAFT",
|
|
39675
|
+
draftId,
|
|
39676
|
+
verdict,
|
|
39677
|
+
server: server2,
|
|
39678
|
+
cached: cached2
|
|
39679
|
+
}))
|
|
39680
|
+
};
|
|
39681
|
+
}
|
|
39491
39682
|
server.registerTool("ofw_list_drafts", {
|
|
39492
39683
|
description: "List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.",
|
|
39493
39684
|
annotations: { readOnlyHint: true },
|
|
@@ -39498,12 +39689,21 @@ ${text}` : text);
|
|
|
39498
39689
|
}, async (args) => {
|
|
39499
39690
|
const page = args.page ?? 1;
|
|
39500
39691
|
const size = args.size ?? 50;
|
|
39501
|
-
const
|
|
39502
|
-
const
|
|
39692
|
+
const cache = cacheProvider();
|
|
39693
|
+
const cacheStatus = await getDraftsCacheStatus(cache);
|
|
39694
|
+
const rows = await cache.listDrafts({ page, size });
|
|
39695
|
+
const drafts = rows.map((d) => ({ ...d, revision: draftRevision(d), cacheStatus }));
|
|
39696
|
+
if (drafts.length === 0) {
|
|
39697
|
+
return jsonResponse({ drafts: [], note: "Cache empty. Call ofw_sync_messages to populate." });
|
|
39698
|
+
}
|
|
39699
|
+
const payload = { drafts };
|
|
39700
|
+
if (cacheStatus !== "fresh") {
|
|
39701
|
+
payload.note = 'cacheStatus "unverified": the last ofw_sync_messages did not finish checking the drafts folder against OurFamilyWizard, so these bodies may be behind the server (drafts edited in the OFW web app do not bump any timestamp). Run ofw_sync_messages again before relying on them. Writes are guarded regardless \u2014 ofw_save_draft and ofw_delete_draft re-check the server and refuse a stale overwrite.';
|
|
39702
|
+
}
|
|
39503
39703
|
return jsonResponse(payload);
|
|
39504
39704
|
});
|
|
39505
39705
|
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.",
|
|
39706
|
+
description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft \u2014 note that under the hood this creates a NEW draft and deletes the old one (OFW's update-in-place endpoint silently no-ops while echoing the posted body, so we don't use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if it changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). The refusal returns the current server body under serverBody \u2014 merge your edit into it and retry with expectedRevision.",
|
|
39507
39707
|
annotations: { readOnlyHint: false },
|
|
39508
39708
|
inputSchema: {
|
|
39509
39709
|
subject: external_exports.string().describe("Message subject"),
|
|
@@ -39511,10 +39711,24 @@ ${text}` : text);
|
|
|
39511
39711
|
recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (optional for drafts)").optional(),
|
|
39512
39712
|
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
39713
|
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()
|
|
39714
|
+
myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment)").optional(),
|
|
39715
|
+
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(),
|
|
39716
|
+
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
39717
|
}
|
|
39516
39718
|
}, async (args) => {
|
|
39517
39719
|
const cache = cacheProvider();
|
|
39720
|
+
let forceNote = null;
|
|
39721
|
+
if (args.messageId !== void 0) {
|
|
39722
|
+
const guard = await guardDestructiveDraftOp({
|
|
39723
|
+
cache,
|
|
39724
|
+
draftId: args.messageId,
|
|
39725
|
+
expectedRevision: args.expectedRevision,
|
|
39726
|
+
force: args.force ?? false,
|
|
39727
|
+
action: "replace"
|
|
39728
|
+
});
|
|
39729
|
+
if (!guard.ok) return guard.response;
|
|
39730
|
+
forceNote = guard.note;
|
|
39731
|
+
}
|
|
39518
39732
|
const requestedReplyTo = args.replyToId ?? null;
|
|
39519
39733
|
let resolvedReplyTo = requestedReplyTo;
|
|
39520
39734
|
let rewriteNote = null;
|
|
@@ -39543,6 +39757,7 @@ ${text}` : text);
|
|
|
39543
39757
|
let persisted = null;
|
|
39544
39758
|
let replaceNote = null;
|
|
39545
39759
|
let verifyNote = null;
|
|
39760
|
+
let newRevision = null;
|
|
39546
39761
|
if (newId !== null) {
|
|
39547
39762
|
verifyNote = verifyWriteLanded("draft", { subject: args.subject, body: args.body }, detail);
|
|
39548
39763
|
persisted = {
|
|
@@ -39555,33 +39770,48 @@ ${text}` : text);
|
|
|
39555
39770
|
listData: detail
|
|
39556
39771
|
};
|
|
39557
39772
|
await cache.upsertDraft(persisted);
|
|
39773
|
+
newRevision = draftRevision(persisted);
|
|
39558
39774
|
if (args.messageId !== void 0 && args.messageId !== newId) {
|
|
39559
39775
|
try {
|
|
39560
39776
|
await deleteOFWMessages(client2, [args.messageId]);
|
|
39561
39777
|
await cache.deleteDraft(args.messageId);
|
|
39562
39778
|
replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.)`;
|
|
39563
39779
|
} catch (e) {
|
|
39564
|
-
replaceNote = `WARNING: New draft ${newId} created successfully, but
|
|
39780
|
+
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
39781
|
}
|
|
39566
39782
|
}
|
|
39567
39783
|
}
|
|
39568
|
-
const responseObj = persisted
|
|
39784
|
+
const responseObj = persisted !== null ? { ...persisted, revision: newRevision, cacheStatus: "fresh" } : raw;
|
|
39569
39785
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
|
|
39570
|
-
const notes = [rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join("\n\n");
|
|
39786
|
+
const notes = [forceNote, rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join("\n\n");
|
|
39571
39787
|
return textResponse(notes ? `${notes}
|
|
39572
39788
|
|
|
39573
39789
|
${text}` : text);
|
|
39574
39790
|
});
|
|
39575
39791
|
if (allowDrafts) server.registerTool("ofw_delete_draft", {
|
|
39576
|
-
description: "Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.",
|
|
39792
|
+
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
39793
|
annotations: { destructiveHint: true },
|
|
39578
39794
|
inputSchema: {
|
|
39579
|
-
messageId: external_exports.number().describe("Draft message ID to delete")
|
|
39795
|
+
messageId: external_exports.number().describe("Draft message ID to delete"),
|
|
39796
|
+
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(),
|
|
39797
|
+
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
39798
|
}
|
|
39581
39799
|
}, async (args) => {
|
|
39800
|
+
const cache = cacheProvider();
|
|
39801
|
+
const guard = await guardDestructiveDraftOp({
|
|
39802
|
+
cache,
|
|
39803
|
+
draftId: args.messageId,
|
|
39804
|
+
expectedRevision: args.expectedRevision,
|
|
39805
|
+
force: args.force ?? false,
|
|
39806
|
+
action: "delete"
|
|
39807
|
+
});
|
|
39808
|
+
if (!guard.ok) return guard.response;
|
|
39582
39809
|
const data = await deleteOFWMessages(client2, [args.messageId]);
|
|
39583
|
-
await
|
|
39584
|
-
|
|
39810
|
+
await cache.deleteDraft(args.messageId);
|
|
39811
|
+
const text = data ? JSON.stringify(data, null, 2) : "Draft deleted.";
|
|
39812
|
+
return textResponse(guard.note ? `${guard.note}
|
|
39813
|
+
|
|
39814
|
+
${text}` : text);
|
|
39585
39815
|
});
|
|
39586
39816
|
server.registerTool("ofw_get_unread_sent", {
|
|
39587
39817
|
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.",
|
|
@@ -40572,7 +40802,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
|
|
|
40572
40802
|
var nodeAttachmentIO = new NodeAttachmentIO();
|
|
40573
40803
|
await runMcp({
|
|
40574
40804
|
name: "ofw",
|
|
40575
|
-
version: "2.6.
|
|
40805
|
+
version: "2.6.7",
|
|
40576
40806
|
// x-release-please-version
|
|
40577
40807
|
deps: client,
|
|
40578
40808
|
tools: [
|
package/dist/index.js
CHANGED
|
@@ -35,7 +35,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
|
|
|
35
35
|
// always succeeds before any credential check runs.
|
|
36
36
|
await runMcp({
|
|
37
37
|
name: 'ofw',
|
|
38
|
-
version: '2.6.
|
|
38
|
+
version: '2.6.7', // x-release-please-version
|
|
39
39
|
deps: client,
|
|
40
40
|
tools: [
|
|
41
41
|
registerUserTools,
|
package/dist/sync.js
CHANGED
|
@@ -323,9 +323,28 @@ const DraftDetailSchema = z.looseObject({
|
|
|
323
323
|
body: z.string().optional(),
|
|
324
324
|
subject: z.string().optional(),
|
|
325
325
|
});
|
|
326
|
+
/**
|
|
327
|
+
* Meta key holding whether the drafts cache has been compared against OFW.
|
|
328
|
+
* `'fresh'` only after a COMPLETE drafts walk; `'unverified'` whenever a walk
|
|
329
|
+
* was deferred for budget. Read by ofw_list_drafts / ofw_get_message to stamp
|
|
330
|
+
* each draft's `cacheStatus`, and by the destructive draft tools to decide how
|
|
331
|
+
* loudly to warn. Absent (never synced) reads as unverified.
|
|
332
|
+
*/
|
|
333
|
+
export const DRAFTS_CACHE_STATUS_KEY = 'drafts_cache_status';
|
|
334
|
+
export async function getDraftsCacheStatus(store) {
|
|
335
|
+
return (await store.getMeta(DRAFTS_CACHE_STATUS_KEY)) === 'fresh' ? 'fresh' : 'unverified';
|
|
336
|
+
}
|
|
326
337
|
export async function syncDrafts(client, draftsFolderId, store, budget) {
|
|
327
338
|
// No budget → unbounded (local stdio): identical to the original walk.
|
|
328
339
|
const b = budget ?? makeBudget(Number.POSITIVE_INFINITY);
|
|
340
|
+
// Deferring means we never compared the drafts cache to OFW on this call.
|
|
341
|
+
// Mark it unverified so reads can say so and the destructive draft tools
|
|
342
|
+
// know the cache is not a trustworthy base — the count we return here is
|
|
343
|
+
// "nothing applied", NOT "nothing changed on the server".
|
|
344
|
+
const defer = async () => {
|
|
345
|
+
await store.setMeta(DRAFTS_CACHE_STATUS_KEY, 'unverified');
|
|
346
|
+
return { synced: 0, done: false };
|
|
347
|
+
};
|
|
329
348
|
// The reconciliation step below DELETES any cached draft not seen in the
|
|
330
349
|
// listing, so a partial walk must apply NOTHING. We therefore buffer the
|
|
331
350
|
// entire walk (all list pages + every detail) BEFORE touching the cache: if
|
|
@@ -337,7 +356,7 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
|
|
|
337
356
|
let page = 1;
|
|
338
357
|
while (true) {
|
|
339
358
|
if (!b.take())
|
|
340
|
-
return
|
|
359
|
+
return defer();
|
|
341
360
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
342
361
|
const list = parseLenient(DraftListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: 'GET /pub/v3/messages?folders={drafts}' });
|
|
343
362
|
const pageItems = list.data ?? [];
|
|
@@ -352,7 +371,7 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
|
|
|
352
371
|
const rows = [];
|
|
353
372
|
for (const item of items) {
|
|
354
373
|
if (!b.take())
|
|
355
|
-
return
|
|
374
|
+
return defer();
|
|
356
375
|
const detail = parseLenient(DraftDetailSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (drafts sync)' });
|
|
357
376
|
rows.push({
|
|
358
377
|
id: item.id,
|
|
@@ -390,10 +409,25 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
|
|
|
390
409
|
if (!seenIds.has(id))
|
|
391
410
|
await store.deleteDraft(id);
|
|
392
411
|
}
|
|
412
|
+
// The complete walk fetched every draft's DETAIL and reconciled deletions, so
|
|
413
|
+
// the cache is now known-equal to the server. Only here is `synced: 0`
|
|
414
|
+
// truthful as "verified no changes".
|
|
415
|
+
await store.setMeta(DRAFTS_CACHE_STATUS_KEY, 'fresh');
|
|
393
416
|
return { synced, done: true };
|
|
394
417
|
}
|
|
395
418
|
export async function syncAll(client, opts, store) {
|
|
396
|
-
const
|
|
419
|
+
const requested = opts.folders ?? ['inbox', 'sent', 'drafts'];
|
|
420
|
+
// Drafts go FIRST. They are the only folder a destructive tool
|
|
421
|
+
// (ofw_save_draft / ofw_delete_draft) reads as its base, and they are cheap
|
|
422
|
+
// and bounded — one list page plus one detail per draft. Running them last,
|
|
423
|
+
// behind inbox and sent, meant a bounded call (the Worker's
|
|
424
|
+
// OFW_SYNC_MAX_REQUESTS=40) spent its whole budget backfilling history and
|
|
425
|
+
// deferred drafts on every single call, so server-side draft edits stayed
|
|
426
|
+
// invisible indefinitely while the response reported `drafts: 0`.
|
|
427
|
+
const folders = [
|
|
428
|
+
...requested.filter((f) => f === 'drafts'),
|
|
429
|
+
...requested.filter((f) => f !== 'drafts'),
|
|
430
|
+
];
|
|
397
431
|
// ONE budget shared across resolveFolderIds and every requested folder, in
|
|
398
432
|
// order — so the whole invocation stays under the hosting subrequest cap.
|
|
399
433
|
const budget = makeBudget(opts.maxRequests ?? Number.POSITIVE_INFINITY);
|
|
@@ -405,6 +439,7 @@ export async function syncAll(client, opts, store) {
|
|
|
405
439
|
const synced = {};
|
|
406
440
|
let unreadInbox = [];
|
|
407
441
|
let done = true;
|
|
442
|
+
let draftsUnverified = false;
|
|
408
443
|
for (const folder of folders) {
|
|
409
444
|
if (folder === 'inbox') {
|
|
410
445
|
const r = await syncMessageFolder(client, 'inbox', ids.inbox, {
|
|
@@ -429,12 +464,22 @@ export async function syncAll(client, opts, store) {
|
|
|
429
464
|
}
|
|
430
465
|
else if (folder === 'drafts') {
|
|
431
466
|
const r = await syncDrafts(client, ids.drafts, store, budget);
|
|
432
|
-
|
|
433
|
-
|
|
467
|
+
// Only report a drafts count when the walk actually compared against
|
|
468
|
+
// OFW. A deferred walk applied nothing, and reporting its `0` as
|
|
469
|
+
// `drafts: 0` reads as "verified, no changes" — the exact lie that let a
|
|
470
|
+
// server-side draft edit be overwritten. Omit the number instead.
|
|
471
|
+
if (r.done)
|
|
472
|
+
synced.drafts = r.synced;
|
|
473
|
+
else {
|
|
474
|
+
draftsUnverified = true;
|
|
434
475
|
done = false;
|
|
476
|
+
}
|
|
435
477
|
}
|
|
436
478
|
}
|
|
437
479
|
const notes = [];
|
|
480
|
+
if (draftsUnverified) {
|
|
481
|
+
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 — or ofw_sync_messages with folders:["drafts"] — before editing or deleting a draft.');
|
|
482
|
+
}
|
|
438
483
|
if (unreadInbox.length > 0) {
|
|
439
484
|
notes.push(`${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them — this will mark them as read on OFW.`);
|
|
440
485
|
}
|
package/dist/tools/_shared.js
CHANGED
|
@@ -6,6 +6,13 @@ import { parseLenient } from '@chrischall/mcp-utils';
|
|
|
6
6
|
export const jsonResponse = textResult;
|
|
7
7
|
// Raw-string tool result. Wrapper over @chrischall/mcp-utils' `rawTextResult`.
|
|
8
8
|
export const textResponse = rawTextResult;
|
|
9
|
+
// A STRUCTURED failure: the machine-readable payload of `jsonResponse` plus
|
|
10
|
+
// `isError`, so a refusal can carry recovery data (e.g. the server draft body
|
|
11
|
+
// we declined to overwrite) without being mistaken for a successful write.
|
|
12
|
+
// mcp-utils' `errorResult` only carries a string.
|
|
13
|
+
export function jsonErrorResponse(data) {
|
|
14
|
+
return { ...textResult(data), isError: true };
|
|
15
|
+
}
|
|
9
16
|
// OFW API shape for `recipients[]` on message/draft list and detail
|
|
10
17
|
// responses. Used wherever we validate the response of a `/pub/v3/messages*`
|
|
11
18
|
// call. Loose: unknown keys pass through (and survive into cached listData).
|
|
@@ -74,23 +81,35 @@ function scrapeSaysRead(listData) {
|
|
|
74
81
|
* resync (which re-scrapes the list flags) can never flip a read message back
|
|
75
82
|
* to unread:
|
|
76
83
|
*
|
|
77
|
-
* - INBOX: the account holder is the recipient
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
84
|
+
* - INBOX: the account holder is the recipient, so ANY recipient's `viewedAt`
|
|
85
|
+
* counts. OFW co-parent threads are 1:1 — the sole inbox recipient is us —
|
|
86
|
+
* so this is exact, not an approximation. Fetching the body marks the message
|
|
87
|
+
* read on OFW, so a non-null `fetchedBodyAt` is also read=true. The stale
|
|
88
|
+
* scrape flag is only a last-resort fallback.
|
|
82
89
|
* - SENT: "read" means a *recipient* has opened it — tracked via their
|
|
83
90
|
* `viewedAt` (the detail endpoint's real timestamp) — never our own body
|
|
84
91
|
* fetch, which is always set for sent messages.
|
|
92
|
+
*
|
|
93
|
+
* This deliberately does NOT discriminate by the account holder's own userId.
|
|
94
|
+
* An earlier `selfUserId` parameter did, but nothing ever passed it, so the
|
|
95
|
+
* branch was dead in production. Reviving it is not as simple as threading the
|
|
96
|
+
* argument through, for two reasons:
|
|
97
|
+
* 1. No non-mutating endpoint exposes our numeric id. /pub/v2/profiles returns
|
|
98
|
+
* name/address/contact and no id at all; /pub/v1/users/useraccountstatus
|
|
99
|
+
* updates last-seen status as a side effect, and view timestamps are
|
|
100
|
+
* evidentiary in custody matters — not something to touch for a read flag.
|
|
101
|
+
* 2. Rows cached before the `user.userId` parse fix (see ApiRecipientSchema)
|
|
102
|
+
* normalized every recipient to `userId: 0`, so an id match would silently
|
|
103
|
+
* fail on historical data until a full re-sync.
|
|
104
|
+
* If OFW ever adds third-party recipients (lawyer, parenting coordinator), both
|
|
105
|
+
* problems need solving together — a bare parameter would regress to dead code.
|
|
85
106
|
*/
|
|
86
|
-
export function deriveRead(row
|
|
107
|
+
export function deriveRead(row) {
|
|
108
|
+
const viewedByAnyone = row.recipients.some((r) => r.viewedAt !== null);
|
|
87
109
|
if (row.folder === 'inbox') {
|
|
88
|
-
|
|
89
|
-
? row.recipients.some((r) => r.userId === selfUserId && r.viewedAt !== null)
|
|
90
|
-
: row.recipients.some((r) => r.viewedAt !== null);
|
|
91
|
-
return viewed || row.fetchedBodyAt !== null || scrapeSaysRead(row.listData);
|
|
110
|
+
return viewedByAnyone || row.fetchedBodyAt !== null || scrapeSaysRead(row.listData);
|
|
92
111
|
}
|
|
93
|
-
return
|
|
112
|
+
return viewedByAnyone || scrapeSaysRead(row.listData);
|
|
94
113
|
}
|
|
95
114
|
/**
|
|
96
115
|
* Return the row augmented with an authoritative top-level `read` boolean and a
|
|
@@ -99,8 +118,8 @@ export function deriveRead(row, selfUserId) {
|
|
|
99
118
|
* carrying `listData.read: false` alongside a populated recipient `viewedAt`).
|
|
100
119
|
* A non-object `listData` (null / legacy string) is passed through untouched.
|
|
101
120
|
*/
|
|
102
|
-
export function withReadState(row
|
|
103
|
-
const read = deriveRead(row
|
|
121
|
+
export function withReadState(row) {
|
|
122
|
+
const read = deriveRead(row);
|
|
104
123
|
const listData = (typeof row.listData === 'object' && row.listData !== null)
|
|
105
124
|
? { ...row.listData, read, showNeverViewed: !read }
|
|
106
125
|
: row.listData;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { parseLenient } from '@chrischall/mcp-utils';
|
|
3
|
+
import { ApiRecipientSchema, mapRecipients } from './_shared.js';
|
|
4
|
+
/** Thrown when the freshness check itself could not be completed. */
|
|
5
|
+
export class DraftFreshnessError extends Error {
|
|
6
|
+
}
|
|
7
|
+
// FNV-1a (64-bit) over a canonical encoding. Not cryptographic — this is a
|
|
8
|
+
// change detector, and it is never the sole guard: an unsupplied token falls
|
|
9
|
+
// back to a full field-by-field comparison against the cached base.
|
|
10
|
+
// BigInt keeps it byte-identical on node and on the Workers runtime.
|
|
11
|
+
const FNV_OFFSET = 0xcbf29ce484222325n;
|
|
12
|
+
const FNV_PRIME = 0x100000001b3n;
|
|
13
|
+
const MASK64 = 0xffffffffffffffffn;
|
|
14
|
+
function fnv1a64(s) {
|
|
15
|
+
let h = FNV_OFFSET;
|
|
16
|
+
for (let i = 0; i < s.length; i++) {
|
|
17
|
+
h = (h ^ BigInt(s.charCodeAt(i))) * FNV_PRIME & MASK64;
|
|
18
|
+
}
|
|
19
|
+
return h.toString(16).padStart(16, '0');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A stable content revision for a draft. Callers get this back from
|
|
23
|
+
* `ofw_list_drafts` / `ofw_get_message` and pass it to `ofw_save_draft` /
|
|
24
|
+
* `ofw_delete_draft` as `expectedRevision`.
|
|
25
|
+
*
|
|
26
|
+
* Recipients reduce to a SORTED set of user ids: their display names and
|
|
27
|
+
* `viewedAt` are presentation detail that differs between a list-sourced and a
|
|
28
|
+
* detail-sourced copy of the same draft, and would otherwise produce a false
|
|
29
|
+
* STALE. Fields are length-prefixed so content cannot shift across a field
|
|
30
|
+
* boundary without changing the hash.
|
|
31
|
+
*/
|
|
32
|
+
export function draftRevision(d) {
|
|
33
|
+
const ids = [...new Set(d.recipients.map((r) => r.userId))].sort((a, b) => a - b);
|
|
34
|
+
const parts = [d.subject, d.body, String(d.replyToId ?? ''), ids.join(',')];
|
|
35
|
+
return `r1:${fnv1a64(parts.map((p) => `${p.length}:${p}`).join('|'))}`;
|
|
36
|
+
}
|
|
37
|
+
const ServerDraftSchema = z.looseObject({
|
|
38
|
+
subject: z.string().optional(),
|
|
39
|
+
body: z.string().optional(),
|
|
40
|
+
replyToId: z.number().nullable().optional(),
|
|
41
|
+
recipients: z.array(ApiRecipientSchema).optional(),
|
|
42
|
+
});
|
|
43
|
+
function isNotFound(e) {
|
|
44
|
+
return e instanceof Error && /OFW API error: 404\b/.test(e.message);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Read a draft's AUTHORITATIVE state straight from OFW, bypassing the cache.
|
|
48
|
+
*
|
|
49
|
+
* Returns `null` when the draft no longer exists (404). Any other failure
|
|
50
|
+
* throws `DraftFreshnessError`: a freshness check that could not run must
|
|
51
|
+
* abort the write, never wave it through — see the callers in messages.ts.
|
|
52
|
+
*/
|
|
53
|
+
export async function fetchServerDraft(client, id) {
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = await client.request('GET', `/pub/v3/messages/${id}`);
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
if (isNotFound(e))
|
|
60
|
+
return null;
|
|
61
|
+
throw new DraftFreshnessError(`could not read the current state of draft ${id} from OurFamilyWizard: ${e.message}`);
|
|
62
|
+
}
|
|
63
|
+
// An empty/null body is OFW's other way of saying "no such message". Treat
|
|
64
|
+
// it as MISSING — which still ABORTS the write — rather than letting the
|
|
65
|
+
// strict parse throw an opaque shape error.
|
|
66
|
+
if (raw === null || raw === undefined)
|
|
67
|
+
return null;
|
|
68
|
+
const detail = parseLenient(ServerDraftSchema, raw, {
|
|
69
|
+
label: 'ofw-mcp',
|
|
70
|
+
context: 'GET /pub/v3/messages/{id} (draft freshness check)',
|
|
71
|
+
mode: 'strict',
|
|
72
|
+
});
|
|
73
|
+
return {
|
|
74
|
+
subject: detail.subject ?? '',
|
|
75
|
+
body: detail.body ?? '',
|
|
76
|
+
replyToId: detail.replyToId ?? null,
|
|
77
|
+
recipients: mapRecipients(detail.recipients),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function diffFields(a, b) {
|
|
81
|
+
const changed = [];
|
|
82
|
+
if (a.subject !== b.subject)
|
|
83
|
+
changed.push('subject');
|
|
84
|
+
if (a.body !== b.body)
|
|
85
|
+
changed.push('body');
|
|
86
|
+
if (a.replyToId !== b.replyToId)
|
|
87
|
+
changed.push('replyToId');
|
|
88
|
+
const ids = (d) => [...new Set(d.recipients.map((r) => r.userId))].sort((x, y) => x - y).join(',');
|
|
89
|
+
if (ids(a) !== ids(b))
|
|
90
|
+
changed.push('recipients');
|
|
91
|
+
return changed;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Decide whether it is safe to destroy the server's copy of a draft.
|
|
95
|
+
*
|
|
96
|
+
* Two independent ways to earn FRESH, in priority order:
|
|
97
|
+
*
|
|
98
|
+
* 1. `expectedRevision` matches the live server revision. The caller has named
|
|
99
|
+
* the exact server state it edited from, which is what optimistic
|
|
100
|
+
* concurrency asserts. A stale cached copy alongside a matching token is
|
|
101
|
+
* not evidence of a conflict, so the token wins.
|
|
102
|
+
* 2. No token supplied → the cached base must match the server EXACTLY. This
|
|
103
|
+
* is the safe default: "no token" never means "force".
|
|
104
|
+
*
|
|
105
|
+
* Everything else — server ahead of cache, no cached base to compare, draft
|
|
106
|
+
* gone from the server — refuses.
|
|
107
|
+
*/
|
|
108
|
+
export function checkDraftFreshness(input) {
|
|
109
|
+
const { server, cached, expectedRevision } = input;
|
|
110
|
+
if (server === null) {
|
|
111
|
+
return {
|
|
112
|
+
verdict: 'MISSING',
|
|
113
|
+
reason: 'The draft no longer exists on OurFamilyWizard — it may have been sent or deleted elsewhere.',
|
|
114
|
+
changedFields: [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (expectedRevision !== undefined) {
|
|
118
|
+
const actual = draftRevision(server);
|
|
119
|
+
if (expectedRevision === actual) {
|
|
120
|
+
return { verdict: 'FRESH', reason: 'expectedRevision matches the live server draft.', changedFields: [] };
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
verdict: 'STALE',
|
|
124
|
+
reason: `expectedRevision ${expectedRevision} does not match the live server draft (${actual}) — it changed after you read it.`,
|
|
125
|
+
changedFields: cached === null ? [] : diffFields(server, cached),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (cached === null) {
|
|
129
|
+
return {
|
|
130
|
+
verdict: 'STALE',
|
|
131
|
+
reason: 'This draft is not in the local cache, so there is no base to confirm the edit against.',
|
|
132
|
+
changedFields: [],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const changedFields = diffFields(server, cached);
|
|
136
|
+
if (changedFields.length === 0) {
|
|
137
|
+
return { verdict: 'FRESH', reason: 'The cached draft matches the live server draft.', changedFields: [] };
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
verdict: 'STALE',
|
|
141
|
+
reason: `The draft on OurFamilyWizard differs from the cached copy (${changedFields.join(', ')}) — it was edited outside this tool.`,
|
|
142
|
+
changedFields,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Build the structured refusal returned when a destructive draft op is blocked.
|
|
147
|
+
* ALWAYS carries the current server body when there is one, so the content we
|
|
148
|
+
* declined to overwrite is recoverable from the tool result itself.
|
|
149
|
+
*/
|
|
150
|
+
export function staleDraftPayload(input) {
|
|
151
|
+
const { error, draftId, verdict, server, cached } = input;
|
|
152
|
+
return {
|
|
153
|
+
error,
|
|
154
|
+
draftId,
|
|
155
|
+
verdict: verdict.verdict,
|
|
156
|
+
reason: verdict.reason,
|
|
157
|
+
...(verdict.changedFields.length > 0 ? { changedFields: verdict.changedFields } : {}),
|
|
158
|
+
...(server !== null
|
|
159
|
+
? { serverBody: server.body, serverSubject: server.subject, serverRevision: draftRevision(server) }
|
|
160
|
+
: {}),
|
|
161
|
+
...(cached !== null ? { cachedBody: cached.body } : {}),
|
|
162
|
+
recovery: server === null
|
|
163
|
+
? '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.'
|
|
164
|
+
: '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.',
|
|
165
|
+
};
|
|
166
|
+
}
|
package/dist/tools/messages.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage } from '../sync.js';
|
|
2
|
+
import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage, getDraftsCacheStatus } from '../sync.js';
|
|
3
|
+
import { checkDraftFreshness, draftRevision, fetchServerDraft, staleDraftPayload, } from './draft-freshness.js';
|
|
3
4
|
import { getAttachmentsDir, getDefaultInlineAttachments, getSyncMaxRequests, getWriteMode } from '../config.js';
|
|
4
5
|
import { basename, join } from 'node:path';
|
|
5
|
-
import { ApiRecipientSchema, expandPath, hasRealView, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
|
|
6
|
+
import { ApiRecipientSchema, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
|
|
6
7
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
7
8
|
// Schemas for the load-bearing fields of each /pub/v3 response this file
|
|
8
9
|
// reads (issue #83). Loose: unknown keys pass through into cached listData.
|
|
@@ -159,6 +160,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
159
160
|
chainRootId: null,
|
|
160
161
|
listData: draftRow.listData,
|
|
161
162
|
attachments: [],
|
|
163
|
+
// Concurrency token — pass as expectedRevision to ofw_save_draft /
|
|
164
|
+
// ofw_delete_draft to assert you are editing THIS version.
|
|
165
|
+
revision: draftRevision(draftRow),
|
|
166
|
+
cacheStatus: await getDraftsCacheStatus(cache),
|
|
162
167
|
});
|
|
163
168
|
}
|
|
164
169
|
const cached = await cache.getMessage(id);
|
|
@@ -376,6 +381,64 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
376
381
|
const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join('\n\n');
|
|
377
382
|
return textResponse(notes ? `${notes}\n\n${text}` : text);
|
|
378
383
|
});
|
|
384
|
+
async function guardDestructiveDraftOp(input) {
|
|
385
|
+
const { cache, draftId, expectedRevision, force, action } = input;
|
|
386
|
+
const cachedRow = await cache.getDraft(draftId);
|
|
387
|
+
const cached = cachedRow === null ? null : {
|
|
388
|
+
subject: cachedRow.subject,
|
|
389
|
+
body: cachedRow.body,
|
|
390
|
+
recipients: cachedRow.recipients,
|
|
391
|
+
replyToId: cachedRow.replyToId,
|
|
392
|
+
};
|
|
393
|
+
let server;
|
|
394
|
+
try {
|
|
395
|
+
server = await fetchServerDraft(client, draftId);
|
|
396
|
+
}
|
|
397
|
+
catch (e) {
|
|
398
|
+
// fetchServerDraft funnels every non-404 failure into DraftFreshnessError,
|
|
399
|
+
// so anything landing here means the check could not RUN. That is not
|
|
400
|
+
// permission to proceed: a transient 5xx must not degrade into a blind
|
|
401
|
+
// overwrite.
|
|
402
|
+
const reason = e.message;
|
|
403
|
+
if (force) {
|
|
404
|
+
return { ok: true, note: `WARNING: force:true — 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.` };
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
ok: false,
|
|
408
|
+
response: jsonErrorResponse({
|
|
409
|
+
error: 'FRESHNESS_CHECK_FAILED',
|
|
410
|
+
draftId,
|
|
411
|
+
reason,
|
|
412
|
+
recovery: 'Nothing was changed. This is usually transient — retry. If it persists, verify the draft on ourfamilywizard.com. Pass force:true only if you accept overwriting a version you have not seen.',
|
|
413
|
+
}),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
const verdict = checkDraftFreshness({ server, cached, expectedRevision });
|
|
417
|
+
if (verdict.verdict === 'FRESH')
|
|
418
|
+
return { ok: true, note: null };
|
|
419
|
+
if (force) {
|
|
420
|
+
// Loud, and the overwritten content rides along in the response so it is
|
|
421
|
+
// recoverable from the tool result itself.
|
|
422
|
+
console.error(`[ofw-mcp] WARNING: force:true overrode a ${verdict.verdict} verdict on draft ${draftId} (${action}). ${verdict.reason}`);
|
|
423
|
+
const echoed = server === null
|
|
424
|
+
? 'The draft no longer existed on OurFamilyWizard.'
|
|
425
|
+
: `The server version that was overwritten is preserved below under "overwrittenServerDraft".`;
|
|
426
|
+
return {
|
|
427
|
+
ok: true,
|
|
428
|
+
note: `WARNING: force:true overrode a ${verdict.verdict} freshness verdict on draft ${draftId}. ${verdict.reason} ${echoed}\n\n${JSON.stringify({ overwrittenServerDraft: server === null ? null : { ...server, revision: draftRevision(server) } }, null, 2)}`,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
return {
|
|
432
|
+
ok: false,
|
|
433
|
+
response: jsonErrorResponse(staleDraftPayload({
|
|
434
|
+
error: verdict.verdict === 'MISSING' ? 'MISSING_DRAFT' : 'STALE_DRAFT',
|
|
435
|
+
draftId,
|
|
436
|
+
verdict,
|
|
437
|
+
server,
|
|
438
|
+
cached,
|
|
439
|
+
})),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
379
442
|
server.registerTool('ofw_list_drafts', {
|
|
380
443
|
description: 'List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.',
|
|
381
444
|
annotations: { readOnlyHint: true },
|
|
@@ -386,15 +449,24 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
386
449
|
}, async (args) => {
|
|
387
450
|
const page = args.page ?? 1;
|
|
388
451
|
const size = args.size ?? 50;
|
|
389
|
-
const
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
452
|
+
const cache = cacheProvider();
|
|
453
|
+
const cacheStatus = await getDraftsCacheStatus(cache);
|
|
454
|
+
const rows = await cache.listDrafts({ page, size });
|
|
455
|
+
// Every draft carries the concurrency token to echo back on a write, plus
|
|
456
|
+
// whether the last sync actually compared this cache against OFW.
|
|
457
|
+
const drafts = rows.map((d) => ({ ...d, revision: draftRevision(d), cacheStatus }));
|
|
458
|
+
if (drafts.length === 0) {
|
|
459
|
+
return jsonResponse({ drafts: [], note: 'Cache empty. Call ofw_sync_messages to populate.' });
|
|
460
|
+
}
|
|
461
|
+
const payload = { drafts };
|
|
462
|
+
if (cacheStatus !== 'fresh') {
|
|
463
|
+
payload.note = 'cacheStatus "unverified": the last ofw_sync_messages did not finish checking the drafts folder against OurFamilyWizard, so these bodies may be behind the server (drafts edited in the OFW web app do not bump any timestamp). Run ofw_sync_messages again before relying on them. Writes are guarded regardless — ofw_save_draft and ofw_delete_draft re-check the server and refuse a stale overwrite.';
|
|
464
|
+
}
|
|
393
465
|
return jsonResponse(payload);
|
|
394
466
|
});
|
|
395
467
|
if (allowDrafts)
|
|
396
468
|
server.registerTool('ofw_save_draft', {
|
|
397
|
-
description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft — 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.',
|
|
469
|
+
description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft — 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 — merge your edit into it and retry with expectedRevision.',
|
|
398
470
|
annotations: { readOnlyHint: false },
|
|
399
471
|
inputSchema: {
|
|
400
472
|
subject: z.string().describe('Message subject'),
|
|
@@ -403,9 +475,26 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
403
475
|
messageId: z.number().describe('ID of an existing draft to replace (the new draft will have a new id; the old is deleted)').optional(),
|
|
404
476
|
replyToId: z.number().describe('ID of the message this draft replies to').optional(),
|
|
405
477
|
myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
|
|
478
|
+
expectedRevision: z.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 — omitting never means "overwrite anyway".').optional(),
|
|
479
|
+
force: z.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(),
|
|
406
480
|
},
|
|
407
481
|
}, async (args) => {
|
|
408
482
|
const cache = cacheProvider();
|
|
483
|
+
// Guard BEFORE the POST: refusing after creating a replacement would leave
|
|
484
|
+
// a stray draft behind for a write we then decline to finish.
|
|
485
|
+
let forceNote = null;
|
|
486
|
+
if (args.messageId !== undefined) {
|
|
487
|
+
const guard = await guardDestructiveDraftOp({
|
|
488
|
+
cache,
|
|
489
|
+
draftId: args.messageId,
|
|
490
|
+
expectedRevision: args.expectedRevision,
|
|
491
|
+
force: args.force ?? false,
|
|
492
|
+
action: 'replace',
|
|
493
|
+
});
|
|
494
|
+
if (!guard.ok)
|
|
495
|
+
return guard.response;
|
|
496
|
+
forceNote = guard.note;
|
|
497
|
+
}
|
|
409
498
|
const requestedReplyTo = args.replyToId ?? null;
|
|
410
499
|
let resolvedReplyTo = requestedReplyTo;
|
|
411
500
|
let rewriteNote = null;
|
|
@@ -435,6 +524,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
435
524
|
let persisted = null;
|
|
436
525
|
let replaceNote = null;
|
|
437
526
|
let verifyNote = null;
|
|
527
|
+
let newRevision = null;
|
|
438
528
|
if (newId !== null) {
|
|
439
529
|
verifyNote = verifyWriteLanded('draft', { subject: args.subject, body: args.body }, detail);
|
|
440
530
|
persisted = {
|
|
@@ -447,6 +537,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
447
537
|
listData: detail,
|
|
448
538
|
};
|
|
449
539
|
await cache.upsertDraft(persisted);
|
|
540
|
+
newRevision = draftRevision(persisted);
|
|
450
541
|
// Replace-path: caller passed messageId, so they want the old draft
|
|
451
542
|
// gone. Delete it after the new one is safely created+cached.
|
|
452
543
|
if (args.messageId !== undefined && args.messageId !== newId) {
|
|
@@ -456,26 +547,44 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
456
547
|
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.)`;
|
|
457
548
|
}
|
|
458
549
|
catch (e) {
|
|
459
|
-
|
|
550
|
+
// Partial-failure safety: the new draft is already created and
|
|
551
|
+
// cached, so BOTH drafts now exist. That is the correct end state —
|
|
552
|
+
// deleting first and failing to create would have lost the content.
|
|
553
|
+
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.`;
|
|
460
554
|
}
|
|
461
555
|
}
|
|
462
556
|
}
|
|
463
|
-
const responseObj = persisted
|
|
557
|
+
const responseObj = persisted !== null
|
|
558
|
+
? { ...persisted, revision: newRevision, cacheStatus: 'fresh' }
|
|
559
|
+
: raw;
|
|
464
560
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
|
|
465
|
-
const notes = [rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join('\n\n');
|
|
561
|
+
const notes = [forceNote, rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join('\n\n');
|
|
466
562
|
return textResponse(notes ? `${notes}\n\n${text}` : text);
|
|
467
563
|
});
|
|
468
564
|
if (allowDrafts)
|
|
469
565
|
server.registerTool('ofw_delete_draft', {
|
|
470
|
-
description: 'Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.',
|
|
566
|
+
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) — pass expectedRevision to assert which version you mean, or force:true to delete regardless.',
|
|
471
567
|
annotations: { destructiveHint: true },
|
|
472
568
|
inputSchema: {
|
|
473
569
|
messageId: z.number().describe('Draft message ID to delete'),
|
|
570
|
+
expectedRevision: z.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(),
|
|
571
|
+
force: z.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(),
|
|
474
572
|
},
|
|
475
573
|
}, async (args) => {
|
|
574
|
+
const cache = cacheProvider();
|
|
575
|
+
const guard = await guardDestructiveDraftOp({
|
|
576
|
+
cache,
|
|
577
|
+
draftId: args.messageId,
|
|
578
|
+
expectedRevision: args.expectedRevision,
|
|
579
|
+
force: args.force ?? false,
|
|
580
|
+
action: 'delete',
|
|
581
|
+
});
|
|
582
|
+
if (!guard.ok)
|
|
583
|
+
return guard.response;
|
|
476
584
|
const data = await deleteOFWMessages(client, [args.messageId]);
|
|
477
|
-
await
|
|
478
|
-
|
|
585
|
+
await cache.deleteDraft(args.messageId);
|
|
586
|
+
const text = data ? JSON.stringify(data, null, 2) : 'Draft deleted.';
|
|
587
|
+
return textResponse(guard.note ? `${guard.note}\n\n${text}` : text);
|
|
479
588
|
});
|
|
480
589
|
server.registerTool('ofw_get_unread_sent', {
|
|
481
590
|
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.',
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/chrischall/ofw-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "2.6.
|
|
9
|
+
"version": "2.6.7",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.6.
|
|
14
|
+
"version": "2.6.7",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|