ofw-mcp 2.0.11 → 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
  }
@@ -31420,24 +31406,24 @@ function markAttachmentDownloaded(fileId, path) {
31420
31406
  }
31421
31407
 
31422
31408
  // src/sync.ts
31423
- async function fetchAndCacheAttachmentMeta(client2, fileId, messageId) {
31424
- try {
31425
- const meta3 = await client2.request("GET", `/pub/v1/myfiles/${fileId}`);
31426
- upsertAttachmentForMessage({
31427
- fileId: meta3.fileId ?? fileId,
31428
- fileName: meta3.fileName ?? `file-${fileId}`,
31429
- label: meta3.label ?? meta3.fileName ?? `file-${fileId}`,
31430
- mimeType: meta3.fileType ?? "application/octet-stream",
31431
- sizeBytes: typeof meta3.fileSize === "number" ? meta3.fileSize : null,
31432
- metadata: meta3,
31433
- messageId
31434
- });
31435
- } catch {
31436
- }
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
+ });
31437
31420
  }
31438
31421
  async function fetchAttachmentMetaForMessage(client2, messageId, fileIds) {
31439
31422
  for (const fid of fileIds) {
31440
- await fetchAndCacheAttachmentMeta(client2, fid, messageId);
31423
+ try {
31424
+ await fetchAttachmentMeta(client2, fid, messageId);
31425
+ } catch {
31426
+ }
31441
31427
  }
31442
31428
  }
31443
31429
  async function resolveFolderIds(client2) {
@@ -31459,13 +31445,6 @@ async function resolveFolderIds(client2) {
31459
31445
  setMeta("drafts_folder_id", ids.drafts);
31460
31446
  return ids;
31461
31447
  }
31462
- function recipientsFromList(item) {
31463
- return (item.recipients ?? []).map((r) => ({
31464
- userId: r.user.id,
31465
- name: r.user.name,
31466
- viewedAt: r.viewed?.dateTime ?? null
31467
- }));
31468
- }
31469
31448
  async function syncMessageFolder(client2, folder, folderId, opts) {
31470
31449
  let page = 1;
31471
31450
  let synced = 0;
@@ -31508,7 +31487,7 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
31508
31487
  subject: item.subject ?? "(no subject)",
31509
31488
  fromUser: item.from?.name ?? "",
31510
31489
  sentAt: item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
31511
- recipients: recipientsFromList(item),
31490
+ recipients: mapRecipients(item.recipients),
31512
31491
  body,
31513
31492
  fetchedBodyAt,
31514
31493
  replyToId: null,
@@ -31548,11 +31527,7 @@ async function syncDrafts(client2, draftsFolderId) {
31548
31527
  id: item.id,
31549
31528
  subject: detail.subject ?? item.subject ?? "(no subject)",
31550
31529
  body: detail.body ?? "",
31551
- recipients: (item.recipients ?? []).map((r) => ({
31552
- userId: r.user?.id ?? 0,
31553
- name: r.user?.name ?? "",
31554
- viewedAt: r.viewed?.dateTime ?? null
31555
- })),
31530
+ recipients: mapRecipients(item.recipients),
31556
31531
  replyToId: item.replyToId ?? null,
31557
31532
  modifiedAt,
31558
31533
  listData: item
@@ -31595,7 +31570,7 @@ async function syncAll(client2, opts) {
31595
31570
 
31596
31571
  // src/tools/messages.ts
31597
31572
  import { mkdirSync as mkdirSync2, readFileSync, statSync, writeFileSync } from "node:fs";
31598
- 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";
31599
31574
  var MIME_BY_EXT = {
31600
31575
  ".pdf": "application/pdf",
31601
31576
  ".png": "image/png",
@@ -31636,7 +31611,7 @@ function registerMessageTools(server2, client2) {
31636
31611
  annotations: { readOnlyHint: true }
31637
31612
  }, async () => {
31638
31613
  const data = await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true");
31639
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
31614
+ return jsonResponse(data);
31640
31615
  });
31641
31616
  server2.registerTool("ofw_list_messages", {
31642
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.",
@@ -31658,15 +31633,10 @@ function registerMessageTools(server2, client2) {
31658
31633
  else if (folderArg === "sent") folder = "sent";
31659
31634
  else if (folderArg === "both") folder = void 0;
31660
31635
  else {
31661
- return {
31662
- content: [{
31663
- type: "text",
31664
- text: JSON.stringify({
31665
- messages: [],
31666
- note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.'
31667
- }, null, 2)
31668
- }]
31669
- };
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
+ });
31670
31640
  }
31671
31641
  const filter = { folder, since: args.since, until: args.until, q: args.q };
31672
31642
  const total = countMessages(filter);
@@ -31677,7 +31647,7 @@ function registerMessageTools(server2, client2) {
31677
31647
  } else if (page * size < total) {
31678
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.`;
31679
31649
  }
31680
- return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
31650
+ return jsonResponse(payload);
31681
31651
  });
31682
31652
  server2.registerTool("ofw_get_message", {
31683
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).",
@@ -31700,14 +31670,9 @@ function registerMessageTools(server2, client2) {
31700
31670
  } catch {
31701
31671
  }
31702
31672
  }
31703
- return { content: [{ type: "text", text: JSON.stringify({ ...cached2, attachments: attachments2 }, null, 2) }] };
31673
+ return jsonResponse({ ...cached2, attachments: attachments2 });
31704
31674
  }
31705
31675
  const detail = await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
31706
- const recipients = (detail.recipients ?? []).map((r) => ({
31707
- userId: r.user.id,
31708
- name: r.user.name,
31709
- viewedAt: r.viewed?.dateTime ?? null
31710
- }));
31711
31676
  const folder = cached2?.folder ?? "inbox";
31712
31677
  const row = {
31713
31678
  id: detail.id,
@@ -31715,7 +31680,7 @@ function registerMessageTools(server2, client2) {
31715
31680
  subject: detail.subject,
31716
31681
  fromUser: detail.from?.name ?? "",
31717
31682
  sentAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
31718
- recipients,
31683
+ recipients: mapRecipients(detail.recipients),
31719
31684
  body: detail.body ?? "",
31720
31685
  fetchedBodyAt: (/* @__PURE__ */ new Date()).toISOString(),
31721
31686
  replyToId: cached2?.replyToId ?? null,
@@ -31727,7 +31692,7 @@ function registerMessageTools(server2, client2) {
31727
31692
  await fetchAttachmentMetaForMessage(client2, detail.id, detail.files);
31728
31693
  }
31729
31694
  const attachments = listAttachmentsForMessage(detail.id);
31730
- return { content: [{ type: "text", text: JSON.stringify({ ...row, attachments }, null, 2) }] };
31695
+ return jsonResponse({ ...row, attachments });
31731
31696
  });
31732
31697
  server2.registerTool("ofw_send_message", {
31733
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.",
@@ -31764,18 +31729,13 @@ function registerMessageTools(server2, client2) {
31764
31729
  replyToId: resolvedReplyTo
31765
31730
  });
31766
31731
  if (data && typeof data.id === "number") {
31767
- const recipients = (data.recipients ?? []).map((r) => ({
31768
- userId: r.user.id,
31769
- name: r.user.name,
31770
- viewedAt: r.viewed?.dateTime ?? null
31771
- }));
31772
31732
  const row = {
31773
31733
  id: data.id,
31774
31734
  folder: "sent",
31775
31735
  subject: data.subject ?? args.subject,
31776
31736
  fromUser: data.from?.name ?? "",
31777
31737
  sentAt: data.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
31778
- recipients,
31738
+ recipients: mapRecipients(data.recipients),
31779
31739
  body: data.body ?? args.body,
31780
31740
  fetchedBodyAt: (/* @__PURE__ */ new Date()).toISOString(),
31781
31741
  replyToId: resolvedReplyTo,
@@ -31797,16 +31757,13 @@ function registerMessageTools(server2, client2) {
31797
31757
  }
31798
31758
  }
31799
31759
  if (args.draftId !== void 0) {
31800
- const form = new FormData();
31801
- form.append("messageIds", String(args.draftId));
31802
- await client2.request("DELETE", "/pub/v1/messages", form);
31760
+ await deleteOFWMessages(client2, [args.draftId]);
31803
31761
  deleteDraft(args.draftId);
31804
31762
  }
31805
31763
  const text = data ? JSON.stringify(data, null, 2) : "Message sent successfully.";
31806
- const finalText = rewriteNote ? `${rewriteNote}
31764
+ return textResponse(rewriteNote ? `${rewriteNote}
31807
31765
 
31808
- ${text}` : text;
31809
- return { content: [{ type: "text", text: finalText }] };
31766
+ ${text}` : text);
31810
31767
  });
31811
31768
  server2.registerTool("ofw_list_drafts", {
31812
31769
  description: "List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.",
@@ -31820,7 +31777,7 @@ ${text}` : text;
31820
31777
  const size = args.size ?? 50;
31821
31778
  const drafts = listDrafts({ page, size });
31822
31779
  const payload = drafts.length === 0 ? { drafts: [], note: "Cache empty. Call ofw_sync_messages to populate." } : { drafts };
31823
- return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
31780
+ return jsonResponse(payload);
31824
31781
  });
31825
31782
  server2.registerTool("ofw_save_draft", {
31826
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.",
@@ -31860,11 +31817,7 @@ ${text}` : text;
31860
31817
  id: data.id,
31861
31818
  subject: data.subject ?? args.subject,
31862
31819
  body: data.body ?? args.body,
31863
- recipients: (data.recipients ?? []).map((r) => ({
31864
- userId: r.user.id,
31865
- name: r.user.name,
31866
- viewedAt: r.viewed?.dateTime ?? null
31867
- })),
31820
+ recipients: mapRecipients(data.recipients),
31868
31821
  replyToId: data.replyToId ?? resolvedReplyTo,
31869
31822
  modifiedAt: data.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
31870
31823
  listData: data
@@ -31872,10 +31825,9 @@ ${text}` : text;
31872
31825
  upsertDraft(draft);
31873
31826
  }
31874
31827
  const text = data ? JSON.stringify(data, null, 2) : "Draft saved.";
31875
- const finalText = rewriteNote ? `${rewriteNote}
31828
+ return textResponse(rewriteNote ? `${rewriteNote}
31876
31829
 
31877
- ${text}` : text;
31878
- return { content: [{ type: "text", text: finalText }] };
31830
+ ${text}` : text);
31879
31831
  });
31880
31832
  server2.registerTool("ofw_delete_draft", {
31881
31833
  description: "Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.",
@@ -31884,11 +31836,9 @@ ${text}` : text;
31884
31836
  messageId: external_exports.number().describe("Draft message ID to delete")
31885
31837
  }
31886
31838
  }, async (args) => {
31887
- const form = new FormData();
31888
- form.append("messageIds", String(args.messageId));
31889
- const data = await client2.request("DELETE", "/pub/v1/messages", form);
31839
+ const data = await deleteOFWMessages(client2, [args.messageId]);
31890
31840
  deleteDraft(args.messageId);
31891
- return { content: [{ type: "text", text: data ? JSON.stringify(data, null, 2) : "Draft deleted." }] };
31841
+ return data ? jsonResponse(data) : textResponse("Draft deleted.");
31892
31842
  });
31893
31843
  server2.registerTool("ofw_get_unread_sent", {
31894
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.",
@@ -31902,9 +31852,7 @@ ${text}` : text;
31902
31852
  const size = args.size ?? 50;
31903
31853
  const sent = listMessages({ folder: "sent", page, size });
31904
31854
  if (sent.length === 0) {
31905
- return { content: [{ type: "text", text: JSON.stringify({
31906
- note: "Sent cache is empty. Call ofw_sync_messages to populate."
31907
- }, null, 2) }] };
31855
+ return jsonResponse({ note: "Sent cache is empty. Call ofw_sync_messages to populate." });
31908
31856
  }
31909
31857
  const unread = [];
31910
31858
  for (const msg of sent) {
@@ -31914,11 +31862,9 @@ ${text}` : text;
31914
31862
  }
31915
31863
  }
31916
31864
  if (unread.length === 0) {
31917
- return { content: [{ type: "text", text: JSON.stringify({
31918
- message: "All scanned sent messages have been read."
31919
- }, null, 2) }] };
31865
+ return jsonResponse({ message: "All scanned sent messages have been read." });
31920
31866
  }
31921
- return { content: [{ type: "text", text: JSON.stringify(unread, null, 2) }] };
31867
+ return jsonResponse(unread);
31922
31868
  });
31923
31869
  server2.registerTool("ofw_upload_attachment", {
31924
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.`,
@@ -31930,8 +31876,7 @@ ${text}` : text;
31930
31876
  description: external_exports.string().describe("Description shown in OFW My Files (default: filename)").optional()
31931
31877
  }
31932
31878
  }, async (args) => {
31933
- const expanded = args.path.startsWith("~/") ? join3(process.env.HOME ?? "", args.path.slice(2)) : args.path;
31934
- const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
31879
+ const abs = expandPath(args.path);
31935
31880
  const stat = statSync(abs);
31936
31881
  if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
31937
31882
  const buf = readFileSync(abs);
@@ -31954,71 +31899,95 @@ ${text}` : text;
31954
31899
  metadata: meta3,
31955
31900
  messageId: 0
31956
31901
  });
31957
- return { content: [{ type: "text", text: JSON.stringify({
31902
+ return jsonResponse({
31958
31903
  fileId: meta3.fileId,
31959
31904
  fileName: meta3.fileName ?? fileName,
31960
31905
  mimeType: meta3.fileType ?? mime,
31961
31906
  sizeBytes: meta3.sizeInBytes ?? buf.length,
31962
31907
  shareClass: meta3.shareClass ?? args.shareClass ?? "PRIVATE",
31963
31908
  note: "Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it."
31964
- }, null, 2) }] };
31909
+ });
31965
31910
  });
31966
31911
  server2.registerTool("ofw_download_attachment", {
31967
- 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).',
31968
31913
  annotations: { readOnlyHint: false },
31969
31914
  inputSchema: {
31970
31915
  fileId: external_exports.number().describe("Attachment file id (from ofw_get_message \u2192 attachments[].fileId)"),
31971
- 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(),
31972
- 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()
31973
31919
  }
31974
31920
  }, async (args) => {
31975
31921
  const fileId = args.fileId;
31922
+ const inline = args.inline ?? getDefaultInlineAttachments();
31976
31923
  let cached2 = getAttachment(fileId);
31977
31924
  if (!cached2) {
31978
- const meta3 = await client2.request("GET", `/pub/v1/myfiles/${fileId}`);
31979
- upsertAttachmentForMessage({
31980
- fileId: meta3.fileId ?? fileId,
31981
- fileName: meta3.fileName ?? `file-${fileId}`,
31982
- label: meta3.label ?? meta3.fileName ?? `file-${fileId}`,
31983
- mimeType: meta3.fileType ?? "application/octet-stream",
31984
- sizeBytes: typeof meta3.fileSize === "number" ? meta3.fileSize : null,
31985
- metadata: meta3,
31986
- messageId: 0
31987
- // placeholder; will be cleaned up if a real message references it
31988
- });
31925
+ await fetchAttachmentMeta(client2, fileId, 0);
31989
31926
  cached2 = getAttachment(fileId);
31990
31927
  if (!cached2) throw new Error(`failed to fetch metadata for fileId ${fileId}`);
31991
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
+ }
31992
31962
  let dest;
31993
31963
  if (args.saveTo) {
31994
- const expanded = args.saveTo.startsWith("~/") ? join3(process.env.HOME ?? "", args.saveTo.slice(2)) : args.saveTo;
31995
- const abs = isAbsolute(expanded) ? expanded : resolve(expanded);
31996
- const isDirArg = expanded.endsWith("/") || expanded.endsWith("\\");
31997
- 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;
31998
31967
  } else {
31999
- dest = join3(getAttachmentsDir(), `${fileId}-${cached2.fileName}`);
31968
+ dest = join4(getAttachmentsDir(), `${fileId}-${cached2.fileName}`);
32000
31969
  }
32001
31970
  if (!args.force && cached2.downloadedPath === dest) {
32002
- return { content: [{ type: "text", text: JSON.stringify({
31971
+ return jsonResponse({
32003
31972
  fileId,
32004
31973
  path: dest,
32005
31974
  mimeType: cached2.mimeType,
32006
31975
  sizeBytes: cached2.sizeBytes,
32007
31976
  fileName: cached2.fileName,
32008
31977
  note: "already downloaded"
32009
- }, null, 2) }] };
31978
+ });
32010
31979
  }
32011
31980
  const response = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
32012
31981
  mkdirSync2(dirname3(dest), { recursive: true });
32013
31982
  writeFileSync(dest, response.body);
32014
31983
  markAttachmentDownloaded(fileId, dest);
32015
- return { content: [{ type: "text", text: JSON.stringify({
31984
+ return jsonResponse({
32016
31985
  fileId,
32017
31986
  path: dest,
32018
31987
  mimeType: response.contentType ?? cached2.mimeType,
32019
31988
  sizeBytes: response.body.length,
32020
31989
  fileName: response.suggestedFileName ?? cached2.fileName
32021
- }, null, 2) }] };
31990
+ });
32022
31991
  });
32023
31992
  server2.registerTool("ofw_sync_messages", {
32024
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).",
@@ -32034,9 +32003,14 @@ ${text}` : text;
32034
32003
  fetchUnreadBodies: args.fetchUnreadBodies,
32035
32004
  deep: args.deep
32036
32005
  });
32037
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
32006
+ return jsonResponse(result);
32038
32007
  });
32039
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
+ }
32040
32014
 
32041
32015
  // src/tools/calendar.ts
32042
32016
  function registerCalendarTools(server2, client2) {
@@ -32054,7 +32028,7 @@ function registerCalendarTools(server2, client2) {
32054
32028
  "GET",
32055
32029
  `/pub/v1/calendar/${variant}?startDate=${encodeURIComponent(args.startDate)}&endDate=${encodeURIComponent(args.endDate)}`
32056
32030
  );
32057
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32031
+ return jsonResponse(data);
32058
32032
  });
32059
32033
  server2.registerTool("ofw_create_event", {
32060
32034
  description: "Create a calendar event in OurFamilyWizard",
@@ -32074,7 +32048,7 @@ function registerCalendarTools(server2, client2) {
32074
32048
  }
32075
32049
  }, async (args) => {
32076
32050
  const data = await client2.request("POST", "/pub/v1/calendar/events", args);
32077
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32051
+ return jsonResponse(data);
32078
32052
  });
32079
32053
  server2.registerTool("ofw_update_event", {
32080
32054
  description: "Update an existing OurFamilyWizard calendar event",
@@ -32092,7 +32066,7 @@ function registerCalendarTools(server2, client2) {
32092
32066
  }, async (args) => {
32093
32067
  const { eventId, ...updateData } = args;
32094
32068
  const data = await client2.request("PUT", `/pub/v1/calendar/events/${encodeURIComponent(eventId)}`, updateData);
32095
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32069
+ return jsonResponse(data);
32096
32070
  });
32097
32071
  server2.registerTool("ofw_delete_event", {
32098
32072
  description: "Delete an OurFamilyWizard calendar event",
@@ -32102,7 +32076,7 @@ function registerCalendarTools(server2, client2) {
32102
32076
  }
32103
32077
  }, async (args) => {
32104
32078
  await client2.request("DELETE", `/pub/v1/calendar/events/${encodeURIComponent(args.eventId)}`);
32105
- return { content: [{ type: "text", text: `Event ${args.eventId} deleted` }] };
32079
+ return textResponse(`Event ${args.eventId} deleted`);
32106
32080
  });
32107
32081
  }
32108
32082
 
@@ -32113,7 +32087,7 @@ function registerExpenseTools(server2, client2) {
32113
32087
  annotations: { readOnlyHint: true }
32114
32088
  }, async () => {
32115
32089
  const data = await client2.request("GET", "/pub/v2/expense/expenses/totals");
32116
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32090
+ return jsonResponse(data);
32117
32091
  });
32118
32092
  server2.registerTool("ofw_list_expenses", {
32119
32093
  description: "List OurFamilyWizard expenses with pagination",
@@ -32126,7 +32100,7 @@ function registerExpenseTools(server2, client2) {
32126
32100
  const start = args.start ?? 0;
32127
32101
  const max = args.max ?? 20;
32128
32102
  const data = await client2.request("GET", `/pub/v2/expense/expenses?start=${start}&max=${max}`);
32129
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32103
+ return jsonResponse(data);
32130
32104
  });
32131
32105
  server2.registerTool("ofw_create_expense", {
32132
32106
  description: "Log a new expense in OurFamilyWizard",
@@ -32137,7 +32111,7 @@ function registerExpenseTools(server2, client2) {
32137
32111
  }
32138
32112
  }, async (args) => {
32139
32113
  const data = await client2.request("POST", "/pub/v2/expense/expenses", args);
32140
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32114
+ return jsonResponse(data);
32141
32115
  });
32142
32116
  }
32143
32117
 
@@ -32154,7 +32128,7 @@ function registerJournalTools(server2, client2) {
32154
32128
  const start = args.start ?? 1;
32155
32129
  const max = args.max ?? 10;
32156
32130
  const data = await client2.request("GET", `/pub/v1/journals?start=${start}&max=${max}`);
32157
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32131
+ return jsonResponse(data);
32158
32132
  });
32159
32133
  server2.registerTool("ofw_create_journal_entry", {
32160
32134
  description: "Create a new journal entry in OurFamilyWizard",
@@ -32165,7 +32139,7 @@ function registerJournalTools(server2, client2) {
32165
32139
  }
32166
32140
  }, async (args) => {
32167
32141
  const data = await client2.request("POST", "/pub/v1/journals", args);
32168
- return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
32142
+ return jsonResponse(data);
32169
32143
  });
32170
32144
  }
32171
32145
 
@@ -32180,7 +32154,7 @@ process.emit = function(event, ...args) {
32180
32154
  }
32181
32155
  return originalEmit(event, ...args);
32182
32156
  };
32183
- var server = new McpServer({ name: "ofw", version: "2.0.11" });
32157
+ var server = new McpServer({ name: "ofw", version: "2.0.12" });
32184
32158
  registerUserTools(server, client);
32185
32159
  registerMessageTools(server, client);
32186
32160
  registerCalendarTools(server, client);