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/index.js
CHANGED
|
@@ -16,6 +16,17 @@ import { registerMessageTools } from './tools/messages.js';
|
|
|
16
16
|
import { registerCalendarTools } from './tools/calendar.js';
|
|
17
17
|
import { registerExpenseTools } from './tools/expenses.js';
|
|
18
18
|
import { registerJournalTools } from './tools/journal.js';
|
|
19
|
+
import { OFWCache } from './cache/node.js';
|
|
20
|
+
import { getCacheDbPath } from './config.js';
|
|
21
|
+
import { NodeAttachmentIO } from './tools/attachments.js';
|
|
22
|
+
// The stdio server backs the message cache with a local `node:sqlite` file,
|
|
23
|
+
// opened lazily on first use (so the server still boots and answers the host's
|
|
24
|
+
// install-time tools/list probe when no cache path is configured). The hosted
|
|
25
|
+
// Cloudflare connector (a later task) injects a Durable-Object-backed
|
|
26
|
+
// CacheStore + a filesystem-free AttachmentIO into the same registrar instead.
|
|
27
|
+
let nodeCache;
|
|
28
|
+
const nodeCacheProvider = () => (nodeCache ??= OFWCache.open(getCacheDbPath()));
|
|
29
|
+
const nodeAttachmentIO = new NodeAttachmentIO();
|
|
19
30
|
// runMcp builds the McpServer, applies the registrars (with `client` threaded
|
|
20
31
|
// through as deps), prints the banner to stderr, wires SIGINT/SIGTERM graceful
|
|
21
32
|
// shutdown, and connects the stdio transport. The deferred-config-error pattern
|
|
@@ -24,11 +35,11 @@ import { registerJournalTools } from './tools/journal.js';
|
|
|
24
35
|
// always succeeds before any credential check runs.
|
|
25
36
|
await runMcp({
|
|
26
37
|
name: 'ofw',
|
|
27
|
-
version: '2.
|
|
38
|
+
version: '2.6.3', // x-release-please-version
|
|
28
39
|
deps: client,
|
|
29
40
|
tools: [
|
|
30
41
|
registerUserTools,
|
|
31
|
-
registerMessageTools,
|
|
42
|
+
(server, deps) => registerMessageTools(server, deps, nodeCacheProvider, nodeAttachmentIO),
|
|
32
43
|
registerCalendarTools,
|
|
33
44
|
registerExpenseTools,
|
|
34
45
|
registerJournalTools,
|
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,6 +1,5 @@
|
|
|
1
|
-
import { setMeta, upsertMessage, getMessage, deleteMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
|
|
2
1
|
import { z } from 'zod';
|
|
3
|
-
import { ApiRecipientSchema, mapRecipients } from './tools/_shared.js';
|
|
2
|
+
import { ApiRecipientSchema, hasRealView, mapRecipients } from './tools/_shared.js';
|
|
4
3
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
5
4
|
// Each OFW message detail returns `files: [fileId, ...]`. We fetch the metadata
|
|
6
5
|
// for each file id (cheap JSON call) so the model can see filenames/mime types
|
|
@@ -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
|
|
@@ -75,35 +111,84 @@ const ListResponseSchema = z.looseObject({ data: z.array(ListItemSchema).optiona
|
|
|
75
111
|
const DetailResponseSchema = z.looseObject({
|
|
76
112
|
body: z.string().optional(),
|
|
77
113
|
files: z.array(z.number()).optional(),
|
|
114
|
+
// The detail endpoint carries the REAL recipient view timestamps (the list
|
|
115
|
+
// endpoint only has an epoch placeholder) — used by the view-status refresh.
|
|
116
|
+
recipients: z.array(ApiRecipientSchema).optional(),
|
|
78
117
|
});
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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;
|
|
82
127
|
let newestId = null;
|
|
128
|
+
let synced = 0;
|
|
83
129
|
const unread = [];
|
|
84
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
|
+
}
|
|
85
135
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
86
136
|
const list = parseLenient(ListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: `GET /pub/v3/messages?folders={${folder}}` });
|
|
87
137
|
const items = list.data ?? [];
|
|
88
|
-
if (items.length === 0)
|
|
89
|
-
|
|
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 = [];
|
|
90
145
|
let pageHadNewItem = false;
|
|
146
|
+
let pageBudgetHit = false;
|
|
91
147
|
for (const item of items) {
|
|
92
148
|
if (newestId === null || item.id > newestId)
|
|
93
149
|
newestId = item.id;
|
|
94
|
-
const existing =
|
|
95
|
-
if (existing)
|
|
150
|
+
const existing = existingById.get(item.id);
|
|
151
|
+
if (existing) {
|
|
152
|
+
// A sent message's read status changes AFTER it's first cached, when
|
|
153
|
+
// the recipient opens it — so we can't just skip existing rows. The
|
|
154
|
+
// list item carries the reliable `showNeverViewed` boolean but only an
|
|
155
|
+
// epoch placeholder for the timestamp; the real "First Viewed" time is
|
|
156
|
+
// on the detail endpoint. So when a sent message has flipped to read
|
|
157
|
+
// and we don't yet hold a real viewed time, re-fetch detail to capture
|
|
158
|
+
// it (no body re-fetch — only the recipient view fields can change).
|
|
159
|
+
if (folder === 'sent' && item.showNeverViewed === false && !hasRealView(existing.recipients)) {
|
|
160
|
+
if (!budget.take()) {
|
|
161
|
+
pageBudgetHit = true;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
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)' });
|
|
165
|
+
toUpsert.push({ ...existing, recipients: mapRecipients(detail.recipients), listData: item });
|
|
166
|
+
synced++;
|
|
167
|
+
}
|
|
96
168
|
continue;
|
|
169
|
+
}
|
|
97
170
|
pageHadNewItem = true;
|
|
98
171
|
const isInboxUnread = folder === 'inbox' && item.showNeverViewed === true;
|
|
99
172
|
const shouldFetchBody = !isInboxUnread || opts.fetchUnreadBodies;
|
|
100
173
|
let body = null;
|
|
101
174
|
let fetchedBodyAt = null;
|
|
102
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;
|
|
103
183
|
if (shouldFetchBody) {
|
|
184
|
+
if (!budget.take()) {
|
|
185
|
+
pageBudgetHit = true;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
104
188
|
const detail = parseLenient(DetailResponseSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (sync)' });
|
|
105
189
|
body = detail.body ?? '';
|
|
106
190
|
fetchedBodyAt = new Date().toISOString();
|
|
191
|
+
detailRecipients = detail.recipients;
|
|
107
192
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
108
193
|
detailFileIds = detail.files;
|
|
109
194
|
}
|
|
@@ -122,33 +207,109 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
|
122
207
|
subject: item.subject ?? '(no subject)',
|
|
123
208
|
fromUser: item.from?.name ?? '',
|
|
124
209
|
sentAt: item.date?.dateTime ?? new Date().toISOString(),
|
|
125
|
-
recipients: mapRecipients(item.recipients),
|
|
210
|
+
recipients: mapRecipients(detailRecipients ?? item.recipients),
|
|
126
211
|
body,
|
|
127
212
|
fetchedBodyAt,
|
|
128
213
|
replyToId: null,
|
|
129
214
|
chainRootId: null,
|
|
130
215
|
listData: item,
|
|
131
216
|
};
|
|
132
|
-
|
|
217
|
+
toUpsert.push(row);
|
|
133
218
|
synced++;
|
|
134
219
|
if (detailFileIds.length > 0) {
|
|
135
|
-
await
|
|
220
|
+
await fetchAttachmentMetaBudgeted(client, item.id, detailFileIds, store, budget);
|
|
136
221
|
}
|
|
137
222
|
}
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
}
|
|
145
237
|
page++;
|
|
146
238
|
}
|
|
147
|
-
|
|
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, {
|
|
148
308
|
lastSyncAt: new Date().toISOString(),
|
|
149
309
|
newestId,
|
|
310
|
+
resumePage,
|
|
150
311
|
});
|
|
151
|
-
return { synced, unread };
|
|
312
|
+
return { synced, unread, done };
|
|
152
313
|
}
|
|
153
314
|
const DraftListItemSchema = z.looseObject({
|
|
154
315
|
id: z.number(),
|
|
@@ -162,13 +323,21 @@ const DraftDetailSchema = z.looseObject({
|
|
|
162
323
|
body: z.string().optional(),
|
|
163
324
|
subject: z.string().optional(),
|
|
164
325
|
});
|
|
165
|
-
export async function syncDrafts(client, draftsFolderId) {
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
//
|
|
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.
|
|
169
336
|
const items = [];
|
|
170
337
|
let page = 1;
|
|
171
338
|
while (true) {
|
|
339
|
+
if (!b.take())
|
|
340
|
+
return { synced: 0, done: false };
|
|
172
341
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
173
342
|
const list = parseLenient(DraftListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: 'GET /pub/v3/messages?folders={drafts}' });
|
|
174
343
|
const pageItems = list.data ?? [];
|
|
@@ -177,32 +346,38 @@ export async function syncDrafts(client, draftsFolderId) {
|
|
|
177
346
|
break;
|
|
178
347
|
page++;
|
|
179
348
|
}
|
|
180
|
-
|
|
181
|
-
|
|
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 = [];
|
|
182
353
|
for (const item of items) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// OFW's list endpoint's `date.dateTime` is NOT a reliable modification
|
|
186
|
-
// timestamp for drafts — direct UI edits don't bump it — so we can't
|
|
187
|
-
// use it to skip the detail fetch. Always re-fetch; drafts are few.
|
|
188
|
-
const existing = getDraft(item.id);
|
|
354
|
+
if (!b.take())
|
|
355
|
+
return { synced: 0, done: false };
|
|
189
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)' });
|
|
190
|
-
|
|
357
|
+
rows.push({
|
|
191
358
|
id: item.id,
|
|
192
359
|
subject: detail.subject ?? item.subject ?? '(no subject)',
|
|
193
360
|
body: detail.body ?? '',
|
|
194
361
|
recipients: mapRecipients(item.recipients),
|
|
195
362
|
replyToId: item.replyToId ?? null,
|
|
196
|
-
modifiedAt,
|
|
363
|
+
modifiedAt: item.date?.dateTime ?? new Date().toISOString(),
|
|
197
364
|
listData: item,
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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);
|
|
206
381
|
if (!existing
|
|
207
382
|
|| existing.body !== row.body
|
|
208
383
|
|| existing.subject !== row.subject
|
|
@@ -210,40 +385,62 @@ export async function syncDrafts(client, draftsFolderId) {
|
|
|
210
385
|
synced++;
|
|
211
386
|
}
|
|
212
387
|
}
|
|
213
|
-
|
|
388
|
+
const seenIds = new Set(ids);
|
|
389
|
+
for (const id of await store.listDraftIds()) {
|
|
214
390
|
if (!seenIds.has(id))
|
|
215
|
-
deleteDraft(id);
|
|
391
|
+
await store.deleteDraft(id);
|
|
216
392
|
}
|
|
217
|
-
return { synced };
|
|
393
|
+
return { synced, done: true };
|
|
218
394
|
}
|
|
219
|
-
export async function syncAll(client, opts) {
|
|
395
|
+
export async function syncAll(client, opts, store) {
|
|
220
396
|
const folders = opts.folders ?? ['inbox', 'sent', 'drafts'];
|
|
221
|
-
|
|
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);
|
|
222
405
|
const synced = {};
|
|
223
406
|
let unreadInbox = [];
|
|
407
|
+
let done = true;
|
|
224
408
|
for (const folder of folders) {
|
|
225
409
|
if (folder === 'inbox') {
|
|
226
410
|
const r = await syncMessageFolder(client, 'inbox', ids.inbox, {
|
|
227
411
|
fetchUnreadBodies: opts.fetchUnreadBodies ?? false,
|
|
228
412
|
deep: opts.deep ?? false,
|
|
229
|
-
|
|
413
|
+
budget,
|
|
414
|
+
}, store);
|
|
230
415
|
synced.inbox = r.synced;
|
|
231
416
|
unreadInbox = r.unread;
|
|
417
|
+
if (!r.done)
|
|
418
|
+
done = false;
|
|
232
419
|
}
|
|
233
420
|
else if (folder === 'sent') {
|
|
234
421
|
const r = await syncMessageFolder(client, 'sent', ids.sent, {
|
|
235
422
|
fetchUnreadBodies: false,
|
|
236
423
|
deep: opts.deep ?? false,
|
|
237
|
-
|
|
424
|
+
budget,
|
|
425
|
+
}, store);
|
|
238
426
|
synced.sent = r.synced;
|
|
427
|
+
if (!r.done)
|
|
428
|
+
done = false;
|
|
239
429
|
}
|
|
240
430
|
else if (folder === 'drafts') {
|
|
241
|
-
const r = await syncDrafts(client, ids.drafts);
|
|
431
|
+
const r = await syncDrafts(client, ids.drafts, store, budget);
|
|
242
432
|
synced.drafts = r.synced;
|
|
433
|
+
if (!r.done)
|
|
434
|
+
done = false;
|
|
243
435
|
}
|
|
244
436
|
}
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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 } : {}) };
|
|
249
446
|
}
|
package/dist/tools/_shared.js
CHANGED
|
@@ -15,13 +15,30 @@ export const ApiRecipientSchema = z.looseObject({
|
|
|
15
15
|
});
|
|
16
16
|
// Translates OFW API recipient shape into the cache's normalized Recipient.
|
|
17
17
|
// Used wherever we surface or persist recipients (sync, get_message, send,
|
|
18
|
-
// save_draft)
|
|
18
|
+
// save_draft).
|
|
19
|
+
//
|
|
20
|
+
// `viewedAt` is the recipient's true "First Viewed" time, or null if not yet
|
|
21
|
+
// viewed. Only the DETAIL endpoint (/pub/v3/messages/{id}) carries a real
|
|
22
|
+
// timestamp; the LIST endpoint returns an epoch-zero PLACEHOLDER
|
|
23
|
+
// ("1970-01-01T00:00:00") in the SAME field even for read messages (on the
|
|
24
|
+
// list, read status lives in `showNeverViewed`, not the timestamp). So treat
|
|
25
|
+
// the epoch placeholder as "no real view time" — otherwise a list-sourced row
|
|
26
|
+
// reports a bogus 1970 read time. A detail re-fetch is what fills in the truth.
|
|
19
27
|
export function mapRecipients(items) {
|
|
20
|
-
return (items ?? []).map((r) =>
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
})
|
|
28
|
+
return (items ?? []).map((r) => {
|
|
29
|
+
const dt = r.viewed?.dateTime;
|
|
30
|
+
const viewedAt = typeof dt === 'string' && !dt.startsWith('1970-01-01') ? dt : null;
|
|
31
|
+
return { userId: r.user?.id ?? 0, name: r.user?.name ?? '', viewedAt };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
// True if any recipient has a *real* "First Viewed" time — i.e. present and
|
|
35
|
+
// not the epoch-zero placeholder. After mapRecipients a fresh `viewedAt` is
|
|
36
|
+
// only ever a real timestamp or null, but a cache row written by older code
|
|
37
|
+
// (which trusted the list endpoint's `viewed`) may still hold the literal
|
|
38
|
+
// "1970-01-01T00:00:00". Treating that as "not viewed" lets sync/get_message
|
|
39
|
+
// re-fetch detail and self-heal the stale row to the real timestamp.
|
|
40
|
+
export function hasRealView(recipients) {
|
|
41
|
+
return recipients.some((r) => r.viewedAt !== null && !r.viewedAt.startsWith('1970-01-01'));
|
|
25
42
|
}
|
|
26
43
|
// Expand a user-provided path: ~ → home, relative → absolute. Re-exports
|
|
27
44
|
// @chrischall/mcp-utils' `expandPath`.
|
|
@@ -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
|
+
}
|