ofw-mcp 2.0.10 → 2.0.12
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 +208 -215
- package/dist/cache.js +24 -30
- package/dist/client.js +38 -60
- package/dist/config.js +17 -4
- package/dist/index.js +1 -1
- package/dist/sync.js +25 -32
- package/dist/tools/_shared.js +22 -0
- package/dist/tools/calendar.js +5 -4
- package/dist/tools/expenses.js +4 -3
- package/dist/tools/journal.js +3 -2
- package/dist/tools/messages.js +132 -102
- package/dist/tools/user.js +3 -2
- package/package.json +4 -4
- package/server.json +2 -2
package/dist/tools/messages.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { syncAll, fetchAttachmentMetaForMessage } from '../sync.js';
|
|
2
|
+
import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage } from '../sync.js';
|
|
3
3
|
import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, upsertDraft, deleteDraft, findLatestReplyTip, listAttachmentsForMessage, getAttachment, upsertAttachmentForMessage, markAttachmentDownloaded, } from '../cache.js';
|
|
4
|
-
import { getAttachmentsDir } from '../config.js';
|
|
4
|
+
import { getAttachmentsDir, getDefaultInlineAttachments } from '../config.js';
|
|
5
5
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
6
|
-
import { basename, dirname, extname, join
|
|
6
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
7
|
+
import { expandPath, jsonResponse, mapRecipients, textResponse } from './_shared.js';
|
|
7
8
|
// Lightweight mime sniff from extension. OFW re-derives mime from the filename
|
|
8
9
|
// server-side anyway, so this is just a polite Content-Type for the Blob.
|
|
9
10
|
const MIME_BY_EXT = {
|
|
@@ -31,13 +32,27 @@ const MIME_BY_EXT = {
|
|
|
31
32
|
function mimeFromName(name) {
|
|
32
33
|
return MIME_BY_EXT[extname(name).toLowerCase()] ?? 'application/octet-stream';
|
|
33
34
|
}
|
|
35
|
+
// The list endpoint payload (cached as `listData`) reports attachments via
|
|
36
|
+
// `files: <count>` (a number) — the actual fileIds only appear on the detail
|
|
37
|
+
// endpoint as `files: [number, ...]`. Some intermediate shapes return an
|
|
38
|
+
// array on the list too. Treat any of those as "this message has files".
|
|
39
|
+
function listDataHintsAtFiles(listData) {
|
|
40
|
+
if (typeof listData !== 'object' || listData === null)
|
|
41
|
+
return false;
|
|
42
|
+
const ld = listData;
|
|
43
|
+
if (typeof ld.files === 'number')
|
|
44
|
+
return ld.files > 0;
|
|
45
|
+
if (Array.isArray(ld.files))
|
|
46
|
+
return ld.files.length > 0;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
34
49
|
export function registerMessageTools(server, client) {
|
|
35
50
|
server.registerTool('ofw_list_message_folders', {
|
|
36
51
|
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.',
|
|
37
52
|
annotations: { readOnlyHint: true },
|
|
38
53
|
}, async () => {
|
|
39
54
|
const data = await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true');
|
|
40
|
-
return
|
|
55
|
+
return jsonResponse(data);
|
|
41
56
|
});
|
|
42
57
|
server.registerTool('ofw_list_messages', {
|
|
43
58
|
description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages — the cache may have 1000+ messages. Call ofw_sync_messages first if the cache is empty or stale.',
|
|
@@ -62,15 +77,10 @@ export function registerMessageTools(server, client) {
|
|
|
62
77
|
else if (folderArg === 'both')
|
|
63
78
|
folder = undefined;
|
|
64
79
|
else {
|
|
65
|
-
return {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
messages: [],
|
|
70
|
-
note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.',
|
|
71
|
-
}, null, 2),
|
|
72
|
-
}],
|
|
73
|
-
};
|
|
80
|
+
return jsonResponse({
|
|
81
|
+
messages: [],
|
|
82
|
+
note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.',
|
|
83
|
+
});
|
|
74
84
|
}
|
|
75
85
|
const filter = { folder, since: args.since, until: args.until, q: args.q };
|
|
76
86
|
const total = countMessages(filter);
|
|
@@ -82,7 +92,7 @@ export function registerMessageTools(server, client) {
|
|
|
82
92
|
else if (page * size < total) {
|
|
83
93
|
payload.note = `Showing ${(page - 1) * size + 1}–${(page - 1) * size + messages.length} of ${total}. Increase 'page' to see more, or narrow with since/until/q.`;
|
|
84
94
|
}
|
|
85
|
-
return
|
|
95
|
+
return jsonResponse(payload);
|
|
86
96
|
});
|
|
87
97
|
server.registerTool('ofw_get_message', {
|
|
88
98
|
description: 'Get a single OurFamilyWizard message by ID. Reads from local cache when available; otherwise fetches from OFW (which will mark unread inbox messages as read on OFW).',
|
|
@@ -94,13 +104,29 @@ export function registerMessageTools(server, client) {
|
|
|
94
104
|
const id = Number(args.messageId);
|
|
95
105
|
const cached = getMessage(id);
|
|
96
106
|
if (cached && cached.body !== null) {
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
let attachments = listAttachmentsForMessage(id);
|
|
108
|
+
// Lazy attachment backfill. The list-endpoint payload (stored in
|
|
109
|
+
// listData) hints at attachments via `files: <count>` but doesn't
|
|
110
|
+
// expose the fileIds — those live only on /pub/v3/messages/{id}.
|
|
111
|
+
// For messages bodied before attachment caching existed, the
|
|
112
|
+
// attachments table is empty even though OFW has files. Re-hit
|
|
113
|
+
// detail to harvest fileIds (idempotent: body is already cached so
|
|
114
|
+
// OFW state isn't changing).
|
|
115
|
+
if (attachments.length === 0 && listDataHintsAtFiles(cached.listData)) {
|
|
116
|
+
try {
|
|
117
|
+
const detail = await client.request('GET', `/pub/v3/messages/${id}`);
|
|
118
|
+
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
119
|
+
await fetchAttachmentMetaForMessage(client, id, detail.files);
|
|
120
|
+
attachments = listAttachmentsForMessage(id);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Backfill is best-effort. Fall through with whatever we have.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return jsonResponse({ ...cached, attachments });
|
|
99
128
|
}
|
|
100
129
|
const detail = await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
|
|
101
|
-
const recipients = (detail.recipients ?? []).map((r) => ({
|
|
102
|
-
userId: r.user.id, name: r.user.name, viewedAt: r.viewed?.dateTime ?? null,
|
|
103
|
-
}));
|
|
104
130
|
const folder = cached?.folder ?? 'inbox';
|
|
105
131
|
const row = {
|
|
106
132
|
id: detail.id,
|
|
@@ -108,7 +134,7 @@ export function registerMessageTools(server, client) {
|
|
|
108
134
|
subject: detail.subject,
|
|
109
135
|
fromUser: detail.from?.name ?? '',
|
|
110
136
|
sentAt: detail.date?.dateTime ?? new Date().toISOString(),
|
|
111
|
-
recipients,
|
|
137
|
+
recipients: mapRecipients(detail.recipients),
|
|
112
138
|
body: detail.body ?? '',
|
|
113
139
|
fetchedBodyAt: new Date().toISOString(),
|
|
114
140
|
replyToId: cached?.replyToId ?? null,
|
|
@@ -120,7 +146,7 @@ export function registerMessageTools(server, client) {
|
|
|
120
146
|
await fetchAttachmentMetaForMessage(client, detail.id, detail.files);
|
|
121
147
|
}
|
|
122
148
|
const attachments = listAttachmentsForMessage(detail.id);
|
|
123
|
-
return
|
|
149
|
+
return jsonResponse({ ...row, attachments });
|
|
124
150
|
});
|
|
125
151
|
server.registerTool('ofw_send_message', {
|
|
126
152
|
description: 'Send a message via OurFamilyWizard. If sending from a draft, pass draftId to delete the draft after sending. 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.',
|
|
@@ -157,16 +183,13 @@ export function registerMessageTools(server, client) {
|
|
|
157
183
|
replyToId: resolvedReplyTo,
|
|
158
184
|
});
|
|
159
185
|
if (data && typeof data.id === 'number') {
|
|
160
|
-
const recipients = (data.recipients ?? []).map((r) => ({
|
|
161
|
-
userId: r.user.id, name: r.user.name, viewedAt: r.viewed?.dateTime ?? null,
|
|
162
|
-
}));
|
|
163
186
|
const row = {
|
|
164
187
|
id: data.id,
|
|
165
188
|
folder: 'sent',
|
|
166
189
|
subject: data.subject ?? args.subject,
|
|
167
190
|
fromUser: data.from?.name ?? '',
|
|
168
191
|
sentAt: data.date?.dateTime ?? new Date().toISOString(),
|
|
169
|
-
recipients,
|
|
192
|
+
recipients: mapRecipients(data.recipients),
|
|
170
193
|
body: data.body ?? args.body,
|
|
171
194
|
fetchedBodyAt: new Date().toISOString(),
|
|
172
195
|
replyToId: resolvedReplyTo,
|
|
@@ -191,14 +214,11 @@ export function registerMessageTools(server, client) {
|
|
|
191
214
|
}
|
|
192
215
|
}
|
|
193
216
|
if (args.draftId !== undefined) {
|
|
194
|
-
|
|
195
|
-
form.append('messageIds', String(args.draftId));
|
|
196
|
-
await client.request('DELETE', '/pub/v1/messages', form);
|
|
217
|
+
await deleteOFWMessages(client, [args.draftId]);
|
|
197
218
|
deleteDraft(args.draftId);
|
|
198
219
|
}
|
|
199
220
|
const text = data ? JSON.stringify(data, null, 2) : 'Message sent successfully.';
|
|
200
|
-
|
|
201
|
-
return { content: [{ type: 'text', text: finalText }] };
|
|
221
|
+
return textResponse(rewriteNote ? `${rewriteNote}\n\n${text}` : text);
|
|
202
222
|
});
|
|
203
223
|
server.registerTool('ofw_list_drafts', {
|
|
204
224
|
description: 'List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.',
|
|
@@ -214,7 +234,7 @@ export function registerMessageTools(server, client) {
|
|
|
214
234
|
const payload = drafts.length === 0
|
|
215
235
|
? { drafts: [], note: 'Cache empty. Call ofw_sync_messages to populate.' }
|
|
216
236
|
: { drafts };
|
|
217
|
-
return
|
|
237
|
+
return jsonResponse(payload);
|
|
218
238
|
});
|
|
219
239
|
server.registerTool('ofw_save_draft', {
|
|
220
240
|
description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. 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.',
|
|
@@ -255,9 +275,7 @@ export function registerMessageTools(server, client) {
|
|
|
255
275
|
id: data.id,
|
|
256
276
|
subject: data.subject ?? args.subject,
|
|
257
277
|
body: data.body ?? args.body,
|
|
258
|
-
recipients: (data.recipients
|
|
259
|
-
userId: r.user.id, name: r.user.name, viewedAt: r.viewed?.dateTime ?? null,
|
|
260
|
-
})),
|
|
278
|
+
recipients: mapRecipients(data.recipients),
|
|
261
279
|
replyToId: data.replyToId ?? resolvedReplyTo,
|
|
262
280
|
modifiedAt: data.date?.dateTime ?? new Date().toISOString(),
|
|
263
281
|
listData: data,
|
|
@@ -265,8 +283,7 @@ export function registerMessageTools(server, client) {
|
|
|
265
283
|
upsertDraft(draft);
|
|
266
284
|
}
|
|
267
285
|
const text = data ? JSON.stringify(data, null, 2) : 'Draft saved.';
|
|
268
|
-
|
|
269
|
-
return { content: [{ type: 'text', text: finalText }] };
|
|
286
|
+
return textResponse(rewriteNote ? `${rewriteNote}\n\n${text}` : text);
|
|
270
287
|
});
|
|
271
288
|
server.registerTool('ofw_delete_draft', {
|
|
272
289
|
description: 'Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.',
|
|
@@ -275,11 +292,9 @@ export function registerMessageTools(server, client) {
|
|
|
275
292
|
messageId: z.number().describe('Draft message ID to delete'),
|
|
276
293
|
},
|
|
277
294
|
}, async (args) => {
|
|
278
|
-
const
|
|
279
|
-
form.append('messageIds', String(args.messageId));
|
|
280
|
-
const data = await client.request('DELETE', '/pub/v1/messages', form);
|
|
295
|
+
const data = await deleteOFWMessages(client, [args.messageId]);
|
|
281
296
|
deleteDraft(args.messageId);
|
|
282
|
-
return
|
|
297
|
+
return data ? jsonResponse(data) : textResponse('Draft deleted.');
|
|
283
298
|
});
|
|
284
299
|
server.registerTool('ofw_get_unread_sent', {
|
|
285
300
|
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.',
|
|
@@ -293,9 +308,7 @@ export function registerMessageTools(server, client) {
|
|
|
293
308
|
const size = args.size ?? 50;
|
|
294
309
|
const sent = listMessages({ folder: 'sent', page, size });
|
|
295
310
|
if (sent.length === 0) {
|
|
296
|
-
return {
|
|
297
|
-
note: 'Sent cache is empty. Call ofw_sync_messages to populate.',
|
|
298
|
-
}, null, 2) }] };
|
|
311
|
+
return jsonResponse({ note: 'Sent cache is empty. Call ofw_sync_messages to populate.' });
|
|
299
312
|
}
|
|
300
313
|
const unread = [];
|
|
301
314
|
for (const msg of sent) {
|
|
@@ -305,11 +318,9 @@ export function registerMessageTools(server, client) {
|
|
|
305
318
|
}
|
|
306
319
|
}
|
|
307
320
|
if (unread.length === 0) {
|
|
308
|
-
return {
|
|
309
|
-
message: 'All scanned sent messages have been read.',
|
|
310
|
-
}, null, 2) }] };
|
|
321
|
+
return jsonResponse({ message: 'All scanned sent messages have been read.' });
|
|
311
322
|
}
|
|
312
|
-
return
|
|
323
|
+
return jsonResponse(unread);
|
|
313
324
|
});
|
|
314
325
|
server.registerTool('ofw_upload_attachment', {
|
|
315
326
|
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.',
|
|
@@ -321,18 +332,14 @@ export function registerMessageTools(server, client) {
|
|
|
321
332
|
description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
|
|
322
333
|
},
|
|
323
334
|
}, async (args) => {
|
|
324
|
-
|
|
325
|
-
const expanded = args.path.startsWith('~/')
|
|
326
|
-
? join(process.env.HOME ?? '', args.path.slice(2))
|
|
327
|
-
: args.path;
|
|
328
|
-
const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
335
|
+
const abs = expandPath(args.path);
|
|
329
336
|
const stat = statSync(abs); // throws if missing
|
|
330
337
|
if (!stat.isFile())
|
|
331
338
|
throw new Error(`Not a file: ${abs}`);
|
|
332
339
|
const buf = readFileSync(abs);
|
|
333
340
|
const fileName = basename(abs);
|
|
334
341
|
const mime = mimeFromName(fileName);
|
|
335
|
-
// Build the multipart payload matching the OFW web UI's request shape
|
|
342
|
+
// Build the multipart payload matching the OFW web UI's request shape.
|
|
336
343
|
const form = new FormData();
|
|
337
344
|
form.append('file', new Blob([new Uint8Array(buf)], { type: mime }), fileName);
|
|
338
345
|
form.append('source', 'message');
|
|
@@ -341,10 +348,9 @@ export function registerMessageTools(server, client) {
|
|
|
341
348
|
form.append('fileName', fileName);
|
|
342
349
|
form.append('shareClass', args.shareClass ?? 'PRIVATE');
|
|
343
350
|
const meta = await client.request('POST', '/pub/v3/myfiles/multipart', form);
|
|
344
|
-
// Cache
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
// message is sent with this fileId in its attachments.
|
|
351
|
+
// Cache metadata so subsequent ofw_get_message calls can surface it and
|
|
352
|
+
// ofw_download_attachment can short-circuit. messageId is 0 (the
|
|
353
|
+
// not-yet-linked sentinel) until a message actually references this file.
|
|
348
354
|
upsertAttachmentForMessage({
|
|
349
355
|
fileId: meta.fileId,
|
|
350
356
|
fileName: meta.fileName ?? fileName,
|
|
@@ -354,77 +360,93 @@ export function registerMessageTools(server, client) {
|
|
|
354
360
|
metadata: meta,
|
|
355
361
|
messageId: 0,
|
|
356
362
|
});
|
|
357
|
-
return
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
363
|
+
return jsonResponse({
|
|
364
|
+
fileId: meta.fileId,
|
|
365
|
+
fileName: meta.fileName ?? fileName,
|
|
366
|
+
mimeType: meta.fileType ?? mime,
|
|
367
|
+
sizeBytes: meta.sizeInBytes ?? buf.length,
|
|
368
|
+
shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
|
|
369
|
+
note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
|
|
370
|
+
});
|
|
365
371
|
});
|
|
366
372
|
server.registerTool('ofw_download_attachment', {
|
|
367
|
-
description: 'Download an OFW message attachment by fileId.
|
|
373
|
+
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).',
|
|
368
374
|
annotations: { readOnlyHint: false },
|
|
369
375
|
inputSchema: {
|
|
370
376
|
fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
|
|
371
|
-
|
|
372
|
-
|
|
377
|
+
inline: z.boolean().describe('If true, return bytes inline as MCP content (image for image/*, embedded resource blob otherwise) and skip the disk write. If false, write to disk and return the path. If omitted, falls back to the OFW_INLINE_ATTACHMENTS env var (default: false = disk).').optional(),
|
|
378
|
+
saveTo: z.string().describe('Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/Downloads/ofw-mcp/<fileId>-<filename>. Ignored when inline:true.').optional(),
|
|
379
|
+
force: z.boolean().describe('Re-download even if already on disk. Default false. Ignored when inline:true (inline always fetches fresh bytes, or reuses an on-disk copy if present).').optional(),
|
|
373
380
|
},
|
|
374
381
|
}, async (args) => {
|
|
375
382
|
const fileId = args.fileId;
|
|
383
|
+
const inline = args.inline ?? getDefaultInlineAttachments();
|
|
376
384
|
let cached = getAttachment(fileId);
|
|
377
385
|
if (!cached) {
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
// We'll re-link if a message later references it during sync.
|
|
382
|
-
upsertAttachmentForMessage({
|
|
383
|
-
fileId: meta.fileId ?? fileId,
|
|
384
|
-
fileName: meta.fileName ?? `file-${fileId}`,
|
|
385
|
-
label: meta.label ?? meta.fileName ?? `file-${fileId}`,
|
|
386
|
-
mimeType: meta.fileType ?? 'application/octet-stream',
|
|
387
|
-
sizeBytes: typeof meta.fileSize === 'number' ? meta.fileSize : null,
|
|
388
|
-
metadata: meta,
|
|
389
|
-
messageId: 0, // placeholder; will be cleaned up if a real message references it
|
|
390
|
-
});
|
|
386
|
+
// Not in cache. Fetch metadata and store under the messageId=0
|
|
387
|
+
// sentinel — gets re-linked if a message later references this file.
|
|
388
|
+
await fetchAttachmentMeta(client, fileId, 0);
|
|
391
389
|
cached = getAttachment(fileId);
|
|
392
390
|
if (!cached)
|
|
393
391
|
throw new Error(`failed to fetch metadata for fileId ${fileId}`);
|
|
394
392
|
}
|
|
395
|
-
|
|
393
|
+
if (inline) {
|
|
394
|
+
// Reuse on-disk bytes if we already have them; otherwise fetch fresh.
|
|
395
|
+
let bytes = null;
|
|
396
|
+
let mimeType = cached.mimeType;
|
|
397
|
+
let fileName = cached.fileName;
|
|
398
|
+
if (cached.downloadedPath) {
|
|
399
|
+
try {
|
|
400
|
+
bytes = readFileSync(cached.downloadedPath);
|
|
401
|
+
}
|
|
402
|
+
catch { /* on-disk copy missing; fall through */ }
|
|
403
|
+
}
|
|
404
|
+
if (bytes === null) {
|
|
405
|
+
const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
|
|
406
|
+
bytes = response.body;
|
|
407
|
+
mimeType = response.contentType ?? cached.mimeType;
|
|
408
|
+
fileName = response.suggestedFileName ?? cached.fileName;
|
|
409
|
+
}
|
|
410
|
+
const base64 = bytes.toString('base64');
|
|
411
|
+
const metaBlock = { type: 'text', text: JSON.stringify({
|
|
412
|
+
fileId, fileName, mimeType, sizeBytes: bytes.length, mode: 'inline',
|
|
413
|
+
}, null, 2) };
|
|
414
|
+
if (mimeType.startsWith('image/')) {
|
|
415
|
+
return { content: [metaBlock, { type: 'image', data: base64, mimeType }] };
|
|
416
|
+
}
|
|
417
|
+
return { content: [metaBlock, { type: 'resource', resource: {
|
|
418
|
+
uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName)}`,
|
|
419
|
+
mimeType,
|
|
420
|
+
blob: base64,
|
|
421
|
+
} }] };
|
|
422
|
+
}
|
|
396
423
|
let dest;
|
|
397
424
|
if (args.saveTo) {
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
402
|
-
// If it looks like a directory (ends with /) OR is an existing directory, treat as dir.
|
|
403
|
-
const isDirArg = expanded.endsWith('/') || expanded.endsWith('\\');
|
|
425
|
+
// Treat saveTo as a directory if it ends with a separator; otherwise as a full path.
|
|
426
|
+
const isDirArg = args.saveTo.endsWith('/') || args.saveTo.endsWith('\\');
|
|
427
|
+
const abs = expandPath(args.saveTo);
|
|
404
428
|
dest = isDirArg ? join(abs, `${fileId}-${cached.fileName}`) : abs;
|
|
405
429
|
}
|
|
406
430
|
else {
|
|
407
431
|
dest = join(getAttachmentsDir(), `${fileId}-${cached.fileName}`);
|
|
408
432
|
}
|
|
409
|
-
// Short-circuit if already downloaded to this path
|
|
410
433
|
if (!args.force && cached.downloadedPath === dest) {
|
|
411
|
-
return
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
434
|
+
return jsonResponse({
|
|
435
|
+
fileId, path: dest, mimeType: cached.mimeType, sizeBytes: cached.sizeBytes,
|
|
436
|
+
fileName: cached.fileName, note: 'already downloaded',
|
|
437
|
+
});
|
|
415
438
|
}
|
|
416
|
-
// Fetch bytes
|
|
417
439
|
const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
|
|
418
440
|
mkdirSync(dirname(dest), { recursive: true });
|
|
419
441
|
writeFileSync(dest, response.body);
|
|
420
442
|
markAttachmentDownloaded(fileId, dest);
|
|
421
|
-
return
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
443
|
+
return jsonResponse({
|
|
444
|
+
fileId,
|
|
445
|
+
path: dest,
|
|
446
|
+
mimeType: response.contentType ?? cached.mimeType,
|
|
447
|
+
sizeBytes: response.body.length,
|
|
448
|
+
fileName: response.suggestedFileName ?? cached.fileName,
|
|
449
|
+
});
|
|
428
450
|
});
|
|
429
451
|
server.registerTool('ofw_sync_messages', {
|
|
430
452
|
description: 'Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps).',
|
|
@@ -440,6 +462,14 @@ export function registerMessageTools(server, client) {
|
|
|
440
462
|
fetchUnreadBodies: args.fetchUnreadBodies,
|
|
441
463
|
deep: args.deep,
|
|
442
464
|
});
|
|
443
|
-
return
|
|
465
|
+
return jsonResponse(result);
|
|
444
466
|
});
|
|
445
467
|
}
|
|
468
|
+
// OFW's bulk-delete endpoint takes a multipart form with `messageIds`.
|
|
469
|
+
// Used by both ofw_delete_draft and ofw_send_message (draft cleanup).
|
|
470
|
+
async function deleteOFWMessages(client, ids) {
|
|
471
|
+
const form = new FormData();
|
|
472
|
+
for (const id of ids)
|
|
473
|
+
form.append('messageIds', String(id));
|
|
474
|
+
return client.request('DELETE', '/pub/v1/messages', form);
|
|
475
|
+
}
|
package/dist/tools/user.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
+
import { jsonResponse } from './_shared.js';
|
|
1
2
|
export function registerUserTools(server, client) {
|
|
2
3
|
server.registerTool('ofw_get_profile', {
|
|
3
4
|
description: 'Get current user and co-parent profile information from OurFamilyWizard',
|
|
4
5
|
annotations: { readOnlyHint: true },
|
|
5
6
|
}, async () => {
|
|
6
7
|
const data = await client.request('GET', '/pub/v2/profiles');
|
|
7
|
-
return
|
|
8
|
+
return jsonResponse(data);
|
|
8
9
|
});
|
|
9
10
|
server.registerTool('ofw_get_notifications', {
|
|
10
11
|
description: 'Get OurFamilyWizard dashboard summary: unread message count, upcoming events, outstanding expenses. Note: updates your last-seen status.',
|
|
11
12
|
annotations: { readOnlyHint: false },
|
|
12
13
|
}, async () => {
|
|
13
14
|
const data = await client.request('GET', '/pub/v1/users/useraccountstatus');
|
|
14
|
-
return
|
|
15
|
+
return jsonResponse(data);
|
|
15
16
|
});
|
|
16
17
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofw-mcp",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.12",
|
|
4
4
|
"mcpName": "io.github.chrischall/ofw-mcp",
|
|
5
5
|
"description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
6
|
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
@@ -32,10 +32,10 @@
|
|
|
32
32
|
"zod": "^4.4.2"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@types/node": "^25.
|
|
36
|
-
"@vitest/coverage-v8": "^4.1.
|
|
35
|
+
"@types/node": "^25.8.0",
|
|
36
|
+
"@vitest/coverage-v8": "^4.1.6",
|
|
37
37
|
"esbuild": "^0.28.0",
|
|
38
38
|
"typescript": "^6.0.2",
|
|
39
|
-
"vitest": "^4.1.
|
|
39
|
+
"vitest": "^4.1.6"
|
|
40
40
|
}
|
|
41
41
|
}
|
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.0.
|
|
9
|
+
"version": "2.0.12",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.0.
|
|
14
|
+
"version": "2.0.12",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|