ofw-mcp 2.0.8 → 2.0.10

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.10"
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.10",
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.10",
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,35 @@ async function syncAll(client2, opts) {
31422
31592
  }
31423
31593
 
31424
31594
  // src/tools/messages.ts
31595
+ import { mkdirSync as mkdirSync2, readFileSync, statSync, writeFileSync } from "node:fs";
31596
+ import { basename, dirname as dirname3, extname, join as join3, isAbsolute, resolve } from "node:path";
31597
+ var MIME_BY_EXT = {
31598
+ ".pdf": "application/pdf",
31599
+ ".png": "image/png",
31600
+ ".jpg": "image/jpeg",
31601
+ ".jpeg": "image/jpeg",
31602
+ ".gif": "image/gif",
31603
+ ".webp": "image/webp",
31604
+ ".heic": "image/heic",
31605
+ ".txt": "text/plain",
31606
+ ".md": "text/markdown",
31607
+ ".csv": "text/csv",
31608
+ ".html": "text/html",
31609
+ ".htm": "text/html",
31610
+ ".json": "application/json",
31611
+ ".xml": "application/xml",
31612
+ ".doc": "application/msword",
31613
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
31614
+ ".xls": "application/vnd.ms-excel",
31615
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
31616
+ ".ppt": "application/vnd.ms-powerpoint",
31617
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
31618
+ ".zip": "application/zip",
31619
+ ".ics": "text/calendar"
31620
+ };
31621
+ function mimeFromName(name) {
31622
+ return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
31623
+ }
31425
31624
  function registerMessageTools(server2, client2) {
31426
31625
  server2.registerTool("ofw_list_message_folders", {
31427
31626
  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 +31680,8 @@ function registerMessageTools(server2, client2) {
31481
31680
  const id = Number(args.messageId);
31482
31681
  const cached2 = getMessage(id);
31483
31682
  if (cached2 && cached2.body !== null) {
31484
- return { content: [{ type: "text", text: JSON.stringify(cached2, null, 2) }] };
31683
+ const attachments2 = listAttachmentsForMessage(id);
31684
+ return { content: [{ type: "text", text: JSON.stringify({ ...cached2, attachments: attachments2 }, null, 2) }] };
31485
31685
  }
31486
31686
  const detail = await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
31487
31687
  const recipients = (detail.recipients ?? []).map((r) => ({
@@ -31495,7 +31695,7 @@ function registerMessageTools(server2, client2) {
31495
31695
  folder,
31496
31696
  subject: detail.subject,
31497
31697
  fromUser: detail.from?.name ?? "",
31498
- sentAt: detail.date.dateTime,
31698
+ sentAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
31499
31699
  recipients,
31500
31700
  body: detail.body ?? "",
31501
31701
  fetchedBodyAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -31504,17 +31704,22 @@ function registerMessageTools(server2, client2) {
31504
31704
  listData: cached2?.listData ?? detail
31505
31705
  };
31506
31706
  upsertMessage(row);
31507
- return { content: [{ type: "text", text: JSON.stringify(row, null, 2) }] };
31707
+ if (Array.isArray(detail.files) && detail.files.length > 0) {
31708
+ await fetchAttachmentMetaForMessage(client2, detail.id, detail.files);
31709
+ }
31710
+ const attachments = listAttachmentsForMessage(detail.id);
31711
+ return { content: [{ type: "text", text: JSON.stringify({ ...row, attachments }, null, 2) }] };
31508
31712
  });
31509
31713
  server2.registerTool("ofw_send_message", {
31510
- 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).",
31714
+ 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). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs.",
31511
31715
  annotations: { destructiveHint: true },
31512
31716
  inputSchema: {
31513
31717
  subject: external_exports.string().describe("Message subject"),
31514
31718
  body: external_exports.string().describe("Message body text"),
31515
31719
  recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (get from ofw_get_profile)"),
31516
31720
  replyToId: external_exports.number().describe("ID of the message being replied to").optional(),
31517
- draftId: external_exports.number().describe("ID of the draft to delete after sending (omit if not sending from a draft)").optional()
31721
+ draftId: external_exports.number().describe("ID of the draft to delete after sending (omit if not sending from a draft)").optional(),
31722
+ myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment) to attach to the message").optional()
31518
31723
  }
31519
31724
  }, async (args) => {
31520
31725
  const requestedReplyTo = args.replyToId ?? null;
@@ -31529,11 +31734,12 @@ function registerMessageTools(server2, client2) {
31529
31734
  const parent = getMessage(resolvedReplyTo);
31530
31735
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
31531
31736
  }
31737
+ const myFileIDs = args.myFileIDs ?? [];
31532
31738
  const data = await client2.request("POST", "/pub/v3/messages", {
31533
31739
  subject: args.subject,
31534
31740
  body: args.body,
31535
31741
  recipientIds: args.recipientIds,
31536
- attachments: { myFileIDs: [] },
31742
+ attachments: { myFileIDs },
31537
31743
  draft: false,
31538
31744
  includeOriginal: resolvedReplyTo !== null,
31539
31745
  replyToId: resolvedReplyTo
@@ -31558,6 +31764,18 @@ function registerMessageTools(server2, client2) {
31558
31764
  listData: data
31559
31765
  };
31560
31766
  upsertMessage(row);
31767
+ for (const fileId of myFileIDs) {
31768
+ const existing = getAttachment(fileId);
31769
+ upsertAttachmentForMessage({
31770
+ fileId,
31771
+ fileName: existing?.fileName ?? `file-${fileId}`,
31772
+ label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
31773
+ mimeType: existing?.mimeType ?? "application/octet-stream",
31774
+ sizeBytes: existing?.sizeBytes ?? null,
31775
+ metadata: existing?.metadata ?? {},
31776
+ messageId: data.id
31777
+ });
31778
+ }
31561
31779
  }
31562
31780
  if (args.draftId !== void 0) {
31563
31781
  const form = new FormData();
@@ -31586,14 +31804,15 @@ ${text}` : text;
31586
31804
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
31587
31805
  });
31588
31806
  server2.registerTool("ofw_save_draft", {
31589
- description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response).",
31807
+ description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs.",
31590
31808
  annotations: { readOnlyHint: false },
31591
31809
  inputSchema: {
31592
31810
  subject: external_exports.string().describe("Message subject"),
31593
31811
  body: external_exports.string().describe("Message body text"),
31594
31812
  recipientIds: external_exports.array(external_exports.number()).describe("Array of recipient user IDs (optional for drafts)").optional(),
31595
31813
  messageId: external_exports.number().describe("ID of an existing draft to update (omit to create a new draft)").optional(),
31596
- replyToId: external_exports.number().describe("ID of the message this draft replies to").optional()
31814
+ replyToId: external_exports.number().describe("ID of the message this draft replies to").optional(),
31815
+ myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment)").optional()
31597
31816
  }
31598
31817
  }, async (args) => {
31599
31818
  const requestedReplyTo = args.replyToId ?? null;
@@ -31605,11 +31824,12 @@ ${text}` : text;
31605
31824
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
31606
31825
  }
31607
31826
  }
31827
+ const myFileIDs = args.myFileIDs ?? [];
31608
31828
  const payload = {
31609
31829
  subject: args.subject,
31610
31830
  body: args.body,
31611
31831
  recipientIds: args.recipientIds ?? [],
31612
- attachments: { myFileIDs: [] },
31832
+ attachments: { myFileIDs },
31613
31833
  draft: true,
31614
31834
  includeOriginal: resolvedReplyTo !== null,
31615
31835
  replyToId: resolvedReplyTo
@@ -31681,6 +31901,106 @@ ${text}` : text;
31681
31901
  }
31682
31902
  return { content: [{ type: "text", text: JSON.stringify(unread, null, 2) }] };
31683
31903
  });
31904
+ server2.registerTool("ofw_upload_attachment", {
31905
+ description: `Upload a local file to OurFamilyWizard's "My Files" so it can be attached to a message. Returns the fileId \u2014 pass that to ofw_send_message or ofw_save_draft in myFileIDs to attach it. The file is uploaded as PRIVATE (visible only to you) by default; pass shareClass:"SHARED" to share with co-parents directly via the My Files area.`,
31906
+ annotations: { destructiveHint: false },
31907
+ inputSchema: {
31908
+ path: external_exports.string().describe("Absolute path to the local file to upload. Tilde (~) is expanded."),
31909
+ shareClass: external_exports.enum(["PRIVATE", "SHARED"]).describe("Share class (default PRIVATE)").optional(),
31910
+ label: external_exports.string().describe("Display label for the file in OFW (default: filename)").optional(),
31911
+ description: external_exports.string().describe("Description shown in OFW My Files (default: filename)").optional()
31912
+ }
31913
+ }, async (args) => {
31914
+ const expanded = args.path.startsWith("~/") ? join3(process.env.HOME ?? "", args.path.slice(2)) : args.path;
31915
+ const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
31916
+ const stat = statSync(abs);
31917
+ if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
31918
+ const buf = readFileSync(abs);
31919
+ const fileName = basename(abs);
31920
+ const mime = mimeFromName(fileName);
31921
+ const form = new FormData();
31922
+ form.append("file", new Blob([new Uint8Array(buf)], { type: mime }), fileName);
31923
+ form.append("source", "message");
31924
+ form.append("description", args.description ?? fileName);
31925
+ form.append("label", args.label ?? fileName);
31926
+ form.append("fileName", fileName);
31927
+ form.append("shareClass", args.shareClass ?? "PRIVATE");
31928
+ const meta3 = await client2.request("POST", "/pub/v3/myfiles/multipart", form);
31929
+ upsertAttachmentForMessage({
31930
+ fileId: meta3.fileId,
31931
+ fileName: meta3.fileName ?? fileName,
31932
+ label: meta3.label ?? args.label ?? fileName,
31933
+ mimeType: meta3.fileType ?? mime,
31934
+ sizeBytes: typeof meta3.sizeInBytes === "number" ? meta3.sizeInBytes : buf.length,
31935
+ metadata: meta3,
31936
+ messageId: 0
31937
+ });
31938
+ return { content: [{ type: "text", text: JSON.stringify({
31939
+ fileId: meta3.fileId,
31940
+ fileName: meta3.fileName ?? fileName,
31941
+ mimeType: meta3.fileType ?? mime,
31942
+ sizeBytes: meta3.sizeInBytes ?? buf.length,
31943
+ shareClass: meta3.shareClass ?? args.shareClass ?? "PRIVATE",
31944
+ note: "Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it."
31945
+ }, null, 2) }] };
31946
+ });
31947
+ server2.registerTool("ofw_download_attachment", {
31948
+ 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.",
31949
+ annotations: { readOnlyHint: false },
31950
+ inputSchema: {
31951
+ fileId: external_exports.number().describe("Attachment file id (from ofw_get_message \u2192 attachments[].fileId)"),
31952
+ 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(),
31953
+ force: external_exports.boolean().describe("Re-download even if already on disk. Default false.").optional()
31954
+ }
31955
+ }, async (args) => {
31956
+ const fileId = args.fileId;
31957
+ let cached2 = getAttachment(fileId);
31958
+ if (!cached2) {
31959
+ const meta3 = await client2.request("GET", `/pub/v1/myfiles/${fileId}`);
31960
+ upsertAttachmentForMessage({
31961
+ fileId: meta3.fileId ?? fileId,
31962
+ fileName: meta3.fileName ?? `file-${fileId}`,
31963
+ label: meta3.label ?? meta3.fileName ?? `file-${fileId}`,
31964
+ mimeType: meta3.fileType ?? "application/octet-stream",
31965
+ sizeBytes: typeof meta3.fileSize === "number" ? meta3.fileSize : null,
31966
+ metadata: meta3,
31967
+ messageId: 0
31968
+ // placeholder; will be cleaned up if a real message references it
31969
+ });
31970
+ cached2 = getAttachment(fileId);
31971
+ if (!cached2) throw new Error(`failed to fetch metadata for fileId ${fileId}`);
31972
+ }
31973
+ let dest;
31974
+ if (args.saveTo) {
31975
+ const expanded = args.saveTo.startsWith("~/") ? join3(process.env.HOME ?? "", args.saveTo.slice(2)) : args.saveTo;
31976
+ const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
31977
+ const isDirArg = expanded.endsWith("/") || expanded.endsWith("\\");
31978
+ dest = isDirArg ? join3(abs, `${fileId}-${cached2.fileName}`) : abs;
31979
+ } else {
31980
+ dest = join3(getAttachmentsDir(), `${fileId}-${cached2.fileName}`);
31981
+ }
31982
+ if (!args.force && cached2.downloadedPath === dest) {
31983
+ return { content: [{ type: "text", text: JSON.stringify({
31984
+ fileId,
31985
+ path: dest,
31986
+ mimeType: cached2.mimeType,
31987
+ sizeBytes: cached2.sizeBytes,
31988
+ fileName: cached2.fileName,
31989
+ note: "already downloaded"
31990
+ }, null, 2) }] };
31991
+ }
31992
+ const response = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
31993
+ mkdirSync2(dirname3(dest), { recursive: true });
31994
+ writeFileSync(dest, response.body);
31995
+ markAttachmentDownloaded(fileId, dest);
31996
+ return { content: [{ type: "text", text: JSON.stringify({
31997
+ fileId,
31998
+ path: dest,
31999
+ mimeType: response.contentType ?? cached2.mimeType,
32000
+ sizeBytes: response.body.length,
32001
+ fileName: response.suggestedFileName ?? cached2.fileName
32002
+ }, null, 2) }] };
32003
+ });
31684
32004
  server2.registerTool("ofw_sync_messages", {
31685
32005
  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
32006
  annotations: { readOnlyHint: false },
@@ -31841,7 +32161,7 @@ process.emit = function(event, ...args) {
31841
32161
  }
31842
32162
  return originalEmit(event, ...args);
31843
32163
  };
31844
- var server = new McpServer({ name: "ofw", version: "2.0.8" });
32164
+ var server = new McpServer({ name: "ofw", version: "2.0.10" });
31845
32165
  registerUserTools(server, client);
31846
32166
  registerMessageTools(server, client);
31847
32167
  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.10' });
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,36 @@
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, readFileSync, statSync, writeFileSync } from 'node:fs';
6
+ import { basename, dirname, extname, join, isAbsolute, resolve } from 'node:path';
7
+ // Lightweight mime sniff from extension. OFW re-derives mime from the filename
8
+ // server-side anyway, so this is just a polite Content-Type for the Blob.
9
+ const MIME_BY_EXT = {
10
+ '.pdf': 'application/pdf',
11
+ '.png': 'image/png',
12
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
13
+ '.gif': 'image/gif',
14
+ '.webp': 'image/webp',
15
+ '.heic': 'image/heic',
16
+ '.txt': 'text/plain',
17
+ '.md': 'text/markdown',
18
+ '.csv': 'text/csv',
19
+ '.html': 'text/html', '.htm': 'text/html',
20
+ '.json': 'application/json',
21
+ '.xml': 'application/xml',
22
+ '.doc': 'application/msword',
23
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
24
+ '.xls': 'application/vnd.ms-excel',
25
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
26
+ '.ppt': 'application/vnd.ms-powerpoint',
27
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
28
+ '.zip': 'application/zip',
29
+ '.ics': 'text/calendar',
30
+ };
31
+ function mimeFromName(name) {
32
+ return MIME_BY_EXT[extname(name).toLowerCase()] ?? 'application/octet-stream';
33
+ }
4
34
  export function registerMessageTools(server, client) {
5
35
  server.registerTool('ofw_list_message_folders', {
6
36
  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 +94,8 @@ export function registerMessageTools(server, client) {
64
94
  const id = Number(args.messageId);
65
95
  const cached = getMessage(id);
66
96
  if (cached && cached.body !== null) {
67
- return { content: [{ type: 'text', text: JSON.stringify(cached, null, 2) }] };
97
+ const attachments = listAttachmentsForMessage(id);
98
+ return { content: [{ type: 'text', text: JSON.stringify({ ...cached, attachments }, null, 2) }] };
68
99
  }
69
100
  const detail = await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
70
101
  const recipients = (detail.recipients ?? []).map((r) => ({
@@ -76,7 +107,7 @@ export function registerMessageTools(server, client) {
76
107
  folder,
77
108
  subject: detail.subject,
78
109
  fromUser: detail.from?.name ?? '',
79
- sentAt: detail.date.dateTime,
110
+ sentAt: detail.date?.dateTime ?? new Date().toISOString(),
80
111
  recipients,
81
112
  body: detail.body ?? '',
82
113
  fetchedBodyAt: new Date().toISOString(),
@@ -85,10 +116,14 @@ export function registerMessageTools(server, client) {
85
116
  listData: cached?.listData ?? detail,
86
117
  };
87
118
  upsertMessage(row);
88
- return { content: [{ type: 'text', text: JSON.stringify(row, null, 2) }] };
119
+ if (Array.isArray(detail.files) && detail.files.length > 0) {
120
+ await fetchAttachmentMetaForMessage(client, detail.id, detail.files);
121
+ }
122
+ const attachments = listAttachmentsForMessage(detail.id);
123
+ return { content: [{ type: 'text', text: JSON.stringify({ ...row, attachments }, null, 2) }] };
89
124
  });
90
125
  server.registerTool('ofw_send_message', {
91
- 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).',
126
+ 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). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs.',
92
127
  annotations: { destructiveHint: true },
93
128
  inputSchema: {
94
129
  subject: z.string().describe('Message subject'),
@@ -96,6 +131,7 @@ export function registerMessageTools(server, client) {
96
131
  recipientIds: z.array(z.number()).describe('Array of recipient user IDs (get from ofw_get_profile)'),
97
132
  replyToId: z.number().describe('ID of the message being replied to').optional(),
98
133
  draftId: z.number().describe('ID of the draft to delete after sending (omit if not sending from a draft)').optional(),
134
+ myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment) to attach to the message').optional(),
99
135
  },
100
136
  }, async (args) => {
101
137
  const requestedReplyTo = args.replyToId ?? null;
@@ -110,11 +146,12 @@ export function registerMessageTools(server, client) {
110
146
  const parent = getMessage(resolvedReplyTo);
111
147
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
112
148
  }
149
+ const myFileIDs = args.myFileIDs ?? [];
113
150
  const data = await client.request('POST', '/pub/v3/messages', {
114
151
  subject: args.subject,
115
152
  body: args.body,
116
153
  recipientIds: args.recipientIds,
117
- attachments: { myFileIDs: [] },
154
+ attachments: { myFileIDs },
118
155
  draft: false,
119
156
  includeOriginal: resolvedReplyTo !== null,
120
157
  replyToId: resolvedReplyTo,
@@ -137,6 +174,21 @@ export function registerMessageTools(server, client) {
137
174
  listData: data,
138
175
  };
139
176
  upsertMessage(row);
177
+ // Link attached files to the new message in the attachments cache.
178
+ // We may not have full metadata if the upload happened in a prior
179
+ // session — fall back to what we know.
180
+ for (const fileId of myFileIDs) {
181
+ const existing = getAttachment(fileId);
182
+ upsertAttachmentForMessage({
183
+ fileId,
184
+ fileName: existing?.fileName ?? `file-${fileId}`,
185
+ label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
186
+ mimeType: existing?.mimeType ?? 'application/octet-stream',
187
+ sizeBytes: existing?.sizeBytes ?? null,
188
+ metadata: existing?.metadata ?? {},
189
+ messageId: data.id,
190
+ });
191
+ }
140
192
  }
141
193
  if (args.draftId !== undefined) {
142
194
  const form = new FormData();
@@ -165,7 +217,7 @@ export function registerMessageTools(server, client) {
165
217
  return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
166
218
  });
167
219
  server.registerTool('ofw_save_draft', {
168
- description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response).',
220
+ description: 'Save a message as a draft in OurFamilyWizard. Recipients are optional. To update an existing draft, provide its messageId. If replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included in response). Attach files by passing their fileIds (from ofw_upload_attachment) in myFileIDs.',
169
221
  annotations: { readOnlyHint: false },
170
222
  inputSchema: {
171
223
  subject: z.string().describe('Message subject'),
@@ -173,6 +225,7 @@ export function registerMessageTools(server, client) {
173
225
  recipientIds: z.array(z.number()).describe('Array of recipient user IDs (optional for drafts)').optional(),
174
226
  messageId: z.number().describe('ID of an existing draft to update (omit to create a new draft)').optional(),
175
227
  replyToId: z.number().describe('ID of the message this draft replies to').optional(),
228
+ myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
176
229
  },
177
230
  }, async (args) => {
178
231
  const requestedReplyTo = args.replyToId ?? null;
@@ -184,11 +237,12 @@ export function registerMessageTools(server, client) {
184
237
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
185
238
  }
186
239
  }
240
+ const myFileIDs = args.myFileIDs ?? [];
187
241
  const payload = {
188
242
  subject: args.subject,
189
243
  body: args.body,
190
244
  recipientIds: args.recipientIds ?? [],
191
- attachments: { myFileIDs: [] },
245
+ attachments: { myFileIDs },
192
246
  draft: true,
193
247
  includeOriginal: resolvedReplyTo !== null,
194
248
  replyToId: resolvedReplyTo,
@@ -257,6 +311,121 @@ export function registerMessageTools(server, client) {
257
311
  }
258
312
  return { content: [{ type: 'text', text: JSON.stringify(unread, null, 2) }] };
259
313
  });
314
+ server.registerTool('ofw_upload_attachment', {
315
+ description: 'Upload a local file to OurFamilyWizard\'s "My Files" so it can be attached to a message. Returns the fileId — pass that to ofw_send_message or ofw_save_draft in myFileIDs to attach it. The file is uploaded as PRIVATE (visible only to you) by default; pass shareClass:"SHARED" to share with co-parents directly via the My Files area.',
316
+ annotations: { destructiveHint: false },
317
+ inputSchema: {
318
+ path: z.string().describe('Absolute path to the local file to upload. Tilde (~) is expanded.'),
319
+ shareClass: z.enum(['PRIVATE', 'SHARED']).describe('Share class (default PRIVATE)').optional(),
320
+ label: z.string().describe('Display label for the file in OFW (default: filename)').optional(),
321
+ description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
322
+ },
323
+ }, async (args) => {
324
+ // Resolve and read the local file
325
+ const expanded = args.path.startsWith('~/')
326
+ ? join(process.env.HOME ?? '', args.path.slice(2))
327
+ : args.path;
328
+ const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
329
+ const stat = statSync(abs); // throws if missing
330
+ if (!stat.isFile())
331
+ throw new Error(`Not a file: ${abs}`);
332
+ const buf = readFileSync(abs);
333
+ const fileName = basename(abs);
334
+ const mime = mimeFromName(fileName);
335
+ // Build the multipart payload matching the OFW web UI's request shape
336
+ const form = new FormData();
337
+ form.append('file', new Blob([new Uint8Array(buf)], { type: mime }), fileName);
338
+ form.append('source', 'message');
339
+ form.append('description', args.description ?? fileName);
340
+ form.append('label', args.label ?? fileName);
341
+ form.append('fileName', fileName);
342
+ form.append('shareClass', args.shareClass ?? 'PRIVATE');
343
+ const meta = await client.request('POST', '/pub/v3/myfiles/multipart', form);
344
+ // Cache the metadata so subsequent ofw_get_message calls can surface it
345
+ // and ofw_download_attachment short-circuits if asked. messageId is 0
346
+ // because no message references this yet — it'll be linked once a
347
+ // message is sent with this fileId in its attachments.
348
+ upsertAttachmentForMessage({
349
+ fileId: meta.fileId,
350
+ fileName: meta.fileName ?? fileName,
351
+ label: meta.label ?? args.label ?? fileName,
352
+ mimeType: meta.fileType ?? mime,
353
+ sizeBytes: typeof meta.sizeInBytes === 'number' ? meta.sizeInBytes : buf.length,
354
+ metadata: meta,
355
+ messageId: 0,
356
+ });
357
+ return { content: [{ type: 'text', text: JSON.stringify({
358
+ fileId: meta.fileId,
359
+ fileName: meta.fileName ?? fileName,
360
+ mimeType: meta.fileType ?? mime,
361
+ sizeBytes: meta.sizeInBytes ?? buf.length,
362
+ shareClass: meta.shareClass ?? args.shareClass ?? 'PRIVATE',
363
+ note: 'Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it.',
364
+ }, null, 2) }] };
365
+ });
366
+ server.registerTool('ofw_download_attachment', {
367
+ 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.',
368
+ annotations: { readOnlyHint: false },
369
+ inputSchema: {
370
+ fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
371
+ 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(),
372
+ force: z.boolean().describe('Re-download even if already on disk. Default false.').optional(),
373
+ },
374
+ }, async (args) => {
375
+ const fileId = args.fileId;
376
+ let cached = getAttachment(fileId);
377
+ if (!cached) {
378
+ // Metadata not in cache — fetch on the fly.
379
+ const meta = await client.request('GET', `/pub/v1/myfiles/${fileId}`);
380
+ // Store with a sentinel "metadata-only, no message link" — we don't know which message asked.
381
+ // We'll re-link if a message later references it during sync.
382
+ upsertAttachmentForMessage({
383
+ fileId: meta.fileId ?? fileId,
384
+ fileName: meta.fileName ?? `file-${fileId}`,
385
+ label: meta.label ?? meta.fileName ?? `file-${fileId}`,
386
+ mimeType: meta.fileType ?? 'application/octet-stream',
387
+ sizeBytes: typeof meta.fileSize === 'number' ? meta.fileSize : null,
388
+ metadata: meta,
389
+ messageId: 0, // placeholder; will be cleaned up if a real message references it
390
+ });
391
+ cached = getAttachment(fileId);
392
+ if (!cached)
393
+ throw new Error(`failed to fetch metadata for fileId ${fileId}`);
394
+ }
395
+ // Decide destination path
396
+ let dest;
397
+ if (args.saveTo) {
398
+ const expanded = args.saveTo.startsWith('~/')
399
+ ? join(process.env.HOME ?? '', args.saveTo.slice(2))
400
+ : args.saveTo;
401
+ const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
402
+ // If it looks like a directory (ends with /) OR is an existing directory, treat as dir.
403
+ const isDirArg = expanded.endsWith('/') || expanded.endsWith('\\');
404
+ dest = isDirArg ? join(abs, `${fileId}-${cached.fileName}`) : abs;
405
+ }
406
+ else {
407
+ dest = join(getAttachmentsDir(), `${fileId}-${cached.fileName}`);
408
+ }
409
+ // Short-circuit if already downloaded to this path
410
+ if (!args.force && cached.downloadedPath === dest) {
411
+ return { content: [{ type: 'text', text: JSON.stringify({
412
+ fileId, path: dest, mimeType: cached.mimeType, sizeBytes: cached.sizeBytes,
413
+ fileName: cached.fileName, note: 'already downloaded',
414
+ }, null, 2) }] };
415
+ }
416
+ // Fetch bytes
417
+ const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
418
+ mkdirSync(dirname(dest), { recursive: true });
419
+ writeFileSync(dest, response.body);
420
+ markAttachmentDownloaded(fileId, dest);
421
+ return { content: [{ type: 'text', text: JSON.stringify({
422
+ fileId,
423
+ path: dest,
424
+ mimeType: response.contentType ?? cached.mimeType,
425
+ sizeBytes: response.body.length,
426
+ fileName: response.suggestedFileName ?? cached.fileName,
427
+ }, null, 2) }] };
428
+ });
260
429
  server.registerTool('ofw_sync_messages', {
261
430
  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
431
  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.10",
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.10",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.0.8",
14
+ "version": "2.0.10",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },