ofw-mcp 2.3.2 → 2.4.1

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
@@ -13,7 +13,11 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
13
13
  throw Error('Dynamic require of "' + x + '" is not supported');
14
14
  });
15
15
  var __commonJS = (cb, mod) => function __require2() {
16
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
16
+ try {
17
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
18
+ } catch (e) {
19
+ throw mod = 0, e;
20
+ }
17
21
  };
18
22
  var __export = (target, all) => {
19
23
  for (var name in all)
@@ -38069,8 +38073,7 @@ async function loginWithPassword(username, password) {
38069
38073
  headers: { ...OFW_PROTOCOL_HEADERS },
38070
38074
  redirect: "manual"
38071
38075
  });
38072
- const setCookie = initResponse.headers.get("set-cookie") ?? "";
38073
- const sessionCookie = setCookie.split(";")[0];
38076
+ const sessionCookie = initResponse.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
38074
38077
  const response = await fetch(`${BASE_URL}/ofw/login`, {
38075
38078
  method: "POST",
38076
38079
  headers: {
@@ -38130,6 +38133,16 @@ function getAttachmentsDir() {
38130
38133
  function parseBoolEnv2(name) {
38131
38134
  return parseBoolEnv(name);
38132
38135
  }
38136
+ function getWriteMode() {
38137
+ const raw = process.env.OFW_WRITE_MODE;
38138
+ if (typeof raw !== "string" || raw.trim().length === 0) return "all";
38139
+ const mode = raw.trim().toLowerCase();
38140
+ if (mode === "none" || mode === "drafts" || mode === "all") return mode;
38141
+ console.error(
38142
+ `[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
38143
+ );
38144
+ return "none";
38145
+ }
38133
38146
  function getDefaultInlineAttachments() {
38134
38147
  return parseBoolEnv2("OFW_INLINE_ATTACHMENTS");
38135
38148
  }
@@ -38137,7 +38150,8 @@ function getDefaultInlineAttachments() {
38137
38150
  // package.json
38138
38151
  var package_default = {
38139
38152
  name: "ofw-mcp",
38140
- version: "2.3.2",
38153
+ version: "2.4.1",
38154
+ license: "MIT",
38141
38155
  mcpName: "io.github.chrischall/ofw-mcp",
38142
38156
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
38143
38157
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38146,6 +38160,9 @@ var package_default = {
38146
38160
  url: "git+https://github.com/chrischall/ofw-mcp.git"
38147
38161
  },
38148
38162
  type: "module",
38163
+ engines: {
38164
+ node: ">=22.5.0"
38165
+ },
38149
38166
  bin: {
38150
38167
  "ofw-mcp": "dist/index.js"
38151
38168
  },
@@ -38244,7 +38261,26 @@ async function resolveAuth() {
38244
38261
  );
38245
38262
  }
38246
38263
 
38264
+ // src/env-bootstrap.ts
38265
+ var USER_CONFIG_KEYS = [
38266
+ "OFW_USERNAME",
38267
+ "OFW_PASSWORD",
38268
+ "OFW_INLINE_ATTACHMENTS",
38269
+ "OFW_ATTACHMENTS_DIR",
38270
+ "OFW_WRITE_MODE"
38271
+ ];
38272
+ function clearBlankInjectedEnv(env = process.env, keys = USER_CONFIG_KEYS) {
38273
+ for (const key of keys) {
38274
+ const value = env[key];
38275
+ if (value === void 0) continue;
38276
+ if (value.trim() === "" || value.includes("${")) {
38277
+ delete env[key];
38278
+ }
38279
+ }
38280
+ }
38281
+
38247
38282
  // src/client.ts
38283
+ clearBlankInjectedEnv();
38248
38284
  var __dirname = dirname(fileURLToPath(import.meta.url));
38249
38285
  await loadDotenvSafely({ path: join4(__dirname, "..", ".env") });
38250
38286
  function parseContentDispositionFilename(cd) {
@@ -38401,9 +38437,24 @@ var OFWClient = class {
38401
38437
  };
38402
38438
  var client = new OFWClient();
38403
38439
 
38440
+ // src/validate.ts
38441
+ function parseOFW(schema, raw, ctx, mode = "lenient") {
38442
+ const result = schema.safeParse(raw);
38443
+ if (result.success) return result.data;
38444
+ const issues = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
38445
+ const message = `OFW response for ${ctx} failed validation: ${issues}`;
38446
+ if (mode === "strict") throw new Error(message);
38447
+ console.error(`[ofw-mcp] WARNING: ${message} \u2014 continuing with the raw response; fields derived from it may be missing or wrong.`);
38448
+ return raw;
38449
+ }
38450
+
38404
38451
  // src/tools/_shared.ts
38405
38452
  var jsonResponse = textResult;
38406
38453
  var textResponse = rawTextResult;
38454
+ var ApiRecipientSchema = external_exports.looseObject({
38455
+ user: external_exports.looseObject({ id: external_exports.number().optional(), name: external_exports.string().optional() }).optional(),
38456
+ viewed: external_exports.looseObject({ dateTime: external_exports.string() }).nullable().optional()
38457
+ });
38407
38458
  function mapRecipients(items) {
38408
38459
  return (items ?? []).map((r) => ({
38409
38460
  userId: r.user?.id ?? 0,
@@ -38412,15 +38463,36 @@ function mapRecipients(items) {
38412
38463
  }));
38413
38464
  }
38414
38465
  var expandPath2 = expandPath;
38415
- async function postMessageAndRefetch(client2, payload) {
38416
- const raw = await client2.request(
38417
- "POST",
38418
- "/pub/v3/messages",
38419
- payload
38466
+ function verifyWriteLanded(kind, sent, persisted) {
38467
+ const mismatches = [];
38468
+ if (typeof persisted.subject !== "string" || !persisted.subject.includes(sent.subject)) {
38469
+ mismatches.push("subject");
38470
+ }
38471
+ if (typeof persisted.body !== "string" || !persisted.body.includes(sent.body)) {
38472
+ mismatches.push("body");
38473
+ }
38474
+ if (mismatches.length === 0) return null;
38475
+ return `WARNING: the ${kind} re-fetched from OFW does not contain the ${mismatches.join(" and ")} that was posted \u2014 OFW may have silently dropped or altered the write. Verify the ${kind} on ourfamilywizard.com before relying on it.`;
38476
+ }
38477
+ var PostMessagesResponseSchema = external_exports.looseObject({
38478
+ id: external_exports.number().optional(),
38479
+ entityId: external_exports.number().optional()
38480
+ }).nullable();
38481
+ async function postMessageAndRefetch(client2, payload, detailSchema, ctx) {
38482
+ const raw = parseOFW(
38483
+ PostMessagesResponseSchema,
38484
+ await client2.request("POST", "/pub/v3/messages", payload),
38485
+ `POST /pub/v3/messages (${ctx})`,
38486
+ "strict"
38420
38487
  );
38421
38488
  const id = typeof raw?.id === "number" ? raw.id : typeof raw?.entityId === "number" ? raw.entityId : null;
38422
38489
  if (id === null) return { id: null, detail: null, raw };
38423
- const detail = await client2.request("GET", `/pub/v3/messages/${id}`);
38490
+ const detail = parseOFW(
38491
+ detailSchema,
38492
+ await client2.request("GET", `/pub/v3/messages/${id}`),
38493
+ `GET /pub/v3/messages/{id} (${ctx})`,
38494
+ "strict"
38495
+ );
38424
38496
  return { id, detail, raw };
38425
38497
  }
38426
38498
 
@@ -38789,8 +38861,20 @@ function markAttachmentDownloaded(fileId, path) {
38789
38861
  }
38790
38862
 
38791
38863
  // src/sync.ts
38864
+ var FileMetaSchema = external_exports.looseObject({
38865
+ fileId: external_exports.number(),
38866
+ label: external_exports.string().optional(),
38867
+ fileName: external_exports.string().optional(),
38868
+ fileType: external_exports.string().optional(),
38869
+ // MIME
38870
+ fileSize: external_exports.number().optional()
38871
+ });
38792
38872
  async function fetchAttachmentMeta(client2, fileId, messageId) {
38793
- const meta3 = await client2.request("GET", `/pub/v1/myfiles/${fileId}`);
38873
+ const meta3 = parseOFW(
38874
+ FileMetaSchema,
38875
+ await client2.request("GET", `/pub/v1/myfiles/${fileId}`),
38876
+ "GET /pub/v1/myfiles/{fileId}"
38877
+ );
38794
38878
  upsertAttachmentForMessage({
38795
38879
  fileId: meta3.fileId ?? fileId,
38796
38880
  fileName: meta3.fileName ?? `file-${fileId}`,
@@ -38804,10 +38888,14 @@ async function fetchAttachmentMeta(client2, fileId, messageId) {
38804
38888
  async function fetchAttachmentMetaForMessage(client2, messageId, fileIds) {
38805
38889
  await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client2, fid, messageId)));
38806
38890
  }
38891
+ var FoldersSchema = external_exports.looseObject({
38892
+ systemFolders: external_exports.array(external_exports.looseObject({ id: external_exports.string(), folderType: external_exports.string() })).optional()
38893
+ });
38807
38894
  async function resolveFolderIds(client2) {
38808
- const data = await client2.request(
38809
- "GET",
38810
- "/pub/v1/messageFolders?includeFolderCounts=true"
38895
+ const data = parseOFW(
38896
+ FoldersSchema,
38897
+ await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true"),
38898
+ "GET /pub/v1/messageFolders"
38811
38899
  );
38812
38900
  const sys = data.systemFolders ?? [];
38813
38901
  const find = (type) => {
@@ -38823,6 +38911,19 @@ async function resolveFolderIds(client2) {
38823
38911
  setMeta("drafts_folder_id", ids.drafts);
38824
38912
  return ids;
38825
38913
  }
38914
+ var ListItemSchema = external_exports.looseObject({
38915
+ id: external_exports.number(),
38916
+ subject: external_exports.string(),
38917
+ date: external_exports.looseObject({ dateTime: external_exports.string() }),
38918
+ from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
38919
+ showNeverViewed: external_exports.boolean(),
38920
+ recipients: external_exports.array(ApiRecipientSchema).optional()
38921
+ });
38922
+ var ListResponseSchema = external_exports.looseObject({ data: external_exports.array(ListItemSchema).optional() });
38923
+ var DetailResponseSchema = external_exports.looseObject({
38924
+ body: external_exports.string().optional(),
38925
+ files: external_exports.array(external_exports.number()).optional()
38926
+ });
38826
38927
  async function syncMessageFolder(client2, folder, folderId, opts) {
38827
38928
  let page = 1;
38828
38929
  let synced = 0;
@@ -38830,7 +38931,11 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
38830
38931
  const unread = [];
38831
38932
  while (true) {
38832
38933
  const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
38833
- const list = await client2.request("GET", path);
38934
+ const list = parseOFW(
38935
+ ListResponseSchema,
38936
+ await client2.request("GET", path),
38937
+ `GET /pub/v3/messages?folders={${folder}}`
38938
+ );
38834
38939
  const items = list.data ?? [];
38835
38940
  if (items.length === 0) break;
38836
38941
  let pageHadNewItem = false;
@@ -38845,7 +38950,11 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
38845
38950
  let fetchedBodyAt = null;
38846
38951
  let detailFileIds = [];
38847
38952
  if (shouldFetchBody) {
38848
- const detail = await client2.request("GET", `/pub/v3/messages/${item.id}`);
38953
+ const detail = parseOFW(
38954
+ DetailResponseSchema,
38955
+ await client2.request("GET", `/pub/v3/messages/${item.id}`),
38956
+ "GET /pub/v3/messages/{id} (sync)"
38957
+ );
38849
38958
  body = detail.body ?? "";
38850
38959
  fetchedBodyAt = (/* @__PURE__ */ new Date()).toISOString();
38851
38960
  if (Array.isArray(detail.files) && detail.files.length > 0) {
@@ -38887,17 +38996,44 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
38887
38996
  });
38888
38997
  return { synced, unread };
38889
38998
  }
38999
+ var DraftListItemSchema = external_exports.looseObject({
39000
+ id: external_exports.number(),
39001
+ subject: external_exports.string(),
39002
+ date: external_exports.looseObject({ dateTime: external_exports.string() }),
39003
+ replyToId: external_exports.number().nullable().optional(),
39004
+ recipients: external_exports.array(ApiRecipientSchema).optional()
39005
+ });
39006
+ var DraftListResponseSchema = external_exports.looseObject({ data: external_exports.array(DraftListItemSchema).optional() });
39007
+ var DraftDetailSchema = external_exports.looseObject({
39008
+ body: external_exports.string().optional(),
39009
+ subject: external_exports.string().optional()
39010
+ });
38890
39011
  async function syncDrafts(client2, draftsFolderId) {
38891
- const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=1&size=50&sort=date&sortDirection=desc`;
38892
- const list = await client2.request("GET", path);
38893
- const items = list.data ?? [];
39012
+ const items = [];
39013
+ let page = 1;
39014
+ while (true) {
39015
+ const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
39016
+ const list = parseOFW(
39017
+ DraftListResponseSchema,
39018
+ await client2.request("GET", path),
39019
+ "GET /pub/v3/messages?folders={drafts}"
39020
+ );
39021
+ const pageItems = list.data ?? [];
39022
+ items.push(...pageItems);
39023
+ if (pageItems.length < 50) break;
39024
+ page++;
39025
+ }
38894
39026
  const seenIds = /* @__PURE__ */ new Set();
38895
39027
  let synced = 0;
38896
39028
  for (const item of items) {
38897
39029
  seenIds.add(item.id);
38898
39030
  const modifiedAt = item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString();
38899
39031
  const existing = getDraft(item.id);
38900
- const detail = await client2.request("GET", `/pub/v3/messages/${item.id}`);
39032
+ const detail = parseOFW(
39033
+ DraftDetailSchema,
39034
+ await client2.request("GET", `/pub/v3/messages/${item.id}`),
39035
+ "GET /pub/v3/messages/{id} (drafts sync)"
39036
+ );
38901
39037
  const row = {
38902
39038
  id: item.id,
38903
39039
  subject: detail.subject ?? item.subject ?? "(no subject)",
@@ -38949,6 +39085,39 @@ async function syncAll(client2, opts) {
38949
39085
  // src/tools/messages.ts
38950
39086
  import { mkdirSync as mkdirSync2, readFileSync, statSync, writeFileSync } from "node:fs";
38951
39087
  import { basename, dirname as dirname3, extname, join as join5 } from "node:path";
39088
+ var DateSchema = external_exports.looseObject({ dateTime: external_exports.string() });
39089
+ var SentDetailSchema = external_exports.looseObject({
39090
+ subject: external_exports.string().optional(),
39091
+ body: external_exports.string().optional(),
39092
+ date: DateSchema.optional(),
39093
+ from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
39094
+ recipients: external_exports.array(ApiRecipientSchema).optional()
39095
+ });
39096
+ var SavedDraftDetailSchema = external_exports.looseObject({
39097
+ subject: external_exports.string().optional(),
39098
+ body: external_exports.string().optional(),
39099
+ date: DateSchema.optional(),
39100
+ replyToId: external_exports.number().nullable().optional(),
39101
+ recipients: external_exports.array(ApiRecipientSchema).optional()
39102
+ });
39103
+ var MessageDetailSchema = external_exports.looseObject({
39104
+ id: external_exports.number(),
39105
+ subject: external_exports.string(),
39106
+ body: external_exports.string().optional(),
39107
+ date: DateSchema,
39108
+ from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
39109
+ files: external_exports.array(external_exports.number()).optional(),
39110
+ recipients: external_exports.array(ApiRecipientSchema).optional()
39111
+ });
39112
+ var DetailFilesSchema = external_exports.looseObject({ files: external_exports.array(external_exports.number()).optional() });
39113
+ var UploadedFileSchema = external_exports.looseObject({
39114
+ fileId: external_exports.number(),
39115
+ fileName: external_exports.string().optional(),
39116
+ label: external_exports.string().optional(),
39117
+ fileType: external_exports.string().optional(),
39118
+ sizeInBytes: external_exports.number().optional(),
39119
+ shareClass: external_exports.string().optional()
39120
+ });
38952
39121
  var MIME_BY_EXT = {
38953
39122
  ".pdf": "application/pdf",
38954
39123
  ".png": "image/png",
@@ -38984,6 +39153,9 @@ function listDataHintsAtFiles(listData) {
38984
39153
  return false;
38985
39154
  }
38986
39155
  function registerMessageTools(server, client2) {
39156
+ const writeMode = getWriteMode();
39157
+ const allowSend = writeMode === "all";
39158
+ const allowDrafts = writeMode !== "none";
38987
39159
  server.registerTool("ofw_list_message_folders", {
38988
39160
  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.",
38989
39161
  annotations: { readOnlyHint: true }
@@ -38996,8 +39168,8 @@ function registerMessageTools(server, client2) {
38996
39168
  annotations: { readOnlyHint: true },
38997
39169
  inputSchema: {
38998
39170
  folderId: external_exports.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
38999
- page: external_exports.number().describe("Page number (default 1)").optional(),
39000
- size: external_exports.number().describe("Messages per page (default 50)").optional(),
39171
+ page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
39172
+ size: external_exports.number().int().min(1).describe("Messages per page (default 50)").optional(),
39001
39173
  since: external_exports.string().describe("ISO date or datetime \u2014 only messages with sent_at >= since (inclusive)").optional(),
39002
39174
  until: external_exports.string().describe("ISO date or datetime \u2014 only messages with sent_at < until (exclusive)").optional(),
39003
39175
  q: external_exports.string().describe("Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.").optional()
@@ -39060,7 +39232,11 @@ function registerMessageTools(server, client2) {
39060
39232
  let attachments2 = listAttachmentsForMessage(id);
39061
39233
  if (attachments2.length === 0 && listDataHintsAtFiles(cached2.listData)) {
39062
39234
  try {
39063
- const detail2 = await client2.request("GET", `/pub/v3/messages/${id}`);
39235
+ const detail2 = parseOFW(
39236
+ DetailFilesSchema,
39237
+ await client2.request("GET", `/pub/v3/messages/${id}`),
39238
+ "GET /pub/v3/messages/{id} (attachment backfill)"
39239
+ );
39064
39240
  if (Array.isArray(detail2.files) && detail2.files.length > 0) {
39065
39241
  await fetchAttachmentMetaForMessage(client2, id, detail2.files);
39066
39242
  attachments2 = listAttachmentsForMessage(id);
@@ -39070,7 +39246,11 @@ function registerMessageTools(server, client2) {
39070
39246
  }
39071
39247
  return jsonResponse({ ...cached2, attachments: attachments2 });
39072
39248
  }
39073
- const detail = await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
39249
+ const detail = parseOFW(
39250
+ MessageDetailSchema,
39251
+ await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`),
39252
+ "GET /pub/v3/messages/{id} (ofw_get_message)"
39253
+ );
39074
39254
  const folder = cached2?.folder ?? "inbox";
39075
39255
  const row = {
39076
39256
  id: detail.id,
@@ -39092,7 +39272,7 @@ function registerMessageTools(server, client2) {
39092
39272
  const attachments = listAttachmentsForMessage(detail.id);
39093
39273
  return jsonResponse({ ...row, attachments });
39094
39274
  });
39095
- server.registerTool("ofw_send_message", {
39275
+ if (allowSend) server.registerTool("ofw_send_message", {
39096
39276
  description: "Send a message via OurFamilyWizard. To send an existing draft, pass messageId \u2014 subject/body/recipientIds become optional overrides (missing fields default to the draft's cached values) and the draft is deleted after sending. To send a fresh message, supply subject/body/recipientIds directly. draftId is the legacy spelling of messageId and works the same way. 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. After sending, the tool re-fetches the message from OFW to populate the local cache and link attachments to the new message id.",
39097
39277
  annotations: { destructiveHint: true },
39098
39278
  inputSchema: {
@@ -39162,9 +39342,11 @@ function registerMessageTools(server, client2) {
39162
39342
  draft: false,
39163
39343
  includeOriginal: resolvedReplyTo !== null,
39164
39344
  replyToId: resolvedReplyTo
39165
- });
39345
+ }, SentDetailSchema, "ofw_send_message");
39166
39346
  let persisted = null;
39347
+ let verifyNote = null;
39167
39348
  if (newId !== null) {
39349
+ verifyNote = verifyWriteLanded("message", { subject, body }, detail);
39168
39350
  persisted = {
39169
39351
  id: newId,
39170
39352
  folder: "sent",
@@ -39192,13 +39374,18 @@ function registerMessageTools(server, client2) {
39192
39374
  });
39193
39375
  }
39194
39376
  }
39195
- if (draftRef !== void 0) {
39377
+ let unconfirmedNote = null;
39378
+ if (newId === null) {
39379
+ const draftClause = draftRef !== void 0 ? `Draft ${draftRef} was NOT deleted \u2014 check` : "Check";
39380
+ unconfirmedNote = `WARNING: OFW's send response did not include a message id, so the send could not be confirmed. ${draftClause} ourfamilywizard.com to see whether the message went out before retrying.`;
39381
+ } else if (draftRef !== void 0) {
39196
39382
  await deleteOFWMessages(client2, [draftRef]);
39197
39383
  deleteDraft(draftRef);
39198
39384
  }
39199
39385
  const responseObj = persisted ?? raw;
39200
39386
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
39201
- return textResponse(rewriteNote ? `${rewriteNote}
39387
+ const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join("\n\n");
39388
+ return textResponse(notes ? `${notes}
39202
39389
 
39203
39390
  ${text}` : text);
39204
39391
  });
@@ -39206,8 +39393,8 @@ ${text}` : text);
39206
39393
  description: "List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.",
39207
39394
  annotations: { readOnlyHint: true },
39208
39395
  inputSchema: {
39209
- page: external_exports.number().describe("Page number (default 1)").optional(),
39210
- size: external_exports.number().describe("Drafts per page (default 50)").optional()
39396
+ page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
39397
+ size: external_exports.number().int().min(1).describe("Drafts per page (default 50)").optional()
39211
39398
  }
39212
39399
  }, async (args) => {
39213
39400
  const page = args.page ?? 1;
@@ -39216,7 +39403,7 @@ ${text}` : text);
39216
39403
  const payload = drafts.length === 0 ? { drafts: [], note: "Cache empty. Call ofw_sync_messages to populate." } : { drafts };
39217
39404
  return jsonResponse(payload);
39218
39405
  });
39219
- server.registerTool("ofw_save_draft", {
39406
+ if (allowDrafts) server.registerTool("ofw_save_draft", {
39220
39407
  description: "Save a message as a draft in OurFamilyWizard. Recipients are optional. Pass messageId to replace an existing draft \u2014 note that under the hood this creates a NEW draft and deletes the old one (OFW's update-in-place endpoint silently no-ops while echoing the posted body, so we don't use it); the response.id will be the NEW id, not the messageId you passed, and the change is documented in a transparency NOTE in the response. 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. After saving, the tool re-fetches the draft from OFW to populate the local cache from authoritative server state.",
39221
39408
  annotations: { readOnlyHint: false },
39222
39409
  inputSchema: {
@@ -39247,10 +39434,17 @@ ${text}` : text);
39247
39434
  includeOriginal: resolvedReplyTo !== null,
39248
39435
  replyToId: resolvedReplyTo
39249
39436
  };
39250
- const { id: newId, detail, raw } = await postMessageAndRefetch(client2, payload);
39437
+ const { id: newId, detail, raw } = await postMessageAndRefetch(
39438
+ client2,
39439
+ payload,
39440
+ SavedDraftDetailSchema,
39441
+ "ofw_save_draft"
39442
+ );
39251
39443
  let persisted = null;
39252
39444
  let replaceNote = null;
39445
+ let verifyNote = null;
39253
39446
  if (newId !== null) {
39447
+ verifyNote = verifyWriteLanded("draft", { subject: args.subject, body: args.body }, detail);
39254
39448
  persisted = {
39255
39449
  id: newId,
39256
39450
  subject: detail.subject ?? args.subject,
@@ -39273,12 +39467,12 @@ ${text}` : text);
39273
39467
  }
39274
39468
  const responseObj = persisted ?? raw;
39275
39469
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
39276
- const notes = [rewriteNote, replaceNote].filter((n) => n !== null).join("\n\n");
39470
+ const notes = [rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join("\n\n");
39277
39471
  return textResponse(notes ? `${notes}
39278
39472
 
39279
39473
  ${text}` : text);
39280
39474
  });
39281
- server.registerTool("ofw_delete_draft", {
39475
+ if (allowDrafts) server.registerTool("ofw_delete_draft", {
39282
39476
  description: "Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.",
39283
39477
  annotations: { destructiveHint: true },
39284
39478
  inputSchema: {
@@ -39293,8 +39487,8 @@ ${text}` : text);
39293
39487
  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.",
39294
39488
  annotations: { readOnlyHint: true },
39295
39489
  inputSchema: {
39296
- page: external_exports.number().describe("Page (default 1)").optional(),
39297
- size: external_exports.number().describe("Per page (default 50)").optional()
39490
+ page: external_exports.number().int().min(1).describe("Page (default 1)").optional(),
39491
+ size: external_exports.number().int().min(1).describe("Per page (default 50)").optional()
39298
39492
  }
39299
39493
  }, async (args) => {
39300
39494
  const page = args.page ?? 1;
@@ -39315,7 +39509,7 @@ ${text}` : text);
39315
39509
  }
39316
39510
  return jsonResponse(unread);
39317
39511
  });
39318
- server.registerTool("ofw_upload_attachment", {
39512
+ if (allowDrafts) server.registerTool("ofw_upload_attachment", {
39319
39513
  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.`,
39320
39514
  annotations: { destructiveHint: false },
39321
39515
  inputSchema: {
@@ -39337,7 +39531,12 @@ ${text}` : text);
39337
39531
  form.append("label", args.label ?? fileName);
39338
39532
  form.append("fileName", fileName);
39339
39533
  form.append("shareClass", args.shareClass ?? "PRIVATE");
39340
- const meta3 = await client2.request("POST", "/pub/v3/myfiles/multipart", form);
39534
+ const meta3 = parseOFW(
39535
+ UploadedFileSchema,
39536
+ await client2.request("POST", "/pub/v3/myfiles/multipart", form),
39537
+ "POST /pub/v3/myfiles/multipart (ofw_upload_attachment)",
39538
+ "strict"
39539
+ );
39341
39540
  upsertAttachmentForMessage({
39342
39541
  fileId: meta3.fileId,
39343
39542
  fileName: meta3.fileName ?? fileName,
@@ -39462,6 +39661,7 @@ async function deleteOFWMessages(client2, ids) {
39462
39661
 
39463
39662
  // src/tools/calendar.ts
39464
39663
  function registerCalendarTools(server, client2) {
39664
+ const allowWrites = getWriteMode() === "all";
39465
39665
  server.registerTool("ofw_list_events", {
39466
39666
  description: "List OurFamilyWizard calendar events in a date range",
39467
39667
  annotations: { readOnlyHint: true },
@@ -39478,7 +39678,7 @@ function registerCalendarTools(server, client2) {
39478
39678
  );
39479
39679
  return jsonResponse(data);
39480
39680
  });
39481
- server.registerTool("ofw_create_event", {
39681
+ if (allowWrites) server.registerTool("ofw_create_event", {
39482
39682
  description: "Create a calendar event in OurFamilyWizard",
39483
39683
  annotations: { destructiveHint: false },
39484
39684
  inputSchema: {
@@ -39498,7 +39698,7 @@ function registerCalendarTools(server, client2) {
39498
39698
  const data = await client2.request("POST", "/pub/v1/calendar/events", args);
39499
39699
  return jsonResponse(data);
39500
39700
  });
39501
- server.registerTool("ofw_update_event", {
39701
+ if (allowWrites) server.registerTool("ofw_update_event", {
39502
39702
  description: "Update an existing OurFamilyWizard calendar event",
39503
39703
  annotations: { destructiveHint: true },
39504
39704
  inputSchema: {
@@ -39516,7 +39716,7 @@ function registerCalendarTools(server, client2) {
39516
39716
  const data = await client2.request("PUT", `/pub/v1/calendar/events/${encodeURIComponent(eventId)}`, updateData);
39517
39717
  return jsonResponse(data);
39518
39718
  });
39519
- server.registerTool("ofw_delete_event", {
39719
+ if (allowWrites) server.registerTool("ofw_delete_event", {
39520
39720
  description: "Delete an OurFamilyWizard calendar event",
39521
39721
  annotations: { destructiveHint: true },
39522
39722
  inputSchema: {
@@ -39530,6 +39730,7 @@ function registerCalendarTools(server, client2) {
39530
39730
 
39531
39731
  // src/tools/expenses.ts
39532
39732
  function registerExpenseTools(server, client2) {
39733
+ const allowWrites = getWriteMode() === "all";
39533
39734
  server.registerTool("ofw_get_expense_totals", {
39534
39735
  description: "Get OurFamilyWizard expense summary totals (owed/paid)",
39535
39736
  annotations: { readOnlyHint: true }
@@ -39541,8 +39742,8 @@ function registerExpenseTools(server, client2) {
39541
39742
  description: "List OurFamilyWizard expenses with pagination",
39542
39743
  annotations: { readOnlyHint: true },
39543
39744
  inputSchema: {
39544
- start: external_exports.number().describe("Start offset (default 0)").optional(),
39545
- max: external_exports.number().describe("Max results (default 20)").optional()
39745
+ start: external_exports.number().int().min(0).describe("Start offset (default 0)").optional(),
39746
+ max: external_exports.number().int().min(1).describe("Max results (default 20)").optional()
39546
39747
  }
39547
39748
  }, async (args) => {
39548
39749
  const start = args.start ?? 0;
@@ -39550,7 +39751,7 @@ function registerExpenseTools(server, client2) {
39550
39751
  const data = await client2.request("GET", `/pub/v2/expense/expenses?start=${start}&max=${max}`);
39551
39752
  return jsonResponse(data);
39552
39753
  });
39553
- server.registerTool("ofw_create_expense", {
39754
+ if (allowWrites) server.registerTool("ofw_create_expense", {
39554
39755
  description: "Log a new expense in OurFamilyWizard",
39555
39756
  annotations: { destructiveHint: false },
39556
39757
  inputSchema: {
@@ -39565,12 +39766,13 @@ function registerExpenseTools(server, client2) {
39565
39766
 
39566
39767
  // src/tools/journal.ts
39567
39768
  function registerJournalTools(server, client2) {
39769
+ const allowWrites = getWriteMode() === "all";
39568
39770
  server.registerTool("ofw_list_journal_entries", {
39569
39771
  description: "List OurFamilyWizard journal entries",
39570
39772
  annotations: { readOnlyHint: true },
39571
39773
  inputSchema: {
39572
- start: external_exports.number().describe("Start offset (default 1)").optional(),
39573
- max: external_exports.number().describe("Max results (default 10)").optional()
39774
+ start: external_exports.number().int().min(1).describe("Start offset (default 1)").optional(),
39775
+ max: external_exports.number().int().min(1).describe("Max results (default 10)").optional()
39574
39776
  }
39575
39777
  }, async (args) => {
39576
39778
  const start = args.start ?? 1;
@@ -39578,7 +39780,7 @@ function registerJournalTools(server, client2) {
39578
39780
  const data = await client2.request("GET", `/pub/v1/journals?start=${start}&max=${max}`);
39579
39781
  return jsonResponse(data);
39580
39782
  });
39581
- server.registerTool("ofw_create_journal_entry", {
39783
+ if (allowWrites) server.registerTool("ofw_create_journal_entry", {
39582
39784
  description: "Create a new journal entry in OurFamilyWizard",
39583
39785
  annotations: { destructiveHint: false },
39584
39786
  inputSchema: {
@@ -39604,7 +39806,7 @@ process.emit = function(event, ...args) {
39604
39806
  };
39605
39807
  await runMcp({
39606
39808
  name: "ofw",
39607
- version: "2.3.2",
39809
+ version: "2.4.1",
39608
39810
  // x-release-please-version
39609
39811
  deps: client,
39610
39812
  tools: [
package/dist/client.js CHANGED
@@ -4,7 +4,13 @@ import { dirname, join } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { resolveAuth } from './auth.js';
6
6
  import { parseBoolEnv } from './config.js';
7
+ import { clearBlankInjectedEnv } from './env-bootstrap.js';
7
8
  import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS } from './protocol.js';
9
+ // When the plugin runs via a host that maps creds from optional
10
+ // `${user_config.*}` fields, an unset field can be injected as a blank /
11
+ // placeholder env value. Clear those first so the next step's .env (and the
12
+ // shell env) can still populate them — a filled field keeps its real value.
13
+ clearBlankInjectedEnv();
8
14
  // Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
9
15
  // bundle). loadDotenvSafely applies override:false + quiet:true and swallows a
10
16
  // missing dotenv module, matching the prior inline try/catch exactly.
package/dist/config.js CHANGED
@@ -54,6 +54,32 @@ export function getAttachmentsDir() {
54
54
  export function parseBoolEnv(name) {
55
55
  return parseBoolEnvUtil(name);
56
56
  }
57
+ /**
58
+ * Gate for write-tool registration, read at registration time (startup).
59
+ *
60
+ * none No write tools are registered — pure read/sync/search surface.
61
+ * drafts Draft-level writes only (ofw_save_draft, ofw_delete_draft,
62
+ * ofw_upload_attachment). Nothing that lands on the court-visible
63
+ * record (send, calendar/expense/journal writes) is registered —
64
+ * the only way to send remains a human in the OFW web UI.
65
+ * all Every tool registers (the default; fully backward compatible).
66
+ *
67
+ * Unregistered tools cannot be invoked by any host permission setting or
68
+ * injected instruction — the gate is structural, not behavioral. An
69
+ * unrecognized value fails closed to 'none': this is a safety control, so a
70
+ * typo must never silently grant write access.
71
+ */
72
+ export function getWriteMode() {
73
+ const raw = process.env.OFW_WRITE_MODE;
74
+ if (typeof raw !== 'string' || raw.trim().length === 0)
75
+ return 'all';
76
+ const mode = raw.trim().toLowerCase();
77
+ if (mode === 'none' || mode === 'drafts' || mode === 'all')
78
+ return mode;
79
+ // stdio transport: stderr only — stdout is reserved for JSON-RPC.
80
+ console.error(`[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" — failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`);
81
+ return 'none';
82
+ }
57
83
  // Default for ofw_download_attachment's `inline` arg when the caller doesn't
58
84
  // pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
59
85
  // MCP content blocks by default (skipping disk) — useful on sandboxed MCP