ofw-mcp 2.7.0 → 2.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +75 -12
- package/dist/index.js +1 -1
- package/dist/tools/draft-freshness.js +49 -2
- package/dist/tools/messages.js +67 -9
- package/package.json +3 -3
- 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.7.
|
|
9
|
+
"version": "2.7.1"
|
|
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.7.
|
|
17
|
+
"version": "2.7.1",
|
|
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.7.
|
|
38410
|
+
version: "2.7.1",
|
|
38411
38411
|
license: "MIT",
|
|
38412
38412
|
mcpName: "io.github.chrischall/ofw-mcp",
|
|
38413
38413
|
description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
|
|
@@ -38449,13 +38449,13 @@ var package_default = {
|
|
|
38449
38449
|
zod: "^4.4.3"
|
|
38450
38450
|
},
|
|
38451
38451
|
devDependencies: {
|
|
38452
|
-
"@chrischall/mcp-connector": "^1.
|
|
38452
|
+
"@chrischall/mcp-connector": "^1.1.1",
|
|
38453
38453
|
"@cloudflare/vitest-pool-workers": "^0.18.4",
|
|
38454
38454
|
"@cloudflare/workers-oauth-provider": "^0.8.1",
|
|
38455
38455
|
"@cloudflare/workers-types": "^5.20260708.1",
|
|
38456
38456
|
"@types/node": "^26.0.0",
|
|
38457
38457
|
"@vitest/coverage-v8": "^4.1.7",
|
|
38458
|
-
agents: "^0.
|
|
38458
|
+
agents: "^0.19.0",
|
|
38459
38459
|
esbuild: "^0.28.0",
|
|
38460
38460
|
typescript: "^7.0.2",
|
|
38461
38461
|
vitest: "^4.1.7",
|
|
@@ -39408,6 +39408,10 @@ async function fetchServerDraft(client2, id) {
|
|
|
39408
39408
|
recipients: mapRecipients(detail.recipients)
|
|
39409
39409
|
};
|
|
39410
39410
|
}
|
|
39411
|
+
var SUBSTANTIVE_FIELDS = ["subject", "body", "recipients"];
|
|
39412
|
+
function substantiveChanges(changed) {
|
|
39413
|
+
return changed.filter((f) => SUBSTANTIVE_FIELDS.includes(f));
|
|
39414
|
+
}
|
|
39411
39415
|
function diffFields(a, b) {
|
|
39412
39416
|
const changed = [];
|
|
39413
39417
|
if (a.subject !== b.subject) changed.push("subject");
|
|
@@ -39431,6 +39435,17 @@ function checkDraftFreshness(input) {
|
|
|
39431
39435
|
if (expectedRevision === actual) {
|
|
39432
39436
|
return { verdict: "FRESH", reason: "expectedRevision matches the live server draft.", changedFields: [] };
|
|
39433
39437
|
}
|
|
39438
|
+
if (cached2 !== null && draftRevision(cached2) === expectedRevision) {
|
|
39439
|
+
const changedFields2 = diffFields(server, cached2);
|
|
39440
|
+
if (substantiveChanges(changedFields2).length === 0) {
|
|
39441
|
+
return {
|
|
39442
|
+
verdict: "FRESH",
|
|
39443
|
+
reason: `Only connector-authored metadata (${changedFields2.join(", ")}) changed since you read the draft; its subject, body and recipients are unchanged, so this is not a conflict.`,
|
|
39444
|
+
changedFields: changedFields2,
|
|
39445
|
+
metadataOnly: true
|
|
39446
|
+
};
|
|
39447
|
+
}
|
|
39448
|
+
}
|
|
39434
39449
|
return {
|
|
39435
39450
|
verdict: "STALE",
|
|
39436
39451
|
reason: `expectedRevision ${expectedRevision} does not match the live server draft (${actual}) \u2014 it changed after you read it.`,
|
|
@@ -39448,6 +39463,14 @@ function checkDraftFreshness(input) {
|
|
|
39448
39463
|
if (changedFields.length === 0) {
|
|
39449
39464
|
return { verdict: "FRESH", reason: "The cached draft matches the live server draft.", changedFields: [] };
|
|
39450
39465
|
}
|
|
39466
|
+
if (substantiveChanges(changedFields).length === 0) {
|
|
39467
|
+
return {
|
|
39468
|
+
verdict: "FRESH",
|
|
39469
|
+
reason: `Only connector-authored metadata (${changedFields.join(", ")}) differs from the cached copy; subject, body and recipients match, so this is not a conflict.`,
|
|
39470
|
+
changedFields,
|
|
39471
|
+
metadataOnly: true
|
|
39472
|
+
};
|
|
39473
|
+
}
|
|
39451
39474
|
return {
|
|
39452
39475
|
verdict: "STALE",
|
|
39453
39476
|
reason: `The draft on OurFamilyWizard differs from the cached copy (${changedFields.join(", ")}) \u2014 it was edited outside this tool.`,
|
|
@@ -39570,7 +39593,9 @@ var SavedDraftDetailSchema = external_exports.looseObject({
|
|
|
39570
39593
|
body: external_exports.string().optional(),
|
|
39571
39594
|
date: DateSchema.optional(),
|
|
39572
39595
|
replyToId: external_exports.number().nullable().optional(),
|
|
39573
|
-
recipients: external_exports.array(ApiRecipientSchema).optional()
|
|
39596
|
+
recipients: external_exports.array(ApiRecipientSchema).optional(),
|
|
39597
|
+
// Read to audit whether requested myFileIDs actually attached (Defect 3).
|
|
39598
|
+
files: external_exports.array(external_exports.number()).optional()
|
|
39574
39599
|
});
|
|
39575
39600
|
var MessageDetailSchema = external_exports.looseObject({
|
|
39576
39601
|
id: external_exports.number(),
|
|
@@ -39935,7 +39960,10 @@ ${text}` : text);
|
|
|
39935
39960
|
};
|
|
39936
39961
|
}
|
|
39937
39962
|
const verdict = checkDraftFreshness({ server: server2, cached: cached2, expectedRevision });
|
|
39938
|
-
if (verdict.verdict === "FRESH")
|
|
39963
|
+
if (verdict.verdict === "FRESH") {
|
|
39964
|
+
const note = verdict.metadataOnly ? `NOTE: draft ${draftId} was treated as current for this ${action}. Since you read it, OurFamilyWizard normalized connector-authored metadata (${verdict.changedFields.join(", ")}); the subject, body and recipients are unchanged, so this is not a conflict.` : null;
|
|
39965
|
+
return { ok: true, note };
|
|
39966
|
+
}
|
|
39939
39967
|
if (force) {
|
|
39940
39968
|
console.error(`[ofw-mcp] WARNING: force:true overrode a ${verdict.verdict} verdict on draft ${draftId} (${action}). ${verdict.reason}`);
|
|
39941
39969
|
const echoed = server2 === null ? "The draft no longer existed on OurFamilyWizard." : `The server version that was overwritten is preserved below under "overwrittenServerDraft".`;
|
|
@@ -39995,7 +40023,7 @@ ${JSON.stringify(
|
|
|
39995
40023
|
return jsonResponse(payload);
|
|
39996
40024
|
});
|
|
39997
40025
|
if (allowDrafts) server.registerTool("ofw_save_draft", {
|
|
39998
|
-
description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft \u2014 note that under the hood this creates a NEW draft and deletes the old one (OFW's update-in-place endpoint silently no-ops while echoing the posted body, so we don't use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if
|
|
40026
|
+
description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft \u2014 note that under the hood this creates a NEW draft and deletes the old one (OFW's update-in-place endpoint silently no-ops while echoing the posted body, so we don't use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response that also lists which fields (subject/body/recipients/replyToId/attachments) were carried over. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state, and the returned `revision` reflects that authoritative state (so it will match on your next edit). FIELD PRESERVATION: the response echoes the effective threading (replyToId/inReplyTo) and, whenever OFW did not carry over a requested replyToId, recipient or attachment, a `warnings[]` entry naming what was dropped \u2014 never a silent null. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if its subject/body/recipients changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). A pure replyToId normalization by OFW is NOT treated as a conflict. The refusal returns the current server body under serverBody \u2014 merge your edit into it and retry with expectedRevision.",
|
|
39999
40027
|
annotations: { readOnlyHint: false },
|
|
40000
40028
|
inputSchema: {
|
|
40001
40029
|
subject: external_exports.string().describe("Message subject"),
|
|
@@ -40050,32 +40078,67 @@ ${JSON.stringify(
|
|
|
40050
40078
|
let replaceNote = null;
|
|
40051
40079
|
let verifyNote = null;
|
|
40052
40080
|
let newRevision = null;
|
|
40081
|
+
const warnings = [];
|
|
40053
40082
|
if (newId !== null) {
|
|
40054
40083
|
verifyNote = verifyWriteLanded("draft", { subject: args.subject, body: args.body }, detail);
|
|
40084
|
+
const effectiveReplyTo = detail.replyToId ?? null;
|
|
40085
|
+
const storedRecipients = mapRecipients(detail.recipients);
|
|
40055
40086
|
persisted = {
|
|
40056
40087
|
id: newId,
|
|
40057
40088
|
subject: detail.subject ?? args.subject,
|
|
40058
40089
|
body: detail.body ?? "",
|
|
40059
|
-
recipients:
|
|
40060
|
-
replyToId:
|
|
40090
|
+
recipients: storedRecipients,
|
|
40091
|
+
replyToId: effectiveReplyTo,
|
|
40061
40092
|
modifiedAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
40062
40093
|
listData: detail
|
|
40063
40094
|
};
|
|
40064
40095
|
await cache.upsertDraft(persisted);
|
|
40065
40096
|
newRevision = draftRevision(persisted);
|
|
40097
|
+
if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
|
|
40098
|
+
const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : "";
|
|
40099
|
+
warnings.push(
|
|
40100
|
+
`replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo === null ? "null" : effectiveReplyTo} \u2014 OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped. If threading matters, verify on ourfamilywizard.com.`
|
|
40101
|
+
);
|
|
40102
|
+
}
|
|
40103
|
+
if (args.recipientIds !== void 0 && Array.isArray(detail.recipients)) {
|
|
40104
|
+
const requested = [...new Set(args.recipientIds)].sort((a, b) => a - b);
|
|
40105
|
+
const stored = [...new Set(storedRecipients.map((r) => r.userId))].sort((a, b) => a - b);
|
|
40106
|
+
if (requested.join(",") !== stored.join(",")) {
|
|
40107
|
+
warnings.push(
|
|
40108
|
+
`recipientIds were requested as [${requested.join(", ")}] but the saved draft has [${stored.join(", ")}]. Verify the recipients on ourfamilywizard.com.`
|
|
40109
|
+
);
|
|
40110
|
+
}
|
|
40111
|
+
}
|
|
40112
|
+
if (myFileIDs.length > 0 && Array.isArray(detail.files)) {
|
|
40113
|
+
const storedFiles = new Set(detail.files);
|
|
40114
|
+
const missing = myFileIDs.filter((id) => !storedFiles.has(id));
|
|
40115
|
+
if (missing.length > 0) {
|
|
40116
|
+
warnings.push(
|
|
40117
|
+
`Attachment fileId(s) ${missing.join(", ")} were requested in myFileIDs but are not attached to the saved draft. Re-upload or re-attach if needed.`
|
|
40118
|
+
);
|
|
40119
|
+
}
|
|
40120
|
+
}
|
|
40066
40121
|
if (args.messageId !== void 0 && args.messageId !== newId) {
|
|
40067
40122
|
try {
|
|
40068
40123
|
await deleteOFWMessages(client2, [args.messageId]);
|
|
40069
40124
|
await cache.deleteDraft(args.messageId);
|
|
40070
|
-
replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.)`;
|
|
40125
|
+
replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.) Fields carried over to the new draft: subject, body, recipients (${persisted.recipients.length}), replyToId (${persisted.replyToId === null ? "none" : persisted.replyToId}), attachments (${myFileIDs.length}).${warnings.length > 0 ? " See warnings above for any field OurFamilyWizard did not carry over." : ""}`;
|
|
40071
40126
|
} catch (e) {
|
|
40072
40127
|
replaceNote = `WARNING: New draft ${newId} was created successfully, but the old draft ${args.messageId} could NOT be deleted: ${e.message}. BOTH drafts now exist on OurFamilyWizard and nothing was lost. Verify ${newId} reads correctly, then remove ${args.messageId} with ofw_delete_draft.`;
|
|
40073
40128
|
}
|
|
40074
40129
|
}
|
|
40075
40130
|
}
|
|
40076
|
-
const responseObj = persisted !== null ? {
|
|
40131
|
+
const responseObj = persisted !== null ? {
|
|
40132
|
+
...persisted,
|
|
40133
|
+
inReplyTo: persisted.replyToId,
|
|
40134
|
+
revision: newRevision,
|
|
40135
|
+
cacheStatus: "fresh",
|
|
40136
|
+
serverConfirmed: true,
|
|
40137
|
+
...warnings.length > 0 ? { warnings } : {}
|
|
40138
|
+
} : raw;
|
|
40077
40139
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
|
|
40078
|
-
const
|
|
40140
|
+
const warnNote = warnings.length > 0 ? `WARNING: ${warnings.join("\n\n")}` : null;
|
|
40141
|
+
const notes = [forceNote, rewriteNote, verifyNote, warnNote, replaceNote].filter((n) => n !== null).join("\n\n");
|
|
40079
40142
|
return textResponse(notes ? `${notes}
|
|
40080
40143
|
|
|
40081
40144
|
${text}` : text);
|
|
@@ -41165,7 +41228,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
|
|
|
41165
41228
|
var nodeAttachmentIO = new NodeAttachmentIO();
|
|
41166
41229
|
await runMcp({
|
|
41167
41230
|
name: "ofw",
|
|
41168
|
-
version: "2.7.
|
|
41231
|
+
version: "2.7.1",
|
|
41169
41232
|
// x-release-please-version
|
|
41170
41233
|
deps: client,
|
|
41171
41234
|
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.7.
|
|
38
|
+
version: '2.7.1', // x-release-please-version
|
|
39
39
|
deps: client,
|
|
40
40
|
tools: [
|
|
41
41
|
registerUserTools,
|
|
@@ -77,6 +77,22 @@ export async function fetchServerDraft(client, id) {
|
|
|
77
77
|
recipients: mapRecipients(detail.recipients),
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* The fields whose divergence constitutes a REAL conflict — the actual message
|
|
82
|
+
* content a caller would lose if we overwrote a copy edited elsewhere. Anything
|
|
83
|
+
* NOT listed here (currently only `replyToId`) is connector/server-authored
|
|
84
|
+
* metadata: OFW normalizes `replyToId` after a draft is saved (dropping it, or
|
|
85
|
+
* re-targeting it to the thread tip), which is the connector's own mutation
|
|
86
|
+
* surfacing later — not third-party interference. A divergence in metadata
|
|
87
|
+
* alone must never refuse the write, or the guard manufactures a false STALE
|
|
88
|
+
* for a change the caller did not make. See issue thread on save/edit round
|
|
89
|
+
* trips.
|
|
90
|
+
*/
|
|
91
|
+
const SUBSTANTIVE_FIELDS = ['subject', 'body', 'recipients'];
|
|
92
|
+
/** The substantive subset of a changed-field list (drops metadata like replyToId). */
|
|
93
|
+
function substantiveChanges(changed) {
|
|
94
|
+
return changed.filter((f) => SUBSTANTIVE_FIELDS.includes(f));
|
|
95
|
+
}
|
|
80
96
|
function diffFields(a, b) {
|
|
81
97
|
const changed = [];
|
|
82
98
|
if (a.subject !== b.subject)
|
|
@@ -102,8 +118,17 @@ function diffFields(a, b) {
|
|
|
102
118
|
* 2. No token supplied → the cached base must match the server EXACTLY. This
|
|
103
119
|
* is the safe default: "no token" never means "force".
|
|
104
120
|
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
121
|
+
* In BOTH modes the conflict decision turns on the SUBSTANTIVE fields
|
|
122
|
+
* (subject/body/recipients), not on any revision delta. When the only thing
|
|
123
|
+
* that moved is connector/server-authored metadata — `replyToId` normalized
|
|
124
|
+
* after the save — the content is intact, so it is FRESH (with `metadataOnly`
|
|
125
|
+
* set) rather than STALE. That is the connector's own mutation resurfacing, not
|
|
126
|
+
* a third party editing the draft; refusing it would be a false positive that
|
|
127
|
+
* trains callers to distrust the guard. The fail-safe direction is untouched:
|
|
128
|
+
* the moment subject, body or recipients differ, it still refuses.
|
|
129
|
+
*
|
|
130
|
+
* Everything else — server ahead of cache on real content, no cached base to
|
|
131
|
+
* compare, draft gone from the server — refuses.
|
|
107
132
|
*/
|
|
108
133
|
export function checkDraftFreshness(input) {
|
|
109
134
|
const { server, cached, expectedRevision } = input;
|
|
@@ -119,6 +144,20 @@ export function checkDraftFreshness(input) {
|
|
|
119
144
|
if (expectedRevision === actual) {
|
|
120
145
|
return { verdict: 'FRESH', reason: 'expectedRevision matches the live server draft.', changedFields: [] };
|
|
121
146
|
}
|
|
147
|
+
// Token mismatch. When the caller's token is the one WE cached, we can name
|
|
148
|
+
// exactly what drifted — and if that is metadata alone, it is our own
|
|
149
|
+
// post-save normalization, not a conflict.
|
|
150
|
+
if (cached !== null && draftRevision(cached) === expectedRevision) {
|
|
151
|
+
const changedFields = diffFields(server, cached);
|
|
152
|
+
if (substantiveChanges(changedFields).length === 0) {
|
|
153
|
+
return {
|
|
154
|
+
verdict: 'FRESH',
|
|
155
|
+
reason: `Only connector-authored metadata (${changedFields.join(', ')}) changed since you read the draft; its subject, body and recipients are unchanged, so this is not a conflict.`,
|
|
156
|
+
changedFields,
|
|
157
|
+
metadataOnly: true,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
122
161
|
return {
|
|
123
162
|
verdict: 'STALE',
|
|
124
163
|
reason: `expectedRevision ${expectedRevision} does not match the live server draft (${actual}) — it changed after you read it.`,
|
|
@@ -136,6 +175,14 @@ export function checkDraftFreshness(input) {
|
|
|
136
175
|
if (changedFields.length === 0) {
|
|
137
176
|
return { verdict: 'FRESH', reason: 'The cached draft matches the live server draft.', changedFields: [] };
|
|
138
177
|
}
|
|
178
|
+
if (substantiveChanges(changedFields).length === 0) {
|
|
179
|
+
return {
|
|
180
|
+
verdict: 'FRESH',
|
|
181
|
+
reason: `Only connector-authored metadata (${changedFields.join(', ')}) differs from the cached copy; subject, body and recipients match, so this is not a conflict.`,
|
|
182
|
+
changedFields,
|
|
183
|
+
metadataOnly: true,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
139
186
|
return {
|
|
140
187
|
verdict: 'STALE',
|
|
141
188
|
reason: `The draft on OurFamilyWizard differs from the cached copy (${changedFields.join(', ')}) — it was edited outside this tool.`,
|
package/dist/tools/messages.js
CHANGED
|
@@ -28,6 +28,8 @@ const SavedDraftDetailSchema = z.looseObject({
|
|
|
28
28
|
date: DateSchema.optional(),
|
|
29
29
|
replyToId: z.number().nullable().optional(),
|
|
30
30
|
recipients: z.array(ApiRecipientSchema).optional(),
|
|
31
|
+
// Read to audit whether requested myFileIDs actually attached (Defect 3).
|
|
32
|
+
files: z.array(z.number()).optional(),
|
|
31
33
|
});
|
|
32
34
|
// ofw_get_message's uncached detail fetch — lenient: a mismatch warns to
|
|
33
35
|
// stderr and the existing ?? fallbacks keep the tool serving.
|
|
@@ -497,8 +499,15 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
497
499
|
};
|
|
498
500
|
}
|
|
499
501
|
const verdict = checkDraftFreshness({ server, cached, expectedRevision });
|
|
500
|
-
if (verdict.verdict === 'FRESH')
|
|
501
|
-
|
|
502
|
+
if (verdict.verdict === 'FRESH') {
|
|
503
|
+
// A metadata-only "conflict" is the connector's own post-save replyToId
|
|
504
|
+
// normalization catching up — safe to proceed, but say so rather than
|
|
505
|
+
// pretending nothing moved.
|
|
506
|
+
const note = verdict.metadataOnly
|
|
507
|
+
? `NOTE: draft ${draftId} was treated as current for this ${action}. Since you read it, OurFamilyWizard normalized connector-authored metadata (${verdict.changedFields.join(', ')}); the subject, body and recipients are unchanged, so this is not a conflict.`
|
|
508
|
+
: null;
|
|
509
|
+
return { ok: true, note };
|
|
510
|
+
}
|
|
502
511
|
if (force) {
|
|
503
512
|
// Loud, and the overwritten content rides along in the response so it is
|
|
504
513
|
// recoverable from the tool result itself.
|
|
@@ -560,7 +569,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
560
569
|
});
|
|
561
570
|
if (allowDrafts)
|
|
562
571
|
server.registerTool('ofw_save_draft', {
|
|
563
|
-
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
|
|
572
|
+
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 that also lists which fields (subject/body/recipients/replyToId/attachments) were carried over. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state, and the returned `revision` reflects that authoritative state (so it will match on your next edit). FIELD PRESERVATION: the response echoes the effective threading (replyToId/inReplyTo) and, whenever OFW did not carry over a requested replyToId, recipient or attachment, a `warnings[]` entry naming what was dropped — never a silent null. SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if its subject/body/recipients changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). A pure replyToId normalization by OFW is NOT treated as a conflict. The refusal returns the current server body under serverBody — merge your edit into it and retry with expectedRevision.',
|
|
564
573
|
annotations: { readOnlyHint: false },
|
|
565
574
|
inputSchema: {
|
|
566
575
|
subject: z.string().describe('Message subject'),
|
|
@@ -619,26 +628,63 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
619
628
|
let replaceNote = null;
|
|
620
629
|
let verifyNote = null;
|
|
621
630
|
let newRevision = null;
|
|
631
|
+
// Fields accepted on the write that the saved draft must carry — or their
|
|
632
|
+
// loss must be reported. Never a silent drop (Defect 3).
|
|
633
|
+
const warnings = [];
|
|
622
634
|
if (newId !== null) {
|
|
623
635
|
verifyNote = verifyWriteLanded('draft', { subject: args.subject, body: args.body }, detail);
|
|
636
|
+
// Trust the re-fetched server detail as the source of truth for the stored
|
|
637
|
+
// replyToId — NOT `resolvedReplyTo` (what we intended to post). OFW
|
|
638
|
+
// normalizes/drops threading after a save, and masking that with our own
|
|
639
|
+
// intent (the old `detail.replyToId ?? resolvedReplyTo`) both returned a
|
|
640
|
+
// revision that was stale on arrival (Defect 1) and hid a dropped reply
|
|
641
|
+
// link (Defect 3). `?? null` keeps a genuinely-echoed value and reflects a
|
|
642
|
+
// null/absent one honestly.
|
|
643
|
+
const effectiveReplyTo = detail.replyToId ?? null;
|
|
644
|
+
const storedRecipients = mapRecipients(detail.recipients);
|
|
624
645
|
persisted = {
|
|
625
646
|
id: newId,
|
|
626
647
|
subject: detail.subject ?? args.subject,
|
|
627
648
|
body: detail.body ?? '',
|
|
628
|
-
recipients:
|
|
629
|
-
replyToId:
|
|
649
|
+
recipients: storedRecipients,
|
|
650
|
+
replyToId: effectiveReplyTo,
|
|
630
651
|
modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
|
|
631
652
|
listData: detail,
|
|
632
653
|
};
|
|
633
654
|
await cache.upsertDraft(persisted);
|
|
655
|
+
// The revision is now computed from the server-authoritative detail, so it
|
|
656
|
+
// is the value a subsequent read/verify will observe (Defect 1).
|
|
634
657
|
newRevision = draftRevision(persisted);
|
|
658
|
+
// Audit every field the caller supplied against what actually landed, so a
|
|
659
|
+
// silent normalization becomes a visible warning rather than a surprise.
|
|
660
|
+
if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
|
|
661
|
+
const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : '';
|
|
662
|
+
warnings.push(`replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo === null ? 'null' : effectiveReplyTo} — OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped. If threading matters, verify on ourfamilywizard.com.`);
|
|
663
|
+
}
|
|
664
|
+
// Only warn on recipients/attachments when the detail actually reported
|
|
665
|
+
// them — an omitted array is "not echoed", not "dropped", and crying wolf
|
|
666
|
+
// there would desensitize the caller to the real drops.
|
|
667
|
+
if (args.recipientIds !== undefined && Array.isArray(detail.recipients)) {
|
|
668
|
+
const requested = [...new Set(args.recipientIds)].sort((a, b) => a - b);
|
|
669
|
+
const stored = [...new Set(storedRecipients.map((r) => r.userId))].sort((a, b) => a - b);
|
|
670
|
+
if (requested.join(',') !== stored.join(',')) {
|
|
671
|
+
warnings.push(`recipientIds were requested as [${requested.join(', ')}] but the saved draft has [${stored.join(', ')}]. Verify the recipients on ourfamilywizard.com.`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (myFileIDs.length > 0 && Array.isArray(detail.files)) {
|
|
675
|
+
const storedFiles = new Set(detail.files);
|
|
676
|
+
const missing = myFileIDs.filter((id) => !storedFiles.has(id));
|
|
677
|
+
if (missing.length > 0) {
|
|
678
|
+
warnings.push(`Attachment fileId(s) ${missing.join(', ')} were requested in myFileIDs but are not attached to the saved draft. Re-upload or re-attach if needed.`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
635
681
|
// Replace-path: caller passed messageId, so they want the old draft
|
|
636
682
|
// gone. Delete it after the new one is safely created+cached.
|
|
637
683
|
if (args.messageId !== undefined && args.messageId !== newId) {
|
|
638
684
|
try {
|
|
639
685
|
await deleteOFWMessages(client, [args.messageId]);
|
|
640
686
|
await cache.deleteDraft(args.messageId);
|
|
641
|
-
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.)`;
|
|
687
|
+
replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.) Fields carried over to the new draft: subject, body, recipients (${persisted.recipients.length}), replyToId (${persisted.replyToId === null ? 'none' : persisted.replyToId}), attachments (${myFileIDs.length}).${warnings.length > 0 ? ' See warnings above for any field OurFamilyWizard did not carry over.' : ''}`;
|
|
642
688
|
}
|
|
643
689
|
catch (e) {
|
|
644
690
|
// Partial-failure safety: the new draft is already created and
|
|
@@ -650,12 +696,24 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
650
696
|
}
|
|
651
697
|
// The draft was just re-fetched from OFW by postMessageAndRefetch, so this
|
|
652
698
|
// one row IS server-confirmed regardless of the drafts folder's overall
|
|
653
|
-
// cache freshness.
|
|
699
|
+
// cache freshness. `inReplyTo` echoes the effective threading alongside the
|
|
700
|
+
// volatile `id`, and `warnings` names any requested field that did not land.
|
|
654
701
|
const responseObj = persisted !== null
|
|
655
|
-
? {
|
|
702
|
+
? {
|
|
703
|
+
...persisted,
|
|
704
|
+
inReplyTo: persisted.replyToId,
|
|
705
|
+
revision: newRevision,
|
|
706
|
+
cacheStatus: 'fresh',
|
|
707
|
+
serverConfirmed: true,
|
|
708
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
709
|
+
}
|
|
656
710
|
: raw;
|
|
657
711
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
|
|
658
|
-
const
|
|
712
|
+
const warnNote = warnings.length > 0
|
|
713
|
+
? `WARNING: ${warnings.join('\n\n')}`
|
|
714
|
+
: null;
|
|
715
|
+
const notes = [forceNote, rewriteNote, verifyNote, warnNote, replaceNote]
|
|
716
|
+
.filter((n) => n !== null).join('\n\n');
|
|
659
717
|
return textResponse(notes ? `${notes}\n\n${text}` : text);
|
|
660
718
|
});
|
|
661
719
|
if (allowDrafts)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofw-mcp",
|
|
3
|
-
"version": "2.7.
|
|
3
|
+
"version": "2.7.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"mcpName": "io.github.chrischall/ofw-mcp",
|
|
6
6
|
"description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
@@ -42,13 +42,13 @@
|
|
|
42
42
|
"zod": "^4.4.3"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@chrischall/mcp-connector": "^1.
|
|
45
|
+
"@chrischall/mcp-connector": "^1.1.1",
|
|
46
46
|
"@cloudflare/vitest-pool-workers": "^0.18.4",
|
|
47
47
|
"@cloudflare/workers-oauth-provider": "^0.8.1",
|
|
48
48
|
"@cloudflare/workers-types": "^5.20260708.1",
|
|
49
49
|
"@types/node": "^26.0.0",
|
|
50
50
|
"@vitest/coverage-v8": "^4.1.7",
|
|
51
|
-
"agents": "^0.
|
|
51
|
+
"agents": "^0.19.0",
|
|
52
52
|
"esbuild": "^0.28.0",
|
|
53
53
|
"typescript": "^7.0.2",
|
|
54
54
|
"vitest": "^4.1.7",
|
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.7.
|
|
9
|
+
"version": "2.7.1",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.7.
|
|
14
|
+
"version": "2.7.1",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|