ofw-mcp 2.5.0 → 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 +11 -0
- package/dist/auth-password.js +8 -1
- package/dist/bundle.js +878 -519
- package/dist/cache/node.js +85 -0
- package/dist/cache/store.js +480 -0
- package/dist/client.js +22 -4
- package/dist/config.js +21 -0
- package/dist/index.js +13 -2
- package/dist/ofw-auth.js +26 -0
- package/dist/sync.js +241 -60
- package/dist/tools/attachments.js +66 -0
- package/dist/tools/messages.js +67 -81
- package/package.json +12 -3
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +2 -0
- package/dist/cache.js +0 -345
package/dist/ofw-auth.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { loginWithPassword } from './auth-password.js';
|
|
2
|
+
/**
|
|
3
|
+
* `ConnectorAuth` for the OurFamilyWizard remote connector: the login page
|
|
4
|
+
* collects the user's own OFW email/username + password, verifies them via the
|
|
5
|
+
* same Spring Security form login the stdio server uses (`loginWithPassword` in
|
|
6
|
+
* `auth-password.js`), and stores `{ username, password }` as the OAuth props
|
|
7
|
+
* that `worker.ts`'s `buildClient` turns into a per-user `OFWClient` capable of
|
|
8
|
+
* re-authenticating when its 6h token expires.
|
|
9
|
+
*/
|
|
10
|
+
export const ofwAuth = {
|
|
11
|
+
service: 'OurFamilyWizard',
|
|
12
|
+
accent: '#00A9A5',
|
|
13
|
+
privacyNote: 'Your OFW email and password are stored encrypted and used only to sign in to OurFamilyWizard on your behalf ' +
|
|
14
|
+
'(OFW sign-in tokens expire every few hours, so your password is needed to renew them).',
|
|
15
|
+
fields: [
|
|
16
|
+
{ name: 'username', label: 'OFW email or username' },
|
|
17
|
+
{ name: 'password', label: 'OFW password', type: 'password' },
|
|
18
|
+
],
|
|
19
|
+
async login(fields) {
|
|
20
|
+
// Verify the credentials up front — a bad password throws here, which the
|
|
21
|
+
// connector surfaces back on the login page. We deliberately discard the
|
|
22
|
+
// returned token: the per-user client logs in again from the stored creds.
|
|
23
|
+
await loginWithPassword(fields.username, fields.password);
|
|
24
|
+
return { username: fields.username, password: fields.password };
|
|
25
|
+
},
|
|
26
|
+
};
|
package/dist/sync.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { setMeta, upsertMessage, getMessage, deleteMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
|
|
2
1
|
import { z } from 'zod';
|
|
3
2
|
import { ApiRecipientSchema, hasRealView, mapRecipients } from './tools/_shared.js';
|
|
4
3
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
@@ -21,9 +20,9 @@ const FileMetaSchema = z.looseObject({
|
|
|
21
20
|
// Throws on network/HTTP errors — callers in bulk-sync paths wrap this in the
|
|
22
21
|
// best-effort helper below; callers that need the result (download tool) let
|
|
23
22
|
// the throw propagate.
|
|
24
|
-
export async function fetchAttachmentMeta(client, fileId, messageId) {
|
|
23
|
+
export async function fetchAttachmentMeta(client, fileId, messageId, store) {
|
|
25
24
|
const meta = parseLenient(FileMetaSchema, await client.request('GET', `/pub/v1/myfiles/${fileId}`), { label: 'ofw-mcp', context: 'GET /pub/v1/myfiles/{fileId}' });
|
|
26
|
-
upsertAttachmentForMessage({
|
|
25
|
+
await store.upsertAttachmentForMessage({
|
|
27
26
|
fileId: meta.fileId ?? fileId,
|
|
28
27
|
fileName: meta.fileName ?? `file-${fileId}`,
|
|
29
28
|
label: meta.label ?? meta.fileName ?? `file-${fileId}`,
|
|
@@ -33,17 +32,50 @@ export async function fetchAttachmentMeta(client, fileId, messageId) {
|
|
|
33
32
|
messageId,
|
|
34
33
|
});
|
|
35
34
|
}
|
|
36
|
-
export async function fetchAttachmentMetaForMessage(client, messageId, fileIds) {
|
|
35
|
+
export async function fetchAttachmentMetaForMessage(client, messageId, fileIds, store) {
|
|
37
36
|
// Fan out in parallel — each fetch is independent and the file id stays
|
|
38
37
|
// in listData on failure (model can retry via ofw_download_attachment,
|
|
39
38
|
// which surfaces the real error). Promise.allSettled so one bad
|
|
40
39
|
// attachment doesn't break the surrounding sync.
|
|
41
|
-
await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client, fid, messageId)));
|
|
40
|
+
await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client, fid, messageId, store)));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build a {@link Budget} that allows `max` requests. `Number.POSITIVE_INFINITY`
|
|
44
|
+
* (the local-stdio default) never exhausts — `take()` always returns true — so
|
|
45
|
+
* bounded logic collapses to the original unbounded walk.
|
|
46
|
+
*/
|
|
47
|
+
export function makeBudget(max) {
|
|
48
|
+
let remaining = max;
|
|
49
|
+
return {
|
|
50
|
+
take() {
|
|
51
|
+
if (remaining <= 0)
|
|
52
|
+
return false;
|
|
53
|
+
remaining -= 1;
|
|
54
|
+
return true;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// Budget-gated attachment-meta backfill. Spends one unit per file id it can
|
|
59
|
+
// afford (in order), skipping the rest, then fetches the affordable ones with
|
|
60
|
+
// the existing best-effort parallel helper. Attachment fetches are best-effort:
|
|
61
|
+
// a skipped file id stays in the message's listData and can be backfilled later
|
|
62
|
+
// by ofw_get_message. Under an infinite budget this fetches every file id — the
|
|
63
|
+
// unbounded behaviour.
|
|
64
|
+
async function fetchAttachmentMetaBudgeted(client, messageId, fileIds, store, budget) {
|
|
65
|
+
const affordable = [];
|
|
66
|
+
for (const fid of fileIds) {
|
|
67
|
+
if (!budget.take())
|
|
68
|
+
break;
|
|
69
|
+
affordable.push(fid);
|
|
70
|
+
}
|
|
71
|
+
if (affordable.length > 0) {
|
|
72
|
+
await fetchAttachmentMetaForMessage(client, messageId, affordable, store);
|
|
73
|
+
}
|
|
42
74
|
}
|
|
43
75
|
const FoldersSchema = z.looseObject({
|
|
44
76
|
systemFolders: z.array(z.looseObject({ id: z.string(), folderType: z.string() })).optional(),
|
|
45
77
|
});
|
|
46
|
-
export async function resolveFolderIds(client) {
|
|
78
|
+
export async function resolveFolderIds(client, store) {
|
|
47
79
|
const data = parseLenient(FoldersSchema, await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true'), { label: 'ofw-mcp', context: 'GET /pub/v1/messageFolders' });
|
|
48
80
|
const sys = data.systemFolders ?? [];
|
|
49
81
|
const find = (type) => {
|
|
@@ -57,7 +89,11 @@ export async function resolveFolderIds(client) {
|
|
|
57
89
|
sent: find('SENT_MESSAGES'),
|
|
58
90
|
drafts: find('DRAFTS'),
|
|
59
91
|
};
|
|
60
|
-
setMeta('drafts_folder_id', ids.drafts);
|
|
92
|
+
await store.setMeta('drafts_folder_id', ids.drafts);
|
|
93
|
+
// Persist the sent folder id too: ofw_get_message's live-fetch path uses it to
|
|
94
|
+
// label an uncached message sent-vs-inbox from the detail payload's own folder
|
|
95
|
+
// id, instead of hard-defaulting to inbox.
|
|
96
|
+
await store.setMeta('sent_folder_id', ids.sent);
|
|
61
97
|
return ids;
|
|
62
98
|
}
|
|
63
99
|
// Required fields are the ones the sync loop reads unguarded (id keys the
|
|
@@ -79,22 +115,39 @@ const DetailResponseSchema = z.looseObject({
|
|
|
79
115
|
// endpoint only has an epoch placeholder) — used by the view-status refresh.
|
|
80
116
|
recipients: z.array(ApiRecipientSchema).optional(),
|
|
81
117
|
});
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
118
|
+
const maxId = (a, b) => a === null ? b : b === null ? a : Math.max(a, b);
|
|
119
|
+
/**
|
|
120
|
+
* Walk one folder's list pages from `startPage` toward older messages, caching
|
|
121
|
+
* what isn't cached yet. Stops on an empty page, when `stopAtCachedPage` says
|
|
122
|
+
* we've reached cached history, or when the request budget runs out.
|
|
123
|
+
*/
|
|
124
|
+
async function walkPages(client, folder, folderId, opts, store) {
|
|
125
|
+
const budget = opts.budget;
|
|
126
|
+
let page = opts.startPage;
|
|
85
127
|
let newestId = null;
|
|
128
|
+
let synced = 0;
|
|
86
129
|
const unread = [];
|
|
87
130
|
while (true) {
|
|
131
|
+
// One unit per list-page fetch. Out of budget → pause and resume at `page`.
|
|
132
|
+
if (!budget.take()) {
|
|
133
|
+
return { synced, unread, newestId, done: false, nextPage: page };
|
|
134
|
+
}
|
|
88
135
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
89
136
|
const list = parseLenient(ListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: `GET /pub/v3/messages?folders={${folder}}` });
|
|
90
137
|
const items = list.data ?? [];
|
|
91
|
-
if (items.length === 0)
|
|
92
|
-
|
|
138
|
+
if (items.length === 0) {
|
|
139
|
+
return { synced, unread, newestId, done: true, nextPage: null };
|
|
140
|
+
}
|
|
141
|
+
// One batch read of this page's ids (S1) instead of a per-item getMessage.
|
|
142
|
+
const existingById = new Map((await store.getMessages(items.map((it) => it.id))).map((row) => [row.id, row]));
|
|
143
|
+
// Rows created/updated this page, flushed in ONE batch upsert (S1).
|
|
144
|
+
const toUpsert = [];
|
|
93
145
|
let pageHadNewItem = false;
|
|
146
|
+
let pageBudgetHit = false;
|
|
94
147
|
for (const item of items) {
|
|
95
148
|
if (newestId === null || item.id > newestId)
|
|
96
149
|
newestId = item.id;
|
|
97
|
-
const existing =
|
|
150
|
+
const existing = existingById.get(item.id);
|
|
98
151
|
if (existing) {
|
|
99
152
|
// A sent message's read status changes AFTER it's first cached, when
|
|
100
153
|
// the recipient opens it — so we can't just skip existing rows. The
|
|
@@ -104,8 +157,12 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
|
104
157
|
// and we don't yet hold a real viewed time, re-fetch detail to capture
|
|
105
158
|
// it (no body re-fetch — only the recipient view fields can change).
|
|
106
159
|
if (folder === 'sent' && item.showNeverViewed === false && !hasRealView(existing.recipients)) {
|
|
160
|
+
if (!budget.take()) {
|
|
161
|
+
pageBudgetHit = true;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
107
164
|
const detail = parseLenient(DetailResponseSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (view-status refresh)' });
|
|
108
|
-
|
|
165
|
+
toUpsert.push({ ...existing, recipients: mapRecipients(detail.recipients), listData: item });
|
|
109
166
|
synced++;
|
|
110
167
|
}
|
|
111
168
|
continue;
|
|
@@ -116,10 +173,22 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
|
116
173
|
let body = null;
|
|
117
174
|
let fetchedBodyAt = null;
|
|
118
175
|
let detailFileIds = [];
|
|
176
|
+
// Prefer the DETAIL endpoint's recipients when we fetch it: the list only
|
|
177
|
+
// ever carries an epoch placeholder for `viewed.dateTime` (even on a read
|
|
178
|
+
// message), while detail carries the real "First Viewed" time. Building
|
|
179
|
+
// the row from the list would cache viewedAt:null for a message that was
|
|
180
|
+
// already read by the time we first saw it — reporting "never viewed" for
|
|
181
|
+
// a message OFW shows as read, until a later sync's refresh healed it.
|
|
182
|
+
let detailRecipients;
|
|
119
183
|
if (shouldFetchBody) {
|
|
184
|
+
if (!budget.take()) {
|
|
185
|
+
pageBudgetHit = true;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
120
188
|
const detail = parseLenient(DetailResponseSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (sync)' });
|
|
121
189
|
body = detail.body ?? '';
|
|
122
190
|
fetchedBodyAt = new Date().toISOString();
|
|
191
|
+
detailRecipients = detail.recipients;
|
|
123
192
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
124
193
|
detailFileIds = detail.files;
|
|
125
194
|
}
|
|
@@ -138,33 +207,109 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
|
138
207
|
subject: item.subject ?? '(no subject)',
|
|
139
208
|
fromUser: item.from?.name ?? '',
|
|
140
209
|
sentAt: item.date?.dateTime ?? new Date().toISOString(),
|
|
141
|
-
recipients: mapRecipients(item.recipients),
|
|
210
|
+
recipients: mapRecipients(detailRecipients ?? item.recipients),
|
|
142
211
|
body,
|
|
143
212
|
fetchedBodyAt,
|
|
144
213
|
replyToId: null,
|
|
145
214
|
chainRootId: null,
|
|
146
215
|
listData: item,
|
|
147
216
|
};
|
|
148
|
-
|
|
217
|
+
toUpsert.push(row);
|
|
149
218
|
synced++;
|
|
150
219
|
if (detailFileIds.length > 0) {
|
|
151
|
-
await
|
|
220
|
+
await fetchAttachmentMetaBudgeted(client, item.id, detailFileIds, store, budget);
|
|
152
221
|
}
|
|
153
222
|
}
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
223
|
+
// Flush the page's rows in one transaction/RPC. Empty array is a no-op.
|
|
224
|
+
await store.upsertMessages(toUpsert);
|
|
225
|
+
if (pageBudgetHit) {
|
|
226
|
+
// Paused mid-page. Resume at THIS page: the partial rows are cached, so
|
|
227
|
+
// getMessages skips them next time and upserts are idempotent.
|
|
228
|
+
return { synced, unread, newestId, done: false, nextPage: page };
|
|
229
|
+
}
|
|
230
|
+
// Reached cached history (see `stopAtCachedPage`). Report THIS page as the
|
|
231
|
+
// resume point rather than the next one: it costs one redundant (cheap,
|
|
232
|
+
// all-cached) fetch if a backfill later starts here, and it cannot skip a
|
|
233
|
+
// message the way an off-by-one `page + 1` could.
|
|
234
|
+
if (opts.stopAtCachedPage && !pageHadNewItem) {
|
|
235
|
+
return { synced, unread, newestId, done: true, nextPage: page };
|
|
236
|
+
}
|
|
161
237
|
page++;
|
|
162
238
|
}
|
|
163
|
-
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Sync one message folder. Runs two independent passes so that a long backfill
|
|
242
|
+
* can never starve new messages:
|
|
243
|
+
*
|
|
244
|
+
* 1. FORWARD — always from page 1, every call, regardless of how deep a
|
|
245
|
+
* backfill is parked. This is what guarantees a message sent or received
|
|
246
|
+
* since the last sync is cached by the next ordinary call. Once caught up
|
|
247
|
+
* it costs a single request: page 1 holds nothing new, so it stops there.
|
|
248
|
+
* 2. BACKFILL — resumes the parked cursor (or, for `deep`, walks past where
|
|
249
|
+
* the forward pass stopped) with whatever budget the forward pass left,
|
|
250
|
+
* and re-parks the cursor if it pauses again.
|
|
251
|
+
*
|
|
252
|
+
* Both passes share one budget, and the forward pass draws first: the newest
|
|
253
|
+
* messages are the ones a caller is most likely to need, and history that has
|
|
254
|
+
* waited months can wait one more call.
|
|
255
|
+
*/
|
|
256
|
+
export async function syncMessageFolder(client, folder, folderId, opts, store) {
|
|
257
|
+
// No budget → unbounded (local stdio): every take() succeeds, so an ordinary
|
|
258
|
+
// sync is byte-for-byte the original unbounded walk (forward pass only).
|
|
259
|
+
const budget = opts.budget ?? makeBudget(Number.POSITIVE_INFINITY);
|
|
260
|
+
const saved = await store.getSyncState(folder);
|
|
261
|
+
const savedResume = saved?.resumePage ?? null;
|
|
262
|
+
const fwd = await walkPages(client, folder, folderId, {
|
|
263
|
+
startPage: 1,
|
|
264
|
+
stopAtCachedPage: true,
|
|
265
|
+
fetchUnreadBodies: opts.fetchUnreadBodies,
|
|
266
|
+
budget,
|
|
267
|
+
}, store);
|
|
268
|
+
let synced = fwd.synced;
|
|
269
|
+
const unread = [...fwd.unread];
|
|
270
|
+
// Never regress the folder's newest id: a pass that only walked cached pages
|
|
271
|
+
// still saw page 1, but a paused one may not have.
|
|
272
|
+
let newestId = maxId(saved?.newestId ?? null, fwd.newestId);
|
|
273
|
+
let done;
|
|
274
|
+
let resumePage;
|
|
275
|
+
if (!fwd.done) {
|
|
276
|
+
// The forward pass itself ran out of budget, so it never reached cached
|
|
277
|
+
// history — everything from `fwd.nextPage` down is unverified. Park the
|
|
278
|
+
// backfill at whichever cursor is higher up the folder, so no page that
|
|
279
|
+
// still owes us messages ends up above the resume point.
|
|
280
|
+
done = false;
|
|
281
|
+
resumePage = savedResume === null ? fwd.nextPage : Math.min(fwd.nextPage, savedResume);
|
|
282
|
+
}
|
|
283
|
+
else if (fwd.nextPage === null) {
|
|
284
|
+
// The forward pass walked clean off the end of the folder — by definition
|
|
285
|
+
// there is no older history left to backfill.
|
|
286
|
+
done = true;
|
|
287
|
+
resumePage = null;
|
|
288
|
+
}
|
|
289
|
+
else if (savedResume === null && !opts.deep) {
|
|
290
|
+
// Ordinary incremental sync with no backfill parked: caught up.
|
|
291
|
+
done = true;
|
|
292
|
+
resumePage = null;
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
const bf = await walkPages(client, folder, folderId, {
|
|
296
|
+
startPage: savedResume ?? fwd.nextPage,
|
|
297
|
+
stopAtCachedPage: false,
|
|
298
|
+
fetchUnreadBodies: opts.fetchUnreadBodies,
|
|
299
|
+
budget,
|
|
300
|
+
}, store);
|
|
301
|
+
synced += bf.synced;
|
|
302
|
+
unread.push(...bf.unread);
|
|
303
|
+
newestId = maxId(newestId, bf.newestId);
|
|
304
|
+
done = bf.done;
|
|
305
|
+
resumePage = bf.done ? null : bf.nextPage;
|
|
306
|
+
}
|
|
307
|
+
await store.setSyncState(folder, {
|
|
164
308
|
lastSyncAt: new Date().toISOString(),
|
|
165
309
|
newestId,
|
|
310
|
+
resumePage,
|
|
166
311
|
});
|
|
167
|
-
return { synced, unread };
|
|
312
|
+
return { synced, unread, done };
|
|
168
313
|
}
|
|
169
314
|
const DraftListItemSchema = z.looseObject({
|
|
170
315
|
id: z.number(),
|
|
@@ -178,13 +323,21 @@ const DraftDetailSchema = z.looseObject({
|
|
|
178
323
|
body: z.string().optional(),
|
|
179
324
|
subject: z.string().optional(),
|
|
180
325
|
});
|
|
181
|
-
export async function syncDrafts(client, draftsFolderId) {
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
//
|
|
326
|
+
export async function syncDrafts(client, draftsFolderId, store, budget) {
|
|
327
|
+
// No budget → unbounded (local stdio): identical to the original walk.
|
|
328
|
+
const b = budget ?? makeBudget(Number.POSITIVE_INFINITY);
|
|
329
|
+
// The reconciliation step below DELETES any cached draft not seen in the
|
|
330
|
+
// listing, so a partial walk must apply NOTHING. We therefore buffer the
|
|
331
|
+
// entire walk (all list pages + every detail) BEFORE touching the cache: if
|
|
332
|
+
// the budget can't fund the whole walk we discard the buffer and defer the
|
|
333
|
+
// drafts folder to a later call (done:false). The OFW requests already spent
|
|
334
|
+
// still count against the budget; drafts are few, so a discarded partial is
|
|
335
|
+
// cheap and — crucially — never evicts a real draft.
|
|
185
336
|
const items = [];
|
|
186
337
|
let page = 1;
|
|
187
338
|
while (true) {
|
|
339
|
+
if (!b.take())
|
|
340
|
+
return { synced: 0, done: false };
|
|
188
341
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
189
342
|
const list = parseLenient(DraftListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: 'GET /pub/v3/messages?folders={drafts}' });
|
|
190
343
|
const pageItems = list.data ?? [];
|
|
@@ -193,32 +346,38 @@ export async function syncDrafts(client, draftsFolderId) {
|
|
|
193
346
|
break;
|
|
194
347
|
page++;
|
|
195
348
|
}
|
|
196
|
-
|
|
197
|
-
|
|
349
|
+
// Fetch every draft's detail up front, still buffered. OFW's list
|
|
350
|
+
// `date.dateTime` is NOT a reliable modification timestamp for drafts —
|
|
351
|
+
// direct UI edits don't bump it — so we can't skip the detail fetch.
|
|
352
|
+
const rows = [];
|
|
198
353
|
for (const item of items) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
// OFW's list endpoint's `date.dateTime` is NOT a reliable modification
|
|
202
|
-
// timestamp for drafts — direct UI edits don't bump it — so we can't
|
|
203
|
-
// use it to skip the detail fetch. Always re-fetch; drafts are few.
|
|
204
|
-
const existing = getDraft(item.id);
|
|
354
|
+
if (!b.take())
|
|
355
|
+
return { synced: 0, done: false };
|
|
205
356
|
const detail = parseLenient(DraftDetailSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (drafts sync)' });
|
|
206
|
-
|
|
357
|
+
rows.push({
|
|
207
358
|
id: item.id,
|
|
208
359
|
subject: detail.subject ?? item.subject ?? '(no subject)',
|
|
209
360
|
body: detail.body ?? '',
|
|
210
361
|
recipients: mapRecipients(item.recipients),
|
|
211
362
|
replyToId: item.replyToId ?? null,
|
|
212
|
-
modifiedAt,
|
|
363
|
+
modifiedAt: item.date?.dateTime ?? new Date().toISOString(),
|
|
213
364
|
listData: item,
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
// Budget funded the whole walk — apply atomically. Batch reads (S1) snapshot
|
|
368
|
+
// pre-upsert state for the synced-count comparison and stale-row eviction.
|
|
369
|
+
const ids = items.map((it) => it.id);
|
|
370
|
+
const existingById = new Map((await store.getDrafts(ids)).map((d) => [d.id, d]));
|
|
371
|
+
await store.upsertDrafts(rows);
|
|
372
|
+
// If a stale `messages` row exists for a draft id (cached by a prior
|
|
373
|
+
// ofw_get_message call before the drafts table knew about this id), evict it.
|
|
374
|
+
// The drafts table is the source of truth for drafts.
|
|
375
|
+
for (const stale of await store.getMessages(ids)) {
|
|
376
|
+
await store.deleteMessage(stale.id);
|
|
377
|
+
}
|
|
378
|
+
let synced = 0;
|
|
379
|
+
for (const row of rows) {
|
|
380
|
+
const existing = existingById.get(row.id);
|
|
222
381
|
if (!existing
|
|
223
382
|
|| existing.body !== row.body
|
|
224
383
|
|| existing.subject !== row.subject
|
|
@@ -226,40 +385,62 @@ export async function syncDrafts(client, draftsFolderId) {
|
|
|
226
385
|
synced++;
|
|
227
386
|
}
|
|
228
387
|
}
|
|
229
|
-
|
|
388
|
+
const seenIds = new Set(ids);
|
|
389
|
+
for (const id of await store.listDraftIds()) {
|
|
230
390
|
if (!seenIds.has(id))
|
|
231
|
-
deleteDraft(id);
|
|
391
|
+
await store.deleteDraft(id);
|
|
232
392
|
}
|
|
233
|
-
return { synced };
|
|
393
|
+
return { synced, done: true };
|
|
234
394
|
}
|
|
235
|
-
export async function syncAll(client, opts) {
|
|
395
|
+
export async function syncAll(client, opts, store) {
|
|
236
396
|
const folders = opts.folders ?? ['inbox', 'sent', 'drafts'];
|
|
237
|
-
|
|
397
|
+
// ONE budget shared across resolveFolderIds and every requested folder, in
|
|
398
|
+
// order — so the whole invocation stays under the hosting subrequest cap.
|
|
399
|
+
const budget = makeBudget(opts.maxRequests ?? Number.POSITIVE_INFINITY);
|
|
400
|
+
// resolveFolderIds always makes exactly one request; the tool guarantees
|
|
401
|
+
// maxRequests >= 1, so this unit is always available (result intentionally
|
|
402
|
+
// ignored — we account for it without a branch that can't be reached).
|
|
403
|
+
budget.take();
|
|
404
|
+
const ids = await resolveFolderIds(client, store);
|
|
238
405
|
const synced = {};
|
|
239
406
|
let unreadInbox = [];
|
|
407
|
+
let done = true;
|
|
240
408
|
for (const folder of folders) {
|
|
241
409
|
if (folder === 'inbox') {
|
|
242
410
|
const r = await syncMessageFolder(client, 'inbox', ids.inbox, {
|
|
243
411
|
fetchUnreadBodies: opts.fetchUnreadBodies ?? false,
|
|
244
412
|
deep: opts.deep ?? false,
|
|
245
|
-
|
|
413
|
+
budget,
|
|
414
|
+
}, store);
|
|
246
415
|
synced.inbox = r.synced;
|
|
247
416
|
unreadInbox = r.unread;
|
|
417
|
+
if (!r.done)
|
|
418
|
+
done = false;
|
|
248
419
|
}
|
|
249
420
|
else if (folder === 'sent') {
|
|
250
421
|
const r = await syncMessageFolder(client, 'sent', ids.sent, {
|
|
251
422
|
fetchUnreadBodies: false,
|
|
252
423
|
deep: opts.deep ?? false,
|
|
253
|
-
|
|
424
|
+
budget,
|
|
425
|
+
}, store);
|
|
254
426
|
synced.sent = r.synced;
|
|
427
|
+
if (!r.done)
|
|
428
|
+
done = false;
|
|
255
429
|
}
|
|
256
430
|
else if (folder === 'drafts') {
|
|
257
|
-
const r = await syncDrafts(client, ids.drafts);
|
|
431
|
+
const r = await syncDrafts(client, ids.drafts, store, budget);
|
|
258
432
|
synced.drafts = r.synced;
|
|
433
|
+
if (!r.done)
|
|
434
|
+
done = false;
|
|
259
435
|
}
|
|
260
436
|
}
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
437
|
+
const notes = [];
|
|
438
|
+
if (unreadInbox.length > 0) {
|
|
439
|
+
notes.push(`${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them — this will mark them as read on OFW.`);
|
|
440
|
+
}
|
|
441
|
+
if (!done) {
|
|
442
|
+
notes.push('Paused after the request budget to stay within the hosting limit; more pages remain — call ofw_sync_messages again with the same arguments to resume where it left off and continue the backfill.');
|
|
443
|
+
}
|
|
444
|
+
const note = notes.length > 0 ? notes.join('\n\n') : undefined;
|
|
445
|
+
return { synced, unreadInbox, done, ...(note ? { note } : {}) };
|
|
265
446
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The attachment-I/O boundary for the message tools.
|
|
2
|
+
//
|
|
3
|
+
// `ofw_upload_attachment` reads a local file off disk; `ofw_download_attachment`
|
|
4
|
+
// writes downloaded bytes to disk (and reads them back for the inline-reuse
|
|
5
|
+
// path). Those are the ONLY node:fs touch points in the message tools — they
|
|
6
|
+
// live behind this {@link AttachmentIO} interface so the stdio server can use
|
|
7
|
+
// the disk-backed {@link NodeAttachmentIO} while the hosted Cloudflare
|
|
8
|
+
// connector (a later task) injects an inline, filesystem-free implementation.
|
|
9
|
+
// Keeping the interface here means src/tools/messages.ts imports nothing from
|
|
10
|
+
// node:fs.
|
|
11
|
+
import { readFileSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { basename, dirname, extname } from 'node:path';
|
|
13
|
+
import { fileBlob, expandPath } from '@chrischall/mcp-utils';
|
|
14
|
+
// Lightweight mime sniff from extension. OFW re-derives mime from the filename
|
|
15
|
+
// server-side anyway, so this is just a polite Content-Type for the Blob.
|
|
16
|
+
const MIME_BY_EXT = {
|
|
17
|
+
'.pdf': 'application/pdf',
|
|
18
|
+
'.png': 'image/png',
|
|
19
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
20
|
+
'.gif': 'image/gif',
|
|
21
|
+
'.webp': 'image/webp',
|
|
22
|
+
'.heic': 'image/heic',
|
|
23
|
+
'.txt': 'text/plain',
|
|
24
|
+
'.md': 'text/markdown',
|
|
25
|
+
'.csv': 'text/csv',
|
|
26
|
+
'.html': 'text/html', '.htm': 'text/html',
|
|
27
|
+
'.json': 'application/json',
|
|
28
|
+
'.xml': 'application/xml',
|
|
29
|
+
'.doc': 'application/msword',
|
|
30
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
31
|
+
'.xls': 'application/vnd.ms-excel',
|
|
32
|
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
33
|
+
'.ppt': 'application/vnd.ms-powerpoint',
|
|
34
|
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
35
|
+
'.zip': 'application/zip',
|
|
36
|
+
'.ics': 'text/calendar',
|
|
37
|
+
};
|
|
38
|
+
export function mimeFromName(name) {
|
|
39
|
+
return MIME_BY_EXT[extname(name).toLowerCase()] ?? 'application/octet-stream';
|
|
40
|
+
}
|
|
41
|
+
/** Disk-backed attachment I/O for the stdio/desktop server. */
|
|
42
|
+
export class NodeAttachmentIO {
|
|
43
|
+
async resolveUpload(path) {
|
|
44
|
+
const abs = expandPath(path);
|
|
45
|
+
const stat = statSync(abs); // throws if missing
|
|
46
|
+
if (!stat.isFile())
|
|
47
|
+
throw new Error(`Not a file: ${abs}`);
|
|
48
|
+
const fileName = basename(abs);
|
|
49
|
+
const mimeType = mimeFromName(fileName);
|
|
50
|
+
// fileBlob streams the file off disk (a file-backed Blob) instead of buffering it.
|
|
51
|
+
const blob = await fileBlob(abs, { type: mimeType });
|
|
52
|
+
return { blob, fileName, mimeType, sizeBytes: stat.size };
|
|
53
|
+
}
|
|
54
|
+
readDownloaded(path) {
|
|
55
|
+
try {
|
|
56
|
+
return readFileSync(path);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
writeDownload(dest, bytes) {
|
|
63
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
64
|
+
writeFileSync(dest, bytes);
|
|
65
|
+
}
|
|
66
|
+
}
|