ofw-mcp 2.3.2 → 2.4.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 +37 -2
- package/.mcp.json +5 -2
- package/LICENSE +21 -0
- package/README.md +42 -22
- package/dist/auth-password.js +7 -2
- package/dist/bundle.js +250 -48
- package/dist/client.js +6 -0
- package/dist/config.js +26 -0
- package/dist/env-bootstrap.js +28 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +68 -9
- package/dist/tools/_shared.js +47 -3
- package/dist/tools/calendar.js +54 -48
- package/dist/tools/expenses.js +17 -13
- package/dist/tools/journal.js +17 -13
- package/dist/tools/messages.js +313 -242
- package/dist/validate.js +35 -0
- package/package.json +5 -1
- package/server.json +8 -2
package/dist/tools/messages.js
CHANGED
|
@@ -1,11 +1,56 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage } from '../sync.js';
|
|
3
3
|
import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, upsertDraft, deleteDraft, findLatestReplyTip, listAttachmentsForMessage, getAttachment, upsertAttachmentForMessage, markAttachmentDownloaded, getDraft, } from '../cache.js';
|
|
4
|
-
import { getAttachmentsDir, getDefaultInlineAttachments } from '../config.js';
|
|
4
|
+
import { getAttachmentsDir, getDefaultInlineAttachments, getWriteMode } from '../config.js';
|
|
5
5
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { basename, dirname, extname, join } from 'node:path';
|
|
7
7
|
import { fileBlob } from '@chrischall/mcp-utils';
|
|
8
|
-
import { expandPath, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse } from './_shared.js';
|
|
8
|
+
import { ApiRecipientSchema, expandPath, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded } from './_shared.js';
|
|
9
|
+
import { parseOFW } from '../validate.js';
|
|
10
|
+
// Schemas for the load-bearing fields of each /pub/v3 response this file
|
|
11
|
+
// reads (issue #83). Loose: unknown keys pass through into cached listData.
|
|
12
|
+
const DateSchema = z.looseObject({ dateTime: z.string() });
|
|
13
|
+
// Detail GET after a send/save POST — validated STRICT inside
|
|
14
|
+
// postMessageAndRefetch (write-verification boundary). All fields optional:
|
|
15
|
+
// absence is handled by verifyWriteLanded's WARNING; a present-but-mistyped
|
|
16
|
+
// field throws.
|
|
17
|
+
const SentDetailSchema = z.looseObject({
|
|
18
|
+
subject: z.string().optional(),
|
|
19
|
+
body: z.string().optional(),
|
|
20
|
+
date: DateSchema.optional(),
|
|
21
|
+
from: z.looseObject({ name: z.string().optional() }).optional(),
|
|
22
|
+
recipients: z.array(ApiRecipientSchema).optional(),
|
|
23
|
+
});
|
|
24
|
+
const SavedDraftDetailSchema = z.looseObject({
|
|
25
|
+
subject: z.string().optional(),
|
|
26
|
+
body: z.string().optional(),
|
|
27
|
+
date: DateSchema.optional(),
|
|
28
|
+
replyToId: z.number().nullable().optional(),
|
|
29
|
+
recipients: z.array(ApiRecipientSchema).optional(),
|
|
30
|
+
});
|
|
31
|
+
// ofw_get_message's uncached detail fetch — lenient: a mismatch warns to
|
|
32
|
+
// stderr and the existing ?? fallbacks keep the tool serving.
|
|
33
|
+
const MessageDetailSchema = z.looseObject({
|
|
34
|
+
id: z.number(),
|
|
35
|
+
subject: z.string(),
|
|
36
|
+
body: z.string().optional(),
|
|
37
|
+
date: DateSchema,
|
|
38
|
+
from: z.looseObject({ name: z.string().optional() }).optional(),
|
|
39
|
+
files: z.array(z.number()).optional(),
|
|
40
|
+
recipients: z.array(ApiRecipientSchema).optional(),
|
|
41
|
+
});
|
|
42
|
+
// Attachment-backfill detail fetch reads only `files`.
|
|
43
|
+
const DetailFilesSchema = z.looseObject({ files: z.array(z.number()).optional() });
|
|
44
|
+
// Upload response — STRICT: fileId is the whole point of the call; caching
|
|
45
|
+
// or returning an undefined/mistyped fileId produces an unusable attachment.
|
|
46
|
+
const UploadedFileSchema = z.looseObject({
|
|
47
|
+
fileId: z.number(),
|
|
48
|
+
fileName: z.string().optional(),
|
|
49
|
+
label: z.string().optional(),
|
|
50
|
+
fileType: z.string().optional(),
|
|
51
|
+
sizeInBytes: z.number().optional(),
|
|
52
|
+
shareClass: z.string().optional(),
|
|
53
|
+
});
|
|
9
54
|
// Lightweight mime sniff from extension. OFW re-derives mime from the filename
|
|
10
55
|
// server-side anyway, so this is just a polite Content-Type for the Blob.
|
|
11
56
|
const MIME_BY_EXT = {
|
|
@@ -48,6 +93,13 @@ function listDataHintsAtFiles(listData) {
|
|
|
48
93
|
return false;
|
|
49
94
|
}
|
|
50
95
|
export function registerMessageTools(server, client) {
|
|
96
|
+
// OFW_WRITE_MODE gate (see config.ts). Send lands on the court-visible
|
|
97
|
+
// record, so it is 'all'-only; draft-level writes (save/delete drafts,
|
|
98
|
+
// upload attachments) also register under 'drafts'. Read/sync/download
|
|
99
|
+
// tools always register.
|
|
100
|
+
const writeMode = getWriteMode();
|
|
101
|
+
const allowSend = writeMode === 'all';
|
|
102
|
+
const allowDrafts = writeMode !== 'none';
|
|
51
103
|
server.registerTool('ofw_list_message_folders', {
|
|
52
104
|
description: 'List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.',
|
|
53
105
|
annotations: { readOnlyHint: true },
|
|
@@ -60,8 +112,8 @@ export function registerMessageTools(server, client) {
|
|
|
60
112
|
annotations: { readOnlyHint: true },
|
|
61
113
|
inputSchema: {
|
|
62
114
|
folderId: z.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
|
|
63
|
-
page: z.number().describe('Page number (default 1)').optional(),
|
|
64
|
-
size: z.number().describe('Messages per page (default 50)').optional(),
|
|
115
|
+
page: z.number().int().min(1).describe('Page number (default 1)').optional(),
|
|
116
|
+
size: z.number().int().min(1).describe('Messages per page (default 50)').optional(),
|
|
65
117
|
since: z.string().describe('ISO date or datetime — only messages with sent_at >= since (inclusive)').optional(),
|
|
66
118
|
until: z.string().describe('ISO date or datetime — only messages with sent_at < until (exclusive)').optional(),
|
|
67
119
|
q: z.string().describe('Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.').optional(),
|
|
@@ -141,7 +193,7 @@ export function registerMessageTools(server, client) {
|
|
|
141
193
|
// OFW state isn't changing).
|
|
142
194
|
if (attachments.length === 0 && listDataHintsAtFiles(cached.listData)) {
|
|
143
195
|
try {
|
|
144
|
-
const detail = await client.request('GET', `/pub/v3/messages/${id}`);
|
|
196
|
+
const detail = parseOFW(DetailFilesSchema, await client.request('GET', `/pub/v3/messages/${id}`), 'GET /pub/v3/messages/{id} (attachment backfill)');
|
|
145
197
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
146
198
|
await fetchAttachmentMetaForMessage(client, id, detail.files);
|
|
147
199
|
attachments = listAttachmentsForMessage(id);
|
|
@@ -153,7 +205,7 @@ export function registerMessageTools(server, client) {
|
|
|
153
205
|
}
|
|
154
206
|
return jsonResponse({ ...cached, attachments });
|
|
155
207
|
}
|
|
156
|
-
const detail = await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
|
|
208
|
+
const detail = parseOFW(MessageDetailSchema, await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`), 'GET /pub/v3/messages/{id} (ofw_get_message)');
|
|
157
209
|
const folder = cached?.folder ?? 'inbox';
|
|
158
210
|
const row = {
|
|
159
211
|
id: detail.id,
|
|
@@ -175,127 +227,141 @@ export function registerMessageTools(server, client) {
|
|
|
175
227
|
const attachments = listAttachmentsForMessage(detail.id);
|
|
176
228
|
return jsonResponse({ ...row, attachments });
|
|
177
229
|
});
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const draftRef = args.messageId ?? args.draftId;
|
|
195
|
-
// Best-effort draft lookup: when draftRef points at a cached draft, use
|
|
196
|
-
// its stored fields (including replyToId) as defaults for anything the
|
|
197
|
-
// caller didn't supply. The "missing draft" case only matters when we
|
|
198
|
-
// actually NEED the defaults — a caller passing all fields explicitly
|
|
199
|
-
// can use draftId as a pure delete-target even on an empty cache.
|
|
200
|
-
let subject = args.subject;
|
|
201
|
-
let body = args.body;
|
|
202
|
-
let recipientIds = args.recipientIds;
|
|
203
|
-
let draftReplyToId = null;
|
|
204
|
-
let draftLookupAttempted = false;
|
|
205
|
-
let draftFound = false;
|
|
206
|
-
if (draftRef !== undefined) {
|
|
207
|
-
draftLookupAttempted = true;
|
|
208
|
-
const draft = getDraft(draftRef);
|
|
209
|
-
if (draft !== null) {
|
|
210
|
-
draftFound = true;
|
|
211
|
-
subject = subject ?? draft.subject;
|
|
212
|
-
body = body ?? draft.body;
|
|
213
|
-
recipientIds = recipientIds ?? draft.recipients.map((r) => r.userId);
|
|
214
|
-
draftReplyToId = draft.replyToId;
|
|
230
|
+
if (allowSend)
|
|
231
|
+
server.registerTool('ofw_send_message', {
|
|
232
|
+
description: 'Send a message via OurFamilyWizard. To send an existing draft, pass messageId — subject/body/recipientIds become optional overrides (missing fields default to the draft\'s cached values) and the draft is deleted after sending. To send a fresh message, supply subject/body/recipientIds directly. draftId is the legacy spelling of messageId and works the same way. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.',
|
|
233
|
+
annotations: { destructiveHint: true },
|
|
234
|
+
inputSchema: {
|
|
235
|
+
subject: z.string().describe('Message subject. Required unless messageId/draftId references a cached draft.').optional(),
|
|
236
|
+
body: z.string().describe('Message body text. Required unless messageId/draftId references a cached draft.').optional(),
|
|
237
|
+
recipientIds: z.array(z.number()).describe('Array of recipient user IDs (get from ofw_get_profile). Required unless messageId/draftId references a cached draft.').optional(),
|
|
238
|
+
replyToId: z.number().describe('ID of the message being replied to').optional(),
|
|
239
|
+
messageId: z.number().describe('ID of an existing draft to send. When set, missing subject/body/recipientIds default to the draft\'s cached values, and the draft is deleted after sending.').optional(),
|
|
240
|
+
draftId: z.number().describe('Legacy synonym for messageId. If both are passed they must be equal.').optional(),
|
|
241
|
+
myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment) to attach to the message').optional(),
|
|
242
|
+
},
|
|
243
|
+
}, async (args) => {
|
|
244
|
+
if (args.messageId !== undefined && args.draftId !== undefined && args.messageId !== args.draftId) {
|
|
245
|
+
throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
|
|
215
246
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
247
|
+
const draftRef = args.messageId ?? args.draftId;
|
|
248
|
+
// Best-effort draft lookup: when draftRef points at a cached draft, use
|
|
249
|
+
// its stored fields (including replyToId) as defaults for anything the
|
|
250
|
+
// caller didn't supply. The "missing draft" case only matters when we
|
|
251
|
+
// actually NEED the defaults — a caller passing all fields explicitly
|
|
252
|
+
// can use draftId as a pure delete-target even on an empty cache.
|
|
253
|
+
let subject = args.subject;
|
|
254
|
+
let body = args.body;
|
|
255
|
+
let recipientIds = args.recipientIds;
|
|
256
|
+
let draftReplyToId = null;
|
|
257
|
+
let draftLookupAttempted = false;
|
|
258
|
+
let draftFound = false;
|
|
259
|
+
if (draftRef !== undefined) {
|
|
260
|
+
draftLookupAttempted = true;
|
|
261
|
+
const draft = getDraft(draftRef);
|
|
262
|
+
if (draft !== null) {
|
|
263
|
+
draftFound = true;
|
|
264
|
+
subject = subject ?? draft.subject;
|
|
265
|
+
body = body ?? draft.body;
|
|
266
|
+
recipientIds = recipientIds ?? draft.recipients.map((r) => r.userId);
|
|
267
|
+
draftReplyToId = draft.replyToId;
|
|
268
|
+
}
|
|
220
269
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const requestedReplyTo = args.replyToId ?? draftReplyToId ?? null;
|
|
232
|
-
let resolvedReplyTo = requestedReplyTo;
|
|
233
|
-
let chainRootId = null;
|
|
234
|
-
let rewriteNote = null;
|
|
235
|
-
if (requestedReplyTo !== null) {
|
|
236
|
-
resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
|
|
237
|
-
if (resolvedReplyTo !== requestedReplyTo) {
|
|
238
|
-
rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
|
|
270
|
+
if (subject === undefined || body === undefined || recipientIds === undefined) {
|
|
271
|
+
if (draftLookupAttempted && !draftFound) {
|
|
272
|
+
throw new Error(`draft ${draftRef} not found in local cache. Call ofw_sync_messages first, or supply subject/body/recipientIds explicitly.`);
|
|
273
|
+
}
|
|
274
|
+
const missing = [
|
|
275
|
+
subject === undefined ? 'subject' : null,
|
|
276
|
+
body === undefined ? 'body' : null,
|
|
277
|
+
recipientIds === undefined ? 'recipientIds' : null,
|
|
278
|
+
].filter((n) => n !== null).join(', ');
|
|
279
|
+
throw new Error(`ofw_send_message requires ${missing}. Pass it directly, or pass messageId to default missing fields from a cached draft.`);
|
|
239
280
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
fetchedBodyAt: new Date().toISOString(),
|
|
281
|
+
// Inherit the draft's replyToId when the caller didn't supply one. A
|
|
282
|
+
// reply-draft saved with replyToId would otherwise be sent as a
|
|
283
|
+
// top-level message — silently losing the thread.
|
|
284
|
+
const requestedReplyTo = args.replyToId ?? draftReplyToId ?? null;
|
|
285
|
+
let resolvedReplyTo = requestedReplyTo;
|
|
286
|
+
let chainRootId = null;
|
|
287
|
+
let rewriteNote = null;
|
|
288
|
+
if (requestedReplyTo !== null) {
|
|
289
|
+
resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
|
|
290
|
+
if (resolvedReplyTo !== requestedReplyTo) {
|
|
291
|
+
rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
|
|
292
|
+
}
|
|
293
|
+
const parent = getMessage(resolvedReplyTo);
|
|
294
|
+
chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
|
|
295
|
+
}
|
|
296
|
+
const myFileIDs = args.myFileIDs ?? [];
|
|
297
|
+
const { id: newId, detail, raw } = await postMessageAndRefetch(client, {
|
|
298
|
+
subject,
|
|
299
|
+
body,
|
|
300
|
+
recipientIds,
|
|
301
|
+
attachments: { myFileIDs },
|
|
302
|
+
draft: false,
|
|
303
|
+
includeOriginal: resolvedReplyTo !== null,
|
|
264
304
|
replyToId: resolvedReplyTo,
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
305
|
+
}, SentDetailSchema, 'ofw_send_message');
|
|
306
|
+
let persisted = null;
|
|
307
|
+
let verifyNote = null;
|
|
308
|
+
if (newId !== null) {
|
|
309
|
+
verifyNote = verifyWriteLanded('message', { subject, body }, detail);
|
|
310
|
+
persisted = {
|
|
311
|
+
id: newId,
|
|
312
|
+
folder: 'sent',
|
|
313
|
+
subject: detail.subject ?? subject,
|
|
314
|
+
fromUser: detail.from?.name ?? '',
|
|
315
|
+
sentAt: detail.date?.dateTime ?? new Date().toISOString(),
|
|
316
|
+
recipients: mapRecipients(detail.recipients),
|
|
317
|
+
body: detail.body ?? body,
|
|
318
|
+
fetchedBodyAt: new Date().toISOString(),
|
|
319
|
+
replyToId: resolvedReplyTo,
|
|
320
|
+
chainRootId,
|
|
321
|
+
listData: detail,
|
|
322
|
+
};
|
|
323
|
+
upsertMessage(persisted);
|
|
324
|
+
// Link attached files to the new message in the attachments cache.
|
|
325
|
+
// We may not have full metadata if the upload happened in a prior
|
|
326
|
+
// session — fall back to what we know.
|
|
327
|
+
for (const fileId of myFileIDs) {
|
|
328
|
+
const existing = getAttachment(fileId);
|
|
329
|
+
upsertAttachmentForMessage({
|
|
330
|
+
fileId,
|
|
331
|
+
fileName: existing?.fileName ?? `file-${fileId}`,
|
|
332
|
+
label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
|
|
333
|
+
mimeType: existing?.mimeType ?? 'application/octet-stream',
|
|
334
|
+
sizeBytes: existing?.sizeBytes ?? null,
|
|
335
|
+
metadata: existing?.metadata ?? {},
|
|
336
|
+
messageId: newId,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
283
339
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
340
|
+
// Only clean up the draft once the send is confirmed (the POST response
|
|
341
|
+
// carried an id). On the unconfirmed path the draft is the user's only
|
|
342
|
+
// copy of the message — keep it.
|
|
343
|
+
let unconfirmedNote = null;
|
|
344
|
+
if (newId === null) {
|
|
345
|
+
const draftClause = draftRef !== undefined
|
|
346
|
+
? `Draft ${draftRef} was NOT deleted — check`
|
|
347
|
+
: 'Check';
|
|
348
|
+
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.`;
|
|
349
|
+
}
|
|
350
|
+
else if (draftRef !== undefined) {
|
|
351
|
+
await deleteOFWMessages(client, [draftRef]);
|
|
352
|
+
deleteDraft(draftRef);
|
|
353
|
+
}
|
|
354
|
+
const responseObj = persisted ?? raw;
|
|
355
|
+
const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
|
|
356
|
+
const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join('\n\n');
|
|
357
|
+
return textResponse(notes ? `${notes}\n\n${text}` : text);
|
|
358
|
+
});
|
|
293
359
|
server.registerTool('ofw_list_drafts', {
|
|
294
360
|
description: 'List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.',
|
|
295
361
|
annotations: { readOnlyHint: true },
|
|
296
362
|
inputSchema: {
|
|
297
|
-
page: z.number().describe('Page number (default 1)').optional(),
|
|
298
|
-
size: z.number().describe('Drafts per page (default 50)').optional(),
|
|
363
|
+
page: z.number().int().min(1).describe('Page number (default 1)').optional(),
|
|
364
|
+
size: z.number().int().min(1).describe('Drafts per page (default 50)').optional(),
|
|
299
365
|
},
|
|
300
366
|
}, async (args) => {
|
|
301
367
|
const page = args.page ?? 1;
|
|
@@ -306,92 +372,96 @@ export function registerMessageTools(server, client) {
|
|
|
306
372
|
: { drafts };
|
|
307
373
|
return jsonResponse(payload);
|
|
308
374
|
});
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
375
|
+
if (allowDrafts)
|
|
376
|
+
server.registerTool('ofw_save_draft', {
|
|
377
|
+
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.',
|
|
378
|
+
annotations: { readOnlyHint: false },
|
|
379
|
+
inputSchema: {
|
|
380
|
+
subject: z.string().describe('Message subject'),
|
|
381
|
+
body: z.string().describe('Message body text'),
|
|
382
|
+
recipientIds: z.array(z.number()).describe('Array of recipient user IDs (optional for drafts)').optional(),
|
|
383
|
+
messageId: z.number().describe('ID of an existing draft to replace (the new draft will have a new id; the old is deleted)').optional(),
|
|
384
|
+
replyToId: z.number().describe('ID of the message this draft replies to').optional(),
|
|
385
|
+
myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
|
|
386
|
+
},
|
|
387
|
+
}, async (args) => {
|
|
388
|
+
const requestedReplyTo = args.replyToId ?? null;
|
|
389
|
+
let resolvedReplyTo = requestedReplyTo;
|
|
390
|
+
let rewriteNote = null;
|
|
391
|
+
if (requestedReplyTo !== null) {
|
|
392
|
+
resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
|
|
393
|
+
if (resolvedReplyTo !== requestedReplyTo) {
|
|
394
|
+
rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
|
|
395
|
+
}
|
|
328
396
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
replyToId: resolvedReplyTo,
|
|
345
|
-
};
|
|
346
|
-
const { id: newId, detail, raw } = await postMessageAndRefetch(client, payload);
|
|
347
|
-
let persisted = null;
|
|
348
|
-
let replaceNote = null;
|
|
349
|
-
if (newId !== null) {
|
|
350
|
-
persisted = {
|
|
351
|
-
id: newId,
|
|
352
|
-
subject: detail.subject ?? args.subject,
|
|
353
|
-
body: detail.body ?? '',
|
|
354
|
-
recipients: mapRecipients(detail.recipients),
|
|
355
|
-
replyToId: detail.replyToId ?? resolvedReplyTo,
|
|
356
|
-
modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
|
|
357
|
-
listData: detail,
|
|
397
|
+
const myFileIDs = args.myFileIDs ?? [];
|
|
398
|
+
// Deliberately do NOT pass `args.messageId` to OFW's POST payload.
|
|
399
|
+
// OFW's update-by-messageId path silently no-ops on subsequent
|
|
400
|
+
// updates while echoing the posted body in the immediate GET — so
|
|
401
|
+
// there is no honest way to detect a failure from the response.
|
|
402
|
+
// We always create a fresh draft; if the caller provided a
|
|
403
|
+
// messageId, we delete the old draft afterward (the "replace" path).
|
|
404
|
+
const payload = {
|
|
405
|
+
subject: args.subject,
|
|
406
|
+
body: args.body,
|
|
407
|
+
recipientIds: args.recipientIds ?? [],
|
|
408
|
+
attachments: { myFileIDs },
|
|
409
|
+
draft: true,
|
|
410
|
+
includeOriginal: resolvedReplyTo !== null,
|
|
411
|
+
replyToId: resolvedReplyTo,
|
|
358
412
|
};
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
413
|
+
const { id: newId, detail, raw } = await postMessageAndRefetch(client, payload, SavedDraftDetailSchema, 'ofw_save_draft');
|
|
414
|
+
let persisted = null;
|
|
415
|
+
let replaceNote = null;
|
|
416
|
+
let verifyNote = null;
|
|
417
|
+
if (newId !== null) {
|
|
418
|
+
verifyNote = verifyWriteLanded('draft', { subject: args.subject, body: args.body }, detail);
|
|
419
|
+
persisted = {
|
|
420
|
+
id: newId,
|
|
421
|
+
subject: detail.subject ?? args.subject,
|
|
422
|
+
body: detail.body ?? '',
|
|
423
|
+
recipients: mapRecipients(detail.recipients),
|
|
424
|
+
replyToId: detail.replyToId ?? resolvedReplyTo,
|
|
425
|
+
modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
|
|
426
|
+
listData: detail,
|
|
427
|
+
};
|
|
428
|
+
upsertDraft(persisted);
|
|
429
|
+
// Replace-path: caller passed messageId, so they want the old draft
|
|
430
|
+
// gone. Delete it after the new one is safely created+cached.
|
|
431
|
+
if (args.messageId !== undefined && args.messageId !== newId) {
|
|
432
|
+
try {
|
|
433
|
+
await deleteOFWMessages(client, [args.messageId]);
|
|
434
|
+
deleteDraft(args.messageId);
|
|
435
|
+
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.)`;
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
replaceNote = `WARNING: New draft ${newId} created successfully, but failed to delete the old draft (${args.messageId}): ${e.message}. You may want to clean it up manually with ofw_delete_draft.`;
|
|
439
|
+
}
|
|
370
440
|
}
|
|
371
441
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
442
|
+
const responseObj = persisted ?? raw;
|
|
443
|
+
const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
|
|
444
|
+
const notes = [rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join('\n\n');
|
|
445
|
+
return textResponse(notes ? `${notes}\n\n${text}` : text);
|
|
446
|
+
});
|
|
447
|
+
if (allowDrafts)
|
|
448
|
+
server.registerTool('ofw_delete_draft', {
|
|
449
|
+
description: 'Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.',
|
|
450
|
+
annotations: { destructiveHint: true },
|
|
451
|
+
inputSchema: {
|
|
452
|
+
messageId: z.number().describe('Draft message ID to delete'),
|
|
453
|
+
},
|
|
454
|
+
}, async (args) => {
|
|
455
|
+
const data = await deleteOFWMessages(client, [args.messageId]);
|
|
456
|
+
deleteDraft(args.messageId);
|
|
457
|
+
return data ? jsonResponse(data) : textResponse('Draft deleted.');
|
|
458
|
+
});
|
|
389
459
|
server.registerTool('ofw_get_unread_sent', {
|
|
390
460
|
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.',
|
|
391
461
|
annotations: { readOnlyHint: true },
|
|
392
462
|
inputSchema: {
|
|
393
|
-
page: z.number().describe('Page (default 1)').optional(),
|
|
394
|
-
size: z.number().describe('Per page (default 50)').optional(),
|
|
463
|
+
page: z.number().int().min(1).describe('Page (default 1)').optional(),
|
|
464
|
+
size: z.number().int().min(1).describe('Per page (default 50)').optional(),
|
|
395
465
|
},
|
|
396
466
|
}, async (args) => {
|
|
397
467
|
const page = args.page ?? 1;
|
|
@@ -412,53 +482,54 @@ export function registerMessageTools(server, client) {
|
|
|
412
482
|
}
|
|
413
483
|
return jsonResponse(unread);
|
|
414
484
|
});
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
485
|
+
if (allowDrafts)
|
|
486
|
+
server.registerTool('ofw_upload_attachment', {
|
|
487
|
+
description: 'Upload a local file to OurFamilyWizard\'s "My Files" so it can be attached to a message. Returns the fileId — pass that to ofw_send_message or ofw_save_draft in myFileIDs to attach it. The file is uploaded as PRIVATE (visible only to you) by default; pass shareClass:"SHARED" to share with co-parents directly via the My Files area.',
|
|
488
|
+
annotations: { destructiveHint: false },
|
|
489
|
+
inputSchema: {
|
|
490
|
+
path: z.string().describe('Absolute path to the local file to upload. Tilde (~) is expanded.'),
|
|
491
|
+
shareClass: z.enum(['PRIVATE', 'SHARED']).describe('Share class (default PRIVATE)').optional(),
|
|
492
|
+
label: z.string().describe('Display label for the file in OFW (default: filename)').optional(),
|
|
493
|
+
description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
|
|
494
|
+
},
|
|
495
|
+
}, async (args) => {
|
|
496
|
+
const abs = expandPath(args.path);
|
|
497
|
+
const stat = statSync(abs); // throws if missing
|
|
498
|
+
if (!stat.isFile())
|
|
499
|
+
throw new Error(`Not a file: ${abs}`);
|
|
500
|
+
const fileName = basename(abs);
|
|
501
|
+
const mime = mimeFromName(fileName);
|
|
502
|
+
// Build the multipart payload matching the OFW web UI's request shape.
|
|
503
|
+
const form = new FormData();
|
|
504
|
+
// fileBlob streams the file off disk (a file-backed Blob) instead of buffering it.
|
|
505
|
+
form.append('file', await fileBlob(abs, { type: mime }), fileName);
|
|
506
|
+
form.append('source', 'message');
|
|
507
|
+
form.append('description', args.description ?? fileName);
|
|
508
|
+
form.append('label', args.label ?? fileName);
|
|
509
|
+
form.append('fileName', fileName);
|
|
510
|
+
form.append('shareClass', args.shareClass ?? 'PRIVATE');
|
|
511
|
+
const meta = parseOFW(UploadedFileSchema, await client.request('POST', '/pub/v3/myfiles/multipart', form), 'POST /pub/v3/myfiles/multipart (ofw_upload_attachment)', 'strict');
|
|
512
|
+
// Cache metadata so subsequent ofw_get_message calls can surface it and
|
|
513
|
+
// ofw_download_attachment can short-circuit. messageId is 0 (the
|
|
514
|
+
// not-yet-linked sentinel) until a message actually references this file.
|
|
515
|
+
upsertAttachmentForMessage({
|
|
516
|
+
fileId: meta.fileId,
|
|
517
|
+
fileName: meta.fileName ?? fileName,
|
|
518
|
+
label: meta.label ?? args.label ?? fileName,
|
|
519
|
+
mimeType: meta.fileType ?? mime,
|
|
520
|
+
sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : stat.size,
|
|
521
|
+
metadata: meta,
|
|
522
|
+
messageId: 0,
|
|
523
|
+
});
|
|
524
|
+
return jsonResponse({
|
|
525
|
+
fileId: meta.fileId,
|
|
526
|
+
fileName: meta.fileName ?? fileName,
|
|
527
|
+
mimeType: meta.fileType ?? mime,
|
|
528
|
+
sizeBytes: meta.sizeInBytes ?? stat.size,
|
|
529
|
+
shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
|
|
530
|
+
note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
|
|
531
|
+
});
|
|
460
532
|
});
|
|
461
|
-
});
|
|
462
533
|
server.registerTool('ofw_download_attachment', {
|
|
463
534
|
description: 'Download an OFW message attachment by fileId. By default, bytes are saved to disk (~/Downloads/ofw-mcp/) and the response carries the absolute path, mime type, and size for the caller to read back. Pass inline:true to skip disk entirely and return the bytes as MCP content blocks — images come back as ImageContent (the model sees them directly); other files come back as an EmbeddedResource blob. Use inline for small files where you want the model to read content immediately and the host is sandboxed; use disk for large files or when you want a persistent local copy. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var (set to "true" to make inline the default). fileId comes from attachments[].fileId on ofw_get_message. Override disk destination with OFW_ATTACHMENTS_DIR or saveTo. Re-downloading to the same path is a no-op (disk mode only).',
|
|
464
535
|
annotations: { readOnlyHint: false },
|