ofw-mcp 2.0.3 → 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.
- package/.claude-plugin/marketplace.json +9 -3
- package/.claude-plugin/plugin.json +8 -2
- package/dist/bundle.js +1931 -682
- package/dist/cache.js +192 -0
- package/dist/client.js +21 -3
- package/dist/config.js +21 -0
- package/dist/index.js +11 -1
- package/dist/sync.js +149 -0
- package/dist/tools/messages.js +160 -57
- package/package.json +7 -5
- package/server.json +35 -0
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, 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
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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.
|
|
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
|
|
33
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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
|
|
163
|
+
replyToId: z.number().describe('ID of the message this draft replies to').optional(),
|
|
87
164
|
},
|
|
88
165
|
}, async (args) => {
|
|
89
|
-
const
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
121
|
-
size: z.number().describe('
|
|
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 ??
|
|
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)
|
|
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
|
|
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
|
-
});
|
|
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({
|
|
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,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofw-mcp",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "2.0.5",
|
|
4
|
+
"mcpName": "io.github.chrischall/ofw-mcp",
|
|
5
|
+
"description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
|
|
6
|
+
"author": "Claude Code (AI) <https://www.anthropic.com/claude>",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
8
9
|
"url": "git+https://github.com/chrischall/ofw-mcp.git"
|
|
@@ -15,7 +16,8 @@
|
|
|
15
16
|
"dist",
|
|
16
17
|
".claude-plugin",
|
|
17
18
|
"skills",
|
|
18
|
-
".mcp.json"
|
|
19
|
+
".mcp.json",
|
|
20
|
+
"server.json"
|
|
19
21
|
],
|
|
20
22
|
"scripts": {
|
|
21
23
|
"build": "tsc && npm run bundle",
|
|
@@ -27,7 +29,7 @@
|
|
|
27
29
|
"dependencies": {
|
|
28
30
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
29
31
|
"dotenv": "^17.4.0",
|
|
30
|
-
"zod": "^4.
|
|
32
|
+
"zod": "^4.4.2"
|
|
31
33
|
},
|
|
32
34
|
"devDependencies": {
|
|
33
35
|
"@types/node": "^25.5.2",
|
package/server.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.chrischall/ofw-mcp",
|
|
4
|
+
"description": "OurFamilyWizard co-parenting for Claude — messages, calendar, expenses, and journal",
|
|
5
|
+
"repository": {
|
|
6
|
+
"url": "https://github.com/chrischall/ofw-mcp",
|
|
7
|
+
"source": "github"
|
|
8
|
+
},
|
|
9
|
+
"version": "2.0.5",
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registryType": "npm",
|
|
13
|
+
"identifier": "ofw-mcp",
|
|
14
|
+
"version": "2.0.5",
|
|
15
|
+
"transport": {
|
|
16
|
+
"type": "stdio"
|
|
17
|
+
},
|
|
18
|
+
"environmentVariables": [
|
|
19
|
+
{
|
|
20
|
+
"name": "OFW_USERNAME",
|
|
21
|
+
"description": "Your OurFamilyWizard login email address",
|
|
22
|
+
"isRequired": true,
|
|
23
|
+
"format": "string"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "OFW_PASSWORD",
|
|
27
|
+
"description": "Your OurFamilyWizard password",
|
|
28
|
+
"isRequired": true,
|
|
29
|
+
"format": "string",
|
|
30
|
+
"isSecret": true
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|