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/dist/bundle.js CHANGED
@@ -38392,8 +38392,13 @@ async function loginWithPassword(username, password) {
38392
38392
  }
38393
38393
  const contentType = response.headers.get("content-type") ?? "";
38394
38394
  if (!contentType.includes("application/json")) {
38395
+ if (contentType.includes("text/html")) {
38396
+ throw new Error(
38397
+ "OFW login failed \u2014 your OurFamilyWizard email or password was not accepted. Check them and try again."
38398
+ );
38399
+ }
38395
38400
  const body = await response.text();
38396
- throw new Error(`OFW login returned unexpected response (${contentType}): ${body.substring(0, 200)}`);
38401
+ throw new Error(`OFW login returned unexpected response (${contentType || "no content-type"}): ${body.substring(0, 200)}`);
38397
38402
  }
38398
38403
  const data = await response.json();
38399
38404
  return {
@@ -38405,7 +38410,7 @@ async function loginWithPassword(username, password) {
38405
38410
  // package.json
38406
38411
  var package_default = {
38407
38412
  name: "ofw-mcp",
38408
- version: "2.5.0",
38413
+ version: "2.6.3",
38409
38414
  license: "MIT",
38410
38415
  mcpName: "io.github.chrischall/ofw-mcp",
38411
38416
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
@@ -38434,7 +38439,10 @@ var package_default = {
38434
38439
  dev: "node --env-file=.env dist/index.js",
38435
38440
  test: "vitest run",
38436
38441
  "test:coverage": "vitest run --coverage",
38437
- "test:watch": "vitest"
38442
+ "test:watch": "vitest",
38443
+ "worker:dev": "wrangler dev",
38444
+ "worker:deploy": "wrangler deploy",
38445
+ "worker:test": "vitest run --config vitest.workers.config.ts"
38438
38446
  },
38439
38447
  dependencies: {
38440
38448
  "@chrischall/mcp-utils": "^0.13.0",
@@ -38444,11 +38452,17 @@ var package_default = {
38444
38452
  zod: "^4.4.3"
38445
38453
  },
38446
38454
  devDependencies: {
38455
+ "@chrischall/mcp-connector": "^0.1.0",
38456
+ "@cloudflare/vitest-pool-workers": "^0.18.4",
38457
+ "@cloudflare/workers-oauth-provider": "^0.0.11",
38458
+ "@cloudflare/workers-types": "^5.20260708.1",
38447
38459
  "@types/node": "^26.0.0",
38448
38460
  "@vitest/coverage-v8": "^4.1.7",
38461
+ agents: "^0.17.3",
38449
38462
  esbuild: "^0.28.0",
38450
38463
  typescript: "^7.0.2",
38451
- vitest: "^4.1.7"
38464
+ vitest: "^4.1.7",
38465
+ wrangler: "^4.110.0"
38452
38466
  }
38453
38467
  };
38454
38468
 
@@ -38514,8 +38528,11 @@ async function resolveAuth() {
38514
38528
  }
38515
38529
 
38516
38530
  // src/client.ts
38517
- var __dirname = dirname(fileURLToPath(import.meta.url));
38518
- await loadDotenvSafely({ path: join3(__dirname, "..", ".env") });
38531
+ try {
38532
+ const dir = dirname(fileURLToPath(import.meta.url));
38533
+ await loadDotenvSafely({ path: join3(dir, "..", ".env") });
38534
+ } catch {
38535
+ }
38519
38536
  function parseContentDispositionFilename(cd) {
38520
38537
  const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
38521
38538
  if (extMatch) {
@@ -38548,6 +38565,16 @@ var OFWClient = class {
38548
38565
  // already-expired placeholder token so the first request drives the refresh
38549
38566
  // callback — i.e. the original "log in on first request" behavior.
38550
38567
  tokenManager;
38568
+ // Optional injected auth resolver. When set, the refresh callback uses it
38569
+ // instead of the module-level global `resolveAuth` (env-var → fetchproxy
38570
+ // priority). A hosted per-user deployment injects its own resolver so each
38571
+ // request carries that user's credentials — see the Cloudflare Worker
38572
+ // deployment. Left undefined by the stdio path, which falls back to the
38573
+ // global resolver, keeping that behaviour byte-for-byte identical.
38574
+ authResolver;
38575
+ constructor(opts) {
38576
+ this.authResolver = opts?.resolveAuth;
38577
+ }
38551
38578
  getTokenManager() {
38552
38579
  if (!this.tokenManager) {
38553
38580
  this.tokenManager = new TokenManager({
@@ -38559,7 +38586,7 @@ var OFWClient = class {
38559
38586
  // path uses (the 401-replay covers a wrong guess). We re-arm the
38560
38587
  // sentinel so the manager can refresh again later.
38561
38588
  refresh: async () => {
38562
- const { token, expiresAt } = await resolveAuth();
38589
+ const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
38563
38590
  return {
38564
38591
  accessToken: token,
38565
38592
  refreshToken: OFW_REFRESH_SENTINEL,
@@ -38732,395 +38759,6 @@ function registerUserTools(server, client2) {
38732
38759
  });
38733
38760
  }
38734
38761
 
38735
- // src/cache.ts
38736
- import { DatabaseSync } from "node:sqlite";
38737
- import { mkdirSync, chmodSync, existsSync } from "node:fs";
38738
- import { dirname as dirname2 } from "node:path";
38739
-
38740
- // src/config.ts
38741
- import { createHash } from "node:crypto";
38742
- import { homedir as homedir3 } from "node:os";
38743
- import { join as join4 } from "node:path";
38744
- function readCacheIdentity() {
38745
- return readEnvVar("OFW_CACHE_IDENTITY") ?? readEnvVar("OFW_USERNAME") ?? "_default";
38746
- }
38747
- function getCacheDir() {
38748
- const override = process.env.OFW_CACHE_DIR;
38749
- if (override && override.trim().length > 0) return override.trim();
38750
- return join4(homedir3(), ".cache", "ofw-mcp");
38751
- }
38752
- function getCacheDbPath() {
38753
- const identity = readCacheIdentity();
38754
- const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
38755
- return join4(getCacheDir(), `${hash2}.db`);
38756
- }
38757
- function getAttachmentsDir() {
38758
- const override = process.env.OFW_ATTACHMENTS_DIR;
38759
- if (override && override.trim().length > 0) return override.trim();
38760
- return join4(homedir3(), "Downloads", "ofw-mcp");
38761
- }
38762
- function getWriteMode() {
38763
- const raw = process.env.OFW_WRITE_MODE;
38764
- if (typeof raw !== "string" || raw.trim().length === 0) return "all";
38765
- const mode = raw.trim().toLowerCase();
38766
- if (mode === "none" || mode === "drafts" || mode === "all") return mode;
38767
- console.error(
38768
- `[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
38769
- );
38770
- return "none";
38771
- }
38772
- function getCalendarWritesAllowed() {
38773
- const mode = getWriteMode();
38774
- if (mode === "all") return true;
38775
- return mode === "drafts" && parseBoolEnv("OFW_CALENDAR_WRITES");
38776
- }
38777
- function getDefaultInlineAttachments() {
38778
- return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
38779
- }
38780
-
38781
- // src/cache.ts
38782
- var instance = null;
38783
- var SCHEMA_V1 = `
38784
- CREATE TABLE IF NOT EXISTS messages (
38785
- id INTEGER PRIMARY KEY,
38786
- folder TEXT NOT NULL,
38787
- subject TEXT NOT NULL,
38788
- from_user TEXT NOT NULL,
38789
- sent_at TEXT NOT NULL,
38790
- recipients_json TEXT NOT NULL,
38791
- body TEXT,
38792
- fetched_body_at TEXT,
38793
- reply_to_id INTEGER,
38794
- chain_root_id INTEGER,
38795
- list_data_json TEXT NOT NULL,
38796
- last_seen_at TEXT NOT NULL
38797
- );
38798
- CREATE INDEX IF NOT EXISTS idx_messages_folder_sent_at ON messages(folder, sent_at DESC);
38799
- CREATE INDEX IF NOT EXISTS idx_messages_chain_root ON messages(chain_root_id);
38800
-
38801
- CREATE TABLE IF NOT EXISTS drafts (
38802
- id INTEGER PRIMARY KEY,
38803
- subject TEXT NOT NULL,
38804
- body TEXT NOT NULL,
38805
- recipients_json TEXT NOT NULL,
38806
- reply_to_id INTEGER,
38807
- modified_at TEXT NOT NULL,
38808
- list_data_json TEXT NOT NULL
38809
- );
38810
-
38811
- CREATE TABLE IF NOT EXISTS sync_state (
38812
- folder TEXT PRIMARY KEY,
38813
- last_sync_at TEXT NOT NULL,
38814
- newest_id INTEGER
38815
- );
38816
-
38817
- CREATE TABLE IF NOT EXISTS meta (
38818
- key TEXT PRIMARY KEY,
38819
- value TEXT NOT NULL
38820
- );
38821
- `;
38822
- var SCHEMA_V2 = `
38823
- CREATE TABLE IF NOT EXISTS attachments (
38824
- file_id INTEGER PRIMARY KEY,
38825
- file_name TEXT NOT NULL,
38826
- label TEXT NOT NULL,
38827
- mime_type TEXT NOT NULL,
38828
- size_bytes INTEGER,
38829
- metadata_json TEXT NOT NULL,
38830
- message_ids_json TEXT NOT NULL, -- JSON array of message ids that reference this file
38831
- downloaded_path TEXT, -- absolute path on disk if/when downloaded
38832
- downloaded_at TEXT,
38833
- fetched_metadata_at TEXT NOT NULL
38834
- );
38835
- `;
38836
- function migrate(db) {
38837
- db.exec(SCHEMA_V1);
38838
- db.exec(SCHEMA_V2);
38839
- db.prepare(
38840
- "INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
38841
- ).run("schema_version", "2");
38842
- }
38843
- function enforceCachePermissions(dbPath) {
38844
- chmodSync(dirname2(dbPath), 448);
38845
- chmodSync(dbPath, 384);
38846
- for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
38847
- if (existsSync(sibling)) chmodSync(sibling, 384);
38848
- }
38849
- }
38850
- function openCache() {
38851
- if (instance) return instance;
38852
- const path = getCacheDbPath();
38853
- mkdirSync(dirname2(path), { recursive: true });
38854
- const db = new DatabaseSync(path);
38855
- enforceCachePermissions(path);
38856
- db.exec("PRAGMA journal_mode = WAL");
38857
- db.exec("PRAGMA foreign_keys = ON");
38858
- migrate(db);
38859
- enforceCachePermissions(path);
38860
- instance = { db };
38861
- return instance;
38862
- }
38863
- function rowFromDb(r) {
38864
- return {
38865
- id: r.id,
38866
- folder: r.folder,
38867
- subject: r.subject,
38868
- fromUser: r.from_user,
38869
- sentAt: r.sent_at,
38870
- recipients: JSON.parse(r.recipients_json),
38871
- body: r.body,
38872
- fetchedBodyAt: r.fetched_body_at,
38873
- replyToId: r.reply_to_id,
38874
- chainRootId: r.chain_root_id,
38875
- listData: JSON.parse(r.list_data_json)
38876
- };
38877
- }
38878
- function nullish3(v) {
38879
- return v === void 0 ? null : v;
38880
- }
38881
- function requireString(field, v) {
38882
- if (typeof v === "string") return v;
38883
- throw new Error(`cache: ${field} is required (got ${v === void 0 ? "undefined" : "null"})`);
38884
- }
38885
- function upsertMessage(row) {
38886
- const { db } = openCache();
38887
- db.prepare(
38888
- `INSERT INTO messages (
38889
- id, folder, subject, from_user, sent_at, recipients_json,
38890
- body, fetched_body_at, reply_to_id, chain_root_id, list_data_json, last_seen_at
38891
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
38892
- ON CONFLICT(id) DO UPDATE SET
38893
- folder=excluded.folder,
38894
- subject=excluded.subject,
38895
- from_user=excluded.from_user,
38896
- sent_at=excluded.sent_at,
38897
- recipients_json=excluded.recipients_json,
38898
- body=excluded.body,
38899
- fetched_body_at=excluded.fetched_body_at,
38900
- reply_to_id=excluded.reply_to_id,
38901
- chain_root_id=excluded.chain_root_id,
38902
- list_data_json=excluded.list_data_json,
38903
- last_seen_at=excluded.last_seen_at`
38904
- ).run(
38905
- row.id,
38906
- requireString("messages.folder", row.folder),
38907
- requireString("messages.subject", row.subject),
38908
- requireString("messages.fromUser", row.fromUser),
38909
- requireString("messages.sentAt", row.sentAt),
38910
- JSON.stringify(row.recipients ?? []),
38911
- nullish3(row.body),
38912
- nullish3(row.fetchedBodyAt),
38913
- nullish3(row.replyToId),
38914
- nullish3(row.chainRootId),
38915
- JSON.stringify(row.listData ?? null),
38916
- (/* @__PURE__ */ new Date()).toISOString()
38917
- );
38918
- }
38919
- function getMessage(id) {
38920
- const { db } = openCache();
38921
- const r = db.prepare("SELECT * FROM messages WHERE id = ?").get(id);
38922
- return r ? rowFromDb(r) : null;
38923
- }
38924
- function deleteMessage(id) {
38925
- const { db } = openCache();
38926
- db.prepare("DELETE FROM messages WHERE id = ?").run(id);
38927
- }
38928
- function buildMessageFilter(opts) {
38929
- const wheres = [];
38930
- const params = [];
38931
- if (opts.folder !== void 0) {
38932
- wheres.push("folder = ?");
38933
- params.push(opts.folder);
38934
- }
38935
- if (opts.since !== void 0) {
38936
- wheres.push("sent_at >= ?");
38937
- params.push(opts.since);
38938
- }
38939
- if (opts.until !== void 0) {
38940
- wheres.push("sent_at < ?");
38941
- params.push(opts.until);
38942
- }
38943
- if (opts.q !== void 0 && opts.q.length > 0) {
38944
- const pattern = `%${opts.q}%`;
38945
- wheres.push("(subject LIKE ? OR body LIKE ?)");
38946
- params.push(pattern, pattern);
38947
- }
38948
- return {
38949
- where: wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "",
38950
- params
38951
- };
38952
- }
38953
- function listMessages(opts) {
38954
- const { db } = openCache();
38955
- const { where, params } = buildMessageFilter(opts);
38956
- const offset = (opts.page - 1) * opts.size;
38957
- const rows = db.prepare(
38958
- `SELECT * FROM messages ${where}
38959
- ORDER BY sent_at DESC, id DESC
38960
- LIMIT ? OFFSET ?`
38961
- ).all(...params, opts.size, offset);
38962
- return rows.map(rowFromDb);
38963
- }
38964
- function countMessages(opts) {
38965
- const { db } = openCache();
38966
- const { where, params } = buildMessageFilter(opts);
38967
- const r = db.prepare(`SELECT COUNT(*) as n FROM messages ${where}`).get(...params);
38968
- return r?.n ?? 0;
38969
- }
38970
- function draftFromDb(r) {
38971
- return {
38972
- id: r.id,
38973
- subject: r.subject,
38974
- body: r.body,
38975
- recipients: JSON.parse(r.recipients_json),
38976
- replyToId: r.reply_to_id,
38977
- modifiedAt: r.modified_at,
38978
- listData: JSON.parse(r.list_data_json)
38979
- };
38980
- }
38981
- function upsertDraft(row) {
38982
- const { db } = openCache();
38983
- db.prepare(
38984
- `INSERT INTO drafts (id, subject, body, recipients_json, reply_to_id, modified_at, list_data_json)
38985
- VALUES (?, ?, ?, ?, ?, ?, ?)
38986
- ON CONFLICT(id) DO UPDATE SET
38987
- subject=excluded.subject,
38988
- body=excluded.body,
38989
- recipients_json=excluded.recipients_json,
38990
- reply_to_id=excluded.reply_to_id,
38991
- modified_at=excluded.modified_at,
38992
- list_data_json=excluded.list_data_json`
38993
- ).run(
38994
- row.id,
38995
- requireString("drafts.subject", row.subject),
38996
- requireString("drafts.body", row.body),
38997
- JSON.stringify(row.recipients ?? []),
38998
- nullish3(row.replyToId),
38999
- requireString("drafts.modifiedAt", row.modifiedAt),
39000
- JSON.stringify(row.listData ?? null)
39001
- );
39002
- }
39003
- function getDraft(id) {
39004
- const { db } = openCache();
39005
- const r = db.prepare("SELECT * FROM drafts WHERE id = ?").get(id);
39006
- return r ? draftFromDb(r) : null;
39007
- }
39008
- function listDrafts(opts) {
39009
- const { db } = openCache();
39010
- const offset = (opts.page - 1) * opts.size;
39011
- const rows = db.prepare(
39012
- "SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?"
39013
- ).all(opts.size, offset);
39014
- return rows.map(draftFromDb);
39015
- }
39016
- function deleteDraft(id) {
39017
- const { db } = openCache();
39018
- db.prepare("DELETE FROM drafts WHERE id = ?").run(id);
39019
- }
39020
- function listDraftIds() {
39021
- const { db } = openCache();
39022
- const rows = db.prepare("SELECT id FROM drafts").all();
39023
- return rows.map((r) => r.id);
39024
- }
39025
- function setSyncState(folder, state) {
39026
- const { db } = openCache();
39027
- db.prepare(
39028
- `INSERT INTO sync_state (folder, last_sync_at, newest_id) VALUES (?, ?, ?)
39029
- ON CONFLICT(folder) DO UPDATE SET
39030
- last_sync_at = excluded.last_sync_at,
39031
- newest_id = excluded.newest_id`
39032
- ).run(folder, state.lastSyncAt, state.newestId);
39033
- }
39034
- function setMeta(key, value) {
39035
- const { db } = openCache();
39036
- db.prepare(
39037
- "INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
39038
- ).run(key, value);
39039
- }
39040
- function findLatestReplyTip(replyToId) {
39041
- const { db } = openCache();
39042
- const parent = db.prepare(
39043
- "SELECT id, folder, chain_root_id FROM messages WHERE id = ?"
39044
- ).get(replyToId);
39045
- if (!parent) return replyToId;
39046
- const chainRoot = parent.chain_root_id ?? parent.id;
39047
- const tip = db.prepare(
39048
- `SELECT id FROM messages
39049
- WHERE folder = 'sent' AND chain_root_id = ?
39050
- ORDER BY id DESC LIMIT 1`
39051
- ).get(chainRoot);
39052
- return tip ? tip.id : replyToId;
39053
- }
39054
- function attachmentFromDb(r) {
39055
- return {
39056
- fileId: r.file_id,
39057
- fileName: r.file_name,
39058
- label: r.label,
39059
- mimeType: r.mime_type,
39060
- sizeBytes: r.size_bytes,
39061
- metadata: JSON.parse(r.metadata_json),
39062
- messageIds: JSON.parse(r.message_ids_json),
39063
- downloadedPath: r.downloaded_path,
39064
- downloadedAt: r.downloaded_at
39065
- };
39066
- }
39067
- function getAttachment(fileId) {
39068
- const { db } = openCache();
39069
- const r = db.prepare("SELECT * FROM attachments WHERE file_id = ?").get(fileId);
39070
- return r ? attachmentFromDb(r) : null;
39071
- }
39072
- function listAttachmentsForMessage(messageId) {
39073
- const { db } = openCache();
39074
- const rows = db.prepare(
39075
- `SELECT * FROM attachments
39076
- WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
39077
- ORDER BY file_id`
39078
- ).all(messageId);
39079
- return rows.map(attachmentFromDb);
39080
- }
39081
- function upsertAttachmentForMessage(input) {
39082
- const { db } = openCache();
39083
- const existing = db.prepare("SELECT message_ids_json FROM attachments WHERE file_id = ?").get(input.fileId);
39084
- const prior = existing ? JSON.parse(existing.message_ids_json) : [];
39085
- let messageIds;
39086
- if (input.messageId === 0) {
39087
- messageIds = prior;
39088
- } else if (prior.includes(input.messageId)) {
39089
- messageIds = prior;
39090
- } else {
39091
- messageIds = [...prior, input.messageId];
39092
- }
39093
- db.prepare(
39094
- `INSERT INTO attachments (
39095
- file_id, file_name, label, mime_type, size_bytes,
39096
- metadata_json, message_ids_json, fetched_metadata_at
39097
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
39098
- ON CONFLICT(file_id) DO UPDATE SET
39099
- file_name=excluded.file_name,
39100
- label=excluded.label,
39101
- mime_type=excluded.mime_type,
39102
- size_bytes=excluded.size_bytes,
39103
- metadata_json=excluded.metadata_json,
39104
- message_ids_json=excluded.message_ids_json,
39105
- fetched_metadata_at=excluded.fetched_metadata_at`
39106
- ).run(
39107
- input.fileId,
39108
- requireString("attachments.fileName", input.fileName),
39109
- requireString("attachments.label", input.label),
39110
- requireString("attachments.mimeType", input.mimeType),
39111
- nullish3(input.sizeBytes),
39112
- JSON.stringify(input.metadata ?? null),
39113
- JSON.stringify(messageIds),
39114
- (/* @__PURE__ */ new Date()).toISOString()
39115
- );
39116
- }
39117
- function markAttachmentDownloaded(fileId, path) {
39118
- const { db } = openCache();
39119
- db.prepare(
39120
- "UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?"
39121
- ).run(path, (/* @__PURE__ */ new Date()).toISOString(), fileId);
39122
- }
39123
-
39124
38762
  // src/sync.ts
39125
38763
  var FileMetaSchema = external_exports.looseObject({
39126
38764
  fileId: external_exports.number(),
@@ -39130,13 +38768,13 @@ var FileMetaSchema = external_exports.looseObject({
39130
38768
  // MIME
39131
38769
  fileSize: external_exports.number().optional()
39132
38770
  });
39133
- async function fetchAttachmentMeta(client2, fileId, messageId) {
38771
+ async function fetchAttachmentMeta(client2, fileId, messageId, store) {
39134
38772
  const meta3 = parseLenient(
39135
38773
  FileMetaSchema,
39136
38774
  await client2.request("GET", `/pub/v1/myfiles/${fileId}`),
39137
38775
  { label: "ofw-mcp", context: "GET /pub/v1/myfiles/{fileId}" }
39138
38776
  );
39139
- upsertAttachmentForMessage({
38777
+ await store.upsertAttachmentForMessage({
39140
38778
  fileId: meta3.fileId ?? fileId,
39141
38779
  fileName: meta3.fileName ?? `file-${fileId}`,
39142
38780
  label: meta3.label ?? meta3.fileName ?? `file-${fileId}`,
@@ -39146,13 +38784,33 @@ async function fetchAttachmentMeta(client2, fileId, messageId) {
39146
38784
  messageId
39147
38785
  });
39148
38786
  }
39149
- async function fetchAttachmentMetaForMessage(client2, messageId, fileIds) {
39150
- await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client2, fid, messageId)));
38787
+ async function fetchAttachmentMetaForMessage(client2, messageId, fileIds, store) {
38788
+ await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client2, fid, messageId, store)));
38789
+ }
38790
+ function makeBudget(max) {
38791
+ let remaining = max;
38792
+ return {
38793
+ take() {
38794
+ if (remaining <= 0) return false;
38795
+ remaining -= 1;
38796
+ return true;
38797
+ }
38798
+ };
38799
+ }
38800
+ async function fetchAttachmentMetaBudgeted(client2, messageId, fileIds, store, budget) {
38801
+ const affordable = [];
38802
+ for (const fid of fileIds) {
38803
+ if (!budget.take()) break;
38804
+ affordable.push(fid);
38805
+ }
38806
+ if (affordable.length > 0) {
38807
+ await fetchAttachmentMetaForMessage(client2, messageId, affordable, store);
38808
+ }
39151
38809
  }
39152
38810
  var FoldersSchema = external_exports.looseObject({
39153
38811
  systemFolders: external_exports.array(external_exports.looseObject({ id: external_exports.string(), folderType: external_exports.string() })).optional()
39154
38812
  });
39155
- async function resolveFolderIds(client2) {
38813
+ async function resolveFolderIds(client2, store) {
39156
38814
  const data = parseLenient(
39157
38815
  FoldersSchema,
39158
38816
  await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true"),
@@ -39169,7 +38827,8 @@ async function resolveFolderIds(client2) {
39169
38827
  sent: find("SENT_MESSAGES"),
39170
38828
  drafts: find("DRAFTS")
39171
38829
  };
39172
- setMeta("drafts_folder_id", ids.drafts);
38830
+ await store.setMeta("drafts_folder_id", ids.drafts);
38831
+ await store.setMeta("sent_folder_id", ids.sent);
39173
38832
  return ids;
39174
38833
  }
39175
38834
  var ListItemSchema = external_exports.looseObject({
@@ -39188,12 +38847,17 @@ var DetailResponseSchema = external_exports.looseObject({
39188
38847
  // endpoint only has an epoch placeholder) — used by the view-status refresh.
39189
38848
  recipients: external_exports.array(ApiRecipientSchema).optional()
39190
38849
  });
39191
- async function syncMessageFolder(client2, folder, folderId, opts) {
39192
- let page = 1;
39193
- let synced = 0;
38850
+ var maxId = (a, b) => a === null ? b : b === null ? a : Math.max(a, b);
38851
+ async function walkPages(client2, folder, folderId, opts, store) {
38852
+ const budget = opts.budget;
38853
+ let page = opts.startPage;
39194
38854
  let newestId = null;
38855
+ let synced = 0;
39195
38856
  const unread = [];
39196
38857
  while (true) {
38858
+ if (!budget.take()) {
38859
+ return { synced, unread, newestId, done: false, nextPage: page };
38860
+ }
39197
38861
  const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
39198
38862
  const list = parseLenient(
39199
38863
  ListResponseSchema,
@@ -39201,19 +38865,30 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
39201
38865
  { label: "ofw-mcp", context: `GET /pub/v3/messages?folders={${folder}}` }
39202
38866
  );
39203
38867
  const items = list.data ?? [];
39204
- if (items.length === 0) break;
38868
+ if (items.length === 0) {
38869
+ return { synced, unread, newestId, done: true, nextPage: null };
38870
+ }
38871
+ const existingById = new Map(
38872
+ (await store.getMessages(items.map((it) => it.id))).map((row) => [row.id, row])
38873
+ );
38874
+ const toUpsert = [];
39205
38875
  let pageHadNewItem = false;
38876
+ let pageBudgetHit = false;
39206
38877
  for (const item of items) {
39207
38878
  if (newestId === null || item.id > newestId) newestId = item.id;
39208
- const existing = getMessage(item.id);
38879
+ const existing = existingById.get(item.id);
39209
38880
  if (existing) {
39210
38881
  if (folder === "sent" && item.showNeverViewed === false && !hasRealView(existing.recipients)) {
38882
+ if (!budget.take()) {
38883
+ pageBudgetHit = true;
38884
+ break;
38885
+ }
39211
38886
  const detail = parseLenient(
39212
38887
  DetailResponseSchema,
39213
38888
  await client2.request("GET", `/pub/v3/messages/${item.id}`),
39214
38889
  { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (view-status refresh)" }
39215
38890
  );
39216
- upsertMessage({ ...existing, recipients: mapRecipients(detail.recipients), listData: item });
38891
+ toUpsert.push({ ...existing, recipients: mapRecipients(detail.recipients), listData: item });
39217
38892
  synced++;
39218
38893
  }
39219
38894
  continue;
@@ -39224,7 +38899,12 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
39224
38899
  let body = null;
39225
38900
  let fetchedBodyAt = null;
39226
38901
  let detailFileIds = [];
38902
+ let detailRecipients;
39227
38903
  if (shouldFetchBody) {
38904
+ if (!budget.take()) {
38905
+ pageBudgetHit = true;
38906
+ break;
38907
+ }
39228
38908
  const detail = parseLenient(
39229
38909
  DetailResponseSchema,
39230
38910
  await client2.request("GET", `/pub/v3/messages/${item.id}`),
@@ -39232,6 +38912,7 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
39232
38912
  );
39233
38913
  body = detail.body ?? "";
39234
38914
  fetchedBodyAt = (/* @__PURE__ */ new Date()).toISOString();
38915
+ detailRecipients = detail.recipients;
39235
38916
  if (Array.isArray(detail.files) && detail.files.length > 0) {
39236
38917
  detailFileIds = detail.files;
39237
38918
  }
@@ -39249,27 +38930,72 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
39249
38930
  subject: item.subject ?? "(no subject)",
39250
38931
  fromUser: item.from?.name ?? "",
39251
38932
  sentAt: item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
39252
- recipients: mapRecipients(item.recipients),
38933
+ recipients: mapRecipients(detailRecipients ?? item.recipients),
39253
38934
  body,
39254
38935
  fetchedBodyAt,
39255
38936
  replyToId: null,
39256
38937
  chainRootId: null,
39257
38938
  listData: item
39258
38939
  };
39259
- upsertMessage(row);
38940
+ toUpsert.push(row);
39260
38941
  synced++;
39261
38942
  if (detailFileIds.length > 0) {
39262
- await fetchAttachmentMetaForMessage(client2, item.id, detailFileIds);
38943
+ await fetchAttachmentMetaBudgeted(client2, item.id, detailFileIds, store, budget);
39263
38944
  }
39264
38945
  }
39265
- if (!opts.deep && !pageHadNewItem) break;
38946
+ await store.upsertMessages(toUpsert);
38947
+ if (pageBudgetHit) {
38948
+ return { synced, unread, newestId, done: false, nextPage: page };
38949
+ }
38950
+ if (opts.stopAtCachedPage && !pageHadNewItem) {
38951
+ return { synced, unread, newestId, done: true, nextPage: page };
38952
+ }
39266
38953
  page++;
39267
38954
  }
39268
- setSyncState(folder, {
38955
+ }
38956
+ async function syncMessageFolder(client2, folder, folderId, opts, store) {
38957
+ const budget = opts.budget ?? makeBudget(Number.POSITIVE_INFINITY);
38958
+ const saved = await store.getSyncState(folder);
38959
+ const savedResume = saved?.resumePage ?? null;
38960
+ const fwd = await walkPages(client2, folder, folderId, {
38961
+ startPage: 1,
38962
+ stopAtCachedPage: true,
38963
+ fetchUnreadBodies: opts.fetchUnreadBodies,
38964
+ budget
38965
+ }, store);
38966
+ let synced = fwd.synced;
38967
+ const unread = [...fwd.unread];
38968
+ let newestId = maxId(saved?.newestId ?? null, fwd.newestId);
38969
+ let done;
38970
+ let resumePage;
38971
+ if (!fwd.done) {
38972
+ done = false;
38973
+ resumePage = savedResume === null ? fwd.nextPage : Math.min(fwd.nextPage, savedResume);
38974
+ } else if (fwd.nextPage === null) {
38975
+ done = true;
38976
+ resumePage = null;
38977
+ } else if (savedResume === null && !opts.deep) {
38978
+ done = true;
38979
+ resumePage = null;
38980
+ } else {
38981
+ const bf = await walkPages(client2, folder, folderId, {
38982
+ startPage: savedResume ?? fwd.nextPage,
38983
+ stopAtCachedPage: false,
38984
+ fetchUnreadBodies: opts.fetchUnreadBodies,
38985
+ budget
38986
+ }, store);
38987
+ synced += bf.synced;
38988
+ unread.push(...bf.unread);
38989
+ newestId = maxId(newestId, bf.newestId);
38990
+ done = bf.done;
38991
+ resumePage = bf.done ? null : bf.nextPage;
38992
+ }
38993
+ await store.setSyncState(folder, {
39269
38994
  lastSyncAt: (/* @__PURE__ */ new Date()).toISOString(),
39270
- newestId
38995
+ newestId,
38996
+ resumePage
39271
38997
  });
39272
- return { synced, unread };
38998
+ return { synced, unread, done };
39273
38999
  }
39274
39000
  var DraftListItemSchema = external_exports.looseObject({
39275
39001
  id: external_exports.number(),
@@ -39283,10 +39009,12 @@ var DraftDetailSchema = external_exports.looseObject({
39283
39009
  body: external_exports.string().optional(),
39284
39010
  subject: external_exports.string().optional()
39285
39011
  });
39286
- async function syncDrafts(client2, draftsFolderId) {
39012
+ async function syncDrafts(client2, draftsFolderId, store, budget) {
39013
+ const b = budget ?? makeBudget(Number.POSITIVE_INFINITY);
39287
39014
  const items = [];
39288
39015
  let page = 1;
39289
39016
  while (true) {
39017
+ if (!b.take()) return { synced: 0, done: false };
39290
39018
  const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
39291
39019
  const list = parseLenient(
39292
39020
  DraftListResponseSchema,
@@ -39298,68 +39026,136 @@ async function syncDrafts(client2, draftsFolderId) {
39298
39026
  if (pageItems.length < 50) break;
39299
39027
  page++;
39300
39028
  }
39301
- const seenIds = /* @__PURE__ */ new Set();
39302
- let synced = 0;
39029
+ const rows = [];
39303
39030
  for (const item of items) {
39304
- seenIds.add(item.id);
39305
- const modifiedAt = item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString();
39306
- const existing = getDraft(item.id);
39031
+ if (!b.take()) return { synced: 0, done: false };
39307
39032
  const detail = parseLenient(
39308
39033
  DraftDetailSchema,
39309
39034
  await client2.request("GET", `/pub/v3/messages/${item.id}`),
39310
39035
  { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (drafts sync)" }
39311
39036
  );
39312
- const row = {
39037
+ rows.push({
39313
39038
  id: item.id,
39314
39039
  subject: detail.subject ?? item.subject ?? "(no subject)",
39315
39040
  body: detail.body ?? "",
39316
39041
  recipients: mapRecipients(item.recipients),
39317
39042
  replyToId: item.replyToId ?? null,
39318
- modifiedAt,
39043
+ modifiedAt: item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
39319
39044
  listData: item
39320
- };
39321
- upsertDraft(row);
39322
- if (getMessage(item.id)) deleteMessage(item.id);
39045
+ });
39046
+ }
39047
+ const ids = items.map((it) => it.id);
39048
+ const existingById = new Map((await store.getDrafts(ids)).map((d) => [d.id, d]));
39049
+ await store.upsertDrafts(rows);
39050
+ for (const stale of await store.getMessages(ids)) {
39051
+ await store.deleteMessage(stale.id);
39052
+ }
39053
+ let synced = 0;
39054
+ for (const row of rows) {
39055
+ const existing = existingById.get(row.id);
39323
39056
  if (!existing || existing.body !== row.body || existing.subject !== row.subject || existing.replyToId !== row.replyToId) {
39324
39057
  synced++;
39325
39058
  }
39326
39059
  }
39327
- for (const id of listDraftIds()) {
39328
- if (!seenIds.has(id)) deleteDraft(id);
39060
+ const seenIds = new Set(ids);
39061
+ for (const id of await store.listDraftIds()) {
39062
+ if (!seenIds.has(id)) await store.deleteDraft(id);
39329
39063
  }
39330
- return { synced };
39064
+ return { synced, done: true };
39331
39065
  }
39332
- async function syncAll(client2, opts) {
39066
+ async function syncAll(client2, opts, store) {
39333
39067
  const folders = opts.folders ?? ["inbox", "sent", "drafts"];
39334
- const ids = await resolveFolderIds(client2);
39068
+ const budget = makeBudget(opts.maxRequests ?? Number.POSITIVE_INFINITY);
39069
+ budget.take();
39070
+ const ids = await resolveFolderIds(client2, store);
39335
39071
  const synced = {};
39336
39072
  let unreadInbox = [];
39073
+ let done = true;
39337
39074
  for (const folder of folders) {
39338
39075
  if (folder === "inbox") {
39339
39076
  const r = await syncMessageFolder(client2, "inbox", ids.inbox, {
39340
39077
  fetchUnreadBodies: opts.fetchUnreadBodies ?? false,
39341
- deep: opts.deep ?? false
39342
- });
39078
+ deep: opts.deep ?? false,
39079
+ budget
39080
+ }, store);
39343
39081
  synced.inbox = r.synced;
39344
39082
  unreadInbox = r.unread;
39083
+ if (!r.done) done = false;
39345
39084
  } else if (folder === "sent") {
39346
39085
  const r = await syncMessageFolder(client2, "sent", ids.sent, {
39347
39086
  fetchUnreadBodies: false,
39348
- deep: opts.deep ?? false
39349
- });
39087
+ deep: opts.deep ?? false,
39088
+ budget
39089
+ }, store);
39350
39090
  synced.sent = r.synced;
39091
+ if (!r.done) done = false;
39351
39092
  } else if (folder === "drafts") {
39352
- const r = await syncDrafts(client2, ids.drafts);
39093
+ const r = await syncDrafts(client2, ids.drafts, store, budget);
39353
39094
  synced.drafts = r.synced;
39095
+ if (!r.done) done = false;
39354
39096
  }
39355
39097
  }
39356
- const note = unreadInbox.length > 0 ? `${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them \u2014 this will mark them as read on OFW.` : void 0;
39357
- return { synced, unreadInbox, ...note ? { note } : {} };
39098
+ const notes = [];
39099
+ if (unreadInbox.length > 0) {
39100
+ notes.push(`${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them \u2014 this will mark them as read on OFW.`);
39101
+ }
39102
+ if (!done) {
39103
+ notes.push("Paused after the request budget to stay within the hosting limit; more pages remain \u2014 call ofw_sync_messages again with the same arguments to resume where it left off and continue the backfill.");
39104
+ }
39105
+ const note = notes.length > 0 ? notes.join("\n\n") : void 0;
39106
+ return { synced, unreadInbox, done, ...note ? { note } : {} };
39107
+ }
39108
+
39109
+ // src/config.ts
39110
+ import { createHash } from "node:crypto";
39111
+ import { homedir as homedir3 } from "node:os";
39112
+ import { join as join4 } from "node:path";
39113
+ function readCacheIdentity() {
39114
+ return readEnvVar("OFW_CACHE_IDENTITY") ?? readEnvVar("OFW_USERNAME") ?? "_default";
39115
+ }
39116
+ function getCacheDir() {
39117
+ const override = process.env.OFW_CACHE_DIR;
39118
+ if (override && override.trim().length > 0) return override.trim();
39119
+ return join4(homedir3(), ".cache", "ofw-mcp");
39120
+ }
39121
+ function getCacheDbPath() {
39122
+ const identity = readCacheIdentity();
39123
+ const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
39124
+ return join4(getCacheDir(), `${hash2}.db`);
39125
+ }
39126
+ function getAttachmentsDir() {
39127
+ const override = process.env.OFW_ATTACHMENTS_DIR;
39128
+ if (override && override.trim().length > 0) return override.trim();
39129
+ return join4(homedir3(), "Downloads", "ofw-mcp");
39130
+ }
39131
+ function getWriteMode() {
39132
+ const raw = process.env.OFW_WRITE_MODE;
39133
+ if (typeof raw !== "string" || raw.trim().length === 0) return "all";
39134
+ const mode = raw.trim().toLowerCase();
39135
+ if (mode === "none" || mode === "drafts" || mode === "all") return mode;
39136
+ console.error(
39137
+ `[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
39138
+ );
39139
+ return "none";
39140
+ }
39141
+ function getCalendarWritesAllowed() {
39142
+ const mode = getWriteMode();
39143
+ if (mode === "all") return true;
39144
+ return mode === "drafts" && parseBoolEnv("OFW_CALENDAR_WRITES");
39145
+ }
39146
+ function getDefaultInlineAttachments() {
39147
+ return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
39148
+ }
39149
+ function getSyncMaxRequests() {
39150
+ const raw = readEnvVar("OFW_SYNC_MAX_REQUESTS");
39151
+ if (raw === void 0) return Number.POSITIVE_INFINITY;
39152
+ const n = Number(raw);
39153
+ if (!Number.isInteger(n) || n <= 0) return Number.POSITIVE_INFINITY;
39154
+ return n;
39358
39155
  }
39359
39156
 
39360
39157
  // src/tools/messages.ts
39361
- import { mkdirSync as mkdirSync2, readFileSync, statSync, writeFileSync } from "node:fs";
39362
- import { basename, dirname as dirname3, extname, join as join5 } from "node:path";
39158
+ import { basename, join as join5 } from "node:path";
39363
39159
  var DateSchema = external_exports.looseObject({ dateTime: external_exports.string() });
39364
39160
  var SentDetailSchema = external_exports.looseObject({
39365
39161
  subject: external_exports.string().optional(),
@@ -39382,7 +39178,11 @@ var MessageDetailSchema = external_exports.looseObject({
39382
39178
  date: DateSchema,
39383
39179
  from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
39384
39180
  files: external_exports.array(external_exports.number()).optional(),
39385
- recipients: external_exports.array(ApiRecipientSchema).optional()
39181
+ recipients: external_exports.array(ApiRecipientSchema).optional(),
39182
+ // The detail payload carries its own owning folder ({id, name}). We read the
39183
+ // id to label a live-fetched message sent-vs-inbox instead of blindly
39184
+ // defaulting to inbox — see the folder derivation in ofw_get_message.
39185
+ folder: external_exports.looseObject({ id: external_exports.number() }).optional()
39386
39186
  });
39387
39187
  var DetailFilesSchema = external_exports.looseObject({ files: external_exports.array(external_exports.number()).optional() });
39388
39188
  var UploadedFileSchema = external_exports.looseObject({
@@ -39393,33 +39193,6 @@ var UploadedFileSchema = external_exports.looseObject({
39393
39193
  sizeInBytes: external_exports.number().optional(),
39394
39194
  shareClass: external_exports.string().optional()
39395
39195
  });
39396
- var MIME_BY_EXT = {
39397
- ".pdf": "application/pdf",
39398
- ".png": "image/png",
39399
- ".jpg": "image/jpeg",
39400
- ".jpeg": "image/jpeg",
39401
- ".gif": "image/gif",
39402
- ".webp": "image/webp",
39403
- ".heic": "image/heic",
39404
- ".txt": "text/plain",
39405
- ".md": "text/markdown",
39406
- ".csv": "text/csv",
39407
- ".html": "text/html",
39408
- ".htm": "text/html",
39409
- ".json": "application/json",
39410
- ".xml": "application/xml",
39411
- ".doc": "application/msword",
39412
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
39413
- ".xls": "application/vnd.ms-excel",
39414
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
39415
- ".ppt": "application/vnd.ms-powerpoint",
39416
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
39417
- ".zip": "application/zip",
39418
- ".ics": "text/calendar"
39419
- };
39420
- function mimeFromName(name) {
39421
- return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
39422
- }
39423
39196
  function listDataHintsAtFiles(listData) {
39424
39197
  if (typeof listData !== "object" || listData === null) return false;
39425
39198
  const ld = listData;
@@ -39427,7 +39200,7 @@ function listDataHintsAtFiles(listData) {
39427
39200
  if (Array.isArray(ld.files)) return ld.files.length > 0;
39428
39201
  return false;
39429
39202
  }
39430
- function registerMessageTools(server, client2) {
39203
+ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39431
39204
  const writeMode = getWriteMode();
39432
39205
  const allowSend = writeMode === "all";
39433
39206
  const allowDrafts = writeMode !== "none";
@@ -39463,9 +39236,10 @@ function registerMessageTools(server, client2) {
39463
39236
  note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.'
39464
39237
  });
39465
39238
  }
39239
+ const cache = cacheProvider();
39466
39240
  const filter = { folder, since: args.since, until: args.until, q: args.q };
39467
- const total = countMessages(filter);
39468
- const messages = listMessages({ ...filter, page, size });
39241
+ const total = await cache.countMessages(filter);
39242
+ const messages = await cache.listMessages({ ...filter, page, size });
39469
39243
  const payload = { messages, total, page, size };
39470
39244
  if (total === 0) {
39471
39245
  payload.note = "No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.";
@@ -39482,7 +39256,8 @@ function registerMessageTools(server, client2) {
39482
39256
  }
39483
39257
  }, async (args) => {
39484
39258
  const id = Number(args.messageId);
39485
- const draftRow = getDraft(id);
39259
+ const cache = cacheProvider();
39260
+ const draftRow = await cache.getDraft(id);
39486
39261
  if (draftRow !== null) {
39487
39262
  return jsonResponse({
39488
39263
  id: draftRow.id,
@@ -39502,7 +39277,7 @@ function registerMessageTools(server, client2) {
39502
39277
  attachments: []
39503
39278
  });
39504
39279
  }
39505
- const cached2 = getMessage(id);
39280
+ const cached2 = await cache.getMessage(id);
39506
39281
  if (cached2 && cached2.body !== null) {
39507
39282
  let row2 = cached2;
39508
39283
  if (cached2.folder === "sent" && !hasRealView(cached2.recipients)) {
@@ -39518,11 +39293,11 @@ function registerMessageTools(server, client2) {
39518
39293
  recipients,
39519
39294
  listData: { ...cached2.listData, showNeverViewed: !hasRealView(recipients) }
39520
39295
  };
39521
- upsertMessage(row2);
39296
+ await cache.upsertMessage(row2);
39522
39297
  } catch {
39523
39298
  }
39524
39299
  }
39525
- let attachments2 = listAttachmentsForMessage(id);
39300
+ let attachments2 = await cache.listAttachmentsForMessage(id);
39526
39301
  if (attachments2.length === 0 && listDataHintsAtFiles(row2.listData)) {
39527
39302
  try {
39528
39303
  const detail2 = parseLenient(
@@ -39531,8 +39306,8 @@ function registerMessageTools(server, client2) {
39531
39306
  { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (attachment backfill)" }
39532
39307
  );
39533
39308
  if (Array.isArray(detail2.files) && detail2.files.length > 0) {
39534
- await fetchAttachmentMetaForMessage(client2, id, detail2.files);
39535
- attachments2 = listAttachmentsForMessage(id);
39309
+ await fetchAttachmentMetaForMessage(client2, id, detail2.files, cache);
39310
+ attachments2 = await cache.listAttachmentsForMessage(id);
39536
39311
  }
39537
39312
  } catch {
39538
39313
  }
@@ -39544,7 +39319,13 @@ function registerMessageTools(server, client2) {
39544
39319
  await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`),
39545
39320
  { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (ofw_get_message)" }
39546
39321
  );
39547
- const folder = cached2?.folder ?? "inbox";
39322
+ let folder = cached2?.folder ?? "inbox";
39323
+ if (!cached2) {
39324
+ const sentFolderId = await cache.getMeta("sent_folder_id");
39325
+ if (sentFolderId !== null && detail.folder?.id != null && String(detail.folder.id) === sentFolderId) {
39326
+ folder = "sent";
39327
+ }
39328
+ }
39548
39329
  const row = {
39549
39330
  id: detail.id,
39550
39331
  folder,
@@ -39558,11 +39339,11 @@ function registerMessageTools(server, client2) {
39558
39339
  chainRootId: cached2?.chainRootId ?? null,
39559
39340
  listData: cached2?.listData ?? detail
39560
39341
  };
39561
- upsertMessage(row);
39342
+ await cache.upsertMessage(row);
39562
39343
  if (Array.isArray(detail.files) && detail.files.length > 0) {
39563
- await fetchAttachmentMetaForMessage(client2, detail.id, detail.files);
39344
+ await fetchAttachmentMetaForMessage(client2, detail.id, detail.files, cache);
39564
39345
  }
39565
- const attachments = listAttachmentsForMessage(detail.id);
39346
+ const attachments = await cache.listAttachmentsForMessage(detail.id);
39566
39347
  return jsonResponse({ ...row, attachments });
39567
39348
  });
39568
39349
  if (allowSend) server.registerTool("ofw_send_message", {
@@ -39582,6 +39363,7 @@ function registerMessageTools(server, client2) {
39582
39363
  throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
39583
39364
  }
39584
39365
  const draftRef = args.messageId ?? args.draftId;
39366
+ const cache = cacheProvider();
39585
39367
  let subject = args.subject;
39586
39368
  let body = args.body;
39587
39369
  let recipientIds = args.recipientIds;
@@ -39590,7 +39372,7 @@ function registerMessageTools(server, client2) {
39590
39372
  let draftFound = false;
39591
39373
  if (draftRef !== void 0) {
39592
39374
  draftLookupAttempted = true;
39593
- const draft = getDraft(draftRef);
39375
+ const draft = await cache.getDraft(draftRef);
39594
39376
  if (draft !== null) {
39595
39377
  draftFound = true;
39596
39378
  subject = subject ?? draft.subject;
@@ -39619,11 +39401,11 @@ function registerMessageTools(server, client2) {
39619
39401
  let chainRootId = null;
39620
39402
  let rewriteNote = null;
39621
39403
  if (requestedReplyTo !== null) {
39622
- resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
39404
+ resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
39623
39405
  if (resolvedReplyTo !== requestedReplyTo) {
39624
39406
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
39625
39407
  }
39626
- const parent = getMessage(resolvedReplyTo);
39408
+ const parent = await cache.getMessage(resolvedReplyTo);
39627
39409
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
39628
39410
  }
39629
39411
  const myFileIDs = args.myFileIDs ?? [];
@@ -39653,10 +39435,10 @@ function registerMessageTools(server, client2) {
39653
39435
  chainRootId,
39654
39436
  listData: detail
39655
39437
  };
39656
- upsertMessage(persisted);
39438
+ await cache.upsertMessage(persisted);
39657
39439
  for (const fileId of myFileIDs) {
39658
- const existing = getAttachment(fileId);
39659
- upsertAttachmentForMessage({
39440
+ const existing = await cache.getAttachment(fileId);
39441
+ await cache.upsertAttachmentForMessage({
39660
39442
  fileId,
39661
39443
  fileName: existing?.fileName ?? `file-${fileId}`,
39662
39444
  label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
@@ -39673,7 +39455,7 @@ function registerMessageTools(server, client2) {
39673
39455
  unconfirmedNote = `WARNING: OFW's send response did not include a message id, so the send could not be confirmed. ${draftClause} ourfamilywizard.com to see whether the message went out before retrying.`;
39674
39456
  } else if (draftRef !== void 0) {
39675
39457
  await deleteOFWMessages(client2, [draftRef]);
39676
- deleteDraft(draftRef);
39458
+ await cache.deleteDraft(draftRef);
39677
39459
  }
39678
39460
  const responseObj = persisted ?? raw;
39679
39461
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
@@ -39692,7 +39474,7 @@ ${text}` : text);
39692
39474
  }, async (args) => {
39693
39475
  const page = args.page ?? 1;
39694
39476
  const size = args.size ?? 50;
39695
- const drafts = listDrafts({ page, size });
39477
+ const drafts = await cacheProvider().listDrafts({ page, size });
39696
39478
  const payload = drafts.length === 0 ? { drafts: [], note: "Cache empty. Call ofw_sync_messages to populate." } : { drafts };
39697
39479
  return jsonResponse(payload);
39698
39480
  });
@@ -39708,11 +39490,12 @@ ${text}` : text);
39708
39490
  myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment)").optional()
39709
39491
  }
39710
39492
  }, async (args) => {
39493
+ const cache = cacheProvider();
39711
39494
  const requestedReplyTo = args.replyToId ?? null;
39712
39495
  let resolvedReplyTo = requestedReplyTo;
39713
39496
  let rewriteNote = null;
39714
39497
  if (requestedReplyTo !== null) {
39715
- resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
39498
+ resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
39716
39499
  if (resolvedReplyTo !== requestedReplyTo) {
39717
39500
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
39718
39501
  }
@@ -39747,11 +39530,11 @@ ${text}` : text);
39747
39530
  modifiedAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
39748
39531
  listData: detail
39749
39532
  };
39750
- upsertDraft(persisted);
39533
+ await cache.upsertDraft(persisted);
39751
39534
  if (args.messageId !== void 0 && args.messageId !== newId) {
39752
39535
  try {
39753
39536
  await deleteOFWMessages(client2, [args.messageId]);
39754
- deleteDraft(args.messageId);
39537
+ await cache.deleteDraft(args.messageId);
39755
39538
  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.)`;
39756
39539
  } catch (e) {
39757
39540
  replaceNote = `WARNING: New draft ${newId} created successfully, but failed to delete the old draft (${args.messageId}): ${e.message}. You may want to clean it up manually with ofw_delete_draft.`;
@@ -39773,7 +39556,7 @@ ${text}` : text);
39773
39556
  }
39774
39557
  }, async (args) => {
39775
39558
  const data = await deleteOFWMessages(client2, [args.messageId]);
39776
- deleteDraft(args.messageId);
39559
+ await cacheProvider().deleteDraft(args.messageId);
39777
39560
  return data ? jsonResponse(data) : textResponse("Draft deleted.");
39778
39561
  });
39779
39562
  server.registerTool("ofw_get_unread_sent", {
@@ -39786,7 +39569,7 @@ ${text}` : text);
39786
39569
  }, async (args) => {
39787
39570
  const page = args.page ?? 1;
39788
39571
  const size = args.size ?? 50;
39789
- const sent = listMessages({ folder: "sent", page, size });
39572
+ const sent = await cacheProvider().listMessages({ folder: "sent", page, size });
39790
39573
  if (sent.length === 0) {
39791
39574
  return jsonResponse({ note: "Sent cache is empty. Call ofw_sync_messages to populate." });
39792
39575
  }
@@ -39812,13 +39595,9 @@ ${text}` : text);
39812
39595
  description: external_exports.string().describe("Description shown in OFW My Files (default: filename)").optional()
39813
39596
  }
39814
39597
  }, async (args) => {
39815
- const abs = expandPath2(args.path);
39816
- const stat = statSync(abs);
39817
- if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
39818
- const fileName = basename(abs);
39819
- const mime = mimeFromName(fileName);
39598
+ const { blob, fileName, mimeType: mime, sizeBytes } = await attachmentIO.resolveUpload(args.path);
39820
39599
  const form = new FormData();
39821
- form.append("file", await fileBlob(abs, { type: mime }), fileName);
39600
+ form.append("file", blob, fileName);
39822
39601
  form.append("source", "message");
39823
39602
  form.append("description", args.description ?? fileName);
39824
39603
  form.append("label", args.label ?? fileName);
@@ -39829,12 +39608,12 @@ ${text}` : text);
39829
39608
  await client2.request("POST", "/pub/v3/myfiles/multipart", form),
39830
39609
  { label: "ofw-mcp", context: "POST /pub/v3/myfiles/multipart (ofw_upload_attachment)", mode: "strict" }
39831
39610
  );
39832
- upsertAttachmentForMessage({
39611
+ await cacheProvider().upsertAttachmentForMessage({
39833
39612
  fileId: meta3.fileId,
39834
39613
  fileName: meta3.fileName ?? fileName,
39835
39614
  label: meta3.label ?? args.label ?? fileName,
39836
39615
  mimeType: meta3.fileType ?? mime,
39837
- sizeBytes: typeof meta3.sizeInBytes === "number" ? meta3.sizeInBytes : stat.size,
39616
+ sizeBytes: typeof meta3.sizeInBytes === "number" ? meta3.sizeInBytes : sizeBytes,
39838
39617
  metadata: meta3,
39839
39618
  messageId: 0
39840
39619
  });
@@ -39842,7 +39621,7 @@ ${text}` : text);
39842
39621
  fileId: meta3.fileId,
39843
39622
  fileName: meta3.fileName ?? fileName,
39844
39623
  mimeType: meta3.fileType ?? mime,
39845
- sizeBytes: meta3.sizeInBytes ?? stat.size,
39624
+ sizeBytes: meta3.sizeInBytes ?? sizeBytes,
39846
39625
  shareClass: meta3.shareClass ?? args.shareClass ?? "PRIVATE",
39847
39626
  note: "Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it."
39848
39627
  });
@@ -39858,11 +39637,12 @@ ${text}` : text);
39858
39637
  }
39859
39638
  }, async (args) => {
39860
39639
  const fileId = args.fileId;
39640
+ const cache = cacheProvider();
39861
39641
  const inline = args.inline ?? getDefaultInlineAttachments();
39862
- let cached2 = getAttachment(fileId);
39642
+ let cached2 = await cache.getAttachment(fileId);
39863
39643
  if (!cached2) {
39864
- await fetchAttachmentMeta(client2, fileId, 0);
39865
- cached2 = getAttachment(fileId);
39644
+ await fetchAttachmentMeta(client2, fileId, 0, cache);
39645
+ cached2 = await cache.getAttachment(fileId);
39866
39646
  if (!cached2) throw new Error(`failed to fetch metadata for fileId ${fileId}`);
39867
39647
  }
39868
39648
  if (inline) {
@@ -39870,10 +39650,7 @@ ${text}` : text);
39870
39650
  let mimeType = cached2.mimeType;
39871
39651
  let fileName = cached2.fileName;
39872
39652
  if (cached2.downloadedPath) {
39873
- try {
39874
- bytes = readFileSync(cached2.downloadedPath);
39875
- } catch {
39876
- }
39653
+ bytes = attachmentIO.readDownloaded(cached2.downloadedPath);
39877
39654
  }
39878
39655
  if (bytes === null) {
39879
39656
  const response2 = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
@@ -39918,9 +39695,8 @@ ${text}` : text);
39918
39695
  });
39919
39696
  }
39920
39697
  const response = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
39921
- mkdirSync2(dirname3(dest), { recursive: true });
39922
- writeFileSync(dest, response.body);
39923
- markAttachmentDownloaded(fileId, dest);
39698
+ attachmentIO.writeDownload(dest, response.body);
39699
+ await cache.markAttachmentDownloaded(fileId, dest);
39924
39700
  return jsonResponse({
39925
39701
  fileId,
39926
39702
  path: dest,
@@ -39930,19 +39706,21 @@ ${text}` : text);
39930
39706
  });
39931
39707
  });
39932
39708
  server.registerTool("ofw_sync_messages", {
39933
- 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).",
39709
+ 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 \u2014 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).",
39934
39710
  annotations: { readOnlyHint: false },
39935
39711
  inputSchema: {
39936
39712
  folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).describe("Folders to sync (default: all three)").optional(),
39937
39713
  fetchUnreadBodies: external_exports.boolean().describe("If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.").optional(),
39938
- deep: external_exports.boolean().describe("If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.").optional()
39714
+ deep: external_exports.boolean().describe("If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.").optional(),
39715
+ maxRequests: external_exports.number().int().min(1).describe("Maximum OFW requests this single call may make before pausing. When hit, the response reports done:false \u2014 call again with the same arguments to continue. Omit to use the server default (OFW_SYNC_MAX_REQUESTS, or unbounded on local installs).").optional()
39939
39716
  }
39940
39717
  }, async (args) => {
39941
39718
  const result = await syncAll(client2, {
39942
39719
  folders: args.folders,
39943
39720
  fetchUnreadBodies: args.fetchUnreadBodies,
39944
- deep: args.deep
39945
- });
39721
+ deep: args.deep,
39722
+ maxRequests: args.maxRequests ?? getSyncMaxRequests()
39723
+ }, cacheProvider());
39946
39724
  return jsonResponse(result);
39947
39725
  });
39948
39726
  }
@@ -40176,6 +39954,584 @@ function registerJournalTools(server, client2) {
40176
39954
  });
40177
39955
  }
40178
39956
 
39957
+ // src/cache/node.ts
39958
+ import { DatabaseSync } from "node:sqlite";
39959
+ import { mkdirSync, chmodSync, existsSync } from "node:fs";
39960
+ import { dirname as dirname2 } from "node:path";
39961
+
39962
+ // src/cache/store.ts
39963
+ function rowFromDb(r) {
39964
+ return {
39965
+ id: r.id,
39966
+ folder: r.folder,
39967
+ subject: r.subject,
39968
+ fromUser: r.from_user,
39969
+ sentAt: r.sent_at,
39970
+ recipients: JSON.parse(r.recipients_json),
39971
+ body: r.body,
39972
+ fetchedBodyAt: r.fetched_body_at,
39973
+ replyToId: r.reply_to_id,
39974
+ chainRootId: r.chain_root_id,
39975
+ listData: JSON.parse(r.list_data_json)
39976
+ };
39977
+ }
39978
+ function draftFromDb(r) {
39979
+ return {
39980
+ id: r.id,
39981
+ subject: r.subject,
39982
+ body: r.body,
39983
+ recipients: JSON.parse(r.recipients_json),
39984
+ replyToId: r.reply_to_id,
39985
+ modifiedAt: r.modified_at,
39986
+ listData: JSON.parse(r.list_data_json)
39987
+ };
39988
+ }
39989
+ function attachmentFromDb(r) {
39990
+ return {
39991
+ fileId: r.file_id,
39992
+ fileName: r.file_name,
39993
+ label: r.label,
39994
+ mimeType: r.mime_type,
39995
+ sizeBytes: r.size_bytes,
39996
+ metadata: JSON.parse(r.metadata_json),
39997
+ messageIds: JSON.parse(r.message_ids_json),
39998
+ downloadedPath: r.downloaded_path,
39999
+ downloadedAt: r.downloaded_at
40000
+ };
40001
+ }
40002
+ function nullish3(v) {
40003
+ return v === void 0 ? null : v;
40004
+ }
40005
+ function requireString(field, v) {
40006
+ if (typeof v === "string") return v;
40007
+ throw new Error(`cache: ${field} is required (got ${v === void 0 ? "undefined" : "null"})`);
40008
+ }
40009
+ var SCHEMA_STATEMENTS = [
40010
+ `CREATE TABLE IF NOT EXISTS messages (
40011
+ id INTEGER PRIMARY KEY,
40012
+ folder TEXT NOT NULL,
40013
+ subject TEXT NOT NULL,
40014
+ from_user TEXT NOT NULL,
40015
+ sent_at TEXT NOT NULL,
40016
+ recipients_json TEXT NOT NULL,
40017
+ body TEXT,
40018
+ fetched_body_at TEXT,
40019
+ reply_to_id INTEGER,
40020
+ chain_root_id INTEGER,
40021
+ list_data_json TEXT NOT NULL,
40022
+ last_seen_at TEXT NOT NULL
40023
+ )`,
40024
+ `CREATE INDEX IF NOT EXISTS idx_messages_folder_sent_at ON messages(folder, sent_at DESC)`,
40025
+ `CREATE INDEX IF NOT EXISTS idx_messages_chain_root ON messages(chain_root_id)`,
40026
+ `CREATE TABLE IF NOT EXISTS drafts (
40027
+ id INTEGER PRIMARY KEY,
40028
+ subject TEXT NOT NULL,
40029
+ body TEXT NOT NULL,
40030
+ recipients_json TEXT NOT NULL,
40031
+ reply_to_id INTEGER,
40032
+ modified_at TEXT NOT NULL,
40033
+ list_data_json TEXT NOT NULL
40034
+ )`,
40035
+ `CREATE TABLE IF NOT EXISTS sync_state (
40036
+ folder TEXT PRIMARY KEY,
40037
+ last_sync_at TEXT NOT NULL,
40038
+ newest_id INTEGER
40039
+ )`,
40040
+ `CREATE TABLE IF NOT EXISTS meta (
40041
+ key TEXT PRIMARY KEY,
40042
+ value TEXT NOT NULL
40043
+ )`,
40044
+ // v2: attachments table. Idempotent — IF NOT EXISTS.
40045
+ `CREATE TABLE IF NOT EXISTS attachments (
40046
+ file_id INTEGER PRIMARY KEY,
40047
+ file_name TEXT NOT NULL,
40048
+ label TEXT NOT NULL,
40049
+ mime_type TEXT NOT NULL,
40050
+ size_bytes INTEGER,
40051
+ metadata_json TEXT NOT NULL,
40052
+ message_ids_json TEXT NOT NULL,
40053
+ downloaded_path TEXT,
40054
+ downloaded_at TEXT,
40055
+ fetched_metadata_at TEXT NOT NULL
40056
+ )`
40057
+ ];
40058
+ var MIGRATIONS = [
40059
+ // Resumable deep-sync cursor. Absent/NULL → SyncState.resumePage null.
40060
+ "ALTER TABLE sync_state ADD COLUMN resume_page INTEGER"
40061
+ ];
40062
+ var SCHEMA_VERSION = "2";
40063
+ function buildMessageFilter(opts) {
40064
+ const wheres = [];
40065
+ const params = [];
40066
+ if (opts.folder !== void 0) {
40067
+ wheres.push("folder = ?");
40068
+ params.push(opts.folder);
40069
+ }
40070
+ if (opts.since !== void 0) {
40071
+ wheres.push("sent_at >= ?");
40072
+ params.push(opts.since);
40073
+ }
40074
+ if (opts.until !== void 0) {
40075
+ wheres.push("sent_at < ?");
40076
+ params.push(opts.until);
40077
+ }
40078
+ if (opts.q !== void 0 && opts.q.length > 0) {
40079
+ const pattern = `%${opts.q}%`;
40080
+ wheres.push("(subject LIKE ? OR body LIKE ?)");
40081
+ params.push(pattern, pattern);
40082
+ }
40083
+ return {
40084
+ where: wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "",
40085
+ params
40086
+ };
40087
+ }
40088
+ var OFWCacheCore = class {
40089
+ constructor(db) {
40090
+ this.db = db;
40091
+ for (const stmt of SCHEMA_STATEMENTS) this.db.execScript(stmt);
40092
+ for (const stmt of MIGRATIONS) {
40093
+ try {
40094
+ this.db.execScript(stmt);
40095
+ } catch {
40096
+ }
40097
+ }
40098
+ this.db.run(
40099
+ "INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
40100
+ ["schema_version", SCHEMA_VERSION]
40101
+ );
40102
+ }
40103
+ db;
40104
+ upsertMessage(row) {
40105
+ this.db.run(
40106
+ `INSERT INTO messages (
40107
+ id, folder, subject, from_user, sent_at, recipients_json,
40108
+ body, fetched_body_at, reply_to_id, chain_root_id, list_data_json, last_seen_at
40109
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
40110
+ ON CONFLICT(id) DO UPDATE SET
40111
+ folder=excluded.folder,
40112
+ subject=excluded.subject,
40113
+ from_user=excluded.from_user,
40114
+ sent_at=excluded.sent_at,
40115
+ recipients_json=excluded.recipients_json,
40116
+ body=excluded.body,
40117
+ fetched_body_at=excluded.fetched_body_at,
40118
+ reply_to_id=excluded.reply_to_id,
40119
+ chain_root_id=excluded.chain_root_id,
40120
+ list_data_json=excluded.list_data_json,
40121
+ last_seen_at=excluded.last_seen_at`,
40122
+ [
40123
+ row.id,
40124
+ requireString("messages.folder", row.folder),
40125
+ requireString("messages.subject", row.subject),
40126
+ requireString("messages.fromUser", row.fromUser),
40127
+ requireString("messages.sentAt", row.sentAt),
40128
+ JSON.stringify(row.recipients ?? []),
40129
+ nullish3(row.body),
40130
+ nullish3(row.fetchedBodyAt),
40131
+ nullish3(row.replyToId),
40132
+ nullish3(row.chainRootId),
40133
+ JSON.stringify(row.listData ?? null),
40134
+ (/* @__PURE__ */ new Date()).toISOString()
40135
+ ]
40136
+ );
40137
+ }
40138
+ /**
40139
+ * Batch upsert every row in a single transaction — one round-trip's worth of
40140
+ * work (crucial on the Durable Object backend, where each RPC is a subrequest).
40141
+ * Empty array is a no-op (no transaction opened).
40142
+ */
40143
+ upsertMessages(rows) {
40144
+ if (rows.length === 0) return;
40145
+ this.db.transaction(() => {
40146
+ for (const row of rows) this.upsertMessage(row);
40147
+ });
40148
+ }
40149
+ getMessage(id) {
40150
+ const r = this.db.get("SELECT * FROM messages WHERE id = ?", [id]);
40151
+ return r ? rowFromDb(r) : null;
40152
+ }
40153
+ /**
40154
+ * Batch read: one `SELECT ... WHERE id IN (...)` returning the present rows
40155
+ * (absent ids are simply omitted — order is not guaranteed). Empty ids returns
40156
+ * `[]` without querying.
40157
+ */
40158
+ getMessages(ids) {
40159
+ if (ids.length === 0) return [];
40160
+ const placeholders = ids.map(() => "?").join(", ");
40161
+ const rows = this.db.all(
40162
+ `SELECT * FROM messages WHERE id IN (${placeholders})`,
40163
+ ids
40164
+ );
40165
+ return rows.map(rowFromDb);
40166
+ }
40167
+ /**
40168
+ * Remove a row from the `messages` table. Used by syncDrafts to evict
40169
+ * stale rows that were cached when a draft was previously read through
40170
+ * `ofw_get_message` (which would have wrongly classified it as `inbox`)
40171
+ * — the drafts table is the authoritative source for that id now.
40172
+ */
40173
+ deleteMessage(id) {
40174
+ this.db.run("DELETE FROM messages WHERE id = ?", [id]);
40175
+ }
40176
+ listMessages(opts) {
40177
+ const { where, params } = buildMessageFilter(opts);
40178
+ const offset = (opts.page - 1) * opts.size;
40179
+ const rows = this.db.all(
40180
+ `SELECT * FROM messages ${where}
40181
+ ORDER BY sent_at DESC, id DESC
40182
+ LIMIT ? OFFSET ?`,
40183
+ [...params, opts.size, offset]
40184
+ );
40185
+ return rows.map(rowFromDb);
40186
+ }
40187
+ countMessages(opts) {
40188
+ const { where, params } = buildMessageFilter(opts);
40189
+ const r = this.db.get(`SELECT COUNT(*) as n FROM messages ${where}`, params);
40190
+ return r?.n ?? 0;
40191
+ }
40192
+ upsertDraft(row) {
40193
+ this.db.run(
40194
+ `INSERT INTO drafts (id, subject, body, recipients_json, reply_to_id, modified_at, list_data_json)
40195
+ VALUES (?, ?, ?, ?, ?, ?, ?)
40196
+ ON CONFLICT(id) DO UPDATE SET
40197
+ subject=excluded.subject,
40198
+ body=excluded.body,
40199
+ recipients_json=excluded.recipients_json,
40200
+ reply_to_id=excluded.reply_to_id,
40201
+ modified_at=excluded.modified_at,
40202
+ list_data_json=excluded.list_data_json`,
40203
+ [
40204
+ row.id,
40205
+ requireString("drafts.subject", row.subject),
40206
+ requireString("drafts.body", row.body),
40207
+ JSON.stringify(row.recipients ?? []),
40208
+ nullish3(row.replyToId),
40209
+ requireString("drafts.modifiedAt", row.modifiedAt),
40210
+ JSON.stringify(row.listData ?? null)
40211
+ ]
40212
+ );
40213
+ }
40214
+ /** Batch upsert every draft in a single transaction. Empty array is a no-op. */
40215
+ upsertDrafts(rows) {
40216
+ if (rows.length === 0) return;
40217
+ this.db.transaction(() => {
40218
+ for (const row of rows) this.upsertDraft(row);
40219
+ });
40220
+ }
40221
+ getDraft(id) {
40222
+ const r = this.db.get("SELECT * FROM drafts WHERE id = ?", [id]);
40223
+ return r ? draftFromDb(r) : null;
40224
+ }
40225
+ /**
40226
+ * Batch read: one `SELECT ... WHERE id IN (...)` returning the present drafts
40227
+ * (absent ids omitted — order not guaranteed). Empty ids returns `[]` without
40228
+ * querying.
40229
+ */
40230
+ getDrafts(ids) {
40231
+ if (ids.length === 0) return [];
40232
+ const placeholders = ids.map(() => "?").join(", ");
40233
+ const rows = this.db.all(
40234
+ `SELECT * FROM drafts WHERE id IN (${placeholders})`,
40235
+ ids
40236
+ );
40237
+ return rows.map(draftFromDb);
40238
+ }
40239
+ listDrafts(opts) {
40240
+ const offset = (opts.page - 1) * opts.size;
40241
+ const rows = this.db.all(
40242
+ "SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?",
40243
+ [opts.size, offset]
40244
+ );
40245
+ return rows.map(draftFromDb);
40246
+ }
40247
+ deleteDraft(id) {
40248
+ this.db.run("DELETE FROM drafts WHERE id = ?", [id]);
40249
+ }
40250
+ listDraftIds() {
40251
+ const rows = this.db.all("SELECT id FROM drafts", []);
40252
+ return rows.map((r) => r.id);
40253
+ }
40254
+ getSyncState(folder) {
40255
+ const r = this.db.get("SELECT last_sync_at, newest_id, resume_page FROM sync_state WHERE folder = ?", [folder]);
40256
+ if (!r) return null;
40257
+ return { lastSyncAt: r.last_sync_at, newestId: r.newest_id, resumePage: r.resume_page ?? null };
40258
+ }
40259
+ setSyncState(folder, state) {
40260
+ this.db.run(
40261
+ `INSERT INTO sync_state (folder, last_sync_at, newest_id, resume_page) VALUES (?, ?, ?, ?)
40262
+ ON CONFLICT(folder) DO UPDATE SET
40263
+ last_sync_at = excluded.last_sync_at,
40264
+ newest_id = excluded.newest_id,
40265
+ resume_page = excluded.resume_page`,
40266
+ [folder, state.lastSyncAt, nullish3(state.newestId), nullish3(state.resumePage)]
40267
+ );
40268
+ }
40269
+ getMeta(key) {
40270
+ const r = this.db.get("SELECT value FROM meta WHERE key = ?", [key]);
40271
+ return r ? r.value : null;
40272
+ }
40273
+ setMeta(key, value) {
40274
+ this.db.run(
40275
+ "INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
40276
+ [key, value]
40277
+ );
40278
+ }
40279
+ findLatestReplyTip(replyToId) {
40280
+ const parent = this.db.get("SELECT id, folder, chain_root_id FROM messages WHERE id = ?", [replyToId]);
40281
+ if (!parent) return replyToId;
40282
+ const chainRoot = parent.chain_root_id ?? parent.id;
40283
+ const tip = this.db.get(
40284
+ `SELECT id FROM messages
40285
+ WHERE folder = 'sent' AND chain_root_id = ?
40286
+ ORDER BY id DESC LIMIT 1`,
40287
+ [chainRoot]
40288
+ );
40289
+ return tip ? tip.id : replyToId;
40290
+ }
40291
+ getAttachment(fileId) {
40292
+ const r = this.db.get("SELECT * FROM attachments WHERE file_id = ?", [fileId]);
40293
+ return r ? attachmentFromDb(r) : null;
40294
+ }
40295
+ listAttachmentsForMessage(messageId) {
40296
+ const rows = this.db.all(
40297
+ `SELECT * FROM attachments
40298
+ WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
40299
+ ORDER BY file_id`,
40300
+ [messageId]
40301
+ );
40302
+ return rows.map(attachmentFromDb);
40303
+ }
40304
+ upsertAttachmentForMessage(input) {
40305
+ const existing = this.db.get("SELECT message_ids_json FROM attachments WHERE file_id = ?", [input.fileId]);
40306
+ const prior = existing ? JSON.parse(existing.message_ids_json) : [];
40307
+ let messageIds;
40308
+ if (input.messageId === 0) {
40309
+ messageIds = prior;
40310
+ } else if (prior.includes(input.messageId)) {
40311
+ messageIds = prior;
40312
+ } else {
40313
+ messageIds = [...prior, input.messageId];
40314
+ }
40315
+ this.db.run(
40316
+ `INSERT INTO attachments (
40317
+ file_id, file_name, label, mime_type, size_bytes,
40318
+ metadata_json, message_ids_json, fetched_metadata_at
40319
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
40320
+ ON CONFLICT(file_id) DO UPDATE SET
40321
+ file_name=excluded.file_name,
40322
+ label=excluded.label,
40323
+ mime_type=excluded.mime_type,
40324
+ size_bytes=excluded.size_bytes,
40325
+ metadata_json=excluded.metadata_json,
40326
+ message_ids_json=excluded.message_ids_json,
40327
+ fetched_metadata_at=excluded.fetched_metadata_at`,
40328
+ [
40329
+ input.fileId,
40330
+ requireString("attachments.fileName", input.fileName),
40331
+ requireString("attachments.label", input.label),
40332
+ requireString("attachments.mimeType", input.mimeType),
40333
+ nullish3(input.sizeBytes),
40334
+ JSON.stringify(input.metadata ?? null),
40335
+ JSON.stringify(messageIds),
40336
+ (/* @__PURE__ */ new Date()).toISOString()
40337
+ ]
40338
+ );
40339
+ }
40340
+ markAttachmentDownloaded(fileId, path) {
40341
+ this.db.run("UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?", [
40342
+ path,
40343
+ (/* @__PURE__ */ new Date()).toISOString(),
40344
+ fileId
40345
+ ]);
40346
+ }
40347
+ };
40348
+ var LocalCacheStore = class {
40349
+ constructor(core) {
40350
+ this.core = core;
40351
+ }
40352
+ core;
40353
+ async upsertMessage(row) {
40354
+ this.core.upsertMessage(row);
40355
+ }
40356
+ async upsertMessages(rows) {
40357
+ this.core.upsertMessages(rows);
40358
+ }
40359
+ async getMessage(id) {
40360
+ return this.core.getMessage(id);
40361
+ }
40362
+ async getMessages(ids) {
40363
+ return this.core.getMessages(ids);
40364
+ }
40365
+ async deleteMessage(id) {
40366
+ this.core.deleteMessage(id);
40367
+ }
40368
+ async listMessages(opts) {
40369
+ return this.core.listMessages(opts);
40370
+ }
40371
+ async countMessages(opts) {
40372
+ return this.core.countMessages(opts);
40373
+ }
40374
+ async upsertDraft(row) {
40375
+ this.core.upsertDraft(row);
40376
+ }
40377
+ async upsertDrafts(rows) {
40378
+ this.core.upsertDrafts(rows);
40379
+ }
40380
+ async getDraft(id) {
40381
+ return this.core.getDraft(id);
40382
+ }
40383
+ async getDrafts(ids) {
40384
+ return this.core.getDrafts(ids);
40385
+ }
40386
+ async listDrafts(opts) {
40387
+ return this.core.listDrafts(opts);
40388
+ }
40389
+ async deleteDraft(id) {
40390
+ this.core.deleteDraft(id);
40391
+ }
40392
+ async listDraftIds() {
40393
+ return this.core.listDraftIds();
40394
+ }
40395
+ async getSyncState(folder) {
40396
+ return this.core.getSyncState(folder);
40397
+ }
40398
+ async setSyncState(folder, state) {
40399
+ this.core.setSyncState(folder, state);
40400
+ }
40401
+ async getMeta(key) {
40402
+ return this.core.getMeta(key);
40403
+ }
40404
+ async setMeta(key, value) {
40405
+ this.core.setMeta(key, value);
40406
+ }
40407
+ async findLatestReplyTip(replyToId) {
40408
+ return this.core.findLatestReplyTip(replyToId);
40409
+ }
40410
+ async getAttachment(fileId) {
40411
+ return this.core.getAttachment(fileId);
40412
+ }
40413
+ async listAttachmentsForMessage(messageId) {
40414
+ return this.core.listAttachmentsForMessage(messageId);
40415
+ }
40416
+ async upsertAttachmentForMessage(input) {
40417
+ this.core.upsertAttachmentForMessage(input);
40418
+ }
40419
+ async markAttachmentDownloaded(fileId, path) {
40420
+ this.core.markAttachmentDownloaded(fileId, path);
40421
+ }
40422
+ };
40423
+
40424
+ // src/cache/node.ts
40425
+ var NodeSqlDriver = class {
40426
+ constructor(db) {
40427
+ this.db = db;
40428
+ }
40429
+ db;
40430
+ execScript(sql) {
40431
+ this.db.exec(sql);
40432
+ }
40433
+ run(sql, params) {
40434
+ this.db.prepare(sql).run(...params);
40435
+ }
40436
+ get(sql, params) {
40437
+ return this.db.prepare(sql).get(...params);
40438
+ }
40439
+ all(sql, params) {
40440
+ return this.db.prepare(sql).all(...params);
40441
+ }
40442
+ transaction(fn) {
40443
+ this.db.exec("BEGIN");
40444
+ try {
40445
+ fn();
40446
+ this.db.exec("COMMIT");
40447
+ } catch (e) {
40448
+ this.db.exec("ROLLBACK");
40449
+ throw e;
40450
+ }
40451
+ }
40452
+ };
40453
+ function enforceCachePermissions(dbPath) {
40454
+ chmodSync(dirname2(dbPath), 448);
40455
+ chmodSync(dbPath, 384);
40456
+ for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
40457
+ if (existsSync(sibling)) chmodSync(sibling, 384);
40458
+ }
40459
+ }
40460
+ var OFWCache = class _OFWCache extends LocalCacheStore {
40461
+ constructor(db, core) {
40462
+ super(core);
40463
+ this.db = db;
40464
+ }
40465
+ db;
40466
+ static open(path) {
40467
+ const memory = path === ":memory:";
40468
+ if (!memory) mkdirSync(dirname2(path), { recursive: true });
40469
+ const db = new DatabaseSync(path);
40470
+ if (!memory) enforceCachePermissions(path);
40471
+ db.exec("PRAGMA journal_mode = WAL");
40472
+ db.exec("PRAGMA foreign_keys = ON");
40473
+ const core = new OFWCacheCore(new NodeSqlDriver(db));
40474
+ if (!memory) enforceCachePermissions(path);
40475
+ return new _OFWCache(db, core);
40476
+ }
40477
+ close() {
40478
+ this.db.close();
40479
+ }
40480
+ };
40481
+
40482
+ // src/tools/attachments.ts
40483
+ import { readFileSync, statSync, mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
40484
+ import { basename as basename2, dirname as dirname3, extname } from "node:path";
40485
+ var MIME_BY_EXT = {
40486
+ ".pdf": "application/pdf",
40487
+ ".png": "image/png",
40488
+ ".jpg": "image/jpeg",
40489
+ ".jpeg": "image/jpeg",
40490
+ ".gif": "image/gif",
40491
+ ".webp": "image/webp",
40492
+ ".heic": "image/heic",
40493
+ ".txt": "text/plain",
40494
+ ".md": "text/markdown",
40495
+ ".csv": "text/csv",
40496
+ ".html": "text/html",
40497
+ ".htm": "text/html",
40498
+ ".json": "application/json",
40499
+ ".xml": "application/xml",
40500
+ ".doc": "application/msword",
40501
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
40502
+ ".xls": "application/vnd.ms-excel",
40503
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
40504
+ ".ppt": "application/vnd.ms-powerpoint",
40505
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
40506
+ ".zip": "application/zip",
40507
+ ".ics": "text/calendar"
40508
+ };
40509
+ function mimeFromName(name) {
40510
+ return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
40511
+ }
40512
+ var NodeAttachmentIO = class {
40513
+ async resolveUpload(path) {
40514
+ const abs = expandPath(path);
40515
+ const stat = statSync(abs);
40516
+ if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
40517
+ const fileName = basename2(abs);
40518
+ const mimeType = mimeFromName(fileName);
40519
+ const blob = await fileBlob(abs, { type: mimeType });
40520
+ return { blob, fileName, mimeType, sizeBytes: stat.size };
40521
+ }
40522
+ readDownloaded(path) {
40523
+ try {
40524
+ return readFileSync(path);
40525
+ } catch {
40526
+ return null;
40527
+ }
40528
+ }
40529
+ writeDownload(dest, bytes) {
40530
+ mkdirSync2(dirname3(dest), { recursive: true });
40531
+ writeFileSync(dest, bytes);
40532
+ }
40533
+ };
40534
+
40179
40535
  // src/index.ts
40180
40536
  var originalEmit = process.emit.bind(process);
40181
40537
  process.emit = function(event, ...args) {
@@ -40187,14 +40543,17 @@ process.emit = function(event, ...args) {
40187
40543
  }
40188
40544
  return originalEmit(event, ...args);
40189
40545
  };
40546
+ var nodeCache;
40547
+ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
40548
+ var nodeAttachmentIO = new NodeAttachmentIO();
40190
40549
  await runMcp({
40191
40550
  name: "ofw",
40192
- version: "2.5.0",
40551
+ version: "2.6.3",
40193
40552
  // x-release-please-version
40194
40553
  deps: client,
40195
40554
  tools: [
40196
40555
  registerUserTools,
40197
- registerMessageTools,
40556
+ (server, deps) => registerMessageTools(server, deps, nodeCacheProvider, nodeAttachmentIO),
40198
40557
  registerCalendarTools,
40199
40558
  registerExpenseTools,
40200
40559
  registerJournalTools