ofw-mcp 2.4.4 → 2.6.3
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/README.md +18 -3
- package/dist/auth-password.js +8 -1
- package/dist/bundle.js +1177 -568
- package/dist/cache/node.js +85 -0
- package/dist/cache/store.js +480 -0
- package/dist/client.js +22 -4
- package/dist/config.js +42 -0
- package/dist/index.js +13 -2
- package/dist/ofw-auth.js +26 -0
- package/dist/sync.js +258 -61
- package/dist/tools/_shared.js +23 -6
- package/dist/tools/attachments.js +66 -0
- package/dist/tools/calendar.js +145 -29
- package/dist/tools/messages.js +95 -83
- package/package.json +14 -5
- package/server.json +8 -2
- package/skills/ofw/SKILL.md +2 -0
- package/skills/ofw-fpx/SKILL.md +106 -0
- package/skills/ofw-fpx/references/requests.md +252 -0
- package/dist/cache.js +0 -345
package/dist/tools/calendar.js
CHANGED
|
@@ -1,9 +1,113 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { jsonResponse, textResponse } from './_shared.js';
|
|
3
|
-
import {
|
|
3
|
+
import { getCalendarWritesAllowed } from '../config.js';
|
|
4
|
+
import { parseLenient } from '@chrischall/mcp-utils';
|
|
5
|
+
// OFW's real event-write API (reverse-engineered from the web app bundle):
|
|
6
|
+
// POST /pub/v3/events create — 201 with the full event
|
|
7
|
+
// GET /pub/v3/events/{eventRecurrenceId} detail
|
|
8
|
+
// PUT /pub/v3/events/{eventRecurrenceId} update — full payload, not a patch
|
|
9
|
+
// DELETE /pub/v3/events/{eventRecurrenceId}?includeFuture=<bool>
|
|
10
|
+
// The id in every URL is `eventRecurrenceId` — the same value calendar
|
|
11
|
+
// listings expose as `id`. (`eventId` in the response is a different,
|
|
12
|
+
// internal identifier; never put it in a URL.)
|
|
13
|
+
// Payload gotchas: dates are `YYYY-MM-DD` with separate `HH:mm` times;
|
|
14
|
+
// privacy is `publicFlag` (true = shared with co-parent); parent ids must be
|
|
15
|
+
// OMITTED when unset — the web form's "0" placeholders draw a 409
|
|
16
|
+
// "Must be a parent" from the API.
|
|
17
|
+
const ofwDate = z.looseObject({ dateTime: z.string() });
|
|
18
|
+
const userRef = z.looseObject({ userId: z.number() });
|
|
19
|
+
const eventDetailSchema = z.looseObject({
|
|
20
|
+
eventRecurrenceId: z.number(),
|
|
21
|
+
title: z.string(),
|
|
22
|
+
allDay: z.boolean(),
|
|
23
|
+
publicFlag: z.boolean(),
|
|
24
|
+
startDate: ofwDate,
|
|
25
|
+
endDate: ofwDate,
|
|
26
|
+
location: z.string().nullish(),
|
|
27
|
+
notes: z.string().nullish(),
|
|
28
|
+
reminderMinutes: z.number().nullish(),
|
|
29
|
+
children: z.array(userRef).nullish(),
|
|
30
|
+
eventParent: userRef.nullish(),
|
|
31
|
+
dropOffParent: userRef.nullish(),
|
|
32
|
+
pickUpParent: userRef.nullish(),
|
|
33
|
+
});
|
|
34
|
+
const eventWriteFields = {
|
|
35
|
+
startDate: z.string().describe('Start date YYYY-MM-DD'),
|
|
36
|
+
endDate: z.string().describe('End date YYYY-MM-DD (default: startDate)').optional(),
|
|
37
|
+
startTime: z.string().describe('Start time HH:mm, 24-hour (required unless allDay)').optional(),
|
|
38
|
+
endTime: z.string().describe('End time HH:mm, 24-hour (required unless allDay)').optional(),
|
|
39
|
+
allDay: z.boolean().optional(),
|
|
40
|
+
privateEvent: z.boolean().describe('true = visible only to you; default false = shared with co-parent').optional(),
|
|
41
|
+
location: z.string().optional(),
|
|
42
|
+
notes: z.string().optional(),
|
|
43
|
+
reminderMinutes: z.number().int().min(0).optional(),
|
|
44
|
+
children: z.array(z.number()).describe('Child userIds to tag (see ofw_get_profile)').optional(),
|
|
45
|
+
eventParentId: z.number().describe("userId of the parent the event is 'for'").optional(),
|
|
46
|
+
dropOffParentId: z.number().describe('userId of the drop-off parent').optional(),
|
|
47
|
+
pickUpParentId: z.number().describe('userId of the pick-up parent').optional(),
|
|
48
|
+
};
|
|
49
|
+
function buildEventPayload(a) {
|
|
50
|
+
const allDay = a.allDay ?? false;
|
|
51
|
+
if (!allDay && (!a.startTime || !a.endTime)) {
|
|
52
|
+
throw new Error('startTime and endTime (HH:mm) are required unless allDay is true');
|
|
53
|
+
}
|
|
54
|
+
const payload = {
|
|
55
|
+
title: a.title,
|
|
56
|
+
startDate: a.startDate,
|
|
57
|
+
endDate: a.endDate ?? a.startDate,
|
|
58
|
+
// The web form always sends times; for all-day events it uses 01:00/02:00
|
|
59
|
+
// placeholders that OFW ignores.
|
|
60
|
+
startTime: a.startTime ?? '01:00',
|
|
61
|
+
endTime: a.endTime ?? '02:00',
|
|
62
|
+
allDay,
|
|
63
|
+
publicFlag: !(a.privateEvent ?? false),
|
|
64
|
+
};
|
|
65
|
+
if (a.location)
|
|
66
|
+
payload.location = a.location;
|
|
67
|
+
if (a.notes)
|
|
68
|
+
payload.notes = a.notes;
|
|
69
|
+
if (a.reminderMinutes !== undefined)
|
|
70
|
+
payload.reminderMinutes = String(a.reminderMinutes);
|
|
71
|
+
// Omitted `children` PRESERVES existing tags on PUT; an explicit [] CLEARS
|
|
72
|
+
// them (verified live 2026-07-13) — so send the array whenever it's defined.
|
|
73
|
+
if (a.children !== undefined)
|
|
74
|
+
payload.children = a.children;
|
|
75
|
+
if (a.eventParentId !== undefined)
|
|
76
|
+
payload.eventParentId = String(a.eventParentId);
|
|
77
|
+
if (a.dropOffParentId !== undefined)
|
|
78
|
+
payload.dropOffParentId = String(a.dropOffParentId);
|
|
79
|
+
if (a.pickUpParentId !== undefined)
|
|
80
|
+
payload.pickUpParentId = String(a.pickUpParentId);
|
|
81
|
+
return payload;
|
|
82
|
+
}
|
|
83
|
+
// Map a detail response back onto write-args so ofw_update_event can send the
|
|
84
|
+
// full payload OFW's PUT expects while the caller only names the changes.
|
|
85
|
+
function detailToWriteArgs(d) {
|
|
86
|
+
const [startDate, startClock] = d.startDate.dateTime.split('T');
|
|
87
|
+
const [endDate, endClock] = d.endDate.dateTime.split('T');
|
|
88
|
+
return {
|
|
89
|
+
title: d.title,
|
|
90
|
+
startDate,
|
|
91
|
+
endDate,
|
|
92
|
+
startTime: (startClock ?? '01:00:00').slice(0, 5),
|
|
93
|
+
endTime: (endClock ?? '02:00:00').slice(0, 5),
|
|
94
|
+
allDay: d.allDay,
|
|
95
|
+
privateEvent: !d.publicFlag,
|
|
96
|
+
location: d.location ?? undefined,
|
|
97
|
+
notes: d.notes ?? undefined,
|
|
98
|
+
reminderMinutes: d.reminderMinutes ?? undefined,
|
|
99
|
+
// Untagged (nullish or empty) → undefined, so the merged PUT omits the
|
|
100
|
+
// field (omission preserves; only a CALLER-supplied [] should clear).
|
|
101
|
+
children: d.children?.length ? d.children.map((c) => c.userId) : undefined,
|
|
102
|
+
eventParentId: d.eventParent?.userId,
|
|
103
|
+
dropOffParentId: d.dropOffParent?.userId,
|
|
104
|
+
pickUpParentId: d.pickUpParent?.userId,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
4
107
|
export function registerCalendarTools(server, client) {
|
|
5
|
-
// Calendar writes land on the court-visible record
|
|
6
|
-
|
|
108
|
+
// Calendar writes land on the court-visible record with no draft stage, but
|
|
109
|
+
// events are reversible — 'all' mode, or 'drafts' + OFW_CALENDAR_WRITES=true.
|
|
110
|
+
const allowWrites = getCalendarWritesAllowed();
|
|
7
111
|
server.registerTool('ofw_list_events', {
|
|
8
112
|
description: 'List OurFamilyWizard calendar events in a date range',
|
|
9
113
|
annotations: { readOnlyHint: true },
|
|
@@ -19,53 +123,65 @@ export function registerCalendarTools(server, client) {
|
|
|
19
123
|
});
|
|
20
124
|
if (allowWrites)
|
|
21
125
|
server.registerTool('ofw_create_event', {
|
|
22
|
-
description: 'Create a calendar event in OurFamilyWizard',
|
|
126
|
+
description: 'Create a calendar event in OurFamilyWizard. Unless privateEvent is true, the event is immediately visible to the co-parent — there is no draft stage.',
|
|
23
127
|
annotations: { destructiveHint: false },
|
|
24
128
|
inputSchema: {
|
|
25
129
|
title: z.string(),
|
|
26
|
-
|
|
27
|
-
endDate: z.string().describe('ISO datetime string'),
|
|
28
|
-
allDay: z.boolean().optional(),
|
|
29
|
-
location: z.string().optional(),
|
|
30
|
-
reminder: z.string().describe('Reminder setting (e.g. "1 hour before")').optional(),
|
|
31
|
-
privateEvent: z.boolean().optional(),
|
|
32
|
-
eventFor: z.string().describe('neither | parent1 | parent2').optional(),
|
|
33
|
-
dropOffParent: z.string().optional(),
|
|
34
|
-
pickUpParent: z.string().optional(),
|
|
35
|
-
children: z.array(z.number()).describe('Array of child IDs').optional(),
|
|
130
|
+
...eventWriteFields,
|
|
36
131
|
},
|
|
37
132
|
}, async (args) => {
|
|
38
|
-
const
|
|
39
|
-
|
|
133
|
+
const raw = await client.request('POST', '/pub/v3/events', buildEventPayload(args));
|
|
134
|
+
const event = parseLenient(eventDetailSchema, raw, { label: 'ofw-mcp', context: 'POST /pub/v3/events', mode: 'strict' });
|
|
135
|
+
return jsonResponse({
|
|
136
|
+
note: `Event created. Use eventRecurrenceId ${event.eventRecurrenceId} as eventId for ofw_update_event/ofw_delete_event.`,
|
|
137
|
+
event,
|
|
138
|
+
});
|
|
40
139
|
});
|
|
41
140
|
if (allowWrites)
|
|
42
141
|
server.registerTool('ofw_update_event', {
|
|
43
|
-
description: 'Update an existing OurFamilyWizard calendar event',
|
|
142
|
+
description: 'Update an existing OurFamilyWizard calendar event. Fetches the event, applies the given changes, and writes the merged result back (OFW has no partial update).',
|
|
44
143
|
annotations: { destructiveHint: true },
|
|
45
144
|
inputSchema: {
|
|
46
|
-
eventId: z.string(),
|
|
145
|
+
eventId: z.string().describe('Event id — the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
|
|
47
146
|
title: z.string().optional(),
|
|
48
|
-
startDate:
|
|
49
|
-
endDate:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
privateEvent:
|
|
147
|
+
startDate: eventWriteFields.startDate.optional(),
|
|
148
|
+
endDate: eventWriteFields.endDate,
|
|
149
|
+
startTime: eventWriteFields.startTime,
|
|
150
|
+
endTime: eventWriteFields.endTime,
|
|
151
|
+
allDay: eventWriteFields.allDay,
|
|
152
|
+
privateEvent: eventWriteFields.privateEvent,
|
|
153
|
+
location: eventWriteFields.location,
|
|
154
|
+
notes: eventWriteFields.notes,
|
|
155
|
+
reminderMinutes: eventWriteFields.reminderMinutes,
|
|
156
|
+
children: z.array(z.number()).describe('Child userIds to tag; pass [] to remove all child tags (omit to keep current tags)').optional(),
|
|
157
|
+
eventParentId: eventWriteFields.eventParentId,
|
|
158
|
+
dropOffParentId: eventWriteFields.dropOffParentId,
|
|
159
|
+
pickUpParentId: eventWriteFields.pickUpParentId,
|
|
54
160
|
},
|
|
55
161
|
}, async (args) => {
|
|
56
|
-
const { eventId, ...
|
|
57
|
-
const
|
|
58
|
-
|
|
162
|
+
const { eventId, ...changes } = args;
|
|
163
|
+
const id = encodeURIComponent(eventId);
|
|
164
|
+
const rawDetail = await client.request('GET', `/pub/v3/events/${id}`);
|
|
165
|
+
const current = parseLenient(eventDetailSchema, rawDetail, { label: 'ofw-mcp', context: `GET /pub/v3/events/${eventId}`, mode: 'strict' });
|
|
166
|
+
const defined = Object.fromEntries(Object.entries(changes).filter(([, v]) => v !== undefined));
|
|
167
|
+
const merged = { ...detailToWriteArgs(current), ...defined };
|
|
168
|
+
await client.request('PUT', `/pub/v3/events/${id}`, buildEventPayload(merged));
|
|
169
|
+
// PUT responses aren't documented — re-fetch the detail as authoritative state.
|
|
170
|
+
const rawAfter = await client.request('GET', `/pub/v3/events/${id}`);
|
|
171
|
+
const event = parseLenient(eventDetailSchema, rawAfter, { label: 'ofw-mcp', context: `GET /pub/v3/events/${eventId} (post-update)`, mode: 'strict' });
|
|
172
|
+
return jsonResponse({ note: 'Event updated; returning re-fetched event state.', event });
|
|
59
173
|
});
|
|
60
174
|
if (allowWrites)
|
|
61
175
|
server.registerTool('ofw_delete_event', {
|
|
62
176
|
description: 'Delete an OurFamilyWizard calendar event',
|
|
63
177
|
annotations: { destructiveHint: true },
|
|
64
178
|
inputSchema: {
|
|
65
|
-
eventId: z.string().describe('Event
|
|
179
|
+
eventId: z.string().describe('Event id — the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
|
|
180
|
+
includeFuture: z.boolean().describe('For repeating events: also delete future occurrences (default false)').optional(),
|
|
66
181
|
},
|
|
67
182
|
}, async (args) => {
|
|
68
|
-
|
|
183
|
+
const includeFuture = args.includeFuture ?? false;
|
|
184
|
+
await client.request('DELETE', `/pub/v3/events/${encodeURIComponent(args.eventId)}?includeFuture=${includeFuture}`);
|
|
69
185
|
return textResponse(`Event ${args.eventId} deleted`);
|
|
70
186
|
});
|
|
71
187
|
}
|
package/dist/tools/messages.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage } from '../sync.js';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { basename, dirname, extname, join } from 'node:path';
|
|
7
|
-
import { fileBlob } from '@chrischall/mcp-utils';
|
|
8
|
-
import { ApiRecipientSchema, expandPath, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded } from './_shared.js';
|
|
3
|
+
import { getAttachmentsDir, getDefaultInlineAttachments, getSyncMaxRequests, getWriteMode } from '../config.js';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
import { ApiRecipientSchema, expandPath, hasRealView, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded } from './_shared.js';
|
|
9
6
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
10
7
|
// Schemas for the load-bearing fields of each /pub/v3 response this file
|
|
11
8
|
// reads (issue #83). Loose: unknown keys pass through into cached listData.
|
|
@@ -38,6 +35,10 @@ const MessageDetailSchema = z.looseObject({
|
|
|
38
35
|
from: z.looseObject({ name: z.string().optional() }).optional(),
|
|
39
36
|
files: z.array(z.number()).optional(),
|
|
40
37
|
recipients: z.array(ApiRecipientSchema).optional(),
|
|
38
|
+
// The detail payload carries its own owning folder ({id, name}). We read the
|
|
39
|
+
// id to label a live-fetched message sent-vs-inbox instead of blindly
|
|
40
|
+
// defaulting to inbox — see the folder derivation in ofw_get_message.
|
|
41
|
+
folder: z.looseObject({ id: z.number() }).optional(),
|
|
41
42
|
});
|
|
42
43
|
// Attachment-backfill detail fetch reads only `files`.
|
|
43
44
|
const DetailFilesSchema = z.looseObject({ files: z.array(z.number()).optional() });
|
|
@@ -51,33 +52,6 @@ const UploadedFileSchema = z.looseObject({
|
|
|
51
52
|
sizeInBytes: z.number().optional(),
|
|
52
53
|
shareClass: z.string().optional(),
|
|
53
54
|
});
|
|
54
|
-
// Lightweight mime sniff from extension. OFW re-derives mime from the filename
|
|
55
|
-
// server-side anyway, so this is just a polite Content-Type for the Blob.
|
|
56
|
-
const MIME_BY_EXT = {
|
|
57
|
-
'.pdf': 'application/pdf',
|
|
58
|
-
'.png': 'image/png',
|
|
59
|
-
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
60
|
-
'.gif': 'image/gif',
|
|
61
|
-
'.webp': 'image/webp',
|
|
62
|
-
'.heic': 'image/heic',
|
|
63
|
-
'.txt': 'text/plain',
|
|
64
|
-
'.md': 'text/markdown',
|
|
65
|
-
'.csv': 'text/csv',
|
|
66
|
-
'.html': 'text/html', '.htm': 'text/html',
|
|
67
|
-
'.json': 'application/json',
|
|
68
|
-
'.xml': 'application/xml',
|
|
69
|
-
'.doc': 'application/msword',
|
|
70
|
-
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
71
|
-
'.xls': 'application/vnd.ms-excel',
|
|
72
|
-
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
73
|
-
'.ppt': 'application/vnd.ms-powerpoint',
|
|
74
|
-
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
75
|
-
'.zip': 'application/zip',
|
|
76
|
-
'.ics': 'text/calendar',
|
|
77
|
-
};
|
|
78
|
-
function mimeFromName(name) {
|
|
79
|
-
return MIME_BY_EXT[extname(name).toLowerCase()] ?? 'application/octet-stream';
|
|
80
|
-
}
|
|
81
55
|
// The list endpoint payload (cached as `listData`) reports attachments via
|
|
82
56
|
// `files: <count>` (a number) — the actual fileIds only appear on the detail
|
|
83
57
|
// endpoint as `files: [number, ...]`. Some intermediate shapes return an
|
|
@@ -92,7 +66,7 @@ function listDataHintsAtFiles(listData) {
|
|
|
92
66
|
return ld.files.length > 0;
|
|
93
67
|
return false;
|
|
94
68
|
}
|
|
95
|
-
export function registerMessageTools(server, client) {
|
|
69
|
+
export function registerMessageTools(server, client, cacheProvider, attachmentIO) {
|
|
96
70
|
// OFW_WRITE_MODE gate (see config.ts). Send lands on the court-visible
|
|
97
71
|
// record, so it is 'all'-only; draft-level writes (save/delete drafts,
|
|
98
72
|
// upload attachments) also register under 'drafts'. Read/sync/download
|
|
@@ -135,9 +109,10 @@ export function registerMessageTools(server, client) {
|
|
|
135
109
|
note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.',
|
|
136
110
|
});
|
|
137
111
|
}
|
|
112
|
+
const cache = cacheProvider();
|
|
138
113
|
const filter = { folder, since: args.since, until: args.until, q: args.q };
|
|
139
|
-
const total = countMessages(filter);
|
|
140
|
-
const messages = listMessages({ ...filter, page, size });
|
|
114
|
+
const total = await cache.countMessages(filter);
|
|
115
|
+
const messages = await cache.listMessages({ ...filter, page, size });
|
|
141
116
|
const payload = { messages, total, page, size };
|
|
142
117
|
if (total === 0) {
|
|
143
118
|
payload.note = 'No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.';
|
|
@@ -155,13 +130,14 @@ export function registerMessageTools(server, client) {
|
|
|
155
130
|
},
|
|
156
131
|
}, async (args) => {
|
|
157
132
|
const id = Number(args.messageId);
|
|
133
|
+
const cache = cacheProvider();
|
|
158
134
|
// Draft routing: if this id is in the drafts cache, return a
|
|
159
135
|
// MessageRow-shaped synthesis built from the draft. The drafts table
|
|
160
136
|
// is the source of truth for draft bodies (sync keeps it fresh);
|
|
161
137
|
// the messages-table cache for the same id is stale by construction
|
|
162
138
|
// when ofw_get_message was called on a draft id before sync caught
|
|
163
139
|
// up — see syncDrafts, which also evicts these stale rows.
|
|
164
|
-
const draftRow = getDraft(id);
|
|
140
|
+
const draftRow = await cache.getDraft(id);
|
|
165
141
|
if (draftRow !== null) {
|
|
166
142
|
return jsonResponse({
|
|
167
143
|
id: draftRow.id,
|
|
@@ -181,9 +157,35 @@ export function registerMessageTools(server, client) {
|
|
|
181
157
|
attachments: [],
|
|
182
158
|
});
|
|
183
159
|
}
|
|
184
|
-
const cached = getMessage(id);
|
|
160
|
+
const cached = await cache.getMessage(id);
|
|
185
161
|
if (cached && cached.body !== null) {
|
|
186
|
-
let
|
|
162
|
+
let row = cached;
|
|
163
|
+
// Refresh view status for a sent message we still believe is unviewed:
|
|
164
|
+
// the recipient may have opened it since the last sync, and the detail
|
|
165
|
+
// endpoint carries the real "First Viewed" timestamp (a list-synced row
|
|
166
|
+
// only knows the showNeverViewed boolean / epoch placeholder). Best-
|
|
167
|
+
// effort and one-way — once a real viewed time is cached we stop re-
|
|
168
|
+
// fetching. Sent-only: re-hitting an unread INBOX detail would mark it
|
|
169
|
+
// read on OFW.
|
|
170
|
+
if (cached.folder === 'sent' && !hasRealView(cached.recipients)) {
|
|
171
|
+
try {
|
|
172
|
+
const detail = parseLenient(MessageDetailSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (view-status refresh)' });
|
|
173
|
+
const recipients = mapRecipients(detail.recipients);
|
|
174
|
+
// Keep the raw listData read-flag in step with the refreshed
|
|
175
|
+
// recipients so `showNeverViewed` can't contradict `viewedAt`.
|
|
176
|
+
// (Spreading a null/absent listData is a no-op, so no guard needed.)
|
|
177
|
+
row = {
|
|
178
|
+
...cached,
|
|
179
|
+
recipients,
|
|
180
|
+
listData: { ...cached.listData, showNeverViewed: !hasRealView(recipients) },
|
|
181
|
+
};
|
|
182
|
+
await cache.upsertMessage(row);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// Best-effort: fall back to the cached row on any fetch/parse error.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
let attachments = await cache.listAttachmentsForMessage(id);
|
|
187
189
|
// Lazy attachment backfill. The list-endpoint payload (stored in
|
|
188
190
|
// listData) hints at attachments via `files: <count>` but doesn't
|
|
189
191
|
// expose the fileIds — those live only on /pub/v3/messages/{id}.
|
|
@@ -191,22 +193,35 @@ export function registerMessageTools(server, client) {
|
|
|
191
193
|
// attachments table is empty even though OFW has files. Re-hit
|
|
192
194
|
// detail to harvest fileIds (idempotent: body is already cached so
|
|
193
195
|
// OFW state isn't changing).
|
|
194
|
-
if (attachments.length === 0 && listDataHintsAtFiles(
|
|
196
|
+
if (attachments.length === 0 && listDataHintsAtFiles(row.listData)) {
|
|
195
197
|
try {
|
|
196
198
|
const detail = parseLenient(DetailFilesSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (attachment backfill)' });
|
|
197
199
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
198
|
-
await fetchAttachmentMetaForMessage(client, id, detail.files);
|
|
199
|
-
attachments = listAttachmentsForMessage(id);
|
|
200
|
+
await fetchAttachmentMetaForMessage(client, id, detail.files, cache);
|
|
201
|
+
attachments = await cache.listAttachmentsForMessage(id);
|
|
200
202
|
}
|
|
201
203
|
}
|
|
202
204
|
catch {
|
|
203
205
|
// Backfill is best-effort. Fall through with whatever we have.
|
|
204
206
|
}
|
|
205
207
|
}
|
|
206
|
-
return jsonResponse({ ...
|
|
208
|
+
return jsonResponse({ ...row, attachments });
|
|
207
209
|
}
|
|
208
210
|
const detail = parseLenient(MessageDetailSchema, await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (ofw_get_message)' });
|
|
209
|
-
|
|
211
|
+
// Derive the folder for a live-fetched message. A cached row (reached here
|
|
212
|
+
// only when its body was NULL) already knows its folder, so keep it.
|
|
213
|
+
// Otherwise use the detail's own folder id, matched against the sent folder
|
|
214
|
+
// id persisted by the last resolveFolderIds — a sent message must not be
|
|
215
|
+
// mislabeled 'inbox' (which would also hide it from ofw_get_unread_sent and
|
|
216
|
+
// a sent-scoped ofw_list_messages). When that mapping isn't known yet (no
|
|
217
|
+
// sync has run in this cache), fall back to 'inbox' as before.
|
|
218
|
+
let folder = cached?.folder ?? 'inbox';
|
|
219
|
+
if (!cached) {
|
|
220
|
+
const sentFolderId = await cache.getMeta('sent_folder_id');
|
|
221
|
+
if (sentFolderId !== null && detail.folder?.id != null && String(detail.folder.id) === sentFolderId) {
|
|
222
|
+
folder = 'sent';
|
|
223
|
+
}
|
|
224
|
+
}
|
|
210
225
|
const row = {
|
|
211
226
|
id: detail.id,
|
|
212
227
|
folder,
|
|
@@ -220,11 +235,11 @@ export function registerMessageTools(server, client) {
|
|
|
220
235
|
chainRootId: cached?.chainRootId ?? null,
|
|
221
236
|
listData: cached?.listData ?? detail,
|
|
222
237
|
};
|
|
223
|
-
upsertMessage(row);
|
|
238
|
+
await cache.upsertMessage(row);
|
|
224
239
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
225
|
-
await fetchAttachmentMetaForMessage(client, detail.id, detail.files);
|
|
240
|
+
await fetchAttachmentMetaForMessage(client, detail.id, detail.files, cache);
|
|
226
241
|
}
|
|
227
|
-
const attachments = listAttachmentsForMessage(detail.id);
|
|
242
|
+
const attachments = await cache.listAttachmentsForMessage(detail.id);
|
|
228
243
|
return jsonResponse({ ...row, attachments });
|
|
229
244
|
});
|
|
230
245
|
if (allowSend)
|
|
@@ -245,6 +260,7 @@ export function registerMessageTools(server, client) {
|
|
|
245
260
|
throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
|
|
246
261
|
}
|
|
247
262
|
const draftRef = args.messageId ?? args.draftId;
|
|
263
|
+
const cache = cacheProvider();
|
|
248
264
|
// Best-effort draft lookup: when draftRef points at a cached draft, use
|
|
249
265
|
// its stored fields (including replyToId) as defaults for anything the
|
|
250
266
|
// caller didn't supply. The "missing draft" case only matters when we
|
|
@@ -258,7 +274,7 @@ export function registerMessageTools(server, client) {
|
|
|
258
274
|
let draftFound = false;
|
|
259
275
|
if (draftRef !== undefined) {
|
|
260
276
|
draftLookupAttempted = true;
|
|
261
|
-
const draft = getDraft(draftRef);
|
|
277
|
+
const draft = await cache.getDraft(draftRef);
|
|
262
278
|
if (draft !== null) {
|
|
263
279
|
draftFound = true;
|
|
264
280
|
subject = subject ?? draft.subject;
|
|
@@ -286,11 +302,11 @@ export function registerMessageTools(server, client) {
|
|
|
286
302
|
let chainRootId = null;
|
|
287
303
|
let rewriteNote = null;
|
|
288
304
|
if (requestedReplyTo !== null) {
|
|
289
|
-
resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
|
|
305
|
+
resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
|
|
290
306
|
if (resolvedReplyTo !== requestedReplyTo) {
|
|
291
307
|
rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
|
|
292
308
|
}
|
|
293
|
-
const parent = getMessage(resolvedReplyTo);
|
|
309
|
+
const parent = await cache.getMessage(resolvedReplyTo);
|
|
294
310
|
chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
|
|
295
311
|
}
|
|
296
312
|
const myFileIDs = args.myFileIDs ?? [];
|
|
@@ -320,13 +336,13 @@ export function registerMessageTools(server, client) {
|
|
|
320
336
|
chainRootId,
|
|
321
337
|
listData: detail,
|
|
322
338
|
};
|
|
323
|
-
upsertMessage(persisted);
|
|
339
|
+
await cache.upsertMessage(persisted);
|
|
324
340
|
// Link attached files to the new message in the attachments cache.
|
|
325
341
|
// We may not have full metadata if the upload happened in a prior
|
|
326
342
|
// session — fall back to what we know.
|
|
327
343
|
for (const fileId of myFileIDs) {
|
|
328
|
-
const existing = getAttachment(fileId);
|
|
329
|
-
upsertAttachmentForMessage({
|
|
344
|
+
const existing = await cache.getAttachment(fileId);
|
|
345
|
+
await cache.upsertAttachmentForMessage({
|
|
330
346
|
fileId,
|
|
331
347
|
fileName: existing?.fileName ?? `file-${fileId}`,
|
|
332
348
|
label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
|
|
@@ -349,7 +365,7 @@ export function registerMessageTools(server, client) {
|
|
|
349
365
|
}
|
|
350
366
|
else if (draftRef !== undefined) {
|
|
351
367
|
await deleteOFWMessages(client, [draftRef]);
|
|
352
|
-
deleteDraft(draftRef);
|
|
368
|
+
await cache.deleteDraft(draftRef);
|
|
353
369
|
}
|
|
354
370
|
const responseObj = persisted ?? raw;
|
|
355
371
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
|
|
@@ -366,7 +382,7 @@ export function registerMessageTools(server, client) {
|
|
|
366
382
|
}, async (args) => {
|
|
367
383
|
const page = args.page ?? 1;
|
|
368
384
|
const size = args.size ?? 50;
|
|
369
|
-
const drafts = listDrafts({ page, size });
|
|
385
|
+
const drafts = await cacheProvider().listDrafts({ page, size });
|
|
370
386
|
const payload = drafts.length === 0
|
|
371
387
|
? { drafts: [], note: 'Cache empty. Call ofw_sync_messages to populate.' }
|
|
372
388
|
: { drafts };
|
|
@@ -385,11 +401,12 @@ export function registerMessageTools(server, client) {
|
|
|
385
401
|
myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
|
|
386
402
|
},
|
|
387
403
|
}, async (args) => {
|
|
404
|
+
const cache = cacheProvider();
|
|
388
405
|
const requestedReplyTo = args.replyToId ?? null;
|
|
389
406
|
let resolvedReplyTo = requestedReplyTo;
|
|
390
407
|
let rewriteNote = null;
|
|
391
408
|
if (requestedReplyTo !== null) {
|
|
392
|
-
resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
|
|
409
|
+
resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
|
|
393
410
|
if (resolvedReplyTo !== requestedReplyTo) {
|
|
394
411
|
rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
|
|
395
412
|
}
|
|
@@ -425,13 +442,13 @@ export function registerMessageTools(server, client) {
|
|
|
425
442
|
modifiedAt: detail.date?.dateTime ?? new Date().toISOString(),
|
|
426
443
|
listData: detail,
|
|
427
444
|
};
|
|
428
|
-
upsertDraft(persisted);
|
|
445
|
+
await cache.upsertDraft(persisted);
|
|
429
446
|
// Replace-path: caller passed messageId, so they want the old draft
|
|
430
447
|
// gone. Delete it after the new one is safely created+cached.
|
|
431
448
|
if (args.messageId !== undefined && args.messageId !== newId) {
|
|
432
449
|
try {
|
|
433
450
|
await deleteOFWMessages(client, [args.messageId]);
|
|
434
|
-
deleteDraft(args.messageId);
|
|
451
|
+
await cache.deleteDraft(args.messageId);
|
|
435
452
|
replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.)`;
|
|
436
453
|
}
|
|
437
454
|
catch (e) {
|
|
@@ -453,7 +470,7 @@ export function registerMessageTools(server, client) {
|
|
|
453
470
|
},
|
|
454
471
|
}, async (args) => {
|
|
455
472
|
const data = await deleteOFWMessages(client, [args.messageId]);
|
|
456
|
-
deleteDraft(args.messageId);
|
|
473
|
+
await cacheProvider().deleteDraft(args.messageId);
|
|
457
474
|
return data ? jsonResponse(data) : textResponse('Draft deleted.');
|
|
458
475
|
});
|
|
459
476
|
server.registerTool('ofw_get_unread_sent', {
|
|
@@ -466,7 +483,7 @@ export function registerMessageTools(server, client) {
|
|
|
466
483
|
}, async (args) => {
|
|
467
484
|
const page = args.page ?? 1;
|
|
468
485
|
const size = args.size ?? 50;
|
|
469
|
-
const sent = listMessages({ folder: 'sent', page, size });
|
|
486
|
+
const sent = await cacheProvider().listMessages({ folder: 'sent', page, size });
|
|
470
487
|
if (sent.length === 0) {
|
|
471
488
|
return jsonResponse({ note: 'Sent cache is empty. Call ofw_sync_messages to populate.' });
|
|
472
489
|
}
|
|
@@ -493,16 +510,12 @@ export function registerMessageTools(server, client) {
|
|
|
493
510
|
description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
|
|
494
511
|
},
|
|
495
512
|
}, async (args) => {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
throw new Error(`Not a file: ${abs}`);
|
|
500
|
-
const fileName = basename(abs);
|
|
501
|
-
const mime = mimeFromName(fileName);
|
|
513
|
+
// Resolve the upload source through the injected attachment-I/O boundary
|
|
514
|
+
// (disk read on node; an in-memory source on the hosted connector).
|
|
515
|
+
const { blob, fileName, mimeType: mime, sizeBytes } = await attachmentIO.resolveUpload(args.path);
|
|
502
516
|
// Build the multipart payload matching the OFW web UI's request shape.
|
|
503
517
|
const form = new FormData();
|
|
504
|
-
|
|
505
|
-
form.append('file', await fileBlob(abs, { type: mime }), fileName);
|
|
518
|
+
form.append('file', blob, fileName);
|
|
506
519
|
form.append('source', 'message');
|
|
507
520
|
form.append('description', args.description ?? fileName);
|
|
508
521
|
form.append('label', args.label ?? fileName);
|
|
@@ -512,12 +525,12 @@ export function registerMessageTools(server, client) {
|
|
|
512
525
|
// Cache metadata so subsequent ofw_get_message calls can surface it and
|
|
513
526
|
// ofw_download_attachment can short-circuit. messageId is 0 (the
|
|
514
527
|
// not-yet-linked sentinel) until a message actually references this file.
|
|
515
|
-
upsertAttachmentForMessage({
|
|
528
|
+
await cacheProvider().upsertAttachmentForMessage({
|
|
516
529
|
fileId: meta.fileId,
|
|
517
530
|
fileName: meta.fileName ?? fileName,
|
|
518
531
|
label: meta.label ?? args.label ?? fileName,
|
|
519
532
|
mimeType: meta.fileType ?? mime,
|
|
520
|
-
sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes :
|
|
533
|
+
sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : sizeBytes,
|
|
521
534
|
metadata: meta,
|
|
522
535
|
messageId: 0,
|
|
523
536
|
});
|
|
@@ -525,7 +538,7 @@ export function registerMessageTools(server, client) {
|
|
|
525
538
|
fileId: meta.fileId,
|
|
526
539
|
fileName: meta.fileName ?? fileName,
|
|
527
540
|
mimeType: meta.fileType ?? mime,
|
|
528
|
-
sizeBytes: meta.sizeInBytes ??
|
|
541
|
+
sizeBytes: meta.sizeInBytes ?? sizeBytes,
|
|
529
542
|
shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
|
|
530
543
|
note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
|
|
531
544
|
});
|
|
@@ -541,13 +554,14 @@ export function registerMessageTools(server, client) {
|
|
|
541
554
|
},
|
|
542
555
|
}, async (args) => {
|
|
543
556
|
const fileId = args.fileId;
|
|
557
|
+
const cache = cacheProvider();
|
|
544
558
|
const inline = args.inline ?? getDefaultInlineAttachments();
|
|
545
|
-
let cached = getAttachment(fileId);
|
|
559
|
+
let cached = await cache.getAttachment(fileId);
|
|
546
560
|
if (!cached) {
|
|
547
561
|
// Not in cache. Fetch metadata and store under the messageId=0
|
|
548
562
|
// sentinel — gets re-linked if a message later references this file.
|
|
549
|
-
await fetchAttachmentMeta(client, fileId, 0);
|
|
550
|
-
cached = getAttachment(fileId);
|
|
563
|
+
await fetchAttachmentMeta(client, fileId, 0, cache);
|
|
564
|
+
cached = await cache.getAttachment(fileId);
|
|
551
565
|
/* v8 ignore next -- fetchAttachmentMeta persists the row it just fetched; a still-null read here is an unreachable storage failure */
|
|
552
566
|
if (!cached)
|
|
553
567
|
throw new Error(`failed to fetch metadata for fileId ${fileId}`);
|
|
@@ -558,10 +572,7 @@ export function registerMessageTools(server, client) {
|
|
|
558
572
|
let mimeType = cached.mimeType;
|
|
559
573
|
let fileName = cached.fileName;
|
|
560
574
|
if (cached.downloadedPath) {
|
|
561
|
-
|
|
562
|
-
bytes = readFileSync(cached.downloadedPath);
|
|
563
|
-
}
|
|
564
|
-
catch { /* on-disk copy missing; fall through */ }
|
|
575
|
+
bytes = attachmentIO.readDownloaded(cached.downloadedPath);
|
|
565
576
|
}
|
|
566
577
|
if (bytes === null) {
|
|
567
578
|
const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
|
|
@@ -604,9 +615,8 @@ export function registerMessageTools(server, client) {
|
|
|
604
615
|
});
|
|
605
616
|
}
|
|
606
617
|
const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
markAttachmentDownloaded(fileId, dest);
|
|
618
|
+
attachmentIO.writeDownload(dest, response.body);
|
|
619
|
+
await cache.markAttachmentDownloaded(fileId, dest);
|
|
610
620
|
return jsonResponse({
|
|
611
621
|
fileId,
|
|
612
622
|
path: dest,
|
|
@@ -616,19 +626,21 @@ export function registerMessageTools(server, client) {
|
|
|
616
626
|
});
|
|
617
627
|
});
|
|
618
628
|
server.registerTool('ofw_sync_messages', {
|
|
619
|
-
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).',
|
|
629
|
+
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. EVERY call re-checks the newest page first, so new messages are picked up promptly even while an old-history backfill is still running; only then does it spend what is left of its budget advancing that backfill. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps). Sync is BOUNDED and RESUMABLE: on hosted deployments a per-call OFW-request budget (env OFW_SYNC_MAX_REQUESTS, or the maxRequests argument) caps how far one call walks; when the budget is hit the response reports done:false with a note — call again with the SAME arguments to resume. done:false means older history is still being backfilled; it does NOT mean recent messages are missing. Local installs are unbounded by default (done is always true).',
|
|
620
630
|
annotations: { readOnlyHint: false },
|
|
621
631
|
inputSchema: {
|
|
622
632
|
folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).describe('Folders to sync (default: all three)').optional(),
|
|
623
633
|
fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.').optional(),
|
|
624
634
|
deep: z.boolean().describe('If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.').optional(),
|
|
635
|
+
maxRequests: z.number().int().min(1).describe('Maximum OFW requests this single call may make before pausing. When hit, the response reports done:false — call again with the same arguments to continue. Omit to use the server default (OFW_SYNC_MAX_REQUESTS, or unbounded on local installs).').optional(),
|
|
625
636
|
},
|
|
626
637
|
}, async (args) => {
|
|
627
638
|
const result = await syncAll(client, {
|
|
628
639
|
folders: args.folders,
|
|
629
640
|
fetchUnreadBodies: args.fetchUnreadBodies,
|
|
630
641
|
deep: args.deep,
|
|
631
|
-
|
|
642
|
+
maxRequests: args.maxRequests ?? getSyncMaxRequests(),
|
|
643
|
+
}, cacheProvider());
|
|
632
644
|
return jsonResponse(result);
|
|
633
645
|
});
|
|
634
646
|
}
|