ofw-mcp 2.0.4 → 2.0.6
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 +1981 -679
- package/dist/cache.js +239 -0
- package/dist/config.js +21 -0
- package/dist/index.js +11 -1
- package/dist/sync.js +156 -0
- package/dist/tools/messages.js +174 -57
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/tools/messages.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { syncAll } from '../sync.js';
|
|
3
|
+
import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, upsertDraft, deleteDraft, findLatestReplyTip, } from '../cache.js';
|
|
2
4
|
export function registerMessageTools(server, client) {
|
|
3
5
|
server.registerTool('ofw_list_message_folders', {
|
|
4
6
|
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.',
|
|
@@ -8,60 +10,146 @@ export function registerMessageTools(server, client) {
|
|
|
8
10
|
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
9
11
|
});
|
|
10
12
|
server.registerTool('ofw_list_messages', {
|
|
11
|
-
description: 'List messages
|
|
13
|
+
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.',
|
|
12
14
|
annotations: { readOnlyHint: true },
|
|
13
15
|
inputSchema: {
|
|
14
|
-
folderId: z.string().describe('Folder
|
|
16
|
+
folderId: z.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
|
|
15
17
|
page: z.number().describe('Page number (default 1)').optional(),
|
|
16
18
|
size: z.number().describe('Messages per page (default 50)').optional(),
|
|
19
|
+
since: z.string().describe('ISO date or datetime — only messages with sent_at >= since (inclusive)').optional(),
|
|
20
|
+
until: z.string().describe('ISO date or datetime — only messages with sent_at < until (exclusive)').optional(),
|
|
21
|
+
q: z.string().describe('Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.').optional(),
|
|
17
22
|
},
|
|
18
23
|
}, async (args) => {
|
|
19
24
|
const page = args.page ?? 1;
|
|
20
25
|
const size = args.size ?? 50;
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
const folderArg = args.folderId ?? 'both';
|
|
27
|
+
let folder;
|
|
28
|
+
if (folderArg === 'inbox')
|
|
29
|
+
folder = 'inbox';
|
|
30
|
+
else if (folderArg === 'sent')
|
|
31
|
+
folder = 'sent';
|
|
32
|
+
else if (folderArg === 'both')
|
|
33
|
+
folder = undefined;
|
|
34
|
+
else {
|
|
35
|
+
return {
|
|
36
|
+
content: [{
|
|
37
|
+
type: 'text',
|
|
38
|
+
text: JSON.stringify({
|
|
39
|
+
messages: [],
|
|
40
|
+
note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.',
|
|
41
|
+
}, null, 2),
|
|
42
|
+
}],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const filter = { folder, since: args.since, until: args.until, q: args.q };
|
|
46
|
+
const total = countMessages(filter);
|
|
47
|
+
const messages = listMessages({ ...filter, page, size });
|
|
48
|
+
const payload = { messages, total, page, size };
|
|
49
|
+
if (total === 0) {
|
|
50
|
+
payload.note = 'No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.';
|
|
51
|
+
}
|
|
52
|
+
else if (page * size < total) {
|
|
53
|
+
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.`;
|
|
54
|
+
}
|
|
55
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
24
56
|
});
|
|
25
57
|
server.registerTool('ofw_get_message', {
|
|
26
|
-
description: 'Get a single OurFamilyWizard message by ID.
|
|
58
|
+
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).',
|
|
27
59
|
annotations: { readOnlyHint: false },
|
|
28
60
|
inputSchema: {
|
|
29
61
|
messageId: z.string().describe('Message ID'),
|
|
30
62
|
},
|
|
31
63
|
}, async (args) => {
|
|
32
|
-
const
|
|
33
|
-
|
|
64
|
+
const id = Number(args.messageId);
|
|
65
|
+
const cached = getMessage(id);
|
|
66
|
+
if (cached && cached.body !== null) {
|
|
67
|
+
return { content: [{ type: 'text', text: JSON.stringify(cached, null, 2) }] };
|
|
68
|
+
}
|
|
69
|
+
const detail = await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
|
|
70
|
+
const recipients = (detail.recipients ?? []).map((r) => ({
|
|
71
|
+
userId: r.user.id, name: r.user.name, viewedAt: r.viewed?.dateTime ?? null,
|
|
72
|
+
}));
|
|
73
|
+
const folder = cached?.folder ?? 'inbox';
|
|
74
|
+
const row = {
|
|
75
|
+
id: detail.id,
|
|
76
|
+
folder,
|
|
77
|
+
subject: detail.subject,
|
|
78
|
+
fromUser: detail.from?.name ?? '',
|
|
79
|
+
sentAt: detail.date.dateTime,
|
|
80
|
+
recipients,
|
|
81
|
+
body: detail.body ?? '',
|
|
82
|
+
fetchedBodyAt: new Date().toISOString(),
|
|
83
|
+
replyToId: cached?.replyToId ?? null,
|
|
84
|
+
chainRootId: cached?.chainRootId ?? null,
|
|
85
|
+
listData: cached?.listData ?? detail,
|
|
86
|
+
};
|
|
87
|
+
upsertMessage(row);
|
|
88
|
+
return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
|
|
34
89
|
});
|
|
35
90
|
server.registerTool('ofw_send_message', {
|
|
36
|
-
description: 'Send a message via OurFamilyWizard. If sending from a draft, pass draftId to
|
|
91
|
+
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).',
|
|
37
92
|
annotations: { destructiveHint: true },
|
|
38
93
|
inputSchema: {
|
|
39
94
|
subject: z.string().describe('Message subject'),
|
|
40
95
|
body: z.string().describe('Message body text'),
|
|
41
96
|
recipientIds: z.array(z.number()).describe('Array of recipient user IDs (get from ofw_get_profile)'),
|
|
42
|
-
replyToId: z.number().describe('ID of the message being replied to
|
|
97
|
+
replyToId: z.number().describe('ID of the message being replied to').optional(),
|
|
43
98
|
draftId: z.number().describe('ID of the draft to delete after sending (omit if not sending from a draft)').optional(),
|
|
44
99
|
},
|
|
45
100
|
}, async (args) => {
|
|
46
|
-
const
|
|
101
|
+
const requestedReplyTo = args.replyToId ?? null;
|
|
102
|
+
let resolvedReplyTo = requestedReplyTo;
|
|
103
|
+
let chainRootId = null;
|
|
104
|
+
let rewriteNote = null;
|
|
105
|
+
if (requestedReplyTo !== null) {
|
|
106
|
+
resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
|
|
107
|
+
if (resolvedReplyTo !== requestedReplyTo) {
|
|
108
|
+
rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
|
|
109
|
+
}
|
|
110
|
+
const parent = getMessage(resolvedReplyTo);
|
|
111
|
+
chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
|
|
112
|
+
}
|
|
47
113
|
const data = await client.request('POST', '/pub/v3/messages', {
|
|
48
114
|
subject: args.subject,
|
|
49
115
|
body: args.body,
|
|
50
116
|
recipientIds: args.recipientIds,
|
|
51
117
|
attachments: { myFileIDs: [] },
|
|
52
118
|
draft: false,
|
|
53
|
-
includeOriginal:
|
|
54
|
-
replyToId,
|
|
119
|
+
includeOriginal: resolvedReplyTo !== null,
|
|
120
|
+
replyToId: resolvedReplyTo,
|
|
55
121
|
});
|
|
122
|
+
if (data && typeof data.id === 'number') {
|
|
123
|
+
const recipients = (data.recipients ?? []).map((r) => ({
|
|
124
|
+
userId: r.user.id, name: r.user.name, viewedAt: r.viewed?.dateTime ?? null,
|
|
125
|
+
}));
|
|
126
|
+
const row = {
|
|
127
|
+
id: data.id,
|
|
128
|
+
folder: 'sent',
|
|
129
|
+
subject: data.subject ?? args.subject,
|
|
130
|
+
fromUser: data.from?.name ?? '',
|
|
131
|
+
sentAt: data.date?.dateTime ?? new Date().toISOString(),
|
|
132
|
+
recipients,
|
|
133
|
+
body: data.body ?? args.body,
|
|
134
|
+
fetchedBodyAt: new Date().toISOString(),
|
|
135
|
+
replyToId: resolvedReplyTo,
|
|
136
|
+
chainRootId,
|
|
137
|
+
listData: data,
|
|
138
|
+
};
|
|
139
|
+
upsertMessage(row);
|
|
140
|
+
}
|
|
56
141
|
if (args.draftId !== undefined) {
|
|
57
142
|
const form = new FormData();
|
|
58
143
|
form.append('messageIds', String(args.draftId));
|
|
59
144
|
await client.request('DELETE', '/pub/v1/messages', form);
|
|
145
|
+
deleteDraft(args.draftId);
|
|
60
146
|
}
|
|
61
|
-
|
|
147
|
+
const text = data ? JSON.stringify(data, null, 2) : 'Message sent successfully.';
|
|
148
|
+
const finalText = rewriteNote ? `${rewriteNote}\n\n${text}` : text;
|
|
149
|
+
return { content: [{ type: 'text', text: finalText }] };
|
|
62
150
|
});
|
|
63
151
|
server.registerTool('ofw_list_drafts', {
|
|
64
|
-
description: 'List
|
|
152
|
+
description: 'List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.',
|
|
65
153
|
annotations: { readOnlyHint: true },
|
|
66
154
|
inputSchema: {
|
|
67
155
|
page: z.number().describe('Page number (default 1)').optional(),
|
|
@@ -70,39 +158,64 @@ export function registerMessageTools(server, client) {
|
|
|
70
158
|
}, async (args) => {
|
|
71
159
|
const page = args.page ?? 1;
|
|
72
160
|
const size = args.size ?? 50;
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
161
|
+
const drafts = listDrafts({ page, size });
|
|
162
|
+
const payload = drafts.length === 0
|
|
163
|
+
? { drafts: [], note: 'Cache empty. Call ofw_sync_messages to populate.' }
|
|
164
|
+
: { drafts };
|
|
165
|
+
return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
|
|
77
166
|
});
|
|
78
167
|
server.registerTool('ofw_save_draft', {
|
|
79
|
-
description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional
|
|
168
|
+
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).',
|
|
80
169
|
annotations: { readOnlyHint: false },
|
|
81
170
|
inputSchema: {
|
|
82
171
|
subject: z.string().describe('Message subject'),
|
|
83
172
|
body: z.string().describe('Message body text'),
|
|
84
173
|
recipientIds: z.array(z.number()).describe('Array of recipient user IDs (optional for drafts)').optional(),
|
|
85
174
|
messageId: z.number().describe('ID of an existing draft to update (omit to create a new draft)').optional(),
|
|
86
|
-
replyToId: z.number().describe('ID of the message this draft
|
|
175
|
+
replyToId: z.number().describe('ID of the message this draft replies to').optional(),
|
|
87
176
|
},
|
|
88
177
|
}, async (args) => {
|
|
89
|
-
const
|
|
178
|
+
const requestedReplyTo = args.replyToId ?? null;
|
|
179
|
+
let resolvedReplyTo = requestedReplyTo;
|
|
180
|
+
let rewriteNote = null;
|
|
181
|
+
if (requestedReplyTo !== null) {
|
|
182
|
+
resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
|
|
183
|
+
if (resolvedReplyTo !== requestedReplyTo) {
|
|
184
|
+
rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
90
187
|
const payload = {
|
|
91
188
|
subject: args.subject,
|
|
92
189
|
body: args.body,
|
|
93
190
|
recipientIds: args.recipientIds ?? [],
|
|
94
191
|
attachments: { myFileIDs: [] },
|
|
95
192
|
draft: true,
|
|
96
|
-
includeOriginal:
|
|
97
|
-
replyToId,
|
|
193
|
+
includeOriginal: resolvedReplyTo !== null,
|
|
194
|
+
replyToId: resolvedReplyTo,
|
|
98
195
|
};
|
|
99
196
|
if (args.messageId !== undefined)
|
|
100
197
|
payload.messageId = args.messageId;
|
|
101
198
|
const data = await client.request('POST', '/pub/v3/messages', payload);
|
|
102
|
-
|
|
199
|
+
if (data && typeof data.id === 'number') {
|
|
200
|
+
const draft = {
|
|
201
|
+
id: data.id,
|
|
202
|
+
subject: data.subject ?? args.subject,
|
|
203
|
+
body: data.body ?? args.body,
|
|
204
|
+
recipients: (data.recipients ?? []).map((r) => ({
|
|
205
|
+
userId: r.user.id, name: r.user.name, viewedAt: r.viewed?.dateTime ?? null,
|
|
206
|
+
})),
|
|
207
|
+
replyToId: data.replyToId ?? resolvedReplyTo,
|
|
208
|
+
modifiedAt: data.date?.dateTime ?? new Date().toISOString(),
|
|
209
|
+
listData: data,
|
|
210
|
+
};
|
|
211
|
+
upsertDraft(draft);
|
|
212
|
+
}
|
|
213
|
+
const text = data ? JSON.stringify(data, null, 2) : 'Draft saved.';
|
|
214
|
+
const finalText = rewriteNote ? `${rewriteNote}\n\n${text}` : text;
|
|
215
|
+
return { content: [{ type: 'text', text: finalText }] };
|
|
103
216
|
});
|
|
104
217
|
server.registerTool('ofw_delete_draft', {
|
|
105
|
-
description: 'Delete a draft message from OurFamilyWizard',
|
|
218
|
+
description: 'Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.',
|
|
106
219
|
annotations: { destructiveHint: true },
|
|
107
220
|
inputSchema: {
|
|
108
221
|
messageId: z.number().describe('Draft message ID to delete'),
|
|
@@ -111,49 +224,53 @@ export function registerMessageTools(server, client) {
|
|
|
111
224
|
const form = new FormData();
|
|
112
225
|
form.append('messageIds', String(args.messageId));
|
|
113
226
|
const data = await client.request('DELETE', '/pub/v1/messages', form);
|
|
114
|
-
|
|
227
|
+
deleteDraft(args.messageId);
|
|
228
|
+
return { content: [{ type: 'text', text: data ? JSON.stringify(data, null, 2) : 'Draft deleted.' }] };
|
|
115
229
|
});
|
|
116
230
|
server.registerTool('ofw_get_unread_sent', {
|
|
117
|
-
description: 'List sent messages that have not been read by one or more recipients.
|
|
231
|
+
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.',
|
|
118
232
|
annotations: { readOnlyHint: true },
|
|
119
233
|
inputSchema: {
|
|
120
|
-
page: z.number().describe('Page
|
|
121
|
-
size: z.number().describe('
|
|
234
|
+
page: z.number().describe('Page (default 1)').optional(),
|
|
235
|
+
size: z.number().describe('Per page (default 50)').optional(),
|
|
122
236
|
},
|
|
123
237
|
}, async (args) => {
|
|
124
238
|
const page = args.page ?? 1;
|
|
125
|
-
const size = args.size ??
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const listPath = `/pub/v3/messages?folders=${encodeURIComponent(sentFolder.id)}&page=${page}&size=${size}&sort=date&sortDirection=desc`;
|
|
133
|
-
const listData = await client.request('GET', listPath);
|
|
134
|
-
const messages = listData.data ?? [];
|
|
135
|
-
// Step 3: filter to unread using showNeverViewed (avoids N+1 detail fetches
|
|
136
|
-
// and the detail endpoint's inconsistent viewed field which can return null
|
|
137
|
-
// for read messages instead of the epoch sentinel the list endpoint uses)
|
|
239
|
+
const size = args.size ?? 50;
|
|
240
|
+
const sent = listMessages({ folder: 'sent', page, size });
|
|
241
|
+
if (sent.length === 0) {
|
|
242
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
243
|
+
note: 'Sent cache is empty. Call ofw_sync_messages to populate.',
|
|
244
|
+
}, null, 2) }] };
|
|
245
|
+
}
|
|
138
246
|
const unread = [];
|
|
139
|
-
for (const msg of
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
.filter((r) => !r.viewed)
|
|
144
|
-
.map((r) => r.user.name);
|
|
145
|
-
if (unreadRecipients.length > 0) {
|
|
146
|
-
unread.push({
|
|
147
|
-
id: msg.id,
|
|
148
|
-
subject: msg.subject,
|
|
149
|
-
sentAt: msg.date.dateTime,
|
|
150
|
-
unreadBy: unreadRecipients,
|
|
151
|
-
});
|
|
247
|
+
for (const msg of sent) {
|
|
248
|
+
const unreadBy = msg.recipients.filter((r) => r.viewedAt === null).map((r) => r.name);
|
|
249
|
+
if (unreadBy.length > 0) {
|
|
250
|
+
unread.push({ id: msg.id, subject: msg.subject, sentAt: msg.sentAt, unreadBy });
|
|
152
251
|
}
|
|
153
252
|
}
|
|
154
253
|
if (unread.length === 0) {
|
|
155
|
-
return { content: [{ type: 'text', text: JSON.stringify({
|
|
254
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
255
|
+
message: 'All scanned sent messages have been read.',
|
|
256
|
+
}, null, 2) }] };
|
|
156
257
|
}
|
|
157
258
|
return { content: [{ type: 'text', text: JSON.stringify(unread, null, 2) }] };
|
|
158
259
|
});
|
|
260
|
+
server.registerTool('ofw_sync_messages', {
|
|
261
|
+
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).',
|
|
262
|
+
annotations: { readOnlyHint: false },
|
|
263
|
+
inputSchema: {
|
|
264
|
+
folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).describe('Folders to sync (default: all three)').optional(),
|
|
265
|
+
fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.').optional(),
|
|
266
|
+
deep: z.boolean().describe('If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.').optional(),
|
|
267
|
+
},
|
|
268
|
+
}, async (args) => {
|
|
269
|
+
const result = await syncAll(client, {
|
|
270
|
+
folders: args.folders,
|
|
271
|
+
fetchUnreadBodies: args.fetchUnreadBodies,
|
|
272
|
+
deep: args.deep,
|
|
273
|
+
});
|
|
274
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
275
|
+
});
|
|
159
276
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofw-mcp",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.6",
|
|
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>",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
31
31
|
"dotenv": "^17.4.0",
|
|
32
|
-
"zod": "^4.
|
|
32
|
+
"zod": "^4.4.2"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^25.5.2",
|
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.6",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.0.
|
|
14
|
+
"version": "2.0.6",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|