ofw-mcp 2.9.2 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +223 -71
- package/dist/index.js +1 -1
- package/dist/sync.js +7 -2
- package/dist/tools/_shared.js +25 -0
- package/dist/tools/draft-freshness.js +13 -2
- package/dist/tools/lifecycle.js +38 -12
- package/dist/tools/messages.js +264 -79
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +5 -5
package/dist/tools/messages.js
CHANGED
|
@@ -8,7 +8,7 @@ import { buildInlineDelivery, tryExtract } from './delivery.js';
|
|
|
8
8
|
import { resolveDownloadMime } from './attachments.js';
|
|
9
9
|
import { getAllowMarkRead, getAttachmentsDir, getAutoRefreshStaleReads, getDefaultInlineAttachments, getFetchUnreadBodies, getSyncMaxRequests, getWriteMode, } from '../config.js';
|
|
10
10
|
import { basename, join } from 'node:path';
|
|
11
|
-
import { ApiRecipientSchema, deriveRead, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
|
|
11
|
+
import { ApiRecipientSchema, deriveRead, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, reportsThreaded, reportsUnthreaded, textResponse, threadedReplyTo, verifyWriteLanded, withReadState } from './_shared.js';
|
|
12
12
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
13
13
|
// Schemas for the load-bearing fields of each /pub/v3 response this file
|
|
14
14
|
// reads (issue #83). Loose: unknown keys pass through into cached listData.
|
|
@@ -23,12 +23,24 @@ const SentDetailSchema = z.looseObject({
|
|
|
23
23
|
date: DateSchema.optional(),
|
|
24
24
|
from: z.looseObject({ name: z.string().optional() }).optional(),
|
|
25
25
|
recipients: z.array(ApiRecipientSchema).optional(),
|
|
26
|
+
// The threading echo, in BOTH spellings plus showContext — OFW reports the
|
|
27
|
+
// reply target inconsistently across payloads (see ThreadingEcho in
|
|
28
|
+
// _shared.ts). Backs the `threaded` verdict on ofw_send_message.
|
|
29
|
+
replyToId: z.number().nullable().optional(),
|
|
30
|
+
inReplyTo: z.number().nullable().optional(),
|
|
31
|
+
showContext: z.boolean().optional(),
|
|
26
32
|
});
|
|
27
33
|
const SavedDraftDetailSchema = z.looseObject({
|
|
28
34
|
subject: z.string().optional(),
|
|
29
35
|
body: z.string().optional(),
|
|
30
36
|
date: DateSchema.optional(),
|
|
37
|
+
// All three threading-echo fields. Reading ONLY `replyToId` here fired a
|
|
38
|
+
// false "OurFamilyWizard did not thread this draft" warning on nearly every
|
|
39
|
+
// threaded save, while the same payload's `inReplyTo`/`showContext` showed
|
|
40
|
+
// the draft WAS threaded — see threadedReplyTo in _shared.ts.
|
|
31
41
|
replyToId: z.number().nullable().optional(),
|
|
42
|
+
inReplyTo: z.number().nullable().optional(),
|
|
43
|
+
showContext: z.boolean().optional(),
|
|
32
44
|
recipients: z.array(ApiRecipientSchema).optional(),
|
|
33
45
|
// Read to audit whether requested myFileIDs actually attached (Defect 3).
|
|
34
46
|
files: z.array(z.number()).optional(),
|
|
@@ -357,6 +369,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
357
369
|
if (draftRow !== null) {
|
|
358
370
|
const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
|
|
359
371
|
return jsonResponse({
|
|
372
|
+
// Stable identity FIRST — the id below changes on every edit
|
|
373
|
+
// (create-then-delete), so callers should key off draftKey. Null when
|
|
374
|
+
// this draft was never written through this tool (e.g. authored in
|
|
375
|
+
// the web app).
|
|
376
|
+
draftKey: (await cache.getDraftLineageById(draftRow.id))?.draftKey ?? null,
|
|
360
377
|
id: draftRow.id,
|
|
361
378
|
folder: 'drafts',
|
|
362
379
|
subject: draftRow.subject,
|
|
@@ -373,13 +390,9 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
373
390
|
listData: draftRow.listData,
|
|
374
391
|
attachments: [],
|
|
375
392
|
// Concurrency token — pass as expectedRevision to ofw_save_draft /
|
|
376
|
-
// ofw_delete_draft to assert you are
|
|
393
|
+
// ofw_delete_draft / ofw_send_message to assert you are acting on
|
|
394
|
+
// THIS version.
|
|
377
395
|
revision: draftRevision(draftRow),
|
|
378
|
-
// Stable logical identity. Survives the create-then-delete id churn of
|
|
379
|
-
// editing AND the transition to sent — pass it to ofw_status to ask
|
|
380
|
-
// "what happened to the thing I was working on?". Null when this draft
|
|
381
|
-
// was never written through this tool (e.g. authored in the web app).
|
|
382
|
-
draftKey: (await cache.getDraftLineageById(draftRow.id))?.draftKey ?? null,
|
|
383
396
|
cacheStatus,
|
|
384
397
|
// False = this draft's existence and unsent status are remembered from
|
|
385
398
|
// a cache, not confirmed on OFW. Call ofw_check_freshness before
|
|
@@ -489,16 +502,19 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
489
502
|
});
|
|
490
503
|
if (allowSend)
|
|
491
504
|
server.registerTool('ofw_send_message', {
|
|
492
|
-
description: 'Send a message via OurFamilyWizard
|
|
505
|
+
description: 'Send a message via OurFamilyWizard — the ONE irreversible operation here, so it carries the strongest guard. TO SEND AN EXISTING DRAFT (the safe default): pass draftId (or messageId — same thing). The tool re-reads the draft from OFW and sends the SERVER\'S version, so what goes out is what is on OurFamilyWizard, not what this session remembers — subject/body act only as explicit overrides. It is guarded exactly like ofw_save_draft: pass expectedRevision to assert which version you are sending; if the draft changed on OFW since you read it — or no longer exists (it may already have been SENT) — the send is REFUSED with the current server content echoed back, and nothing goes out. RECIPIENTS: OurFamilyWizard does not persist recipients on drafts, so recipientIds is usually still required at send time (ids from ofw_get_profile). After the send is CONFIRMED (OFW returned the new message id and the re-fetched sent record matches what was posted), the source draft is deleted automatically; pass deleteDraftOnSuccess:false to keep it. On ANY failure or ambiguity the draft is never deleted — the response carries draftRetained:true with the reason. TO COMPOSE FROM SCRATCH: supply subject/body/recipientIds with no draftId. If replyToId is provided (or inherited from the draft), the cache may rewrite it to the latest reply in the same thread (a note is included when this happens). ATTACHMENTS: when sending by draftId, the server draft\'s own attachments carry over automatically; myFileIDs (from ofw_upload_attachment) overrides or attaches files on a fresh compose. The response leads with sentMessageId and the stable draftKey, and reports threaded (whether OFW actually linked the reply) and draftDeleted.',
|
|
493
506
|
annotations: { destructiveHint: true },
|
|
494
507
|
inputSchema: {
|
|
495
|
-
subject: z.string().describe('Message subject. Required unless messageId
|
|
496
|
-
body: z.string().describe('Message body text. Required unless messageId
|
|
497
|
-
recipientIds: z.array(z.number()).describe('Array of recipient user IDs (get from ofw_get_profile).
|
|
498
|
-
replyToId: z.number().describe('ID of the message being replied to').optional(),
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
508
|
+
subject: z.string().describe('Message subject. Required unless draftId/messageId is given (then it overrides the server draft\'s subject).').optional(),
|
|
509
|
+
body: z.string().describe('Message body text. Required unless draftId/messageId is given (then it overrides the server draft\'s body — omit it to send exactly what is on OurFamilyWizard).').optional(),
|
|
510
|
+
recipientIds: z.array(z.number()).describe('Array of recipient user IDs (get from ofw_get_profile). Usually required even when sending a draft: OurFamilyWizard does not persist recipients on drafts.').optional(),
|
|
511
|
+
replyToId: z.number().describe('ID of the message being replied to. Defaults to the draft\'s stored reply target when sending by draftId.').optional(),
|
|
512
|
+
draftId: z.number().describe('ID of an existing draft to send. The draft is re-read from OurFamilyWizard and its SERVER content is sent; missing subject/body default from it. Guarded: a draft that changed since you read it, or that was already sent/deleted, refuses rather than sending blind.').optional(),
|
|
513
|
+
messageId: z.number().describe('Synonym for draftId (if both are passed they must be equal).').optional(),
|
|
514
|
+
expectedRevision: z.string().describe('With draftId: the `revision` from ofw_list_drafts / ofw_get_message / ofw_check_freshness for that draft. Asserts you are sending THAT version; if the draft changed on OFW since, the send is refused and the current server content returned. Omit and the tool compares the server against the local cache instead — omitting never means "send whatever is there now".').optional(),
|
|
515
|
+
deleteDraftOnSuccess: z.boolean().describe('Default true. Delete the source draft after — and ONLY after — the send is confirmed (new message id returned and the re-fetched sent record checks out). Set false to keep the draft. On a failed or unverifiable send the draft is ALWAYS kept, regardless of this flag.').optional(),
|
|
516
|
+
force: z.boolean().describe('Default false. Send even when the draft changed on OurFamilyWizard since you read it, or its current state could not be read. Only use after showing the user the conflict.').optional(),
|
|
517
|
+
myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment) to attach to the message. When sending by draftId, omit it to carry the server draft\'s own attachments over; passing it overrides them.').optional(),
|
|
502
518
|
},
|
|
503
519
|
}, async (args) => {
|
|
504
520
|
if (args.messageId !== undefined && args.draftId !== undefined && args.messageId !== args.draftId) {
|
|
@@ -506,38 +522,67 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
506
522
|
}
|
|
507
523
|
const draftRef = args.messageId ?? args.draftId;
|
|
508
524
|
const cache = cacheProvider();
|
|
509
|
-
|
|
510
|
-
// its stored fields (including replyToId) as defaults for anything the
|
|
511
|
-
// caller didn't supply. The "missing draft" case only matters when we
|
|
512
|
-
// actually NEED the defaults — a caller passing all fields explicitly
|
|
513
|
-
// can use draftId as a pure delete-target even on an empty cache.
|
|
525
|
+
const deleteOnSuccess = args.deleteDraftOnSuccess ?? true;
|
|
514
526
|
let subject = args.subject;
|
|
515
527
|
let body = args.body;
|
|
516
528
|
let recipientIds = args.recipientIds;
|
|
517
529
|
let draftReplyToId = null;
|
|
518
|
-
let
|
|
519
|
-
let
|
|
530
|
+
let guardNote = null;
|
|
531
|
+
let serverDraft;
|
|
520
532
|
if (draftRef !== undefined) {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
533
|
+
const cachedDraft = await cache.getDraft(draftRef);
|
|
534
|
+
// The guard runs whenever this call would TRUST the draft (a content
|
|
535
|
+
// field defaults from it) or DESTROY it (delete after send). Only a call
|
|
536
|
+
// that overrides every field AND keeps the draft touches nothing that
|
|
537
|
+
// needs guarding. Sending is the one irreversible operation here, so it
|
|
538
|
+
// is never less protected than ofw_save_draft.
|
|
539
|
+
const needsContent = subject === undefined || body === undefined || recipientIds === undefined;
|
|
540
|
+
if (needsContent || deleteOnSuccess) {
|
|
541
|
+
const guard = await guardDestructiveDraftOp({
|
|
542
|
+
cache,
|
|
543
|
+
draftId: draftRef,
|
|
544
|
+
expectedRevision: args.expectedRevision,
|
|
545
|
+
force: args.force ?? false,
|
|
546
|
+
action: 'send',
|
|
547
|
+
});
|
|
548
|
+
if (!guard.ok)
|
|
549
|
+
return guard.response;
|
|
550
|
+
guardNote = guard.note;
|
|
551
|
+
serverDraft = guard.server;
|
|
552
|
+
}
|
|
553
|
+
// Content defaults come from the SERVER draft the guard just read — the
|
|
554
|
+
// point of sending by id is that the artifact sent is the artifact on
|
|
555
|
+
// the server. The cached row is a fallback only for the force paths
|
|
556
|
+
// where the server copy could not be read (or the guard was skipped).
|
|
557
|
+
const base = serverDraft ?? cachedDraft;
|
|
558
|
+
if (base != null) {
|
|
559
|
+
subject = subject ?? base.subject;
|
|
560
|
+
body = body ?? base.body;
|
|
561
|
+
draftReplyToId = base.replyToId;
|
|
562
|
+
}
|
|
563
|
+
if (recipientIds === undefined) {
|
|
564
|
+
// OFW does not persist recipients on drafts (the server copy routinely
|
|
565
|
+
// reports []), so take any NON-EMPTY recipient set we have — server
|
|
566
|
+
// first, then cache — and otherwise require the caller to supply one.
|
|
567
|
+
// Defaulting to [] would "send" to nobody.
|
|
568
|
+
const source = [serverDraft ?? null, cachedDraft].find((s) => s !== null && s !== undefined && s.recipients.some((r) => r.userId !== 0));
|
|
569
|
+
if (source != null) {
|
|
570
|
+
recipientIds = [...new Set(source.recipients.map((r) => r.userId).filter((id) => id !== 0))];
|
|
571
|
+
}
|
|
529
572
|
}
|
|
530
573
|
}
|
|
531
574
|
if (subject === undefined || body === undefined || recipientIds === undefined) {
|
|
532
|
-
if (draftLookupAttempted && !draftFound) {
|
|
533
|
-
throw new Error(`draft ${draftRef} not found in local cache. Call ofw_sync_messages first, or supply subject/body/recipientIds explicitly.`);
|
|
534
|
-
}
|
|
535
575
|
const missing = [
|
|
536
576
|
subject === undefined ? 'subject' : null,
|
|
537
577
|
body === undefined ? 'body' : null,
|
|
538
578
|
recipientIds === undefined ? 'recipientIds' : null,
|
|
539
579
|
].filter((n) => n !== null).join(', ');
|
|
540
|
-
|
|
580
|
+
const hint = draftRef === undefined
|
|
581
|
+
? 'Pass them directly, or pass draftId to send an existing draft.'
|
|
582
|
+
: missing === 'recipientIds'
|
|
583
|
+
? `Draft ${draftRef} carries no stored recipients — OurFamilyWizard does not persist recipients on drafts, so they must be supplied at send time. Get the co-parent's user id from ofw_get_profile and pass recipientIds.`
|
|
584
|
+
: `Draft ${draftRef}'s content was not readable from OurFamilyWizard or the local cache, so it cannot supply the missing fields. Pass them explicitly.`;
|
|
585
|
+
throw new Error(`ofw_send_message requires ${missing}. ${hint}`);
|
|
541
586
|
}
|
|
542
587
|
// Inherit the draft's replyToId when the caller didn't supply one. A
|
|
543
588
|
// reply-draft saved with replyToId would otherwise be sent as a
|
|
@@ -554,7 +599,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
554
599
|
const parent = await cache.getMessage(resolvedReplyTo);
|
|
555
600
|
chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
|
|
556
601
|
}
|
|
557
|
-
|
|
602
|
+
// Attachments carry over from the SERVER draft the guard read — sending
|
|
603
|
+
// "the draft as it exists on the server" includes its files, or the send
|
|
604
|
+
// would silently strip them. Explicit myFileIDs still overrides.
|
|
605
|
+
const myFileIDs = args.myFileIDs ?? serverDraft?.files ?? [];
|
|
558
606
|
const { id: newId, detail, raw } = await postMessageAndRefetch(client, {
|
|
559
607
|
subject,
|
|
560
608
|
body,
|
|
@@ -567,19 +615,65 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
567
615
|
let persisted = null;
|
|
568
616
|
let verifyNote = null;
|
|
569
617
|
let sentDraftKey = null;
|
|
618
|
+
let threaded = false;
|
|
619
|
+
let threadNote = null;
|
|
570
620
|
if (newId !== null) {
|
|
571
621
|
verifyNote = verifyWriteLanded('message', { subject, body }, detail);
|
|
622
|
+
// Threading verdict, from OFW's own echo on the re-fetched sent record.
|
|
623
|
+
// Both directions demand POSITIVE evidence (see reportsUnthreaded): a
|
|
624
|
+
// bare `replyToId: null` — OFW's normal shape even on threaded items —
|
|
625
|
+
// and a total absence of echo fields are both "not echoed", NOT
|
|
626
|
+
// "dropped". A warning the caller can see is false is a warning it
|
|
627
|
+
// learns to skip.
|
|
628
|
+
const echoed = threadedReplyTo(detail);
|
|
629
|
+
if (resolvedReplyTo === null) {
|
|
630
|
+
threaded = reportsThreaded(detail);
|
|
631
|
+
}
|
|
632
|
+
else if (reportsThreaded(detail)) {
|
|
633
|
+
threaded = true;
|
|
634
|
+
if (echoed !== null && echoed !== resolvedReplyTo) {
|
|
635
|
+
threadNote = `NOTE: the sent message threads to ${echoed}, not the requested ${resolvedReplyTo} — OurFamilyWizard re-targeted the reply within the thread.`;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
else if (reportsUnthreaded(detail)) {
|
|
639
|
+
threaded = false;
|
|
640
|
+
threadNote = `WARNING: the sent message came back UNTHREADED — replyToId ${resolvedReplyTo} was posted but OurFamilyWizard reports no reply linkage on the sent record, so it went out as a new top-level conversation. Verify on ourfamilywizard.com.`;
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
threaded = true;
|
|
644
|
+
}
|
|
645
|
+
// Recipient confirmation is part of "the send is confirmed" (it gates
|
|
646
|
+
// the draft delete below). Only a NON-EMPTY echo can disconfirm: an
|
|
647
|
+
// omitted or empty recipients array is "not echoed" (OFW routinely
|
|
648
|
+
// echoes [] the way it does on drafts), and crying wolf on it would
|
|
649
|
+
// retain the draft after every ordinary send.
|
|
650
|
+
const storedRecipients = mapRecipients(detail.recipients);
|
|
651
|
+
if (Array.isArray(detail.recipients) && detail.recipients.length > 0) {
|
|
652
|
+
const landed = new Set(storedRecipients.map((r) => r.userId));
|
|
653
|
+
const missingRecipients = recipientIds.filter((rid) => !landed.has(rid));
|
|
654
|
+
if (missingRecipients.length > 0) {
|
|
655
|
+
verifyNote = [
|
|
656
|
+
verifyNote,
|
|
657
|
+
`WARNING: the sent record does not list requested recipient id(s) ${missingRecipients.join(', ')}, so the send could not be fully confirmed. Verify on ourfamilywizard.com.`,
|
|
658
|
+
].filter((n) => n !== null).join('\n\n');
|
|
659
|
+
}
|
|
660
|
+
}
|
|
572
661
|
persisted = {
|
|
573
662
|
id: newId,
|
|
574
663
|
folder: 'sent',
|
|
575
664
|
subject: detail.subject ?? subject,
|
|
576
665
|
fromUser: detail.from?.name ?? '',
|
|
577
666
|
sentAt: detail.date?.dateTime ?? new Date().toISOString(),
|
|
578
|
-
recipients:
|
|
667
|
+
recipients: storedRecipients,
|
|
579
668
|
body: detail.body ?? body,
|
|
580
669
|
fetchedBodyAt: new Date().toISOString(),
|
|
581
|
-
|
|
582
|
-
|
|
670
|
+
// Prefer OFW's own echo of where the reply landed; keep what was
|
|
671
|
+
// posted when OFW echoed nothing (sent rows feed findLatestReplyTip,
|
|
672
|
+
// and a null would break the chain for a message that IS threaded).
|
|
673
|
+
// A positively UNTHREADED send stores null — the chain link OFW says
|
|
674
|
+
// does not exist must not be invented.
|
|
675
|
+
replyToId: threaded ? echoed ?? resolvedReplyTo : null,
|
|
676
|
+
chainRootId: threaded ? chainRootId : null,
|
|
583
677
|
listData: detail,
|
|
584
678
|
};
|
|
585
679
|
await cache.upsertMessage(persisted);
|
|
@@ -614,25 +708,66 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
614
708
|
});
|
|
615
709
|
}
|
|
616
710
|
}
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
//
|
|
711
|
+
// Clean up the draft ONLY once the send is confirmed: OFW returned the new
|
|
712
|
+
// message id AND the re-fetched sent record raised no verification warning
|
|
713
|
+
// (subject/body landed, requested recipients listed). On any failure or
|
|
714
|
+
// ambiguity the draft is the user's only reliable copy — keep it and say
|
|
715
|
+
// why, never silently.
|
|
620
716
|
let unconfirmedNote = null;
|
|
717
|
+
let draftDeleted = false;
|
|
718
|
+
let draftRetainedReason = null;
|
|
621
719
|
if (newId === null) {
|
|
622
720
|
const draftClause = draftRef !== undefined
|
|
623
721
|
? `Draft ${draftRef} was NOT deleted — check`
|
|
624
722
|
: 'Check';
|
|
625
723
|
unconfirmedNote = `WARNING: OFW's send response did not include a message id, so the send could not be confirmed. ${draftClause} ourfamilywizard.com to see whether the message went out before retrying.`;
|
|
724
|
+
if (draftRef !== undefined) {
|
|
725
|
+
draftRetainedReason = 'the send could not be confirmed (OFW returned no message id), so the draft is your only reliable copy of the message';
|
|
726
|
+
}
|
|
626
727
|
}
|
|
627
728
|
else if (draftRef !== undefined) {
|
|
628
|
-
|
|
629
|
-
|
|
729
|
+
if (verifyNote !== null) {
|
|
730
|
+
draftRetainedReason = 'the sent record could not be fully verified against what was posted (see WARNING above) — the draft is kept until you confirm the send on ourfamilywizard.com';
|
|
731
|
+
}
|
|
732
|
+
else if (!deleteOnSuccess) {
|
|
733
|
+
draftRetainedReason = 'deleteDraftOnSuccess:false — kept by request';
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
try {
|
|
737
|
+
await deleteOFWMessages(client, [draftRef]);
|
|
738
|
+
await cache.deleteDraft(draftRef);
|
|
739
|
+
draftDeleted = true;
|
|
740
|
+
}
|
|
741
|
+
catch (e) {
|
|
742
|
+
draftRetainedReason = `the send succeeded but the draft delete failed (${e.message}) — remove it with ofw_delete_draft once you have verified the sent message`;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
630
745
|
}
|
|
746
|
+
const retainNote = draftRef !== undefined && newId !== null && !draftDeleted
|
|
747
|
+
? `NOTE: draft ${draftRef} was retained: ${draftRetainedReason}.`
|
|
748
|
+
: null;
|
|
749
|
+
// Leads with the stable identifiers (sentMessageId, draftKey) — the
|
|
750
|
+
// volatile ids and the full sent row follow.
|
|
631
751
|
const responseObj = persisted === null
|
|
632
|
-
?
|
|
633
|
-
|
|
752
|
+
? (draftRef !== undefined
|
|
753
|
+
? { sendConfirmed: false, draftDeleted: false, draftRetained: true, draftRetainedReason, raw }
|
|
754
|
+
: raw)
|
|
755
|
+
: {
|
|
756
|
+
sentMessageId: newId,
|
|
757
|
+
draftKey: sentDraftKey,
|
|
758
|
+
threaded,
|
|
759
|
+
...(draftRef !== undefined
|
|
760
|
+
? {
|
|
761
|
+
draftDeleted,
|
|
762
|
+
...(draftDeleted ? {} : { draftRetained: true, draftRetainedReason }),
|
|
763
|
+
previousId: draftRef,
|
|
764
|
+
}
|
|
765
|
+
: {}),
|
|
766
|
+
...persisted,
|
|
767
|
+
};
|
|
634
768
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
|
|
635
|
-
const notes = [rewriteNote, verifyNote, unconfirmedNote]
|
|
769
|
+
const notes = [guardNote, rewriteNote, verifyNote, threadNote, unconfirmedNote, retainNote]
|
|
770
|
+
.filter((n) => n !== null).join('\n\n');
|
|
636
771
|
return textResponse(notes ? `${notes}\n\n${text}` : text);
|
|
637
772
|
});
|
|
638
773
|
async function guardDestructiveDraftOp(input) {
|
|
@@ -658,7 +793,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
658
793
|
// `.message`, which every Error carries.)
|
|
659
794
|
const reason = e.message;
|
|
660
795
|
if (force) {
|
|
661
|
-
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
|
|
796
|
+
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.`, server: undefined };
|
|
662
797
|
}
|
|
663
798
|
return {
|
|
664
799
|
ok: false,
|
|
@@ -678,7 +813,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
678
813
|
const note = verdict.metadataOnly
|
|
679
814
|
? `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.`
|
|
680
815
|
: null;
|
|
681
|
-
return { ok: true, note };
|
|
816
|
+
return { ok: true, note, server };
|
|
682
817
|
}
|
|
683
818
|
if (force) {
|
|
684
819
|
// Loud, and the overwritten content rides along in the response so it is
|
|
@@ -690,6 +825,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
690
825
|
return {
|
|
691
826
|
ok: true,
|
|
692
827
|
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)}`,
|
|
828
|
+
server,
|
|
693
829
|
};
|
|
694
830
|
}
|
|
695
831
|
return {
|
|
@@ -704,17 +840,41 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
704
840
|
};
|
|
705
841
|
}
|
|
706
842
|
server.registerTool('ofw_list_drafts', {
|
|
707
|
-
description: 'List draft messages
|
|
843
|
+
description: 'List draft messages, verified against OurFamilyWizard in ONE call: when the local drafts cache is not verified-fresh, a cheap drafts sync runs first by default (verify:true), so the answer is server-confirmed without a second call. Pass verify:false to answer purely from the cache (no OFW requests). Returns an explicit `complete` boolean describing the RESULT SET: true means "these are ALL the drafts on OurFamilyWizard as of freshness.asOf" — check it before saying "you have N drafts". Each draft carries its `draftKey` (stable across the create-then-delete churn of editing) when one is known. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY"); pass autoRefresh:true to sync and answer instead.',
|
|
708
844
|
annotations: { readOnlyHint: false },
|
|
709
845
|
inputSchema: {
|
|
710
846
|
page: z.number().int().min(1).describe('Page number (default 1)').optional(),
|
|
711
847
|
size: z.number().int().min(1).describe('Drafts per page (default 50)').optional(),
|
|
848
|
+
verify: z.boolean().describe('Default true: when the drafts cache is not verified-fresh, run a drafts sync first (cheap — one list page plus one detail per draft) so the response is server-confirmed in one call. Set false to serve straight from the local cache with no OFW requests.').optional(),
|
|
712
849
|
autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
|
|
713
850
|
},
|
|
714
851
|
}, async (args) => {
|
|
715
852
|
const page = args.page ?? 1;
|
|
716
853
|
const size = args.size ?? 50;
|
|
717
854
|
const cache = cacheProvider();
|
|
855
|
+
// Auto-verify (default on): drafts change rarely but INVISIBLY — a web-app
|
|
856
|
+
// edit bumps no timestamp — so an aged cache used to answer "unverified,
|
|
857
|
+
// don't state a count" and force a second call. The drafts walk is cheap
|
|
858
|
+
// enough to just run it. Two honesty rules: `autoVerified` is derived from
|
|
859
|
+
// the POST-sync cache status, because a budget-paused walk applies nothing
|
|
860
|
+
// and claiming autoVerified next to serverConfirmed:false would be one
|
|
861
|
+
// payload contradicting itself; and a sync that CANNOT run (OFW/network
|
|
862
|
+
// down) degrades to the honestly-labelled cache answer below rather than
|
|
863
|
+
// turning a previously infallible cache read into a hard error.
|
|
864
|
+
let autoVerified = false;
|
|
865
|
+
let verifyNote = null;
|
|
866
|
+
if (args.verify ?? true) {
|
|
867
|
+
const { cacheStatus } = await draftsFreshness(cache);
|
|
868
|
+
if (cacheStatus !== 'fresh') {
|
|
869
|
+
try {
|
|
870
|
+
await syncAll(client, { folders: ['drafts'], maxRequests: getSyncMaxRequests() }, cache);
|
|
871
|
+
autoVerified = await getDraftsCacheStatus(cache) === 'fresh';
|
|
872
|
+
}
|
|
873
|
+
catch (e) {
|
|
874
|
+
verifyNote = `The automatic drafts verification could not reach OurFamilyWizard (${e.message}). Answering from the local cache — the freshness block below labels its age, and an empty result will still be refused rather than reported as an absence.`;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
718
878
|
const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
|
|
719
879
|
client,
|
|
720
880
|
cache,
|
|
@@ -748,7 +908,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
748
908
|
freshness: value.freshness,
|
|
749
909
|
refreshed,
|
|
750
910
|
remedy: 'Call ofw_sync_messages(folders:["drafts"]) and retry, re-call with autoRefresh:true, or use ofw_status(includeDraftInventory:true) for a single live answer.',
|
|
751
|
-
extra: { page, size },
|
|
911
|
+
extra: { page, size, ...(verifyNote !== null ? { verifyNote } : {}) },
|
|
752
912
|
});
|
|
753
913
|
}
|
|
754
914
|
const { drafts, total, freshness, serverConfirmed } = value;
|
|
@@ -771,11 +931,17 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
771
931
|
if (refreshed) {
|
|
772
932
|
payload.autoRefreshed = true;
|
|
773
933
|
}
|
|
934
|
+
if (autoVerified) {
|
|
935
|
+
payload.autoVerified = true;
|
|
936
|
+
}
|
|
937
|
+
if (verifyNote !== null) {
|
|
938
|
+
payload.verifyNote = verifyNote;
|
|
939
|
+
}
|
|
774
940
|
return jsonResponse(payload);
|
|
775
941
|
});
|
|
776
942
|
if (allowDrafts)
|
|
777
943
|
server.registerTool('ofw_save_draft', {
|
|
778
|
-
description: 'Save a message as a draft in OurFamilyWizard.
|
|
944
|
+
description: 'Save a message as a draft in OurFamilyWizard. RECIPIENTS: OurFamilyWizard does NOT persist recipients on drafts — recipientIds are accepted but the saved draft comes back with none (documented OFW behavior, noted once in the response, not warned about; supply recipientIds at send time instead). IDENTITY: the response leads with `draftKey`, the stable identity that survives editing — key off it, because the `id` changes on EVERY edit (replacing a draft creates a NEW draft and deletes the old one; OFW\'s update-in-place endpoint silently no-ops, so we never use it). Pass messageId to replace an existing draft; the response.id will be the NEW id, and a transparency NOTE documents the swap and which fields were carried over. THREADING: if replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included). The threading verdict is read from OFW\'s full echo (replyToId/inReplyTo/showContext) — a warning appears ONLY when the reply linkage was genuinely dropped or re-targeted, and the response\'s top-level replyToId/inReplyTo always agree with its listData. Attach files via myFileIDs (from ofw_upload_attachment). After saving, the tool re-fetches the draft from OFW, and the returned `revision` reflects that authoritative state (so it will match on your next edit). 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.',
|
|
779
945
|
annotations: { readOnlyHint: false },
|
|
780
946
|
inputSchema: {
|
|
781
947
|
subject: z.string().describe('Message subject'),
|
|
@@ -833,6 +999,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
833
999
|
let persisted = null;
|
|
834
1000
|
let replaceNote = null;
|
|
835
1001
|
let verifyNote = null;
|
|
1002
|
+
let recipientsNote = null;
|
|
836
1003
|
let newRevision = null;
|
|
837
1004
|
let draftKey = null;
|
|
838
1005
|
// Fields accepted on the write that the saved draft must carry — or their
|
|
@@ -845,9 +1012,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
845
1012
|
// normalizes/drops threading after a save, and masking that with our own
|
|
846
1013
|
// intent (the old `detail.replyToId ?? resolvedReplyTo`) both returned a
|
|
847
1014
|
// revision that was stale on arrival (Defect 1) and hid a dropped reply
|
|
848
|
-
// link (Defect 3).
|
|
849
|
-
//
|
|
850
|
-
|
|
1015
|
+
// link (Defect 3). The echo is read via threadedReplyTo because OFW
|
|
1016
|
+
// reports the target as `inReplyTo` on payloads where `replyToId` is
|
|
1017
|
+
// null — reading only `replyToId` fired a false "did not thread" warning
|
|
1018
|
+
// on nearly every threaded save while the same response's listData
|
|
1019
|
+
// showed inReplyTo populated.
|
|
1020
|
+
const effectiveReplyTo = threadedReplyTo(detail);
|
|
851
1021
|
const storedRecipients = mapRecipients(detail.recipients);
|
|
852
1022
|
persisted = {
|
|
853
1023
|
id: newId,
|
|
@@ -892,26 +1062,40 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
892
1062
|
});
|
|
893
1063
|
// Audit every field the caller supplied against what actually landed, so a
|
|
894
1064
|
// silent normalization becomes a visible warning rather than a surprise.
|
|
895
|
-
|
|
1065
|
+
// The verdict comes from the FULL threading echo (replyToId, inReplyTo,
|
|
1066
|
+
// showContext) — a draft reporting inReplyTo (or showContext:true) IS
|
|
1067
|
+
// threaded, and warning about it anyway is the false positive that
|
|
1068
|
+
// trained callers to skim warnings. The "dropped" warning demands
|
|
1069
|
+
// POSITIVE evidence (reportsUnthreaded): a bare `replyToId: null` or a
|
|
1070
|
+
// detail omitting every echo field is "not echoed", never "dropped".
|
|
1071
|
+
// The stored replyToId is still the server echo (null when unreported) —
|
|
1072
|
+
// masking it with intent was Defect 1, and the revision must hash what
|
|
1073
|
+
// the next sync will read.
|
|
1074
|
+
if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo && reportsUnthreaded(detail)) {
|
|
1075
|
+
const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : '';
|
|
1076
|
+
warnings.push(`replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId null — OurFamilyWizard did not thread this draft (its inReplyTo/showContext are empty). The subject and body were saved; only the reply linkage was dropped. If threading matters, verify on ourfamilywizard.com.`);
|
|
1077
|
+
}
|
|
1078
|
+
else if (resolvedReplyTo !== null && effectiveReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
|
|
1079
|
+
// OFW RE-TARGETED the link to another message in the thread — the
|
|
1080
|
+
// draft IS threaded, just not to the requested id, and the inReplyTo
|
|
1081
|
+
// in this response reflects where it actually landed.
|
|
896
1082
|
const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : '';
|
|
897
|
-
|
|
898
|
-
// warnings. OFW either DROPPED the link (null) or RE-TARGETED it to
|
|
899
|
-
// another message in the thread. Describing both as "did not thread
|
|
900
|
-
// this draft (its inReplyTo/showContext will be empty)" contradicted
|
|
901
|
-
// the non-null inReplyTo the same response echoes — and a warning the
|
|
902
|
-
// caller can see is false is a warning it learns to skip.
|
|
903
|
-
const outcome = effectiveReplyTo === null
|
|
904
|
-
? 'OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped.'
|
|
905
|
-
: `OurFamilyWizard re-targeted the reply to message ${effectiveReplyTo} instead. The draft IS threaded — to that message, not the one requested — and the inReplyTo in this response reflects where it actually landed.`;
|
|
906
|
-
warnings.push(`replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo === null ? 'null' : effectiveReplyTo} — ${outcome} If threading matters, verify on ourfamilywizard.com.`);
|
|
1083
|
+
warnings.push(`replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo} — OurFamilyWizard re-targeted the reply to message ${effectiveReplyTo} instead. The draft IS threaded — to that message, not the one requested. If threading matters, verify on ourfamilywizard.com.`);
|
|
907
1084
|
}
|
|
908
|
-
//
|
|
909
|
-
//
|
|
910
|
-
//
|
|
911
|
-
|
|
1085
|
+
// Recipients: OFW does NOT persist recipients on drafts — the saved copy
|
|
1086
|
+
// routinely comes back with [] no matter what was posted. That is
|
|
1087
|
+
// documented OFW behavior (and doubles as an accidental-send guard), so
|
|
1088
|
+
// it gets a one-line NOTE, not a per-call WARNING that cries wolf on
|
|
1089
|
+
// every save. A PARTIAL echo (some stored, but not what was asked) is a
|
|
1090
|
+
// genuine drop and still warns. Both only when the detail actually
|
|
1091
|
+
// reported recipients — an omitted array is "not echoed", not "dropped".
|
|
1092
|
+
if (args.recipientIds !== undefined && args.recipientIds.length > 0 && Array.isArray(detail.recipients)) {
|
|
912
1093
|
const requested = [...new Set(args.recipientIds)].sort((a, b) => a - b);
|
|
913
1094
|
const stored = [...new Set(storedRecipients.map((r) => r.userId))].sort((a, b) => a - b);
|
|
914
|
-
if (
|
|
1095
|
+
if (stored.length === 0) {
|
|
1096
|
+
recipientsNote = 'NOTE: OurFamilyWizard does not persist recipients on drafts — the recipientIds you passed were accepted but are not stored on the draft (documented OFW behavior, not an error; it also means a draft cannot be sent by accident). Supply recipientIds when you send: ofw_send_message requires them when the draft carries none.';
|
|
1097
|
+
}
|
|
1098
|
+
else if (requested.join(',') !== stored.join(',')) {
|
|
915
1099
|
warnings.push(`recipientIds were requested as [${requested.join(', ')}] but the saved draft has [${stored.join(', ')}]. Verify the recipients on ourfamilywizard.com.`);
|
|
916
1100
|
}
|
|
917
1101
|
}
|
|
@@ -928,7 +1112,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
928
1112
|
try {
|
|
929
1113
|
await deleteOFWMessages(client, [args.messageId]);
|
|
930
1114
|
await cache.deleteDraft(args.messageId);
|
|
931
|
-
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.
|
|
1115
|
+
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. The draftKey is UNCHANGED — key off it rather than the volatile id, which changes on every edit. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it.) 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.' : ''}`;
|
|
932
1116
|
}
|
|
933
1117
|
catch (e) {
|
|
934
1118
|
// Partial-failure safety: the new draft is already created and
|
|
@@ -940,28 +1124,29 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
940
1124
|
}
|
|
941
1125
|
// The draft was just re-fetched from OFW by postMessageAndRefetch, so this
|
|
942
1126
|
// one row IS server-confirmed regardless of the drafts folder's overall
|
|
943
|
-
// cache freshness.
|
|
944
|
-
//
|
|
1127
|
+
// cache freshness. The response LEADS with `draftKey` (stable across the
|
|
1128
|
+
// create-then-delete id churn of editing — key off it, not `id`) and the
|
|
1129
|
+
// `revision` concurrency token; the volatile `id` is carried further down
|
|
1130
|
+
// as an implementation detail. `inReplyTo` echoes the effective threading,
|
|
1131
|
+
// and `warnings` names any requested field that did not land.
|
|
945
1132
|
const responseObj = persisted !== null
|
|
946
1133
|
? {
|
|
1134
|
+
draftKey,
|
|
1135
|
+
revision: newRevision,
|
|
947
1136
|
...persisted,
|
|
948
1137
|
inReplyTo: persisted.replyToId,
|
|
949
|
-
revision: newRevision,
|
|
950
|
-
// The id above is volatile — it changes on every edit. `draftKey` is
|
|
951
|
-
// not: pass it to ofw_status to resolve the chain's CURRENT id, or to
|
|
952
|
-
// find out that the draft was sent and when.
|
|
953
|
-
draftKey,
|
|
954
1138
|
previousId: args.messageId ?? null,
|
|
955
1139
|
cacheStatus: 'fresh',
|
|
956
1140
|
serverConfirmed: true,
|
|
957
1141
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
1142
|
+
...(recipientsNote !== null ? { recipientsNote } : {}),
|
|
958
1143
|
}
|
|
959
1144
|
: raw;
|
|
960
1145
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
|
|
961
1146
|
const warnNote = warnings.length > 0
|
|
962
1147
|
? `WARNING: ${warnings.join('\n\n')}`
|
|
963
1148
|
: null;
|
|
964
|
-
const notes = [forceNote, rewriteNote, verifyNote, warnNote, replaceNote]
|
|
1149
|
+
const notes = [forceNote, rewriteNote, verifyNote, warnNote, recipientsNote, replaceNote]
|
|
965
1150
|
.filter((n) => n !== null).join('\n\n');
|
|
966
1151
|
return textResponse(notes ? `${notes}\n\n${text}` : text);
|
|
967
1152
|
});
|
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.
|
|
9
|
+
"version": "2.10.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.10.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|