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.
@@ -0,0 +1,85 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { mkdirSync, chmodSync, existsSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { OFWCacheCore, LocalCacheStore } from './store.js';
5
+ // The `node:sqlite` backend for the OFW message cache — a local on-disk SQLite
6
+ // file used by the stdio/desktop server. The query logic lives in OFWCacheCore
7
+ // (src/cache/store.ts); this file only adapts `node:sqlite` to the SqlDriver
8
+ // surface and manages the file handle + permissions. (The hosted Cloudflare
9
+ // connector uses a Durable Object backend instead — a later task.)
10
+ /** Adapts a `node:sqlite` DatabaseSync to the driver surface the core needs. */
11
+ export class NodeSqlDriver {
12
+ db;
13
+ constructor(db) {
14
+ this.db = db;
15
+ }
16
+ execScript(sql) {
17
+ this.db.exec(sql);
18
+ }
19
+ run(sql, params) {
20
+ this.db.prepare(sql).run(...params);
21
+ }
22
+ get(sql, params) {
23
+ return this.db.prepare(sql).get(...params);
24
+ }
25
+ all(sql, params) {
26
+ return this.db.prepare(sql).all(...params);
27
+ }
28
+ transaction(fn) {
29
+ this.db.exec('BEGIN');
30
+ try {
31
+ fn();
32
+ this.db.exec('COMMIT');
33
+ }
34
+ catch (e) {
35
+ this.db.exec('ROLLBACK');
36
+ throw e;
37
+ }
38
+ }
39
+ }
40
+ // The cache holds full co-parenting message history — keep it private to the
41
+ // owning user. Modes are asserted on every open (not just creation): mkdirSync
42
+ // `mode` and SQLite's default file mode only apply when the path is first
43
+ // created, so a pre-existing dir/db keeps whatever (world-readable) mode it
44
+ // had. The -wal/-shm siblings appear and disappear with WAL checkpoints, hence
45
+ // the existence check.
46
+ export function enforceCachePermissions(dbPath) {
47
+ chmodSync(dirname(dbPath), 0o700);
48
+ chmodSync(dbPath, 0o600);
49
+ for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
50
+ if (existsSync(sibling))
51
+ chmodSync(sibling, 0o600);
52
+ }
53
+ }
54
+ /**
55
+ * A file-backed OFW message cache. `open()` creates parent dirs, locks the dir
56
+ * and DB down to 0700/0600, enables WAL, and applies the schema. Pass
57
+ * `:memory:` for an ephemeral in-memory cache (tests) — no dirs or chmod.
58
+ */
59
+ export class OFWCache extends LocalCacheStore {
60
+ db;
61
+ constructor(db, core) {
62
+ super(core);
63
+ this.db = db;
64
+ }
65
+ static open(path) {
66
+ const memory = path === ':memory:';
67
+ if (!memory)
68
+ mkdirSync(dirname(path), { recursive: true });
69
+ const db = new DatabaseSync(path);
70
+ // First pass: lock down dir + db before WAL siblings exist.
71
+ if (!memory)
72
+ enforceCachePermissions(path);
73
+ db.exec('PRAGMA journal_mode = WAL');
74
+ db.exec('PRAGMA foreign_keys = ON');
75
+ // Constructing the core applies the schema (and writes the -wal/-shm files).
76
+ const core = new OFWCacheCore(new NodeSqlDriver(db));
77
+ // Second pass: lock down the -wal/-shm the schema writes created.
78
+ if (!memory)
79
+ enforceCachePermissions(path);
80
+ return new OFWCache(db, core);
81
+ }
82
+ close() {
83
+ this.db.close();
84
+ }
85
+ }
@@ -0,0 +1,480 @@
1
+ // Storage-agnostic core for the OFW message cache.
2
+ //
3
+ // All message reads (list/get/drafts/unread-sent) are served from this cache;
4
+ // only ofw_sync_messages walks OFW for new content. The SQL lives here ONCE,
5
+ // over a tiny synchronous {@link SqlDriver}, so the same schema/queries back
6
+ // both engines: `node:sqlite` on the stdio/desktop server (src/cache/node.ts)
7
+ // and a Durable Object's SQLite on the hosted Cloudflare connector (a later
8
+ // task). This module imports nothing platform-specific.
9
+ function rowFromDb(r) {
10
+ return {
11
+ id: r.id,
12
+ folder: r.folder,
13
+ subject: r.subject,
14
+ fromUser: r.from_user,
15
+ sentAt: r.sent_at,
16
+ recipients: JSON.parse(r.recipients_json),
17
+ body: r.body,
18
+ fetchedBodyAt: r.fetched_body_at,
19
+ replyToId: r.reply_to_id,
20
+ chainRootId: r.chain_root_id,
21
+ listData: JSON.parse(r.list_data_json),
22
+ };
23
+ }
24
+ function draftFromDb(r) {
25
+ return {
26
+ id: r.id,
27
+ subject: r.subject,
28
+ body: r.body,
29
+ recipients: JSON.parse(r.recipients_json),
30
+ replyToId: r.reply_to_id,
31
+ modifiedAt: r.modified_at,
32
+ listData: JSON.parse(r.list_data_json),
33
+ };
34
+ }
35
+ function attachmentFromDb(r) {
36
+ return {
37
+ fileId: r.file_id,
38
+ fileName: r.file_name,
39
+ label: r.label,
40
+ mimeType: r.mime_type,
41
+ sizeBytes: r.size_bytes,
42
+ metadata: JSON.parse(r.metadata_json),
43
+ messageIds: JSON.parse(r.message_ids_json),
44
+ downloadedPath: r.downloaded_path,
45
+ downloadedAt: r.downloaded_at,
46
+ };
47
+ }
48
+ // node:sqlite rejects `undefined` as a bound parameter ("Provided value cannot
49
+ // be bound"). Normalize undefined to null for nullable columns so callers
50
+ // don't have to remember; throw with a useful error for NOT NULL fields that
51
+ // somehow arrived as undefined.
52
+ function nullish(v) {
53
+ return v === undefined ? null : v;
54
+ }
55
+ function requireString(field, v) {
56
+ if (typeof v === 'string')
57
+ return v;
58
+ throw new Error(`cache: ${field} is required (got ${v === undefined ? 'undefined' : 'null'})`);
59
+ }
60
+ /** Schema statements, split so a driver that only runs one statement per call works. */
61
+ export const SCHEMA_STATEMENTS = [
62
+ `CREATE TABLE IF NOT EXISTS messages (
63
+ id INTEGER PRIMARY KEY,
64
+ folder TEXT NOT NULL,
65
+ subject TEXT NOT NULL,
66
+ from_user TEXT NOT NULL,
67
+ sent_at TEXT NOT NULL,
68
+ recipients_json TEXT NOT NULL,
69
+ body TEXT,
70
+ fetched_body_at TEXT,
71
+ reply_to_id INTEGER,
72
+ chain_root_id INTEGER,
73
+ list_data_json TEXT NOT NULL,
74
+ last_seen_at TEXT NOT NULL
75
+ )`,
76
+ `CREATE INDEX IF NOT EXISTS idx_messages_folder_sent_at ON messages(folder, sent_at DESC)`,
77
+ `CREATE INDEX IF NOT EXISTS idx_messages_chain_root ON messages(chain_root_id)`,
78
+ `CREATE TABLE IF NOT EXISTS drafts (
79
+ id INTEGER PRIMARY KEY,
80
+ subject TEXT NOT NULL,
81
+ body TEXT NOT NULL,
82
+ recipients_json TEXT NOT NULL,
83
+ reply_to_id INTEGER,
84
+ modified_at TEXT NOT NULL,
85
+ list_data_json TEXT NOT NULL
86
+ )`,
87
+ `CREATE TABLE IF NOT EXISTS sync_state (
88
+ folder TEXT PRIMARY KEY,
89
+ last_sync_at TEXT NOT NULL,
90
+ newest_id INTEGER
91
+ )`,
92
+ `CREATE TABLE IF NOT EXISTS meta (
93
+ key TEXT PRIMARY KEY,
94
+ value TEXT NOT NULL
95
+ )`,
96
+ // v2: attachments table. Idempotent — IF NOT EXISTS.
97
+ `CREATE TABLE IF NOT EXISTS attachments (
98
+ file_id INTEGER PRIMARY KEY,
99
+ file_name TEXT NOT NULL,
100
+ label TEXT NOT NULL,
101
+ mime_type TEXT NOT NULL,
102
+ size_bytes INTEGER,
103
+ metadata_json TEXT NOT NULL,
104
+ message_ids_json TEXT NOT NULL,
105
+ downloaded_path TEXT,
106
+ downloaded_at TEXT,
107
+ fetched_metadata_at TEXT NOT NULL
108
+ )`,
109
+ ];
110
+ /**
111
+ * Idempotent post-schema migrations, applied after {@link SCHEMA_STATEMENTS} on
112
+ * every open. SQLite has no `ADD COLUMN IF NOT EXISTS`, so each statement runs
113
+ * inside a try/catch — re-running against an already-migrated DB throws
114
+ * "duplicate column name", which is swallowed. Driver-agnostic: both
115
+ * `node:sqlite` and the Durable Object's SQLite raise synchronously.
116
+ */
117
+ export const MIGRATIONS = [
118
+ // Resumable deep-sync cursor. Absent/NULL → SyncState.resumePage null.
119
+ 'ALTER TABLE sync_state ADD COLUMN resume_page INTEGER',
120
+ ];
121
+ /** The schema version stamped into the `meta` table on open. */
122
+ export const SCHEMA_VERSION = '2';
123
+ // Build the WHERE clause + bound params for message queries. listMessages and
124
+ // countMessages share this so the filter semantics can't drift.
125
+ function buildMessageFilter(opts) {
126
+ const wheres = [];
127
+ const params = [];
128
+ if (opts.folder !== undefined) {
129
+ wheres.push('folder = ?');
130
+ params.push(opts.folder);
131
+ }
132
+ if (opts.since !== undefined) {
133
+ wheres.push('sent_at >= ?');
134
+ params.push(opts.since);
135
+ }
136
+ if (opts.until !== undefined) {
137
+ wheres.push('sent_at < ?');
138
+ params.push(opts.until);
139
+ }
140
+ if (opts.q !== undefined && opts.q.length > 0) {
141
+ const pattern = `%${opts.q}%`;
142
+ wheres.push('(subject LIKE ? OR body LIKE ?)');
143
+ params.push(pattern, pattern);
144
+ }
145
+ return {
146
+ where: wheres.length > 0 ? `WHERE ${wheres.join(' AND ')}` : '',
147
+ params,
148
+ };
149
+ }
150
+ /**
151
+ * The OFW cache logic over a synchronous {@link SqlDriver}. The constructor
152
+ * applies the schema (idempotent CREATE IF NOT EXISTS) and stamps the schema
153
+ * version into the `meta` table.
154
+ */
155
+ export class OFWCacheCore {
156
+ db;
157
+ constructor(db) {
158
+ this.db = db;
159
+ for (const stmt of SCHEMA_STATEMENTS)
160
+ this.db.execScript(stmt);
161
+ for (const stmt of MIGRATIONS) {
162
+ try {
163
+ this.db.execScript(stmt);
164
+ }
165
+ catch {
166
+ // Idempotent: the column already exists on a previously-migrated DB.
167
+ // SQLite lacks ADD COLUMN IF NOT EXISTS, so a re-run throws "duplicate
168
+ // column name" — swallow it and move on.
169
+ }
170
+ }
171
+ this.db.run('INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value', ['schema_version', SCHEMA_VERSION]);
172
+ }
173
+ upsertMessage(row) {
174
+ this.db.run(`INSERT INTO messages (
175
+ id, folder, subject, from_user, sent_at, recipients_json,
176
+ body, fetched_body_at, reply_to_id, chain_root_id, list_data_json, last_seen_at
177
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
178
+ ON CONFLICT(id) DO UPDATE SET
179
+ folder=excluded.folder,
180
+ subject=excluded.subject,
181
+ from_user=excluded.from_user,
182
+ sent_at=excluded.sent_at,
183
+ recipients_json=excluded.recipients_json,
184
+ body=excluded.body,
185
+ fetched_body_at=excluded.fetched_body_at,
186
+ reply_to_id=excluded.reply_to_id,
187
+ chain_root_id=excluded.chain_root_id,
188
+ list_data_json=excluded.list_data_json,
189
+ last_seen_at=excluded.last_seen_at`, [
190
+ row.id,
191
+ requireString('messages.folder', row.folder),
192
+ requireString('messages.subject', row.subject),
193
+ requireString('messages.fromUser', row.fromUser),
194
+ requireString('messages.sentAt', row.sentAt),
195
+ JSON.stringify(row.recipients ?? []),
196
+ nullish(row.body),
197
+ nullish(row.fetchedBodyAt),
198
+ nullish(row.replyToId),
199
+ nullish(row.chainRootId),
200
+ JSON.stringify(row.listData ?? null),
201
+ new Date().toISOString(),
202
+ ]);
203
+ }
204
+ /**
205
+ * Batch upsert every row in a single transaction — one round-trip's worth of
206
+ * work (crucial on the Durable Object backend, where each RPC is a subrequest).
207
+ * Empty array is a no-op (no transaction opened).
208
+ */
209
+ upsertMessages(rows) {
210
+ if (rows.length === 0)
211
+ return;
212
+ this.db.transaction(() => {
213
+ for (const row of rows)
214
+ this.upsertMessage(row);
215
+ });
216
+ }
217
+ getMessage(id) {
218
+ const r = this.db.get('SELECT * FROM messages WHERE id = ?', [id]);
219
+ return r ? rowFromDb(r) : null;
220
+ }
221
+ /**
222
+ * Batch read: one `SELECT ... WHERE id IN (...)` returning the present rows
223
+ * (absent ids are simply omitted — order is not guaranteed). Empty ids returns
224
+ * `[]` without querying.
225
+ */
226
+ getMessages(ids) {
227
+ if (ids.length === 0)
228
+ return [];
229
+ const placeholders = ids.map(() => '?').join(', ');
230
+ const rows = this.db.all(`SELECT * FROM messages WHERE id IN (${placeholders})`, ids);
231
+ return rows.map(rowFromDb);
232
+ }
233
+ /**
234
+ * Remove a row from the `messages` table. Used by syncDrafts to evict
235
+ * stale rows that were cached when a draft was previously read through
236
+ * `ofw_get_message` (which would have wrongly classified it as `inbox`)
237
+ * — the drafts table is the authoritative source for that id now.
238
+ */
239
+ deleteMessage(id) {
240
+ this.db.run('DELETE FROM messages WHERE id = ?', [id]);
241
+ }
242
+ listMessages(opts) {
243
+ const { where, params } = buildMessageFilter(opts);
244
+ const offset = (opts.page - 1) * opts.size;
245
+ const rows = this.db.all(`SELECT * FROM messages ${where}
246
+ ORDER BY sent_at DESC, id DESC
247
+ LIMIT ? OFFSET ?`, [...params, opts.size, offset]);
248
+ return rows.map(rowFromDb);
249
+ }
250
+ countMessages(opts) {
251
+ const { where, params } = buildMessageFilter(opts);
252
+ const r = this.db.get(`SELECT COUNT(*) as n FROM messages ${where}`, params);
253
+ /* v8 ignore next -- SELECT COUNT(*) always returns exactly one row; the ?./?? are defensive */
254
+ return r?.n ?? 0;
255
+ }
256
+ upsertDraft(row) {
257
+ this.db.run(`INSERT INTO drafts (id, subject, body, recipients_json, reply_to_id, modified_at, list_data_json)
258
+ VALUES (?, ?, ?, ?, ?, ?, ?)
259
+ ON CONFLICT(id) DO UPDATE SET
260
+ subject=excluded.subject,
261
+ body=excluded.body,
262
+ recipients_json=excluded.recipients_json,
263
+ reply_to_id=excluded.reply_to_id,
264
+ modified_at=excluded.modified_at,
265
+ list_data_json=excluded.list_data_json`, [
266
+ row.id,
267
+ requireString('drafts.subject', row.subject),
268
+ requireString('drafts.body', row.body),
269
+ JSON.stringify(row.recipients ?? []),
270
+ nullish(row.replyToId),
271
+ requireString('drafts.modifiedAt', row.modifiedAt),
272
+ JSON.stringify(row.listData ?? null),
273
+ ]);
274
+ }
275
+ /** Batch upsert every draft in a single transaction. Empty array is a no-op. */
276
+ upsertDrafts(rows) {
277
+ if (rows.length === 0)
278
+ return;
279
+ this.db.transaction(() => {
280
+ for (const row of rows)
281
+ this.upsertDraft(row);
282
+ });
283
+ }
284
+ getDraft(id) {
285
+ const r = this.db.get('SELECT * FROM drafts WHERE id = ?', [id]);
286
+ return r ? draftFromDb(r) : null;
287
+ }
288
+ /**
289
+ * Batch read: one `SELECT ... WHERE id IN (...)` returning the present drafts
290
+ * (absent ids omitted — order not guaranteed). Empty ids returns `[]` without
291
+ * querying.
292
+ */
293
+ getDrafts(ids) {
294
+ if (ids.length === 0)
295
+ return [];
296
+ const placeholders = ids.map(() => '?').join(', ');
297
+ const rows = this.db.all(`SELECT * FROM drafts WHERE id IN (${placeholders})`, ids);
298
+ return rows.map(draftFromDb);
299
+ }
300
+ listDrafts(opts) {
301
+ const offset = (opts.page - 1) * opts.size;
302
+ const rows = this.db.all('SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?', [opts.size, offset]);
303
+ return rows.map(draftFromDb);
304
+ }
305
+ deleteDraft(id) {
306
+ this.db.run('DELETE FROM drafts WHERE id = ?', [id]);
307
+ }
308
+ listDraftIds() {
309
+ const rows = this.db.all('SELECT id FROM drafts', []);
310
+ return rows.map((r) => r.id);
311
+ }
312
+ getSyncState(folder) {
313
+ const r = this.db.get('SELECT last_sync_at, newest_id, resume_page FROM sync_state WHERE folder = ?', [folder]);
314
+ if (!r)
315
+ return null;
316
+ // A DB migrated before resume_page existed can still return the row
317
+ // without the column; normalize a missing/NULL value to null.
318
+ return { lastSyncAt: r.last_sync_at, newestId: r.newest_id, resumePage: r.resume_page ?? null };
319
+ }
320
+ setSyncState(folder, state) {
321
+ this.db.run(`INSERT INTO sync_state (folder, last_sync_at, newest_id, resume_page) VALUES (?, ?, ?, ?)
322
+ ON CONFLICT(folder) DO UPDATE SET
323
+ last_sync_at = excluded.last_sync_at,
324
+ newest_id = excluded.newest_id,
325
+ resume_page = excluded.resume_page`, [folder, state.lastSyncAt, nullish(state.newestId), nullish(state.resumePage)]);
326
+ }
327
+ getMeta(key) {
328
+ const r = this.db.get('SELECT value FROM meta WHERE key = ?', [key]);
329
+ return r ? r.value : null;
330
+ }
331
+ setMeta(key, value) {
332
+ this.db.run('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value', [key, value]);
333
+ }
334
+ findLatestReplyTip(replyToId) {
335
+ const parent = this.db.get('SELECT id, folder, chain_root_id FROM messages WHERE id = ?', [replyToId]);
336
+ if (!parent)
337
+ return replyToId;
338
+ const chainRoot = parent.chain_root_id ?? parent.id;
339
+ const tip = this.db.get(`SELECT id FROM messages
340
+ WHERE folder = 'sent' AND chain_root_id = ?
341
+ ORDER BY id DESC LIMIT 1`, [chainRoot]);
342
+ return tip ? tip.id : replyToId;
343
+ }
344
+ getAttachment(fileId) {
345
+ const r = this.db.get('SELECT * FROM attachments WHERE file_id = ?', [fileId]);
346
+ return r ? attachmentFromDb(r) : null;
347
+ }
348
+ listAttachmentsForMessage(messageId) {
349
+ // SQLite JSON1 contains check
350
+ const rows = this.db.all(`SELECT * FROM attachments
351
+ WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
352
+ ORDER BY file_id`, [messageId]);
353
+ return rows.map(attachmentFromDb);
354
+ }
355
+ upsertAttachmentForMessage(input) {
356
+ const existing = this.db.get('SELECT message_ids_json FROM attachments WHERE file_id = ?', [input.fileId]);
357
+ // messageId === 0 is the "metadata-only, not yet linked to a message"
358
+ // sentinel used by upload-without-send and download-by-id. Don't
359
+ // pollute the array with it — leave the list empty / unchanged.
360
+ const prior = existing ? JSON.parse(existing.message_ids_json) : [];
361
+ let messageIds;
362
+ if (input.messageId === 0) {
363
+ messageIds = prior;
364
+ }
365
+ else if (prior.includes(input.messageId)) {
366
+ messageIds = prior;
367
+ }
368
+ else {
369
+ messageIds = [...prior, input.messageId];
370
+ }
371
+ this.db.run(`INSERT INTO attachments (
372
+ file_id, file_name, label, mime_type, size_bytes,
373
+ metadata_json, message_ids_json, fetched_metadata_at
374
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
375
+ ON CONFLICT(file_id) DO UPDATE SET
376
+ file_name=excluded.file_name,
377
+ label=excluded.label,
378
+ mime_type=excluded.mime_type,
379
+ size_bytes=excluded.size_bytes,
380
+ metadata_json=excluded.metadata_json,
381
+ message_ids_json=excluded.message_ids_json,
382
+ fetched_metadata_at=excluded.fetched_metadata_at`, [
383
+ input.fileId,
384
+ requireString('attachments.fileName', input.fileName),
385
+ requireString('attachments.label', input.label),
386
+ requireString('attachments.mimeType', input.mimeType),
387
+ nullish(input.sizeBytes),
388
+ JSON.stringify(input.metadata ?? null),
389
+ JSON.stringify(messageIds),
390
+ new Date().toISOString(),
391
+ ]);
392
+ }
393
+ markAttachmentDownloaded(fileId, path) {
394
+ this.db.run('UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?', [
395
+ path,
396
+ new Date().toISOString(),
397
+ fileId,
398
+ ]);
399
+ }
400
+ }
401
+ /**
402
+ * Adapts a synchronous {@link OFWCacheCore} to the async {@link CacheStore}
403
+ * interface. Used by the in-process node backend; the Durable Object backend
404
+ * implements CacheStore over a real RPC boundary instead.
405
+ */
406
+ export class LocalCacheStore {
407
+ core;
408
+ constructor(core) {
409
+ this.core = core;
410
+ }
411
+ async upsertMessage(row) {
412
+ this.core.upsertMessage(row);
413
+ }
414
+ async upsertMessages(rows) {
415
+ this.core.upsertMessages(rows);
416
+ }
417
+ async getMessage(id) {
418
+ return this.core.getMessage(id);
419
+ }
420
+ async getMessages(ids) {
421
+ return this.core.getMessages(ids);
422
+ }
423
+ async deleteMessage(id) {
424
+ this.core.deleteMessage(id);
425
+ }
426
+ async listMessages(opts) {
427
+ return this.core.listMessages(opts);
428
+ }
429
+ async countMessages(opts) {
430
+ return this.core.countMessages(opts);
431
+ }
432
+ async upsertDraft(row) {
433
+ this.core.upsertDraft(row);
434
+ }
435
+ async upsertDrafts(rows) {
436
+ this.core.upsertDrafts(rows);
437
+ }
438
+ async getDraft(id) {
439
+ return this.core.getDraft(id);
440
+ }
441
+ async getDrafts(ids) {
442
+ return this.core.getDrafts(ids);
443
+ }
444
+ async listDrafts(opts) {
445
+ return this.core.listDrafts(opts);
446
+ }
447
+ async deleteDraft(id) {
448
+ this.core.deleteDraft(id);
449
+ }
450
+ async listDraftIds() {
451
+ return this.core.listDraftIds();
452
+ }
453
+ async getSyncState(folder) {
454
+ return this.core.getSyncState(folder);
455
+ }
456
+ async setSyncState(folder, state) {
457
+ this.core.setSyncState(folder, state);
458
+ }
459
+ async getMeta(key) {
460
+ return this.core.getMeta(key);
461
+ }
462
+ async setMeta(key, value) {
463
+ this.core.setMeta(key, value);
464
+ }
465
+ async findLatestReplyTip(replyToId) {
466
+ return this.core.findLatestReplyTip(replyToId);
467
+ }
468
+ async getAttachment(fileId) {
469
+ return this.core.getAttachment(fileId);
470
+ }
471
+ async listAttachmentsForMessage(messageId) {
472
+ return this.core.listAttachmentsForMessage(messageId);
473
+ }
474
+ async upsertAttachmentForMessage(input) {
475
+ this.core.upsertAttachmentForMessage(input);
476
+ }
477
+ async markAttachmentDownloaded(fileId, path) {
478
+ this.core.markAttachmentDownloaded(fileId, path);
479
+ }
480
+ }
package/dist/client.js CHANGED
@@ -6,9 +6,17 @@ import { resolveAuth } from './auth.js';
6
6
  import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS } from './protocol.js';
7
7
  // Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
8
8
  // bundle). loadDotenvSafely applies override:false + quiet:true and swallows a
9
- // missing dotenv module, matching the prior inline try/catch exactly.
10
- const __dirname = dirname(fileURLToPath(import.meta.url));
11
- await loadDotenvSafely({ path: join(__dirname, '..', '.env') });
9
+ // missing dotenv module. The try/catch additionally guards the Cloudflare
10
+ // Worker runtime, where `import.meta.url` is undefined and
11
+ // `fileURLToPath(undefined)` would otherwise throw at module init (Worker
12
+ // startup validation) — there is no filesystem / .env to load there anyway.
13
+ try {
14
+ const dir = dirname(fileURLToPath(import.meta.url));
15
+ await loadDotenvSafely({ path: join(dir, '..', '.env') });
16
+ }
17
+ catch {
18
+ /* v8 ignore next -- only reached in a non-Node runtime (Workers): no .env to load */
19
+ }
12
20
  // Parse a Content-Disposition header for a filename. Prefers RFC 6266
13
21
  // `filename*=UTF-8''…` (percent-decoded) and falls back to `filename="…"`.
14
22
  function parseContentDispositionFilename(cd) {
@@ -58,6 +66,16 @@ export class OFWClient {
58
66
  // already-expired placeholder token so the first request drives the refresh
59
67
  // callback — i.e. the original "log in on first request" behavior.
60
68
  tokenManager;
69
+ // Optional injected auth resolver. When set, the refresh callback uses it
70
+ // instead of the module-level global `resolveAuth` (env-var → fetchproxy
71
+ // priority). A hosted per-user deployment injects its own resolver so each
72
+ // request carries that user's credentials — see the Cloudflare Worker
73
+ // deployment. Left undefined by the stdio path, which falls back to the
74
+ // global resolver, keeping that behaviour byte-for-byte identical.
75
+ authResolver;
76
+ constructor(opts) {
77
+ this.authResolver = opts?.resolveAuth;
78
+ }
61
79
  getTokenManager() {
62
80
  if (!this.tokenManager) {
63
81
  this.tokenManager = new TokenManager({
@@ -69,7 +87,7 @@ export class OFWClient {
69
87
  // path uses (the 401-replay covers a wrong guess). We re-arm the
70
88
  // sentinel so the manager can refresh again later.
71
89
  refresh: async () => {
72
- const { token, expiresAt } = await resolveAuth();
90
+ const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
73
91
  return {
74
92
  accessToken: token,
75
93
  refreshToken: OFW_REFRESH_SENTINEL,
package/dist/config.js CHANGED
@@ -61,6 +61,27 @@ export function getWriteMode() {
61
61
  console.error(`[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" — failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`);
62
62
  return 'none';
63
63
  }
64
+ /**
65
+ * Calendar-write opt-in for 'drafts' deployments.
66
+ *
67
+ * Messages have a draft stage (the human sends from the web UI), so 'drafts'
68
+ * mode keeps a human between model output and the court-visible record.
69
+ * Calendar events have no draft stage — but unlike a sent message they are
70
+ * fully reversible (editable and deletable), so a drafts-mode user may accept
71
+ * direct calendar writes without accepting sends. Setting
72
+ * OFW_CALENDAR_WRITES=true registers the calendar write tools
73
+ * (ofw_create_event, ofw_update_event, ofw_delete_event) alongside the
74
+ * draft-level message writes.
75
+ *
76
+ * The flag never overrides 'none': that mode is the hard read-only guarantee,
77
+ * including the fail-closed result of an unrecognized OFW_WRITE_MODE.
78
+ */
79
+ export function getCalendarWritesAllowed() {
80
+ const mode = getWriteMode();
81
+ if (mode === 'all')
82
+ return true;
83
+ return mode === 'drafts' && parseBoolEnv('OFW_CALENDAR_WRITES');
84
+ }
64
85
  // Default for ofw_download_attachment's `inline` arg when the caller doesn't
65
86
  // pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
66
87
  // MCP content blocks by default (skipping disk) — useful on sandboxed MCP
@@ -68,3 +89,24 @@ export function getWriteMode() {
68
89
  export function getDefaultInlineAttachments() {
69
90
  return parseBoolEnv('OFW_INLINE_ATTACHMENTS');
70
91
  }
92
+ /**
93
+ * Per-invocation OFW-request budget for ofw_sync_messages.
94
+ *
95
+ * The hosted Cloudflare Worker connector enforces a subrequest cap per request
96
+ * (every OFW API fetch and every Durable-Object cache RPC counts), so a deep
97
+ * backfill must be bounded and resumable there. Set OFW_SYNC_MAX_REQUESTS to a
98
+ * positive integer to cap the number of OFW requests one sync call may make
99
+ * before pausing; the next call resumes the walk (deep or not) where it left off.
100
+ *
101
+ * Unset / blank / non-positive / non-integer → POSITIVE_INFINITY, i.e. the
102
+ * local stdio server stays unbounded (walks fully in one call) by default.
103
+ */
104
+ export function getSyncMaxRequests() {
105
+ const raw = readEnvVar('OFW_SYNC_MAX_REQUESTS');
106
+ if (raw === undefined)
107
+ return Number.POSITIVE_INFINITY;
108
+ const n = Number(raw);
109
+ if (!Number.isInteger(n) || n <= 0)
110
+ return Number.POSITIVE_INFINITY;
111
+ return n;
112
+ }