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.
@@ -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 in an OurFamilyWizard folder. Call ofw_list_message_folders first to get folder IDs. Returns actual message content.',
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 ID (get from ofw_list_message_folders)'),
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 path = `/pub/v3/messages?folders=${encodeURIComponent(args.folderId)}&page=${page}&size=${size}&sort=date&sortDirection=desc`;
22
- const data = await client.request('GET', path);
23
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
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. Note: reading an unread message marks it as read.',
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 data = await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
33
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
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 automatically delete the draft after sending.',
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. When provided, the original message thread is included (like a standard email reply).').optional(),
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 replyToId = args.replyToId ?? null;
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: replyToId !== null,
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
- return { content: [{ type: 'text', text: data ? JSON.stringify(data, null, 2) : 'Message sent successfully.' }] };
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 all draft messages in OurFamilyWizard',
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
- // 13471259 is the system Drafts folder (folderType: DRAFTS)
74
- const path = `/pub/v3/messages?folders=13471259&page=${page}&size=${size}&sort=date&sortDirection=desc`;
75
- const data = await client.request('GET', path);
76
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
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 — a draft can be saved without them. To update an existing draft, provide its messageId.',
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 is replying to (omit for new messages)').optional(),
175
+ replyToId: z.number().describe('ID of the message this draft replies to').optional(),
87
176
  },
88
177
  }, async (args) => {
89
- const replyToId = args.replyToId ?? null;
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: replyToId !== null,
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
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
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
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
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. Fetches sent messages page by page and returns only those with unread 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 of sent messages to scan (default 1)').optional(),
121
- size: z.number().describe('Number of sent messages per page, max 50 (default 20)').optional(),
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 ?? 20;
126
- // Step 1: find the sent folder
127
- const foldersData = await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true');
128
- const sentFolder = (foldersData.systemFolders ?? []).find((f) => f.folderType === 'SENT_MESSAGES');
129
- if (!sentFolder)
130
- throw new Error('Sent folder not found');
131
- // Step 2: list sent messages (the list endpoint already includes per-recipient viewed status)
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 messages) {
140
- if (!msg.showNeverViewed)
141
- continue;
142
- const unreadRecipients = (msg.recipients ?? [])
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({ message: 'All scanned sent messages have been read.' }, null, 2) }] };
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.4",
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.3.6"
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.4",
9
+ "version": "2.0.6",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.0.4",
14
+ "version": "2.0.6",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },