ofw-mcp 2.0.10 → 2.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bundle.js CHANGED
@@ -30880,74 +30880,53 @@ function readVar(key) {
30880
30880
  return trimmed;
30881
30881
  }
30882
30882
  var BASE_URL = "https://ofw.ourfamilywizard.com";
30883
- var STATIC_HEADERS = {
30883
+ var OFW_PROTOCOL_HEADERS = {
30884
30884
  "ofw-client": "WebApplication",
30885
- "ofw-version": "1.0.0",
30886
- Accept: "application/json",
30887
- "Content-Type": "application/json"
30885
+ "ofw-version": "1.0.0"
30888
30886
  };
30887
+ function parseContentDispositionFilename(cd) {
30888
+ const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
30889
+ if (extMatch) {
30890
+ const raw = extMatch[1].trim().replace(/^"|"$/g, "");
30891
+ try {
30892
+ return decodeURIComponent(raw);
30893
+ } catch {
30894
+ return raw;
30895
+ }
30896
+ }
30897
+ const m = /filename="?([^";]+)"?/i.exec(cd);
30898
+ return m ? m[1] : null;
30899
+ }
30889
30900
  var OFWClient = class {
30890
30901
  token = null;
30891
30902
  tokenExpiry = null;
30892
30903
  async request(method, path, body) {
30893
30904
  await this.ensureAuthenticated();
30894
- return this.doRequest(method, path, body, false);
30905
+ const response = await this.fetchWithRetry(method, path, body, "application/json", false);
30906
+ const text = await response.text();
30907
+ return text ? JSON.parse(text) : null;
30895
30908
  }
30896
30909
  /** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
30897
30910
  async requestBinary(method, path) {
30898
30911
  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
- }
30912
+ const response = await this.fetchWithRetry(method, path, void 0, "application/octet-stream", false);
30936
30913
  return {
30937
- body: buf,
30914
+ body: Buffer.from(await response.arrayBuffer()),
30938
30915
  contentType: response.headers.get("content-type"),
30939
- suggestedFileName
30916
+ suggestedFileName: parseContentDispositionFilename(response.headers.get("content-disposition") ?? "")
30940
30917
  };
30941
30918
  }
30942
- async doRequest(method, path, body, isRetry) {
30919
+ // Single fetch+retry scaffold for both JSON and binary callers. Handles
30920
+ // 401 (re-auth and replay once), 429 (wait 2s and replay once), and
30921
+ // turns any other non-2xx into a thrown Error.
30922
+ async fetchWithRetry(method, path, body, accept, isRetry) {
30943
30923
  const isFormData = body instanceof FormData;
30944
30924
  const headers = {
30945
- "ofw-client": "WebApplication",
30946
- "ofw-version": "1.0.0",
30947
- Accept: "application/json",
30925
+ ...OFW_PROTOCOL_HEADERS,
30926
+ Accept: accept,
30948
30927
  Authorization: `Bearer ${this.token}`
30949
30928
  };
30950
- if (!isFormData) headers["Content-Type"] = "application/json";
30929
+ if (body !== void 0 && !isFormData) headers["Content-Type"] = "application/json";
30951
30930
  const response = await fetch(`${BASE_URL}${path}`, {
30952
30931
  method,
30953
30932
  headers,
@@ -30957,22 +30936,19 @@ var OFWClient = class {
30957
30936
  this.token = null;
30958
30937
  this.tokenExpiry = null;
30959
30938
  await this.ensureAuthenticated();
30960
- return this.doRequest(method, path, body, true);
30939
+ return this.fetchWithRetry(method, path, body, accept, true);
30961
30940
  }
30962
30941
  if (response.status === 429) {
30963
30942
  if (!isRetry) {
30964
30943
  await new Promise((r) => setTimeout(r, 2e3));
30965
- return this.doRequest(method, path, body, true);
30944
+ return this.fetchWithRetry(method, path, body, accept, true);
30966
30945
  }
30967
30946
  throw new Error("Rate limited by OFW API");
30968
30947
  }
30969
30948
  if (!response.ok) {
30970
- throw new Error(
30971
- `OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`
30972
- );
30949
+ throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
30973
30950
  }
30974
- const text = await response.text();
30975
- return text ? JSON.parse(text) : null;
30951
+ return response;
30976
30952
  }
30977
30953
  async ensureAuthenticated() {
30978
30954
  if (!this.isTokenExpiredSoon()) return;
@@ -30985,7 +30961,7 @@ var OFWClient = class {
30985
30961
  throw new Error("OFW_USERNAME and OFW_PASSWORD must be set");
30986
30962
  }
30987
30963
  const initResponse = await fetch(`${BASE_URL}/ofw/login.form`, {
30988
- headers: { "ofw-client": "WebApplication", "ofw-version": "1.0.0" },
30964
+ headers: { ...OFW_PROTOCOL_HEADERS },
30989
30965
  redirect: "manual"
30990
30966
  });
30991
30967
  const setCookie = initResponse.headers.get("set-cookie") ?? "";
@@ -30993,7 +30969,8 @@ var OFWClient = class {
30993
30969
  const response = await fetch(`${BASE_URL}/ofw/login`, {
30994
30970
  method: "POST",
30995
30971
  headers: {
30996
- ...STATIC_HEADERS,
30972
+ ...OFW_PROTOCOL_HEADERS,
30973
+ Accept: "application/json",
30997
30974
  "Content-Type": "application/x-www-form-urlencoded",
30998
30975
  ...sessionCookie ? { Cookie: sessionCookie } : {}
30999
30976
  },
@@ -31023,6 +31000,26 @@ var OFWClient = class {
31023
31000
  };
31024
31001
  var client = new OFWClient();
31025
31002
 
31003
+ // src/tools/_shared.ts
31004
+ import { isAbsolute, join as join2, resolve } from "node:path";
31005
+ function jsonResponse(payload) {
31006
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
31007
+ }
31008
+ function textResponse(text) {
31009
+ return { content: [{ type: "text", text }] };
31010
+ }
31011
+ function mapRecipients(items) {
31012
+ return (items ?? []).map((r) => ({
31013
+ userId: r.user?.id ?? 0,
31014
+ name: r.user?.name ?? "",
31015
+ viewedAt: r.viewed?.dateTime ?? null
31016
+ }));
31017
+ }
31018
+ function expandPath(p) {
31019
+ const expanded = p.startsWith("~/") ? join2(process.env.HOME ?? "", p.slice(2)) : p;
31020
+ return isAbsolute(expanded) ? expanded : resolve(expanded);
31021
+ }
31022
+
31026
31023
  // src/tools/user.ts
31027
31024
  function registerUserTools(server2, client2) {
31028
31025
  server2.registerTool("ofw_get_profile", {
@@ -31030,14 +31027,14 @@ function registerUserTools(server2, client2) {
31030
31027
  annotations: { readOnlyHint: true }
31031
31028
  }, async () => {
31032
31029
  const data = await client2.request("GET", "/pub/v2/profiles");
31033
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
31030
+ return jsonResponse(data);
31034
31031
  });
31035
31032
  server2.registerTool("ofw_get_notifications", {
31036
31033
  description: "Get OurFamilyWizard dashboard summary: unread message count, upcoming events, outstanding expenses. Note: updates your last-seen status.",
31037
31034
  annotations: { readOnlyHint: false }
31038
31035
  }, async () => {
31039
31036
  const data = await client2.request("GET", "/pub/v1/users/useraccountstatus");
31040
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
31037
+ return jsonResponse(data);
31041
31038
  });
31042
31039
  }
31043
31040
 
@@ -31049,7 +31046,7 @@ import { dirname as dirname2 } from "node:path";
31049
31046
  // src/config.ts
31050
31047
  import { createHash } from "node:crypto";
31051
31048
  import { homedir } from "node:os";
31052
- import { join as join2 } from "node:path";
31049
+ import { join as join3 } from "node:path";
31053
31050
  function readUsername() {
31054
31051
  const raw = process.env.OFW_USERNAME;
31055
31052
  if (typeof raw !== "string" || raw.trim().length === 0) {
@@ -31060,19 +31057,22 @@ function readUsername() {
31060
31057
  function getCacheDir() {
31061
31058
  const override = process.env.OFW_CACHE_DIR;
31062
31059
  if (override && override.trim().length > 0) return override.trim();
31063
- return join2(homedir(), ".cache", "ofw-mcp");
31060
+ return join3(homedir(), ".cache", "ofw-mcp");
31064
31061
  }
31065
31062
  function getCacheDbPath() {
31066
31063
  const username = readUsername();
31067
31064
  const hash2 = createHash("sha256").update(username).digest("hex").slice(0, 16);
31068
- return join2(getCacheDir(), `${hash2}.db`);
31065
+ return join3(getCacheDir(), `${hash2}.db`);
31069
31066
  }
31070
31067
  function getAttachmentsDir() {
31071
31068
  const override = process.env.OFW_ATTACHMENTS_DIR;
31072
31069
  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);
31070
+ return join3(homedir(), "Downloads", "ofw-mcp");
31071
+ }
31072
+ function getDefaultInlineAttachments() {
31073
+ const raw = process.env.OFW_INLINE_ATTACHMENTS;
31074
+ if (typeof raw !== "string") return false;
31075
+ return ["1", "true", "yes", "on"].includes(raw.trim().toLowerCase());
31076
31076
  }
31077
31077
 
31078
31078
  // src/cache.ts
@@ -31209,9 +31209,7 @@ function getMessage(id) {
31209
31209
  const r = db.prepare("SELECT * FROM messages WHERE id = ?").get(id);
31210
31210
  return r ? rowFromDb(r) : null;
31211
31211
  }
31212
- function listMessages(opts) {
31213
- const { db } = openCache();
31214
- const offset = (opts.page - 1) * opts.size;
31212
+ function buildMessageFilter(opts) {
31215
31213
  const wheres = [];
31216
31214
  const params = [];
31217
31215
  if (opts.folder !== void 0) {
@@ -31231,37 +31229,25 @@ function listMessages(opts) {
31231
31229
  wheres.push("(subject LIKE ? OR body LIKE ?)");
31232
31230
  params.push(pattern, pattern);
31233
31231
  }
31234
- const where = wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "";
31235
- params.push(opts.size, offset);
31232
+ return {
31233
+ where: wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "",
31234
+ params
31235
+ };
31236
+ }
31237
+ function listMessages(opts) {
31238
+ const { db } = openCache();
31239
+ const { where, params } = buildMessageFilter(opts);
31240
+ const offset = (opts.page - 1) * opts.size;
31236
31241
  const rows = db.prepare(
31237
31242
  `SELECT * FROM messages ${where}
31238
31243
  ORDER BY sent_at DESC, id DESC
31239
31244
  LIMIT ? OFFSET ?`
31240
- ).all(...params);
31245
+ ).all(...params, opts.size, offset);
31241
31246
  return rows.map(rowFromDb);
31242
31247
  }
31243
31248
  function countMessages(opts) {
31244
31249
  const { db } = openCache();
31245
- const wheres = [];
31246
- const params = [];
31247
- if (opts.folder !== void 0) {
31248
- wheres.push("folder = ?");
31249
- params.push(opts.folder);
31250
- }
31251
- if (opts.since !== void 0) {
31252
- wheres.push("sent_at >= ?");
31253
- params.push(opts.since);
31254
- }
31255
- if (opts.until !== void 0) {
31256
- wheres.push("sent_at < ?");
31257
- params.push(opts.until);
31258
- }
31259
- if (opts.q !== void 0 && opts.q.length > 0) {
31260
- const pattern = `%${opts.q}%`;
31261
- wheres.push("(subject LIKE ? OR body LIKE ?)");
31262
- params.push(pattern, pattern);
31263
- }
31264
- const where = wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "";
31250
+ const { where, params } = buildMessageFilter(opts);
31265
31251
  const r = db.prepare(`SELECT COUNT(*) as n FROM messages ${where}`).get(...params);
31266
31252
  return r?.n ?? 0;
31267
31253
  }
@@ -31379,12 +31365,14 @@ function listAttachmentsForMessage(messageId) {
31379
31365
  function upsertAttachmentForMessage(input) {
31380
31366
  const { db } = openCache();
31381
31367
  const existing = db.prepare("SELECT message_ids_json FROM attachments WHERE file_id = ?").get(input.fileId);
31368
+ const prior = existing ? JSON.parse(existing.message_ids_json) : [];
31382
31369
  let messageIds;
31383
- if (existing) {
31384
- const arr = JSON.parse(existing.message_ids_json);
31385
- messageIds = arr.includes(input.messageId) ? arr : [...arr, input.messageId];
31370
+ if (input.messageId === 0) {
31371
+ messageIds = prior;
31372
+ } else if (prior.includes(input.messageId)) {
31373
+ messageIds = prior;
31386
31374
  } else {
31387
- messageIds = [input.messageId];
31375
+ messageIds = [...prior, input.messageId];
31388
31376
  }
31389
31377
  db.prepare(
31390
31378
  `INSERT INTO attachments (
@@ -31418,24 +31406,24 @@ function markAttachmentDownloaded(fileId, path) {
31418
31406
  }
31419
31407
 
31420
31408
  // 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
- }
31409
+ async function fetchAttachmentMeta(client2, fileId, messageId) {
31410
+ const meta3 = await client2.request("GET", `/pub/v1/myfiles/${fileId}`);
31411
+ upsertAttachmentForMessage({
31412
+ fileId: meta3.fileId ?? fileId,
31413
+ fileName: meta3.fileName ?? `file-${fileId}`,
31414
+ label: meta3.label ?? meta3.fileName ?? `file-${fileId}`,
31415
+ mimeType: meta3.fileType ?? "application/octet-stream",
31416
+ sizeBytes: typeof meta3.fileSize === "number" ? meta3.fileSize : null,
31417
+ metadata: meta3,
31418
+ messageId
31419
+ });
31435
31420
  }
31436
31421
  async function fetchAttachmentMetaForMessage(client2, messageId, fileIds) {
31437
31422
  for (const fid of fileIds) {
31438
- await fetchAndCacheAttachmentMeta(client2, fid, messageId);
31423
+ try {
31424
+ await fetchAttachmentMeta(client2, fid, messageId);
31425
+ } catch {
31426
+ }
31439
31427
  }
31440
31428
  }
31441
31429
  async function resolveFolderIds(client2) {
@@ -31457,13 +31445,6 @@ async function resolveFolderIds(client2) {
31457
31445
  setMeta("drafts_folder_id", ids.drafts);
31458
31446
  return ids;
31459
31447
  }
31460
- function recipientsFromList(item) {
31461
- return (item.recipients ?? []).map((r) => ({
31462
- userId: r.user.id,
31463
- name: r.user.name,
31464
- viewedAt: r.viewed?.dateTime ?? null
31465
- }));
31466
- }
31467
31448
  async function syncMessageFolder(client2, folder, folderId, opts) {
31468
31449
  let page = 1;
31469
31450
  let synced = 0;
@@ -31506,7 +31487,7 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
31506
31487
  subject: item.subject ?? "(no subject)",
31507
31488
  fromUser: item.from?.name ?? "",
31508
31489
  sentAt: item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
31509
- recipients: recipientsFromList(item),
31490
+ recipients: mapRecipients(item.recipients),
31510
31491
  body,
31511
31492
  fetchedBodyAt,
31512
31493
  replyToId: null,
@@ -31546,11 +31527,7 @@ async function syncDrafts(client2, draftsFolderId) {
31546
31527
  id: item.id,
31547
31528
  subject: detail.subject ?? item.subject ?? "(no subject)",
31548
31529
  body: detail.body ?? "",
31549
- recipients: (item.recipients ?? []).map((r) => ({
31550
- userId: r.user?.id ?? 0,
31551
- name: r.user?.name ?? "",
31552
- viewedAt: r.viewed?.dateTime ?? null
31553
- })),
31530
+ recipients: mapRecipients(item.recipients),
31554
31531
  replyToId: item.replyToId ?? null,
31555
31532
  modifiedAt,
31556
31533
  listData: item
@@ -31593,7 +31570,7 @@ async function syncAll(client2, opts) {
31593
31570
 
31594
31571
  // src/tools/messages.ts
31595
31572
  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";
31573
+ import { basename, dirname as dirname3, extname, join as join4 } from "node:path";
31597
31574
  var MIME_BY_EXT = {
31598
31575
  ".pdf": "application/pdf",
31599
31576
  ".png": "image/png",
@@ -31621,13 +31598,20 @@ var MIME_BY_EXT = {
31621
31598
  function mimeFromName(name) {
31622
31599
  return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
31623
31600
  }
31601
+ function listDataHintsAtFiles(listData) {
31602
+ if (typeof listData !== "object" || listData === null) return false;
31603
+ const ld = listData;
31604
+ if (typeof ld.files === "number") return ld.files > 0;
31605
+ if (Array.isArray(ld.files)) return ld.files.length > 0;
31606
+ return false;
31607
+ }
31624
31608
  function registerMessageTools(server2, client2) {
31625
31609
  server2.registerTool("ofw_list_message_folders", {
31626
31610
  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.",
31627
31611
  annotations: { readOnlyHint: true }
31628
31612
  }, async () => {
31629
31613
  const data = await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true");
31630
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
31614
+ return jsonResponse(data);
31631
31615
  });
31632
31616
  server2.registerTool("ofw_list_messages", {
31633
31617
  description: "List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages \u2014 the cache may have 1000+ messages. Call ofw_sync_messages first if the cache is empty or stale.",
@@ -31649,15 +31633,10 @@ function registerMessageTools(server2, client2) {
31649
31633
  else if (folderArg === "sent") folder = "sent";
31650
31634
  else if (folderArg === "both") folder = void 0;
31651
31635
  else {
31652
- return {
31653
- content: [{
31654
- type: "text",
31655
- text: JSON.stringify({
31656
- messages: [],
31657
- note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.'
31658
- }, null, 2)
31659
- }]
31660
- };
31636
+ return jsonResponse({
31637
+ messages: [],
31638
+ note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.'
31639
+ });
31661
31640
  }
31662
31641
  const filter = { folder, since: args.since, until: args.until, q: args.q };
31663
31642
  const total = countMessages(filter);
@@ -31668,7 +31647,7 @@ function registerMessageTools(server2, client2) {
31668
31647
  } else if (page * size < total) {
31669
31648
  payload.note = `Showing ${(page - 1) * size + 1}\u2013${(page - 1) * size + messages.length} of ${total}. Increase 'page' to see more, or narrow with since/until/q.`;
31670
31649
  }
31671
- return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
31650
+ return jsonResponse(payload);
31672
31651
  });
31673
31652
  server2.registerTool("ofw_get_message", {
31674
31653
  description: "Get a single OurFamilyWizard message by ID. Reads from local cache when available; otherwise fetches from OFW (which will mark unread inbox messages as read on OFW).",
@@ -31680,15 +31659,20 @@ function registerMessageTools(server2, client2) {
31680
31659
  const id = Number(args.messageId);
31681
31660
  const cached2 = getMessage(id);
31682
31661
  if (cached2 && cached2.body !== null) {
31683
- const attachments2 = listAttachmentsForMessage(id);
31684
- return { content: [{ type: "text", text: JSON.stringify({ ...cached2, attachments: attachments2 }, null, 2) }] };
31662
+ let attachments2 = listAttachmentsForMessage(id);
31663
+ if (attachments2.length === 0 && listDataHintsAtFiles(cached2.listData)) {
31664
+ try {
31665
+ const detail2 = await client2.request("GET", `/pub/v3/messages/${id}`);
31666
+ if (Array.isArray(detail2.files) && detail2.files.length > 0) {
31667
+ await fetchAttachmentMetaForMessage(client2, id, detail2.files);
31668
+ attachments2 = listAttachmentsForMessage(id);
31669
+ }
31670
+ } catch {
31671
+ }
31672
+ }
31673
+ return jsonResponse({ ...cached2, attachments: attachments2 });
31685
31674
  }
31686
31675
  const detail = await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
31687
- const recipients = (detail.recipients ?? []).map((r) => ({
31688
- userId: r.user.id,
31689
- name: r.user.name,
31690
- viewedAt: r.viewed?.dateTime ?? null
31691
- }));
31692
31676
  const folder = cached2?.folder ?? "inbox";
31693
31677
  const row = {
31694
31678
  id: detail.id,
@@ -31696,7 +31680,7 @@ function registerMessageTools(server2, client2) {
31696
31680
  subject: detail.subject,
31697
31681
  fromUser: detail.from?.name ?? "",
31698
31682
  sentAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
31699
- recipients,
31683
+ recipients: mapRecipients(detail.recipients),
31700
31684
  body: detail.body ?? "",
31701
31685
  fetchedBodyAt: (/* @__PURE__ */ new Date()).toISOString(),
31702
31686
  replyToId: cached2?.replyToId ?? null,
@@ -31708,7 +31692,7 @@ function registerMessageTools(server2, client2) {
31708
31692
  await fetchAttachmentMetaForMessage(client2, detail.id, detail.files);
31709
31693
  }
31710
31694
  const attachments = listAttachmentsForMessage(detail.id);
31711
- return { content: [{ type: "text", text: JSON.stringify({ ...row, attachments }, null, 2) }] };
31695
+ return jsonResponse({ ...row, attachments });
31712
31696
  });
31713
31697
  server2.registerTool("ofw_send_message", {
31714
31698
  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.",
@@ -31745,18 +31729,13 @@ function registerMessageTools(server2, client2) {
31745
31729
  replyToId: resolvedReplyTo
31746
31730
  });
31747
31731
  if (data && typeof data.id === "number") {
31748
- const recipients = (data.recipients ?? []).map((r) => ({
31749
- userId: r.user.id,
31750
- name: r.user.name,
31751
- viewedAt: r.viewed?.dateTime ?? null
31752
- }));
31753
31732
  const row = {
31754
31733
  id: data.id,
31755
31734
  folder: "sent",
31756
31735
  subject: data.subject ?? args.subject,
31757
31736
  fromUser: data.from?.name ?? "",
31758
31737
  sentAt: data.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
31759
- recipients,
31738
+ recipients: mapRecipients(data.recipients),
31760
31739
  body: data.body ?? args.body,
31761
31740
  fetchedBodyAt: (/* @__PURE__ */ new Date()).toISOString(),
31762
31741
  replyToId: resolvedReplyTo,
@@ -31778,16 +31757,13 @@ function registerMessageTools(server2, client2) {
31778
31757
  }
31779
31758
  }
31780
31759
  if (args.draftId !== void 0) {
31781
- const form = new FormData();
31782
- form.append("messageIds", String(args.draftId));
31783
- await client2.request("DELETE", "/pub/v1/messages", form);
31760
+ await deleteOFWMessages(client2, [args.draftId]);
31784
31761
  deleteDraft(args.draftId);
31785
31762
  }
31786
31763
  const text = data ? JSON.stringify(data, null, 2) : "Message sent successfully.";
31787
- const finalText = rewriteNote ? `${rewriteNote}
31764
+ return textResponse(rewriteNote ? `${rewriteNote}
31788
31765
 
31789
- ${text}` : text;
31790
- return { content: [{ type: "text", text: finalText }] };
31766
+ ${text}` : text);
31791
31767
  });
31792
31768
  server2.registerTool("ofw_list_drafts", {
31793
31769
  description: "List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.",
@@ -31801,7 +31777,7 @@ ${text}` : text;
31801
31777
  const size = args.size ?? 50;
31802
31778
  const drafts = listDrafts({ page, size });
31803
31779
  const payload = drafts.length === 0 ? { drafts: [], note: "Cache empty. Call ofw_sync_messages to populate." } : { drafts };
31804
- return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
31780
+ return jsonResponse(payload);
31805
31781
  });
31806
31782
  server2.registerTool("ofw_save_draft", {
31807
31783
  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.",
@@ -31841,11 +31817,7 @@ ${text}` : text;
31841
31817
  id: data.id,
31842
31818
  subject: data.subject ?? args.subject,
31843
31819
  body: data.body ?? args.body,
31844
- recipients: (data.recipients ?? []).map((r) => ({
31845
- userId: r.user.id,
31846
- name: r.user.name,
31847
- viewedAt: r.viewed?.dateTime ?? null
31848
- })),
31820
+ recipients: mapRecipients(data.recipients),
31849
31821
  replyToId: data.replyToId ?? resolvedReplyTo,
31850
31822
  modifiedAt: data.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
31851
31823
  listData: data
@@ -31853,10 +31825,9 @@ ${text}` : text;
31853
31825
  upsertDraft(draft);
31854
31826
  }
31855
31827
  const text = data ? JSON.stringify(data, null, 2) : "Draft saved.";
31856
- const finalText = rewriteNote ? `${rewriteNote}
31828
+ return textResponse(rewriteNote ? `${rewriteNote}
31857
31829
 
31858
- ${text}` : text;
31859
- return { content: [{ type: "text", text: finalText }] };
31830
+ ${text}` : text);
31860
31831
  });
31861
31832
  server2.registerTool("ofw_delete_draft", {
31862
31833
  description: "Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.",
@@ -31865,11 +31836,9 @@ ${text}` : text;
31865
31836
  messageId: external_exports.number().describe("Draft message ID to delete")
31866
31837
  }
31867
31838
  }, async (args) => {
31868
- const form = new FormData();
31869
- form.append("messageIds", String(args.messageId));
31870
- const data = await client2.request("DELETE", "/pub/v1/messages", form);
31839
+ const data = await deleteOFWMessages(client2, [args.messageId]);
31871
31840
  deleteDraft(args.messageId);
31872
- return { content: [{ type: "text", text: data ? JSON.stringify(data, null, 2) : "Draft deleted." }] };
31841
+ return data ? jsonResponse(data) : textResponse("Draft deleted.");
31873
31842
  });
31874
31843
  server2.registerTool("ofw_get_unread_sent", {
31875
31844
  description: "List sent messages that have not been read by one or more recipients. Reads from local cache; call ofw_sync_messages first if cache is stale.",
@@ -31883,9 +31852,7 @@ ${text}` : text;
31883
31852
  const size = args.size ?? 50;
31884
31853
  const sent = listMessages({ folder: "sent", page, size });
31885
31854
  if (sent.length === 0) {
31886
- return { content: [{ type: "text", text: JSON.stringify({
31887
- note: "Sent cache is empty. Call ofw_sync_messages to populate."
31888
- }, null, 2) }] };
31855
+ return jsonResponse({ note: "Sent cache is empty. Call ofw_sync_messages to populate." });
31889
31856
  }
31890
31857
  const unread = [];
31891
31858
  for (const msg of sent) {
@@ -31895,11 +31862,9 @@ ${text}` : text;
31895
31862
  }
31896
31863
  }
31897
31864
  if (unread.length === 0) {
31898
- return { content: [{ type: "text", text: JSON.stringify({
31899
- message: "All scanned sent messages have been read."
31900
- }, null, 2) }] };
31865
+ return jsonResponse({ message: "All scanned sent messages have been read." });
31901
31866
  }
31902
- return { content: [{ type: "text", text: JSON.stringify(unread, null, 2) }] };
31867
+ return jsonResponse(unread);
31903
31868
  });
31904
31869
  server2.registerTool("ofw_upload_attachment", {
31905
31870
  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.`,
@@ -31911,8 +31876,7 @@ ${text}` : text;
31911
31876
  description: external_exports.string().describe("Description shown in OFW My Files (default: filename)").optional()
31912
31877
  }
31913
31878
  }, 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);
31879
+ const abs = expandPath(args.path);
31916
31880
  const stat = statSync(abs);
31917
31881
  if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
31918
31882
  const buf = readFileSync(abs);
@@ -31935,71 +31899,95 @@ ${text}` : text;
31935
31899
  metadata: meta3,
31936
31900
  messageId: 0
31937
31901
  });
31938
- return { content: [{ type: "text", text: JSON.stringify({
31902
+ return jsonResponse({
31939
31903
  fileId: meta3.fileId,
31940
31904
  fileName: meta3.fileName ?? fileName,
31941
31905
  mimeType: meta3.fileType ?? mime,
31942
31906
  sizeBytes: meta3.sizeInBytes ?? buf.length,
31943
31907
  shareClass: meta3.shareClass ?? args.shareClass ?? "PRIVATE",
31944
31908
  note: "Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it."
31945
- }, null, 2) }] };
31909
+ });
31946
31910
  });
31947
31911
  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.",
31912
+ description: 'Download an OFW message attachment by fileId. By default, bytes are saved to disk (~/Downloads/ofw-mcp/) and the response carries the absolute path, mime type, and size for the caller to read back. Pass inline:true to skip disk entirely and return the bytes as MCP content blocks \u2014 images come back as ImageContent (the model sees them directly); other files come back as an EmbeddedResource blob. Use inline for small files where you want the model to read content immediately and the host is sandboxed; use disk for large files or when you want a persistent local copy. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var (set to "true" to make inline the default). fileId comes from attachments[].fileId on ofw_get_message. Override disk destination with OFW_ATTACHMENTS_DIR or saveTo. Re-downloading to the same path is a no-op (disk mode only).',
31949
31913
  annotations: { readOnlyHint: false },
31950
31914
  inputSchema: {
31951
31915
  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()
31916
+ inline: external_exports.boolean().describe("If true, return bytes inline as MCP content (image for image/*, embedded resource blob otherwise) and skip the disk write. If false, write to disk and return the path. If omitted, falls back to the OFW_INLINE_ATTACHMENTS env var (default: false = disk).").optional(),
31917
+ saveTo: external_exports.string().describe("Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/Downloads/ofw-mcp/<fileId>-<filename>. Ignored when inline:true.").optional(),
31918
+ force: external_exports.boolean().describe("Re-download even if already on disk. Default false. Ignored when inline:true (inline always fetches fresh bytes, or reuses an on-disk copy if present).").optional()
31954
31919
  }
31955
31920
  }, async (args) => {
31956
31921
  const fileId = args.fileId;
31922
+ const inline = args.inline ?? getDefaultInlineAttachments();
31957
31923
  let cached2 = getAttachment(fileId);
31958
31924
  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
- });
31925
+ await fetchAttachmentMeta(client2, fileId, 0);
31970
31926
  cached2 = getAttachment(fileId);
31971
31927
  if (!cached2) throw new Error(`failed to fetch metadata for fileId ${fileId}`);
31972
31928
  }
31929
+ if (inline) {
31930
+ let bytes = null;
31931
+ let mimeType = cached2.mimeType;
31932
+ let fileName = cached2.fileName;
31933
+ if (cached2.downloadedPath) {
31934
+ try {
31935
+ bytes = readFileSync(cached2.downloadedPath);
31936
+ } catch {
31937
+ }
31938
+ }
31939
+ if (bytes === null) {
31940
+ const response2 = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
31941
+ bytes = response2.body;
31942
+ mimeType = response2.contentType ?? cached2.mimeType;
31943
+ fileName = response2.suggestedFileName ?? cached2.fileName;
31944
+ }
31945
+ const base643 = bytes.toString("base64");
31946
+ const metaBlock = { type: "text", text: JSON.stringify({
31947
+ fileId,
31948
+ fileName,
31949
+ mimeType,
31950
+ sizeBytes: bytes.length,
31951
+ mode: "inline"
31952
+ }, null, 2) };
31953
+ if (mimeType.startsWith("image/")) {
31954
+ return { content: [metaBlock, { type: "image", data: base643, mimeType }] };
31955
+ }
31956
+ return { content: [metaBlock, { type: "resource", resource: {
31957
+ uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName)}`,
31958
+ mimeType,
31959
+ blob: base643
31960
+ } }] };
31961
+ }
31973
31962
  let dest;
31974
31963
  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;
31964
+ const isDirArg = args.saveTo.endsWith("/") || args.saveTo.endsWith("\\");
31965
+ const abs = expandPath(args.saveTo);
31966
+ dest = isDirArg ? join4(abs, `${fileId}-${cached2.fileName}`) : abs;
31979
31967
  } else {
31980
- dest = join3(getAttachmentsDir(), `${fileId}-${cached2.fileName}`);
31968
+ dest = join4(getAttachmentsDir(), `${fileId}-${cached2.fileName}`);
31981
31969
  }
31982
31970
  if (!args.force && cached2.downloadedPath === dest) {
31983
- return { content: [{ type: "text", text: JSON.stringify({
31971
+ return jsonResponse({
31984
31972
  fileId,
31985
31973
  path: dest,
31986
31974
  mimeType: cached2.mimeType,
31987
31975
  sizeBytes: cached2.sizeBytes,
31988
31976
  fileName: cached2.fileName,
31989
31977
  note: "already downloaded"
31990
- }, null, 2) }] };
31978
+ });
31991
31979
  }
31992
31980
  const response = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
31993
31981
  mkdirSync2(dirname3(dest), { recursive: true });
31994
31982
  writeFileSync(dest, response.body);
31995
31983
  markAttachmentDownloaded(fileId, dest);
31996
- return { content: [{ type: "text", text: JSON.stringify({
31984
+ return jsonResponse({
31997
31985
  fileId,
31998
31986
  path: dest,
31999
31987
  mimeType: response.contentType ?? cached2.mimeType,
32000
31988
  sizeBytes: response.body.length,
32001
31989
  fileName: response.suggestedFileName ?? cached2.fileName
32002
- }, null, 2) }] };
31990
+ });
32003
31991
  });
32004
31992
  server2.registerTool("ofw_sync_messages", {
32005
31993
  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).",
@@ -32015,9 +32003,14 @@ ${text}` : text;
32015
32003
  fetchUnreadBodies: args.fetchUnreadBodies,
32016
32004
  deep: args.deep
32017
32005
  });
32018
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
32006
+ return jsonResponse(result);
32019
32007
  });
32020
32008
  }
32009
+ async function deleteOFWMessages(client2, ids) {
32010
+ const form = new FormData();
32011
+ for (const id of ids) form.append("messageIds", String(id));
32012
+ return client2.request("DELETE", "/pub/v1/messages", form);
32013
+ }
32021
32014
 
32022
32015
  // src/tools/calendar.ts
32023
32016
  function registerCalendarTools(server2, client2) {
@@ -32035,7 +32028,7 @@ function registerCalendarTools(server2, client2) {
32035
32028
  "GET",
32036
32029
  `/pub/v1/calendar/${variant}?startDate=${encodeURIComponent(args.startDate)}&endDate=${encodeURIComponent(args.endDate)}`
32037
32030
  );
32038
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32031
+ return jsonResponse(data);
32039
32032
  });
32040
32033
  server2.registerTool("ofw_create_event", {
32041
32034
  description: "Create a calendar event in OurFamilyWizard",
@@ -32055,7 +32048,7 @@ function registerCalendarTools(server2, client2) {
32055
32048
  }
32056
32049
  }, async (args) => {
32057
32050
  const data = await client2.request("POST", "/pub/v1/calendar/events", args);
32058
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32051
+ return jsonResponse(data);
32059
32052
  });
32060
32053
  server2.registerTool("ofw_update_event", {
32061
32054
  description: "Update an existing OurFamilyWizard calendar event",
@@ -32073,7 +32066,7 @@ function registerCalendarTools(server2, client2) {
32073
32066
  }, async (args) => {
32074
32067
  const { eventId, ...updateData } = args;
32075
32068
  const data = await client2.request("PUT", `/pub/v1/calendar/events/${encodeURIComponent(eventId)}`, updateData);
32076
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32069
+ return jsonResponse(data);
32077
32070
  });
32078
32071
  server2.registerTool("ofw_delete_event", {
32079
32072
  description: "Delete an OurFamilyWizard calendar event",
@@ -32083,7 +32076,7 @@ function registerCalendarTools(server2, client2) {
32083
32076
  }
32084
32077
  }, async (args) => {
32085
32078
  await client2.request("DELETE", `/pub/v1/calendar/events/${encodeURIComponent(args.eventId)}`);
32086
- return { content: [{ type: "text", text: `Event ${args.eventId} deleted` }] };
32079
+ return textResponse(`Event ${args.eventId} deleted`);
32087
32080
  });
32088
32081
  }
32089
32082
 
@@ -32094,7 +32087,7 @@ function registerExpenseTools(server2, client2) {
32094
32087
  annotations: { readOnlyHint: true }
32095
32088
  }, async () => {
32096
32089
  const data = await client2.request("GET", "/pub/v2/expense/expenses/totals");
32097
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32090
+ return jsonResponse(data);
32098
32091
  });
32099
32092
  server2.registerTool("ofw_list_expenses", {
32100
32093
  description: "List OurFamilyWizard expenses with pagination",
@@ -32107,7 +32100,7 @@ function registerExpenseTools(server2, client2) {
32107
32100
  const start = args.start ?? 0;
32108
32101
  const max = args.max ?? 20;
32109
32102
  const data = await client2.request("GET", `/pub/v2/expense/expenses?start=${start}&max=${max}`);
32110
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32103
+ return jsonResponse(data);
32111
32104
  });
32112
32105
  server2.registerTool("ofw_create_expense", {
32113
32106
  description: "Log a new expense in OurFamilyWizard",
@@ -32118,7 +32111,7 @@ function registerExpenseTools(server2, client2) {
32118
32111
  }
32119
32112
  }, async (args) => {
32120
32113
  const data = await client2.request("POST", "/pub/v2/expense/expenses", args);
32121
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32114
+ return jsonResponse(data);
32122
32115
  });
32123
32116
  }
32124
32117
 
@@ -32135,7 +32128,7 @@ function registerJournalTools(server2, client2) {
32135
32128
  const start = args.start ?? 1;
32136
32129
  const max = args.max ?? 10;
32137
32130
  const data = await client2.request("GET", `/pub/v1/journals?start=${start}&max=${max}`);
32138
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32131
+ return jsonResponse(data);
32139
32132
  });
32140
32133
  server2.registerTool("ofw_create_journal_entry", {
32141
32134
  description: "Create a new journal entry in OurFamilyWizard",
@@ -32146,7 +32139,7 @@ function registerJournalTools(server2, client2) {
32146
32139
  }
32147
32140
  }, async (args) => {
32148
32141
  const data = await client2.request("POST", "/pub/v1/journals", args);
32149
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32142
+ return jsonResponse(data);
32150
32143
  });
32151
32144
  }
32152
32145
 
@@ -32161,7 +32154,7 @@ process.emit = function(event, ...args) {
32161
32154
  }
32162
32155
  return originalEmit(event, ...args);
32163
32156
  };
32164
- var server = new McpServer({ name: "ofw", version: "2.0.10" });
32157
+ var server = new McpServer({ name: "ofw", version: "2.0.12" });
32165
32158
  registerUserTools(server, client);
32166
32159
  registerMessageTools(server, client);
32167
32160
  registerCalendarTools(server, client);