ofw-mcp 2.0.4 → 2.0.5

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, 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,134 @@ 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. folderId accepts "inbox" or "sent". 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" or "sent"'),
15
17
  page: z.number().describe('Page number (default 1)').optional(),
16
18
  size: z.number().describe('Messages per page (default 50)').optional(),
17
19
  },
18
20
  }, async (args) => {
19
21
  const page = args.page ?? 1;
20
22
  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) }] };
23
+ let folder = null;
24
+ if (args.folderId === 'inbox')
25
+ folder = 'inbox';
26
+ else if (args.folderId === 'sent')
27
+ folder = 'sent';
28
+ else {
29
+ return {
30
+ content: [{
31
+ type: 'text',
32
+ text: JSON.stringify({
33
+ messages: [],
34
+ note: 'Cache is keyed by folder name. Pass folderId: "inbox" or "sent" (numeric folder IDs are not yet supported by the cache layer).',
35
+ }, null, 2),
36
+ }],
37
+ };
38
+ }
39
+ const messages = listMessages({ folder, page, size });
40
+ const payload = messages.length === 0
41
+ ? { messages: [], note: 'Cache empty for this folder. Call ofw_sync_messages to populate.' }
42
+ : { messages };
43
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
24
44
  });
25
45
  server.registerTool('ofw_get_message', {
26
- description: 'Get a single OurFamilyWizard message by ID. Note: reading an unread message marks it as read.',
46
+ 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
47
  annotations: { readOnlyHint: false },
28
48
  inputSchema: {
29
49
  messageId: z.string().describe('Message ID'),
30
50
  },
31
51
  }, 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) }] };
52
+ const id = Number(args.messageId);
53
+ const cached = getMessage(id);
54
+ if (cached && cached.body !== null) {
55
+ return { content: [{ type: 'text', text: JSON.stringify(cached, null, 2) }] };
56
+ }
57
+ const detail = await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
58
+ const recipients = (detail.recipients ?? []).map((r) => ({
59
+ userId: r.user.id, name: r.user.name, viewedAt: r.viewed?.dateTime ?? null,
60
+ }));
61
+ const folder = cached?.folder ?? 'inbox';
62
+ const row = {
63
+ id: detail.id,
64
+ folder,
65
+ subject: detail.subject,
66
+ fromUser: detail.from?.name ?? '',
67
+ sentAt: detail.date.dateTime,
68
+ recipients,
69
+ body: detail.body ?? '',
70
+ fetchedBodyAt: new Date().toISOString(),
71
+ replyToId: cached?.replyToId ?? null,
72
+ chainRootId: cached?.chainRootId ?? null,
73
+ listData: cached?.listData ?? detail,
74
+ };
75
+ upsertMessage(row);
76
+ return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
34
77
  });
35
78
  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.',
79
+ 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
80
  annotations: { destructiveHint: true },
38
81
  inputSchema: {
39
82
  subject: z.string().describe('Message subject'),
40
83
  body: z.string().describe('Message body text'),
41
84
  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(),
85
+ replyToId: z.number().describe('ID of the message being replied to').optional(),
43
86
  draftId: z.number().describe('ID of the draft to delete after sending (omit if not sending from a draft)').optional(),
44
87
  },
45
88
  }, async (args) => {
46
- const replyToId = args.replyToId ?? null;
89
+ const requestedReplyTo = args.replyToId ?? null;
90
+ let resolvedReplyTo = requestedReplyTo;
91
+ let chainRootId = null;
92
+ let rewriteNote = null;
93
+ if (requestedReplyTo !== null) {
94
+ resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
95
+ if (resolvedReplyTo !== requestedReplyTo) {
96
+ rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
97
+ }
98
+ const parent = getMessage(resolvedReplyTo);
99
+ chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
100
+ }
47
101
  const data = await client.request('POST', '/pub/v3/messages', {
48
102
  subject: args.subject,
49
103
  body: args.body,
50
104
  recipientIds: args.recipientIds,
51
105
  attachments: { myFileIDs: [] },
52
106
  draft: false,
53
- includeOriginal: replyToId !== null,
54
- replyToId,
107
+ includeOriginal: resolvedReplyTo !== null,
108
+ replyToId: resolvedReplyTo,
55
109
  });
110
+ if (data && typeof data.id === 'number') {
111
+ const recipients = (data.recipients ?? []).map((r) => ({
112
+ userId: r.user.id, name: r.user.name, viewedAt: r.viewed?.dateTime ?? null,
113
+ }));
114
+ const row = {
115
+ id: data.id,
116
+ folder: 'sent',
117
+ subject: data.subject ?? args.subject,
118
+ fromUser: data.from?.name ?? '',
119
+ sentAt: data.date?.dateTime ?? new Date().toISOString(),
120
+ recipients,
121
+ body: data.body ?? args.body,
122
+ fetchedBodyAt: new Date().toISOString(),
123
+ replyToId: resolvedReplyTo,
124
+ chainRootId,
125
+ listData: data,
126
+ };
127
+ upsertMessage(row);
128
+ }
56
129
  if (args.draftId !== undefined) {
57
130
  const form = new FormData();
58
131
  form.append('messageIds', String(args.draftId));
59
132
  await client.request('DELETE', '/pub/v1/messages', form);
133
+ deleteDraft(args.draftId);
60
134
  }
61
- return { content: [{ type: 'text', text: data ? JSON.stringify(data, null, 2) : 'Message sent successfully.' }] };
135
+ const text = data ? JSON.stringify(data, null, 2) : 'Message sent successfully.';
136
+ const finalText = rewriteNote ? `${rewriteNote}\n\n${text}` : text;
137
+ return { content: [{ type: 'text', text: finalText }] };
62
138
  });
63
139
  server.registerTool('ofw_list_drafts', {
64
- description: 'List all draft messages in OurFamilyWizard',
140
+ description: 'List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.',
65
141
  annotations: { readOnlyHint: true },
66
142
  inputSchema: {
67
143
  page: z.number().describe('Page number (default 1)').optional(),
@@ -70,39 +146,64 @@ export function registerMessageTools(server, client) {
70
146
  }, async (args) => {
71
147
  const page = args.page ?? 1;
72
148
  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) }] };
149
+ const drafts = listDrafts({ page, size });
150
+ const payload = drafts.length === 0
151
+ ? { drafts: [], note: 'Cache empty. Call ofw_sync_messages to populate.' }
152
+ : { drafts };
153
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
77
154
  });
78
155
  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.',
156
+ 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
157
  annotations: { readOnlyHint: false },
81
158
  inputSchema: {
82
159
  subject: z.string().describe('Message subject'),
83
160
  body: z.string().describe('Message body text'),
84
161
  recipientIds: z.array(z.number()).describe('Array of recipient user IDs (optional for drafts)').optional(),
85
162
  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(),
163
+ replyToId: z.number().describe('ID of the message this draft replies to').optional(),
87
164
  },
88
165
  }, async (args) => {
89
- const replyToId = args.replyToId ?? null;
166
+ const requestedReplyTo = args.replyToId ?? null;
167
+ let resolvedReplyTo = requestedReplyTo;
168
+ let rewriteNote = null;
169
+ if (requestedReplyTo !== null) {
170
+ resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
171
+ if (resolvedReplyTo !== requestedReplyTo) {
172
+ rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
173
+ }
174
+ }
90
175
  const payload = {
91
176
  subject: args.subject,
92
177
  body: args.body,
93
178
  recipientIds: args.recipientIds ?? [],
94
179
  attachments: { myFileIDs: [] },
95
180
  draft: true,
96
- includeOriginal: replyToId !== null,
97
- replyToId,
181
+ includeOriginal: resolvedReplyTo !== null,
182
+ replyToId: resolvedReplyTo,
98
183
  };
99
184
  if (args.messageId !== undefined)
100
185
  payload.messageId = args.messageId;
101
186
  const data = await client.request('POST', '/pub/v3/messages', payload);
102
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
187
+ if (data && typeof data.id === 'number') {
188
+ const draft = {
189
+ id: data.id,
190
+ subject: data.subject ?? args.subject,
191
+ body: data.body ?? args.body,
192
+ recipients: (data.recipients ?? []).map((r) => ({
193
+ userId: r.user.id, name: r.user.name, viewedAt: r.viewed?.dateTime ?? null,
194
+ })),
195
+ replyToId: data.replyToId ?? resolvedReplyTo,
196
+ modifiedAt: data.date?.dateTime ?? new Date().toISOString(),
197
+ listData: data,
198
+ };
199
+ upsertDraft(draft);
200
+ }
201
+ const text = data ? JSON.stringify(data, null, 2) : 'Draft saved.';
202
+ const finalText = rewriteNote ? `${rewriteNote}\n\n${text}` : text;
203
+ return { content: [{ type: 'text', text: finalText }] };
103
204
  });
104
205
  server.registerTool('ofw_delete_draft', {
105
- description: 'Delete a draft message from OurFamilyWizard',
206
+ description: 'Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.',
106
207
  annotations: { destructiveHint: true },
107
208
  inputSchema: {
108
209
  messageId: z.number().describe('Draft message ID to delete'),
@@ -111,49 +212,51 @@ export function registerMessageTools(server, client) {
111
212
  const form = new FormData();
112
213
  form.append('messageIds', String(args.messageId));
113
214
  const data = await client.request('DELETE', '/pub/v1/messages', form);
114
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
215
+ deleteDraft(args.messageId);
216
+ return { content: [{ type: 'text', text: data ? JSON.stringify(data, null, 2) : 'Draft deleted.' }] };
115
217
  });
116
218
  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.',
219
+ 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
220
  annotations: { readOnlyHint: true },
119
221
  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(),
222
+ page: z.number().describe('Page (default 1)').optional(),
223
+ size: z.number().describe('Per page (default 50)').optional(),
122
224
  },
123
225
  }, async (args) => {
124
226
  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)
227
+ const size = args.size ?? 50;
228
+ const sent = listMessages({ folder: 'sent', page, size });
229
+ if (sent.length === 0) {
230
+ return { content: [{ type: 'text', text: JSON.stringify({
231
+ note: 'Sent cache is empty. Call ofw_sync_messages to populate.',
232
+ }, null, 2) }] };
233
+ }
138
234
  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
- });
235
+ for (const msg of sent) {
236
+ const unreadBy = msg.recipients.filter((r) => r.viewedAt === null).map((r) => r.name);
237
+ if (unreadBy.length > 0) {
238
+ unread.push({ id: msg.id, subject: msg.subject, sentAt: msg.sentAt, unreadBy });
152
239
  }
153
240
  }
154
241
  if (unread.length === 0) {
155
- return { content: [{ type: 'text', text: JSON.stringify({ message: 'All scanned sent messages have been read.' }, null, 2) }] };
242
+ return { content: [{ type: 'text', text: JSON.stringify({
243
+ message: 'All scanned sent messages have been read.',
244
+ }, null, 2) }] };
156
245
  }
157
246
  return { content: [{ type: 'text', text: JSON.stringify(unread, null, 2) }] };
158
247
  });
248
+ server.registerTool('ofw_sync_messages', {
249
+ 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.',
250
+ annotations: { readOnlyHint: false },
251
+ inputSchema: {
252
+ folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).describe('Folders to sync (default: all three)').optional(),
253
+ fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.').optional(),
254
+ },
255
+ }, async (args) => {
256
+ const result = await syncAll(client, {
257
+ folders: args.folders,
258
+ fetchUnreadBodies: args.fetchUnreadBodies,
259
+ });
260
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
261
+ });
159
262
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
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.5",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.0.4",
14
+ "version": "2.0.5",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },