ofw-mcp 2.0.8 → 2.0.9

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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "OurFamilyWizard tools for Claude Code",
9
- "version": "2.0.8"
9
+ "version": "2.0.9"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -14,7 +14,7 @@
14
14
  "displayName": "OurFamilyWizard",
15
15
  "source": "./",
16
16
  "description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
17
- "version": "2.0.8",
17
+ "version": "2.0.9",
18
18
  "author": {
19
19
  "name": "Chris Chall"
20
20
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ofw",
3
3
  "displayName": "OurFamilyWizard",
4
- "version": "2.0.8",
4
+ "version": "2.0.9",
5
5
  "description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
6
6
  "author": {
7
7
  "name": "Chris Chall"
package/dist/bundle.js CHANGED
@@ -2980,7 +2980,7 @@ var require_compile = __commonJS({
2980
2980
  const schOrFunc = root.refs[ref];
2981
2981
  if (schOrFunc)
2982
2982
  return schOrFunc;
2983
- let _sch = resolve.call(this, root, ref);
2983
+ let _sch = resolve2.call(this, root, ref);
2984
2984
  if (_sch === void 0) {
2985
2985
  const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref];
2986
2986
  const { schemaId } = this.opts;
@@ -3007,7 +3007,7 @@ var require_compile = __commonJS({
3007
3007
  function sameSchemaEnv(s1, s2) {
3008
3008
  return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId;
3009
3009
  }
3010
- function resolve(root, ref) {
3010
+ function resolve2(root, ref) {
3011
3011
  let sch;
3012
3012
  while (typeof (sch = this.refs[ref]) == "string")
3013
3013
  ref = sch;
@@ -3582,7 +3582,7 @@ var require_fast_uri = __commonJS({
3582
3582
  }
3583
3583
  return uri;
3584
3584
  }
3585
- function resolve(baseURI, relativeURI, options) {
3585
+ function resolve2(baseURI, relativeURI, options) {
3586
3586
  const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" };
3587
3587
  const resolved = resolveComponent(parse3(baseURI, schemelessOptions), parse3(relativeURI, schemelessOptions), schemelessOptions, true);
3588
3588
  schemelessOptions.skipEscape = true;
@@ -3809,7 +3809,7 @@ var require_fast_uri = __commonJS({
3809
3809
  var fastUri = {
3810
3810
  SCHEMES,
3811
3811
  normalize,
3812
- resolve,
3812
+ resolve: resolve2,
3813
3813
  resolveComponent,
3814
3814
  equal,
3815
3815
  serialize,
@@ -12700,7 +12700,7 @@ var Doc = class {
12700
12700
  var version = {
12701
12701
  major: 4,
12702
12702
  minor: 4,
12703
- patch: 2
12703
+ patch: 3
12704
12704
  };
12705
12705
 
12706
12706
  // node_modules/zod/v4/core/schemas.js
@@ -14299,6 +14299,7 @@ var $ZodFile = /* @__PURE__ */ $constructor("$ZodFile", (inst, def) => {
14299
14299
  });
14300
14300
  var $ZodTransform = /* @__PURE__ */ $constructor("$ZodTransform", (inst, def) => {
14301
14301
  $ZodType.init(inst, def);
14302
+ inst._zod.optin = "optional";
14302
14303
  inst._zod.parse = (payload, ctx) => {
14303
14304
  if (ctx.direction === "backward") {
14304
14305
  throw new $ZodEncodeError(inst.constructor.name);
@@ -14308,6 +14309,7 @@ var $ZodTransform = /* @__PURE__ */ $constructor("$ZodTransform", (inst, def) =>
14308
14309
  const output = _out instanceof Promise ? _out : Promise.resolve(_out);
14309
14310
  return output.then((output2) => {
14310
14311
  payload.value = output2;
14312
+ payload.fallback = true;
14311
14313
  return payload;
14312
14314
  });
14313
14315
  }
@@ -14315,11 +14317,12 @@ var $ZodTransform = /* @__PURE__ */ $constructor("$ZodTransform", (inst, def) =>
14315
14317
  throw new $ZodAsyncError();
14316
14318
  }
14317
14319
  payload.value = _out;
14320
+ payload.fallback = true;
14318
14321
  return payload;
14319
14322
  };
14320
14323
  });
14321
14324
  function handleOptionalResult(result, input) {
14322
- if (result.issues.length && input === void 0) {
14325
+ if (input === void 0 && (result.issues.length || result.fallback)) {
14323
14326
  return { issues: [], value: void 0 };
14324
14327
  }
14325
14328
  return result;
@@ -14337,10 +14340,11 @@ var $ZodOptional = /* @__PURE__ */ $constructor("$ZodOptional", (inst, def) => {
14337
14340
  });
14338
14341
  inst._zod.parse = (payload, ctx) => {
14339
14342
  if (def.innerType._zod.optin === "optional") {
14343
+ const input = payload.value;
14340
14344
  const result = def.innerType._zod.run(payload, ctx);
14341
14345
  if (result instanceof Promise)
14342
- return result.then((r) => handleOptionalResult(r, payload.value));
14343
- return handleOptionalResult(result, payload.value);
14346
+ return result.then((r) => handleOptionalResult(r, input));
14347
+ return handleOptionalResult(result, input);
14344
14348
  }
14345
14349
  if (payload.value === void 0) {
14346
14350
  return payload;
@@ -14456,7 +14460,7 @@ var $ZodSuccess = /* @__PURE__ */ $constructor("$ZodSuccess", (inst, def) => {
14456
14460
  });
14457
14461
  var $ZodCatch = /* @__PURE__ */ $constructor("$ZodCatch", (inst, def) => {
14458
14462
  $ZodType.init(inst, def);
14459
- defineLazy(inst._zod, "optin", () => def.innerType._zod.optin);
14463
+ inst._zod.optin = "optional";
14460
14464
  defineLazy(inst._zod, "optout", () => def.innerType._zod.optout);
14461
14465
  defineLazy(inst._zod, "values", () => def.innerType._zod.values);
14462
14466
  inst._zod.parse = (payload, ctx) => {
@@ -14476,6 +14480,7 @@ var $ZodCatch = /* @__PURE__ */ $constructor("$ZodCatch", (inst, def) => {
14476
14480
  input: payload.value
14477
14481
  });
14478
14482
  payload.issues = [];
14483
+ payload.fallback = true;
14479
14484
  }
14480
14485
  return payload;
14481
14486
  });
@@ -14490,6 +14495,7 @@ var $ZodCatch = /* @__PURE__ */ $constructor("$ZodCatch", (inst, def) => {
14490
14495
  input: payload.value
14491
14496
  });
14492
14497
  payload.issues = [];
14498
+ payload.fallback = true;
14493
14499
  }
14494
14500
  return payload;
14495
14501
  };
@@ -14535,7 +14541,7 @@ function handlePipeResult(left, next, ctx) {
14535
14541
  left.aborted = true;
14536
14542
  return left;
14537
14543
  }
14538
- return next._zod.run({ value: left.value, issues: left.issues }, ctx);
14544
+ return next._zod.run({ value: left.value, issues: left.issues, fallback: left.fallback }, ctx);
14539
14545
  }
14540
14546
  var $ZodCodec = /* @__PURE__ */ $constructor("$ZodCodec", (inst, def) => {
14541
14547
  $ZodType.init(inst, def);
@@ -14589,8 +14595,6 @@ function handleCodecTxResult(left, value, nextSchema, ctx) {
14589
14595
  }
14590
14596
  var $ZodPreprocess = /* @__PURE__ */ $constructor("$ZodPreprocess", (inst, def) => {
14591
14597
  $ZodPipe.init(inst, def);
14592
- defineLazy(inst._zod, "optin", () => def.out._zod.optin);
14593
- defineLazy(inst._zod, "optout", () => def.out._zod.optout);
14594
14598
  });
14595
14599
  var $ZodReadonly = /* @__PURE__ */ $constructor("$ZodReadonly", (inst, def) => {
14596
14600
  $ZodType.init(inst, def);
@@ -24541,10 +24545,12 @@ var ZodTransform = /* @__PURE__ */ $constructor("ZodTransform", (inst, def) => {
24541
24545
  if (output instanceof Promise) {
24542
24546
  return output.then((output2) => {
24543
24547
  payload.value = output2;
24548
+ payload.fallback = true;
24544
24549
  return payload;
24545
24550
  });
24546
24551
  }
24547
24552
  payload.value = output;
24553
+ payload.fallback = true;
24548
24554
  return payload;
24549
24555
  };
24550
24556
  });
@@ -28734,7 +28740,7 @@ var Protocol = class {
28734
28740
  return;
28735
28741
  }
28736
28742
  const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3;
28737
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
28743
+ await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
28738
28744
  options?.signal?.throwIfAborted();
28739
28745
  }
28740
28746
  } catch (error51) {
@@ -28751,7 +28757,7 @@ var Protocol = class {
28751
28757
  */
28752
28758
  request(request, resultSchema, options) {
28753
28759
  const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {};
28754
- return new Promise((resolve, reject) => {
28760
+ return new Promise((resolve2, reject) => {
28755
28761
  const earlyReject = (error51) => {
28756
28762
  reject(error51);
28757
28763
  };
@@ -28829,7 +28835,7 @@ var Protocol = class {
28829
28835
  if (!parseResult.success) {
28830
28836
  reject(parseResult.error);
28831
28837
  } else {
28832
- resolve(parseResult.data);
28838
+ resolve2(parseResult.data);
28833
28839
  }
28834
28840
  } catch (error51) {
28835
28841
  reject(error51);
@@ -29090,12 +29096,12 @@ var Protocol = class {
29090
29096
  }
29091
29097
  } catch {
29092
29098
  }
29093
- return new Promise((resolve, reject) => {
29099
+ return new Promise((resolve2, reject) => {
29094
29100
  if (signal.aborted) {
29095
29101
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
29096
29102
  return;
29097
29103
  }
29098
- const timeoutId = setTimeout(resolve, interval);
29104
+ const timeoutId = setTimeout(resolve2, interval);
29099
29105
  signal.addEventListener("abort", () => {
29100
29106
  clearTimeout(timeoutId);
29101
29107
  reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled"));
@@ -30195,7 +30201,7 @@ var McpServer = class {
30195
30201
  let task = createTaskResult.task;
30196
30202
  const pollInterval = task.pollInterval ?? 5e3;
30197
30203
  while (task.status !== "completed" && task.status !== "failed" && task.status !== "cancelled") {
30198
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
30204
+ await new Promise((resolve2) => setTimeout(resolve2, pollInterval));
30199
30205
  const updatedTask = await extra.taskStore.getTask(taskId);
30200
30206
  if (!updatedTask) {
30201
30207
  throw new McpError(ErrorCode.InternalError, `Task ${taskId} not found during polling`);
@@ -30844,12 +30850,12 @@ var StdioServerTransport = class {
30844
30850
  this.onclose?.();
30845
30851
  }
30846
30852
  send(message) {
30847
- return new Promise((resolve) => {
30853
+ return new Promise((resolve2) => {
30848
30854
  const json2 = serializeMessage(message);
30849
30855
  if (this._stdout.write(json2)) {
30850
- resolve();
30856
+ resolve2();
30851
30857
  } else {
30852
- this._stdout.once("drain", resolve);
30858
+ this._stdout.once("drain", resolve2);
30853
30859
  }
30854
30860
  });
30855
30861
  }
@@ -30887,6 +30893,52 @@ var OFWClient = class {
30887
30893
  await this.ensureAuthenticated();
30888
30894
  return this.doRequest(method, path, body, false);
30889
30895
  }
30896
+ /** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
30897
+ async requestBinary(method, path) {
30898
+ await this.ensureAuthenticated();
30899
+ return this.doRequestBinary(method, path, false);
30900
+ }
30901
+ async doRequestBinary(method, path, isRetry) {
30902
+ const headers = {
30903
+ "ofw-client": "WebApplication",
30904
+ "ofw-version": "1.0.0",
30905
+ Accept: "application/octet-stream",
30906
+ Authorization: `Bearer ${this.token}`
30907
+ };
30908
+ const response = await fetch(`${BASE_URL}${path}`, { method, headers });
30909
+ if (response.status === 401 && !isRetry) {
30910
+ this.token = null;
30911
+ this.tokenExpiry = null;
30912
+ await this.ensureAuthenticated();
30913
+ return this.doRequestBinary(method, path, true);
30914
+ }
30915
+ if (response.status === 429 && !isRetry) {
30916
+ await new Promise((r) => setTimeout(r, 2e3));
30917
+ return this.doRequestBinary(method, path, true);
30918
+ }
30919
+ if (!response.ok) {
30920
+ throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
30921
+ }
30922
+ const buf = Buffer.from(await response.arrayBuffer());
30923
+ const cd = response.headers.get("content-disposition") ?? "";
30924
+ let suggestedFileName = null;
30925
+ const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
30926
+ if (extMatch) {
30927
+ try {
30928
+ suggestedFileName = decodeURIComponent(extMatch[1].trim().replace(/^"|"$/g, ""));
30929
+ } catch {
30930
+ suggestedFileName = extMatch[1];
30931
+ }
30932
+ } else {
30933
+ const m = /filename="?([^";]+)"?/i.exec(cd);
30934
+ if (m) suggestedFileName = m[1];
30935
+ }
30936
+ return {
30937
+ body: buf,
30938
+ contentType: response.headers.get("content-type"),
30939
+ suggestedFileName
30940
+ };
30941
+ }
30890
30942
  async doRequest(method, path, body, isRetry) {
30891
30943
  const isFormData = body instanceof FormData;
30892
30944
  const headers = {
@@ -31015,6 +31067,13 @@ function getCacheDbPath() {
31015
31067
  const hash2 = createHash("sha256").update(username).digest("hex").slice(0, 16);
31016
31068
  return join2(getCacheDir(), `${hash2}.db`);
31017
31069
  }
31070
+ function getAttachmentsDir() {
31071
+ const override = process.env.OFW_ATTACHMENTS_DIR;
31072
+ if (override && override.trim().length > 0) return override.trim();
31073
+ const username = readUsername();
31074
+ const hash2 = createHash("sha256").update(username).digest("hex").slice(0, 16);
31075
+ return join2(getCacheDir(), "attachments", hash2);
31076
+ }
31018
31077
 
31019
31078
  // src/cache.ts
31020
31079
  var instance = null;
@@ -31057,9 +31116,26 @@ CREATE TABLE IF NOT EXISTS meta (
31057
31116
  value TEXT NOT NULL
31058
31117
  );
31059
31118
  `;
31119
+ var SCHEMA_V2 = `
31120
+ CREATE TABLE IF NOT EXISTS attachments (
31121
+ file_id INTEGER PRIMARY KEY,
31122
+ file_name TEXT NOT NULL,
31123
+ label TEXT NOT NULL,
31124
+ mime_type TEXT NOT NULL,
31125
+ size_bytes INTEGER,
31126
+ metadata_json TEXT NOT NULL,
31127
+ message_ids_json TEXT NOT NULL, -- JSON array of message ids that reference this file
31128
+ downloaded_path TEXT, -- absolute path on disk if/when downloaded
31129
+ downloaded_at TEXT,
31130
+ fetched_metadata_at TEXT NOT NULL
31131
+ );
31132
+ `;
31060
31133
  function migrate(db) {
31061
31134
  db.exec(SCHEMA_V1);
31062
- db.prepare("INSERT OR IGNORE INTO meta(key, value) VALUES(?, ?)").run("schema_version", "1");
31135
+ db.exec(SCHEMA_V2);
31136
+ db.prepare(
31137
+ "INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
31138
+ ).run("schema_version", "2");
31063
31139
  }
31064
31140
  function openCache() {
31065
31141
  if (instance) return instance;
@@ -31273,8 +31349,95 @@ function findLatestReplyTip(replyToId) {
31273
31349
  ).get(chainRoot);
31274
31350
  return tip ? tip.id : replyToId;
31275
31351
  }
31352
+ function attachmentFromDb(r) {
31353
+ return {
31354
+ fileId: r.file_id,
31355
+ fileName: r.file_name,
31356
+ label: r.label,
31357
+ mimeType: r.mime_type,
31358
+ sizeBytes: r.size_bytes,
31359
+ metadata: JSON.parse(r.metadata_json),
31360
+ messageIds: JSON.parse(r.message_ids_json),
31361
+ downloadedPath: r.downloaded_path,
31362
+ downloadedAt: r.downloaded_at
31363
+ };
31364
+ }
31365
+ function getAttachment(fileId) {
31366
+ const { db } = openCache();
31367
+ const r = db.prepare("SELECT * FROM attachments WHERE file_id = ?").get(fileId);
31368
+ return r ? attachmentFromDb(r) : null;
31369
+ }
31370
+ function listAttachmentsForMessage(messageId) {
31371
+ const { db } = openCache();
31372
+ const rows = db.prepare(
31373
+ `SELECT * FROM attachments
31374
+ WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
31375
+ ORDER BY file_id`
31376
+ ).all(messageId);
31377
+ return rows.map(attachmentFromDb);
31378
+ }
31379
+ function upsertAttachmentForMessage(input) {
31380
+ const { db } = openCache();
31381
+ const existing = db.prepare("SELECT message_ids_json FROM attachments WHERE file_id = ?").get(input.fileId);
31382
+ let messageIds;
31383
+ if (existing) {
31384
+ const arr = JSON.parse(existing.message_ids_json);
31385
+ messageIds = arr.includes(input.messageId) ? arr : [...arr, input.messageId];
31386
+ } else {
31387
+ messageIds = [input.messageId];
31388
+ }
31389
+ db.prepare(
31390
+ `INSERT INTO attachments (
31391
+ file_id, file_name, label, mime_type, size_bytes,
31392
+ metadata_json, message_ids_json, fetched_metadata_at
31393
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
31394
+ ON CONFLICT(file_id) DO UPDATE SET
31395
+ file_name=excluded.file_name,
31396
+ label=excluded.label,
31397
+ mime_type=excluded.mime_type,
31398
+ size_bytes=excluded.size_bytes,
31399
+ metadata_json=excluded.metadata_json,
31400
+ message_ids_json=excluded.message_ids_json,
31401
+ fetched_metadata_at=excluded.fetched_metadata_at`
31402
+ ).run(
31403
+ input.fileId,
31404
+ requireString("attachments.fileName", input.fileName),
31405
+ requireString("attachments.label", input.label),
31406
+ requireString("attachments.mimeType", input.mimeType),
31407
+ nullish3(input.sizeBytes),
31408
+ JSON.stringify(input.metadata ?? null),
31409
+ JSON.stringify(messageIds),
31410
+ (/* @__PURE__ */ new Date()).toISOString()
31411
+ );
31412
+ }
31413
+ function markAttachmentDownloaded(fileId, path) {
31414
+ const { db } = openCache();
31415
+ db.prepare(
31416
+ "UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?"
31417
+ ).run(path, (/* @__PURE__ */ new Date()).toISOString(), fileId);
31418
+ }
31276
31419
 
31277
31420
  // src/sync.ts
31421
+ async function fetchAndCacheAttachmentMeta(client2, fileId, messageId) {
31422
+ try {
31423
+ const meta3 = await client2.request("GET", `/pub/v1/myfiles/${fileId}`);
31424
+ upsertAttachmentForMessage({
31425
+ fileId: meta3.fileId ?? fileId,
31426
+ fileName: meta3.fileName ?? `file-${fileId}`,
31427
+ label: meta3.label ?? meta3.fileName ?? `file-${fileId}`,
31428
+ mimeType: meta3.fileType ?? "application/octet-stream",
31429
+ sizeBytes: typeof meta3.fileSize === "number" ? meta3.fileSize : null,
31430
+ metadata: meta3,
31431
+ messageId
31432
+ });
31433
+ } catch {
31434
+ }
31435
+ }
31436
+ async function fetchAttachmentMetaForMessage(client2, messageId, fileIds) {
31437
+ for (const fid of fileIds) {
31438
+ await fetchAndCacheAttachmentMeta(client2, fid, messageId);
31439
+ }
31440
+ }
31278
31441
  async function resolveFolderIds(client2) {
31279
31442
  const data = await client2.request(
31280
31443
  "GET",
@@ -31321,10 +31484,14 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
31321
31484
  const shouldFetchBody = !isInboxUnread || opts.fetchUnreadBodies;
31322
31485
  let body = null;
31323
31486
  let fetchedBodyAt = null;
31487
+ let detailFileIds = [];
31324
31488
  if (shouldFetchBody) {
31325
31489
  const detail = await client2.request("GET", `/pub/v3/messages/${item.id}`);
31326
31490
  body = detail.body ?? "";
31327
31491
  fetchedBodyAt = (/* @__PURE__ */ new Date()).toISOString();
31492
+ if (Array.isArray(detail.files) && detail.files.length > 0) {
31493
+ detailFileIds = detail.files;
31494
+ }
31328
31495
  } else {
31329
31496
  unread.push({
31330
31497
  id: item.id,
@@ -31348,6 +31515,9 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
31348
31515
  };
31349
31516
  upsertMessage(row);
31350
31517
  synced++;
31518
+ if (detailFileIds.length > 0) {
31519
+ await fetchAttachmentMetaForMessage(client2, item.id, detailFileIds);
31520
+ }
31351
31521
  }
31352
31522
  if (!opts.deep && !pageHadNewItem) break;
31353
31523
  page++;
@@ -31422,6 +31592,8 @@ async function syncAll(client2, opts) {
31422
31592
  }
31423
31593
 
31424
31594
  // src/tools/messages.ts
31595
+ import { mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
31596
+ import { dirname as dirname3, join as join3, isAbsolute, resolve } from "node:path";
31425
31597
  function registerMessageTools(server2, client2) {
31426
31598
  server2.registerTool("ofw_list_message_folders", {
31427
31599
  description: "List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.",
@@ -31481,7 +31653,8 @@ function registerMessageTools(server2, client2) {
31481
31653
  const id = Number(args.messageId);
31482
31654
  const cached2 = getMessage(id);
31483
31655
  if (cached2 && cached2.body !== null) {
31484
- return { content: [{ type: "text", text: JSON.stringify(cached2, null, 2) }] };
31656
+ const attachments2 = listAttachmentsForMessage(id);
31657
+ return { content: [{ type: "text", text: JSON.stringify({ ...cached2, attachments: attachments2 }, null, 2) }] };
31485
31658
  }
31486
31659
  const detail = await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
31487
31660
  const recipients = (detail.recipients ?? []).map((r) => ({
@@ -31495,7 +31668,7 @@ function registerMessageTools(server2, client2) {
31495
31668
  folder,
31496
31669
  subject: detail.subject,
31497
31670
  fromUser: detail.from?.name ?? "",
31498
- sentAt: detail.date.dateTime,
31671
+ sentAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
31499
31672
  recipients,
31500
31673
  body: detail.body ?? "",
31501
31674
  fetchedBodyAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -31504,7 +31677,11 @@ function registerMessageTools(server2, client2) {
31504
31677
  listData: cached2?.listData ?? detail
31505
31678
  };
31506
31679
  upsertMessage(row);
31507
- return { content: [{ type: "text", text: JSON.stringify(row, null, 2) }] };
31680
+ if (Array.isArray(detail.files) && detail.files.length > 0) {
31681
+ await fetchAttachmentMetaForMessage(client2, detail.id, detail.files);
31682
+ }
31683
+ const attachments = listAttachmentsForMessage(detail.id);
31684
+ return { content: [{ type: "text", text: JSON.stringify({ ...row, attachments }, null, 2) }] };
31508
31685
  });
31509
31686
  server2.registerTool("ofw_send_message", {
31510
31687
  description: "Send a message via OurFamilyWizard. If sending from a draft, pass draftId to delete the draft after sending. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens).",
@@ -31681,6 +31858,63 @@ ${text}` : text;
31681
31858
  }
31682
31859
  return { content: [{ type: "text", text: JSON.stringify(unread, null, 2) }] };
31683
31860
  });
31861
+ server2.registerTool("ofw_download_attachment", {
31862
+ description: "Download an OFW message attachment by fileId. Bytes are saved to disk; the tool returns the absolute path, mime type, and size so the caller can then read/analyze the file. fileId comes from the attachments array on ofw_get_message. Saves under ~/.cache/ofw-mcp/attachments/<hash>/ by default (override via OFW_ATTACHMENTS_DIR or the saveTo argument). Re-downloading is a no-op if the file is already on disk.",
31863
+ annotations: { readOnlyHint: false },
31864
+ inputSchema: {
31865
+ fileId: external_exports.number().describe("Attachment file id (from ofw_get_message \u2192 attachments[].fileId)"),
31866
+ saveTo: external_exports.string().describe("Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/.cache/ofw-mcp/attachments/<hash>/<fileId>-<filename>").optional(),
31867
+ force: external_exports.boolean().describe("Re-download even if already on disk. Default false.").optional()
31868
+ }
31869
+ }, async (args) => {
31870
+ const fileId = args.fileId;
31871
+ let cached2 = getAttachment(fileId);
31872
+ if (!cached2) {
31873
+ const meta3 = await client2.request("GET", `/pub/v1/myfiles/${fileId}`);
31874
+ upsertAttachmentForMessage({
31875
+ fileId: meta3.fileId ?? fileId,
31876
+ fileName: meta3.fileName ?? `file-${fileId}`,
31877
+ label: meta3.label ?? meta3.fileName ?? `file-${fileId}`,
31878
+ mimeType: meta3.fileType ?? "application/octet-stream",
31879
+ sizeBytes: typeof meta3.fileSize === "number" ? meta3.fileSize : null,
31880
+ metadata: meta3,
31881
+ messageId: 0
31882
+ // placeholder; will be cleaned up if a real message references it
31883
+ });
31884
+ cached2 = getAttachment(fileId);
31885
+ if (!cached2) throw new Error(`failed to fetch metadata for fileId ${fileId}`);
31886
+ }
31887
+ let dest;
31888
+ if (args.saveTo) {
31889
+ const expanded = args.saveTo.startsWith("~/") ? join3(process.env.HOME ?? "", args.saveTo.slice(2)) : args.saveTo;
31890
+ const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
31891
+ const isDirArg = expanded.endsWith("/") || expanded.endsWith("\\");
31892
+ dest = isDirArg ? join3(abs, `${fileId}-${cached2.fileName}`) : abs;
31893
+ } else {
31894
+ dest = join3(getAttachmentsDir(), `${fileId}-${cached2.fileName}`);
31895
+ }
31896
+ if (!args.force && cached2.downloadedPath === dest) {
31897
+ return { content: [{ type: "text", text: JSON.stringify({
31898
+ fileId,
31899
+ path: dest,
31900
+ mimeType: cached2.mimeType,
31901
+ sizeBytes: cached2.sizeBytes,
31902
+ fileName: cached2.fileName,
31903
+ note: "already downloaded"
31904
+ }, null, 2) }] };
31905
+ }
31906
+ const response = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
31907
+ mkdirSync2(dirname3(dest), { recursive: true });
31908
+ writeFileSync(dest, response.body);
31909
+ markAttachmentDownloaded(fileId, dest);
31910
+ return { content: [{ type: "text", text: JSON.stringify({
31911
+ fileId,
31912
+ path: dest,
31913
+ mimeType: response.contentType ?? cached2.mimeType,
31914
+ sizeBytes: response.body.length,
31915
+ fileName: response.suggestedFileName ?? cached2.fileName
31916
+ }, null, 2) }] };
31917
+ });
31684
31918
  server2.registerTool("ofw_sync_messages", {
31685
31919
  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).",
31686
31920
  annotations: { readOnlyHint: false },
@@ -31841,7 +32075,7 @@ process.emit = function(event, ...args) {
31841
32075
  }
31842
32076
  return originalEmit(event, ...args);
31843
32077
  };
31844
- var server = new McpServer({ name: "ofw", version: "2.0.8" });
32078
+ var server = new McpServer({ name: "ofw", version: "2.0.9" });
31845
32079
  registerUserTools(server, client);
31846
32080
  registerMessageTools(server, client);
31847
32081
  registerCalendarTools(server, client);
package/dist/cache.js CHANGED
@@ -42,9 +42,25 @@ CREATE TABLE IF NOT EXISTS meta (
42
42
  value TEXT NOT NULL
43
43
  );
44
44
  `;
45
+ // v2: add attachments table. Idempotent — IF NOT EXISTS.
46
+ const SCHEMA_V2 = `
47
+ CREATE TABLE IF NOT EXISTS attachments (
48
+ file_id INTEGER PRIMARY KEY,
49
+ file_name TEXT NOT NULL,
50
+ label TEXT NOT NULL,
51
+ mime_type TEXT NOT NULL,
52
+ size_bytes INTEGER,
53
+ metadata_json TEXT NOT NULL,
54
+ message_ids_json TEXT NOT NULL, -- JSON array of message ids that reference this file
55
+ downloaded_path TEXT, -- absolute path on disk if/when downloaded
56
+ downloaded_at TEXT,
57
+ fetched_metadata_at TEXT NOT NULL
58
+ );
59
+ `;
45
60
  function migrate(db) {
46
61
  db.exec(SCHEMA_V1);
47
- db.prepare('INSERT OR IGNORE INTO meta(key, value) VALUES(?, ?)').run('schema_version', '1');
62
+ db.exec(SCHEMA_V2);
63
+ db.prepare('INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run('schema_version', '2');
48
64
  }
49
65
  export function openCache() {
50
66
  if (instance)
@@ -249,3 +265,58 @@ export function findLatestReplyTip(replyToId) {
249
265
  ORDER BY id DESC LIMIT 1`).get(chainRoot);
250
266
  return tip ? tip.id : replyToId;
251
267
  }
268
+ function attachmentFromDb(r) {
269
+ return {
270
+ fileId: r.file_id,
271
+ fileName: r.file_name,
272
+ label: r.label,
273
+ mimeType: r.mime_type,
274
+ sizeBytes: r.size_bytes,
275
+ metadata: JSON.parse(r.metadata_json),
276
+ messageIds: JSON.parse(r.message_ids_json),
277
+ downloadedPath: r.downloaded_path,
278
+ downloadedAt: r.downloaded_at,
279
+ };
280
+ }
281
+ export function getAttachment(fileId) {
282
+ const { db } = openCache();
283
+ const r = db.prepare('SELECT * FROM attachments WHERE file_id = ?').get(fileId);
284
+ return r ? attachmentFromDb(r) : null;
285
+ }
286
+ export function listAttachmentsForMessage(messageId) {
287
+ const { db } = openCache();
288
+ // SQLite JSON1 contains check
289
+ const rows = db.prepare(`SELECT * FROM attachments
290
+ WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
291
+ ORDER BY file_id`).all(messageId);
292
+ return rows.map(attachmentFromDb);
293
+ }
294
+ export function upsertAttachmentForMessage(input) {
295
+ const { db } = openCache();
296
+ const existing = db.prepare('SELECT message_ids_json FROM attachments WHERE file_id = ?')
297
+ .get(input.fileId);
298
+ let messageIds;
299
+ if (existing) {
300
+ const arr = JSON.parse(existing.message_ids_json);
301
+ messageIds = arr.includes(input.messageId) ? arr : [...arr, input.messageId];
302
+ }
303
+ else {
304
+ messageIds = [input.messageId];
305
+ }
306
+ db.prepare(`INSERT INTO attachments (
307
+ file_id, file_name, label, mime_type, size_bytes,
308
+ metadata_json, message_ids_json, fetched_metadata_at
309
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
310
+ ON CONFLICT(file_id) DO UPDATE SET
311
+ file_name=excluded.file_name,
312
+ label=excluded.label,
313
+ mime_type=excluded.mime_type,
314
+ size_bytes=excluded.size_bytes,
315
+ metadata_json=excluded.metadata_json,
316
+ message_ids_json=excluded.message_ids_json,
317
+ fetched_metadata_at=excluded.fetched_metadata_at`).run(input.fileId, requireString('attachments.fileName', input.fileName), requireString('attachments.label', input.label), requireString('attachments.mimeType', input.mimeType), nullish(input.sizeBytes), JSON.stringify(input.metadata ?? null), JSON.stringify(messageIds), new Date().toISOString());
318
+ }
319
+ export function markAttachmentDownloaded(fileId, path) {
320
+ const { db } = openCache();
321
+ db.prepare('UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?').run(path, new Date().toISOString(), fileId);
322
+ }
package/dist/client.js CHANGED
@@ -41,6 +41,56 @@ export class OFWClient {
41
41
  await this.ensureAuthenticated();
42
42
  return this.doRequest(method, path, body, false);
43
43
  }
44
+ /** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
45
+ async requestBinary(method, path) {
46
+ await this.ensureAuthenticated();
47
+ return this.doRequestBinary(method, path, false);
48
+ }
49
+ async doRequestBinary(method, path, isRetry) {
50
+ const headers = {
51
+ 'ofw-client': 'WebApplication',
52
+ 'ofw-version': '1.0.0',
53
+ Accept: 'application/octet-stream',
54
+ Authorization: `Bearer ${this.token}`,
55
+ };
56
+ const response = await fetch(`${BASE_URL}${path}`, { method, headers });
57
+ if (response.status === 401 && !isRetry) {
58
+ this.token = null;
59
+ this.tokenExpiry = null;
60
+ await this.ensureAuthenticated();
61
+ return this.doRequestBinary(method, path, true);
62
+ }
63
+ if (response.status === 429 && !isRetry) {
64
+ await new Promise((r) => setTimeout(r, 2000));
65
+ return this.doRequestBinary(method, path, true);
66
+ }
67
+ if (!response.ok) {
68
+ throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
69
+ }
70
+ const buf = Buffer.from(await response.arrayBuffer());
71
+ const cd = response.headers.get('content-disposition') ?? '';
72
+ // RFC 6266: filename*=UTF-8''… takes priority; fall back to filename="…"
73
+ let suggestedFileName = null;
74
+ const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
75
+ if (extMatch) {
76
+ try {
77
+ suggestedFileName = decodeURIComponent(extMatch[1].trim().replace(/^"|"$/g, ''));
78
+ }
79
+ catch {
80
+ suggestedFileName = extMatch[1];
81
+ }
82
+ }
83
+ else {
84
+ const m = /filename="?([^";]+)"?/i.exec(cd);
85
+ if (m)
86
+ suggestedFileName = m[1];
87
+ }
88
+ return {
89
+ body: buf,
90
+ contentType: response.headers.get('content-type'),
91
+ suggestedFileName,
92
+ };
93
+ }
44
94
  async doRequest(method, path, body, isRetry) {
45
95
  const isFormData = body instanceof FormData;
46
96
  const headers = {
package/dist/config.js CHANGED
@@ -19,3 +19,12 @@ export function getCacheDbPath() {
19
19
  const hash = createHash('sha256').update(username).digest('hex').slice(0, 16);
20
20
  return join(getCacheDir(), `${hash}.db`);
21
21
  }
22
+ export function getAttachmentsDir() {
23
+ const override = process.env.OFW_ATTACHMENTS_DIR;
24
+ if (override && override.trim().length > 0)
25
+ return override.trim();
26
+ // Sibling to the cache db: ~/.cache/ofw-mcp/attachments/<hash>/
27
+ const username = readUsername();
28
+ const hash = createHash('sha256').update(username).digest('hex').slice(0, 16);
29
+ return join(getCacheDir(), 'attachments', hash);
30
+ }
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import { registerMessageTools } from './tools/messages.js';
17
17
  import { registerCalendarTools } from './tools/calendar.js';
18
18
  import { registerExpenseTools } from './tools/expenses.js';
19
19
  import { registerJournalTools } from './tools/journal.js';
20
- const server = new McpServer({ name: 'ofw', version: '2.0.8' });
20
+ const server = new McpServer({ name: 'ofw', version: '2.0.9' });
21
21
  registerUserTools(server, client);
22
22
  registerMessageTools(server, client);
23
23
  registerCalendarTools(server, client);
package/dist/sync.js CHANGED
@@ -1,4 +1,28 @@
1
- import { setMeta, upsertMessage, getMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, } from './cache.js';
1
+ import { setMeta, upsertMessage, getMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
2
+ export async function fetchAndCacheAttachmentMeta(client, fileId, messageId) {
3
+ try {
4
+ const meta = await client.request('GET', `/pub/v1/myfiles/${fileId}`);
5
+ upsertAttachmentForMessage({
6
+ fileId: meta.fileId ?? fileId,
7
+ fileName: meta.fileName ?? `file-${fileId}`,
8
+ label: meta.label ?? meta.fileName ?? `file-${fileId}`,
9
+ mimeType: meta.fileType ?? 'application/octet-stream',
10
+ sizeBytes: typeof meta.fileSize === 'number' ? meta.fileSize : null,
11
+ metadata: meta,
12
+ messageId,
13
+ });
14
+ }
15
+ catch {
16
+ // Attachment metadata failures shouldn't break the surrounding sync.
17
+ // The file ids stay in the message's listData; the model can retry later
18
+ // via ofw_download_attachment, which will surface the actual error.
19
+ }
20
+ }
21
+ export async function fetchAttachmentMetaForMessage(client, messageId, fileIds) {
22
+ for (const fid of fileIds) {
23
+ await fetchAndCacheAttachmentMeta(client, fid, messageId);
24
+ }
25
+ }
2
26
  export async function resolveFolderIds(client) {
3
27
  const data = await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true');
4
28
  const sys = data.systemFolders ?? [];
@@ -46,10 +70,14 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
46
70
  const shouldFetchBody = !isInboxUnread || opts.fetchUnreadBodies;
47
71
  let body = null;
48
72
  let fetchedBodyAt = null;
73
+ let detailFileIds = [];
49
74
  if (shouldFetchBody) {
50
75
  const detail = await client.request('GET', `/pub/v3/messages/${item.id}`);
51
76
  body = detail.body ?? '';
52
77
  fetchedBodyAt = new Date().toISOString();
78
+ if (Array.isArray(detail.files) && detail.files.length > 0) {
79
+ detailFileIds = detail.files;
80
+ }
53
81
  }
54
82
  else {
55
83
  unread.push({
@@ -74,6 +102,9 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
74
102
  };
75
103
  upsertMessage(row);
76
104
  synced++;
105
+ if (detailFileIds.length > 0) {
106
+ await fetchAttachmentMetaForMessage(client, item.id, detailFileIds);
107
+ }
77
108
  }
78
109
  // Stop heuristic: a page with no new items means we've reached cached
79
110
  // history (OFW returns date-desc). A page with even ONE new item could
@@ -1,6 +1,9 @@
1
1
  import { z } from 'zod';
2
- import { syncAll } from '../sync.js';
3
- import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, upsertDraft, deleteDraft, findLatestReplyTip, } from '../cache.js';
2
+ import { syncAll, fetchAttachmentMetaForMessage } from '../sync.js';
3
+ import { listMessages, countMessages, listDrafts, getMessage, upsertMessage, upsertDraft, deleteDraft, findLatestReplyTip, listAttachmentsForMessage, getAttachment, upsertAttachmentForMessage, markAttachmentDownloaded, } from '../cache.js';
4
+ import { getAttachmentsDir } from '../config.js';
5
+ import { mkdirSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join, isAbsolute, resolve } from 'node:path';
4
7
  export function registerMessageTools(server, client) {
5
8
  server.registerTool('ofw_list_message_folders', {
6
9
  description: 'List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.',
@@ -64,7 +67,8 @@ export function registerMessageTools(server, client) {
64
67
  const id = Number(args.messageId);
65
68
  const cached = getMessage(id);
66
69
  if (cached && cached.body !== null) {
67
- return { content: [{ type: 'text', text: JSON.stringify(cached, null, 2) }] };
70
+ const attachments = listAttachmentsForMessage(id);
71
+ return { content: [{ type: 'text', text: JSON.stringify({ ...cached, attachments }, null, 2) }] };
68
72
  }
69
73
  const detail = await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
70
74
  const recipients = (detail.recipients ?? []).map((r) => ({
@@ -76,7 +80,7 @@ export function registerMessageTools(server, client) {
76
80
  folder,
77
81
  subject: detail.subject,
78
82
  fromUser: detail.from?.name ?? '',
79
- sentAt: detail.date.dateTime,
83
+ sentAt: detail.date?.dateTime ?? new Date().toISOString(),
80
84
  recipients,
81
85
  body: detail.body ?? '',
82
86
  fetchedBodyAt: new Date().toISOString(),
@@ -85,7 +89,11 @@ export function registerMessageTools(server, client) {
85
89
  listData: cached?.listData ?? detail,
86
90
  };
87
91
  upsertMessage(row);
88
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
92
+ if (Array.isArray(detail.files) && detail.files.length > 0) {
93
+ await fetchAttachmentMetaForMessage(client, detail.id, detail.files);
94
+ }
95
+ const attachments = listAttachmentsForMessage(detail.id);
96
+ return { content: [{ type: 'text', text: JSON.stringify({ ...row, attachments }, null, 2) }] };
89
97
  });
90
98
  server.registerTool('ofw_send_message', {
91
99
  description: 'Send a message via OurFamilyWizard. If sending from a draft, pass draftId to delete the draft after sending. If replyToId is provided, the cache may rewrite it to the latest reply in the same thread (a note is included in the response when this happens).',
@@ -257,6 +265,69 @@ export function registerMessageTools(server, client) {
257
265
  }
258
266
  return { content: [{ type: 'text', text: JSON.stringify(unread, null, 2) }] };
259
267
  });
268
+ server.registerTool('ofw_download_attachment', {
269
+ description: 'Download an OFW message attachment by fileId. Bytes are saved to disk; the tool returns the absolute path, mime type, and size so the caller can then read/analyze the file. fileId comes from the attachments array on ofw_get_message. Saves under ~/.cache/ofw-mcp/attachments/<hash>/ by default (override via OFW_ATTACHMENTS_DIR or the saveTo argument). Re-downloading is a no-op if the file is already on disk.',
270
+ annotations: { readOnlyHint: false },
271
+ inputSchema: {
272
+ fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
273
+ saveTo: z.string().describe('Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/.cache/ofw-mcp/attachments/<hash>/<fileId>-<filename>').optional(),
274
+ force: z.boolean().describe('Re-download even if already on disk. Default false.').optional(),
275
+ },
276
+ }, async (args) => {
277
+ const fileId = args.fileId;
278
+ let cached = getAttachment(fileId);
279
+ if (!cached) {
280
+ // Metadata not in cache — fetch on the fly.
281
+ const meta = await client.request('GET', `/pub/v1/myfiles/${fileId}`);
282
+ // Store with a sentinel "metadata-only, no message link" — we don't know which message asked.
283
+ // We'll re-link if a message later references it during sync.
284
+ upsertAttachmentForMessage({
285
+ fileId: meta.fileId ?? fileId,
286
+ fileName: meta.fileName ?? `file-${fileId}`,
287
+ label: meta.label ?? meta.fileName ?? `file-${fileId}`,
288
+ mimeType: meta.fileType ?? 'application/octet-stream',
289
+ sizeBytes: typeof meta.fileSize === 'number' ? meta.fileSize : null,
290
+ metadata: meta,
291
+ messageId: 0, // placeholder; will be cleaned up if a real message references it
292
+ });
293
+ cached = getAttachment(fileId);
294
+ if (!cached)
295
+ throw new Error(`failed to fetch metadata for fileId ${fileId}`);
296
+ }
297
+ // Decide destination path
298
+ let dest;
299
+ if (args.saveTo) {
300
+ const expanded = args.saveTo.startsWith('~/')
301
+ ? join(process.env.HOME ?? '', args.saveTo.slice(2))
302
+ : args.saveTo;
303
+ const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
304
+ // If it looks like a directory (ends with /) OR is an existing directory, treat as dir.
305
+ const isDirArg = expanded.endsWith('/') || expanded.endsWith('\\');
306
+ dest = isDirArg ? join(abs, `${fileId}-${cached.fileName}`) : abs;
307
+ }
308
+ else {
309
+ dest = join(getAttachmentsDir(), `${fileId}-${cached.fileName}`);
310
+ }
311
+ // Short-circuit if already downloaded to this path
312
+ if (!args.force && cached.downloadedPath === dest) {
313
+ return { content: [{ type: 'text', text: JSON.stringify({
314
+ fileId, path: dest, mimeType: cached.mimeType, sizeBytes: cached.sizeBytes,
315
+ fileName: cached.fileName, note: 'already downloaded',
316
+ }, null, 2) }] };
317
+ }
318
+ // Fetch bytes
319
+ const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
320
+ mkdirSync(dirname(dest), { recursive: true });
321
+ writeFileSync(dest, response.body);
322
+ markAttachmentDownloaded(fileId, dest);
323
+ return { content: [{ type: 'text', text: JSON.stringify({
324
+ fileId,
325
+ path: dest,
326
+ mimeType: response.contentType ?? cached.mimeType,
327
+ sizeBytes: response.body.length,
328
+ fileName: response.suggestedFileName ?? cached.fileName,
329
+ }, null, 2) }] };
330
+ });
260
331
  server.registerTool('ofw_sync_messages', {
261
332
  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).',
262
333
  annotations: { readOnlyHint: false },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.0.8",
3
+ "version": "2.0.9",
4
4
  "mcpName": "io.github.chrischall/ofw-mcp",
5
5
  "description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/ofw-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.0.8",
9
+ "version": "2.0.9",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.0.8",
14
+ "version": "2.0.9",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },