ofw-mcp 2.3.2 → 2.4.0

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
@@ -38069,8 +38069,7 @@ async function loginWithPassword(username, password) {
38069
38069
  headers: { ...OFW_PROTOCOL_HEADERS },
38070
38070
  redirect: "manual"
38071
38071
  });
38072
- const setCookie = initResponse.headers.get("set-cookie") ?? "";
38073
- const sessionCookie = setCookie.split(";")[0];
38072
+ const sessionCookie = initResponse.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
38074
38073
  const response = await fetch(`${BASE_URL}/ofw/login`, {
38075
38074
  method: "POST",
38076
38075
  headers: {
@@ -38130,6 +38129,16 @@ function getAttachmentsDir() {
38130
38129
  function parseBoolEnv2(name) {
38131
38130
  return parseBoolEnv(name);
38132
38131
  }
38132
+ function getWriteMode() {
38133
+ const raw = process.env.OFW_WRITE_MODE;
38134
+ if (typeof raw !== "string" || raw.trim().length === 0) return "all";
38135
+ const mode = raw.trim().toLowerCase();
38136
+ if (mode === "none" || mode === "drafts" || mode === "all") return mode;
38137
+ console.error(
38138
+ `[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
38139
+ );
38140
+ return "none";
38141
+ }
38133
38142
  function getDefaultInlineAttachments() {
38134
38143
  return parseBoolEnv2("OFW_INLINE_ATTACHMENTS");
38135
38144
  }
@@ -38137,7 +38146,8 @@ function getDefaultInlineAttachments() {
38137
38146
  // package.json
38138
38147
  var package_default = {
38139
38148
  name: "ofw-mcp",
38140
- version: "2.3.2",
38149
+ version: "2.4.0",
38150
+ license: "MIT",
38141
38151
  mcpName: "io.github.chrischall/ofw-mcp",
38142
38152
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
38143
38153
  author: "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -38146,6 +38156,9 @@ var package_default = {
38146
38156
  url: "git+https://github.com/chrischall/ofw-mcp.git"
38147
38157
  },
38148
38158
  type: "module",
38159
+ engines: {
38160
+ node: ">=22.5.0"
38161
+ },
38149
38162
  bin: {
38150
38163
  "ofw-mcp": "dist/index.js"
38151
38164
  },
@@ -38401,9 +38414,24 @@ var OFWClient = class {
38401
38414
  };
38402
38415
  var client = new OFWClient();
38403
38416
 
38417
+ // src/validate.ts
38418
+ function parseOFW(schema, raw, ctx, mode = "lenient") {
38419
+ const result = schema.safeParse(raw);
38420
+ if (result.success) return result.data;
38421
+ const issues = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
38422
+ const message = `OFW response for ${ctx} failed validation: ${issues}`;
38423
+ if (mode === "strict") throw new Error(message);
38424
+ console.error(`[ofw-mcp] WARNING: ${message} \u2014 continuing with the raw response; fields derived from it may be missing or wrong.`);
38425
+ return raw;
38426
+ }
38427
+
38404
38428
  // src/tools/_shared.ts
38405
38429
  var jsonResponse = textResult;
38406
38430
  var textResponse = rawTextResult;
38431
+ var ApiRecipientSchema = external_exports.looseObject({
38432
+ user: external_exports.looseObject({ id: external_exports.number().optional(), name: external_exports.string().optional() }).optional(),
38433
+ viewed: external_exports.looseObject({ dateTime: external_exports.string() }).nullable().optional()
38434
+ });
38407
38435
  function mapRecipients(items) {
38408
38436
  return (items ?? []).map((r) => ({
38409
38437
  userId: r.user?.id ?? 0,
@@ -38412,15 +38440,36 @@ function mapRecipients(items) {
38412
38440
  }));
38413
38441
  }
38414
38442
  var expandPath2 = expandPath;
38415
- async function postMessageAndRefetch(client2, payload) {
38416
- const raw = await client2.request(
38417
- "POST",
38418
- "/pub/v3/messages",
38419
- payload
38443
+ function verifyWriteLanded(kind, sent, persisted) {
38444
+ const mismatches = [];
38445
+ if (typeof persisted.subject !== "string" || !persisted.subject.includes(sent.subject)) {
38446
+ mismatches.push("subject");
38447
+ }
38448
+ if (typeof persisted.body !== "string" || !persisted.body.includes(sent.body)) {
38449
+ mismatches.push("body");
38450
+ }
38451
+ if (mismatches.length === 0) return null;
38452
+ 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.`;
38453
+ }
38454
+ var PostMessagesResponseSchema = external_exports.looseObject({
38455
+ id: external_exports.number().optional(),
38456
+ entityId: external_exports.number().optional()
38457
+ }).nullable();
38458
+ async function postMessageAndRefetch(client2, payload, detailSchema, ctx) {
38459
+ const raw = parseOFW(
38460
+ PostMessagesResponseSchema,
38461
+ await client2.request("POST", "/pub/v3/messages", payload),
38462
+ `POST /pub/v3/messages (${ctx})`,
38463
+ "strict"
38420
38464
  );
38421
38465
  const id = typeof raw?.id === "number" ? raw.id : typeof raw?.entityId === "number" ? raw.entityId : null;
38422
38466
  if (id === null) return { id: null, detail: null, raw };
38423
- const detail = await client2.request("GET", `/pub/v3/messages/${id}`);
38467
+ const detail = parseOFW(
38468
+ detailSchema,
38469
+ await client2.request("GET", `/pub/v3/messages/${id}`),
38470
+ `GET /pub/v3/messages/{id} (${ctx})`,
38471
+ "strict"
38472
+ );
38424
38473
  return { id, detail, raw };
38425
38474
  }
38426
38475
 
@@ -38789,8 +38838,20 @@ function markAttachmentDownloaded(fileId, path) {
38789
38838
  }
38790
38839
 
38791
38840
  // src/sync.ts
38841
+ var FileMetaSchema = external_exports.looseObject({
38842
+ fileId: external_exports.number(),
38843
+ label: external_exports.string().optional(),
38844
+ fileName: external_exports.string().optional(),
38845
+ fileType: external_exports.string().optional(),
38846
+ // MIME
38847
+ fileSize: external_exports.number().optional()
38848
+ });
38792
38849
  async function fetchAttachmentMeta(client2, fileId, messageId) {
38793
- const meta3 = await client2.request("GET", `/pub/v1/myfiles/${fileId}`);
38850
+ const meta3 = parseOFW(
38851
+ FileMetaSchema,
38852
+ await client2.request("GET", `/pub/v1/myfiles/${fileId}`),
38853
+ "GET /pub/v1/myfiles/{fileId}"
38854
+ );
38794
38855
  upsertAttachmentForMessage({
38795
38856
  fileId: meta3.fileId ?? fileId,
38796
38857
  fileName: meta3.fileName ?? `file-${fileId}`,
@@ -38804,10 +38865,14 @@ async function fetchAttachmentMeta(client2, fileId, messageId) {
38804
38865
  async function fetchAttachmentMetaForMessage(client2, messageId, fileIds) {
38805
38866
  await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client2, fid, messageId)));
38806
38867
  }
38868
+ var FoldersSchema = external_exports.looseObject({
38869
+ systemFolders: external_exports.array(external_exports.looseObject({ id: external_exports.string(), folderType: external_exports.string() })).optional()
38870
+ });
38807
38871
  async function resolveFolderIds(client2) {
38808
- const data = await client2.request(
38809
- "GET",
38810
- "/pub/v1/messageFolders?includeFolderCounts=true"
38872
+ const data = parseOFW(
38873
+ FoldersSchema,
38874
+ await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true"),
38875
+ "GET /pub/v1/messageFolders"
38811
38876
  );
38812
38877
  const sys = data.systemFolders ?? [];
38813
38878
  const find = (type) => {
@@ -38823,6 +38888,19 @@ async function resolveFolderIds(client2) {
38823
38888
  setMeta("drafts_folder_id", ids.drafts);
38824
38889
  return ids;
38825
38890
  }
38891
+ var ListItemSchema = external_exports.looseObject({
38892
+ id: external_exports.number(),
38893
+ subject: external_exports.string(),
38894
+ date: external_exports.looseObject({ dateTime: external_exports.string() }),
38895
+ from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
38896
+ showNeverViewed: external_exports.boolean(),
38897
+ recipients: external_exports.array(ApiRecipientSchema).optional()
38898
+ });
38899
+ var ListResponseSchema = external_exports.looseObject({ data: external_exports.array(ListItemSchema).optional() });
38900
+ var DetailResponseSchema = external_exports.looseObject({
38901
+ body: external_exports.string().optional(),
38902
+ files: external_exports.array(external_exports.number()).optional()
38903
+ });
38826
38904
  async function syncMessageFolder(client2, folder, folderId, opts) {
38827
38905
  let page = 1;
38828
38906
  let synced = 0;
@@ -38830,7 +38908,11 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
38830
38908
  const unread = [];
38831
38909
  while (true) {
38832
38910
  const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
38833
- const list = await client2.request("GET", path);
38911
+ const list = parseOFW(
38912
+ ListResponseSchema,
38913
+ await client2.request("GET", path),
38914
+ `GET /pub/v3/messages?folders={${folder}}`
38915
+ );
38834
38916
  const items = list.data ?? [];
38835
38917
  if (items.length === 0) break;
38836
38918
  let pageHadNewItem = false;
@@ -38845,7 +38927,11 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
38845
38927
  let fetchedBodyAt = null;
38846
38928
  let detailFileIds = [];
38847
38929
  if (shouldFetchBody) {
38848
- const detail = await client2.request("GET", `/pub/v3/messages/${item.id}`);
38930
+ const detail = parseOFW(
38931
+ DetailResponseSchema,
38932
+ await client2.request("GET", `/pub/v3/messages/${item.id}`),
38933
+ "GET /pub/v3/messages/{id} (sync)"
38934
+ );
38849
38935
  body = detail.body ?? "";
38850
38936
  fetchedBodyAt = (/* @__PURE__ */ new Date()).toISOString();
38851
38937
  if (Array.isArray(detail.files) && detail.files.length > 0) {
@@ -38887,17 +38973,44 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
38887
38973
  });
38888
38974
  return { synced, unread };
38889
38975
  }
38976
+ var DraftListItemSchema = external_exports.looseObject({
38977
+ id: external_exports.number(),
38978
+ subject: external_exports.string(),
38979
+ date: external_exports.looseObject({ dateTime: external_exports.string() }),
38980
+ replyToId: external_exports.number().nullable().optional(),
38981
+ recipients: external_exports.array(ApiRecipientSchema).optional()
38982
+ });
38983
+ var DraftListResponseSchema = external_exports.looseObject({ data: external_exports.array(DraftListItemSchema).optional() });
38984
+ var DraftDetailSchema = external_exports.looseObject({
38985
+ body: external_exports.string().optional(),
38986
+ subject: external_exports.string().optional()
38987
+ });
38890
38988
  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 ?? [];
38989
+ const items = [];
38990
+ let page = 1;
38991
+ while (true) {
38992
+ const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
38993
+ const list = parseOFW(
38994
+ DraftListResponseSchema,
38995
+ await client2.request("GET", path),
38996
+ "GET /pub/v3/messages?folders={drafts}"
38997
+ );
38998
+ const pageItems = list.data ?? [];
38999
+ items.push(...pageItems);
39000
+ if (pageItems.length < 50) break;
39001
+ page++;
39002
+ }
38894
39003
  const seenIds = /* @__PURE__ */ new Set();
38895
39004
  let synced = 0;
38896
39005
  for (const item of items) {
38897
39006
  seenIds.add(item.id);
38898
39007
  const modifiedAt = item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString();
38899
39008
  const existing = getDraft(item.id);
38900
- const detail = await client2.request("GET", `/pub/v3/messages/${item.id}`);
39009
+ const detail = parseOFW(
39010
+ DraftDetailSchema,
39011
+ await client2.request("GET", `/pub/v3/messages/${item.id}`),
39012
+ "GET /pub/v3/messages/{id} (drafts sync)"
39013
+ );
38901
39014
  const row = {
38902
39015
  id: item.id,
38903
39016
  subject: detail.subject ?? item.subject ?? "(no subject)",
@@ -38949,6 +39062,39 @@ async function syncAll(client2, opts) {
38949
39062
  // src/tools/messages.ts
38950
39063
  import { mkdirSync as mkdirSync2, readFileSync, statSync, writeFileSync } from "node:fs";
38951
39064
  import { basename, dirname as dirname3, extname, join as join5 } from "node:path";
39065
+ var DateSchema = external_exports.looseObject({ dateTime: external_exports.string() });
39066
+ var SentDetailSchema = external_exports.looseObject({
39067
+ subject: external_exports.string().optional(),
39068
+ body: external_exports.string().optional(),
39069
+ date: DateSchema.optional(),
39070
+ from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
39071
+ recipients: external_exports.array(ApiRecipientSchema).optional()
39072
+ });
39073
+ var SavedDraftDetailSchema = external_exports.looseObject({
39074
+ subject: external_exports.string().optional(),
39075
+ body: external_exports.string().optional(),
39076
+ date: DateSchema.optional(),
39077
+ replyToId: external_exports.number().nullable().optional(),
39078
+ recipients: external_exports.array(ApiRecipientSchema).optional()
39079
+ });
39080
+ var MessageDetailSchema = external_exports.looseObject({
39081
+ id: external_exports.number(),
39082
+ subject: external_exports.string(),
39083
+ body: external_exports.string().optional(),
39084
+ date: DateSchema,
39085
+ from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
39086
+ files: external_exports.array(external_exports.number()).optional(),
39087
+ recipients: external_exports.array(ApiRecipientSchema).optional()
39088
+ });
39089
+ var DetailFilesSchema = external_exports.looseObject({ files: external_exports.array(external_exports.number()).optional() });
39090
+ var UploadedFileSchema = external_exports.looseObject({
39091
+ fileId: external_exports.number(),
39092
+ fileName: external_exports.string().optional(),
39093
+ label: external_exports.string().optional(),
39094
+ fileType: external_exports.string().optional(),
39095
+ sizeInBytes: external_exports.number().optional(),
39096
+ shareClass: external_exports.string().optional()
39097
+ });
38952
39098
  var MIME_BY_EXT = {
38953
39099
  ".pdf": "application/pdf",
38954
39100
  ".png": "image/png",
@@ -38984,6 +39130,9 @@ function listDataHintsAtFiles(listData) {
38984
39130
  return false;
38985
39131
  }
38986
39132
  function registerMessageTools(server, client2) {
39133
+ const writeMode = getWriteMode();
39134
+ const allowSend = writeMode === "all";
39135
+ const allowDrafts = writeMode !== "none";
38987
39136
  server.registerTool("ofw_list_message_folders", {
38988
39137
  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
39138
  annotations: { readOnlyHint: true }
@@ -38996,8 +39145,8 @@ function registerMessageTools(server, client2) {
38996
39145
  annotations: { readOnlyHint: true },
38997
39146
  inputSchema: {
38998
39147
  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(),
39148
+ page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
39149
+ size: external_exports.number().int().min(1).describe("Messages per page (default 50)").optional(),
39001
39150
  since: external_exports.string().describe("ISO date or datetime \u2014 only messages with sent_at >= since (inclusive)").optional(),
39002
39151
  until: external_exports.string().describe("ISO date or datetime \u2014 only messages with sent_at < until (exclusive)").optional(),
39003
39152
  q: external_exports.string().describe("Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.").optional()
@@ -39060,7 +39209,11 @@ function registerMessageTools(server, client2) {
39060
39209
  let attachments2 = listAttachmentsForMessage(id);
39061
39210
  if (attachments2.length === 0 && listDataHintsAtFiles(cached2.listData)) {
39062
39211
  try {
39063
- const detail2 = await client2.request("GET", `/pub/v3/messages/${id}`);
39212
+ const detail2 = parseOFW(
39213
+ DetailFilesSchema,
39214
+ await client2.request("GET", `/pub/v3/messages/${id}`),
39215
+ "GET /pub/v3/messages/{id} (attachment backfill)"
39216
+ );
39064
39217
  if (Array.isArray(detail2.files) && detail2.files.length > 0) {
39065
39218
  await fetchAttachmentMetaForMessage(client2, id, detail2.files);
39066
39219
  attachments2 = listAttachmentsForMessage(id);
@@ -39070,7 +39223,11 @@ function registerMessageTools(server, client2) {
39070
39223
  }
39071
39224
  return jsonResponse({ ...cached2, attachments: attachments2 });
39072
39225
  }
39073
- const detail = await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`);
39226
+ const detail = parseOFW(
39227
+ MessageDetailSchema,
39228
+ await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`),
39229
+ "GET /pub/v3/messages/{id} (ofw_get_message)"
39230
+ );
39074
39231
  const folder = cached2?.folder ?? "inbox";
39075
39232
  const row = {
39076
39233
  id: detail.id,
@@ -39092,7 +39249,7 @@ function registerMessageTools(server, client2) {
39092
39249
  const attachments = listAttachmentsForMessage(detail.id);
39093
39250
  return jsonResponse({ ...row, attachments });
39094
39251
  });
39095
- server.registerTool("ofw_send_message", {
39252
+ if (allowSend) server.registerTool("ofw_send_message", {
39096
39253
  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
39254
  annotations: { destructiveHint: true },
39098
39255
  inputSchema: {
@@ -39162,9 +39319,11 @@ function registerMessageTools(server, client2) {
39162
39319
  draft: false,
39163
39320
  includeOriginal: resolvedReplyTo !== null,
39164
39321
  replyToId: resolvedReplyTo
39165
- });
39322
+ }, SentDetailSchema, "ofw_send_message");
39166
39323
  let persisted = null;
39324
+ let verifyNote = null;
39167
39325
  if (newId !== null) {
39326
+ verifyNote = verifyWriteLanded("message", { subject, body }, detail);
39168
39327
  persisted = {
39169
39328
  id: newId,
39170
39329
  folder: "sent",
@@ -39192,13 +39351,18 @@ function registerMessageTools(server, client2) {
39192
39351
  });
39193
39352
  }
39194
39353
  }
39195
- if (draftRef !== void 0) {
39354
+ let unconfirmedNote = null;
39355
+ if (newId === null) {
39356
+ const draftClause = draftRef !== void 0 ? `Draft ${draftRef} was NOT deleted \u2014 check` : "Check";
39357
+ 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.`;
39358
+ } else if (draftRef !== void 0) {
39196
39359
  await deleteOFWMessages(client2, [draftRef]);
39197
39360
  deleteDraft(draftRef);
39198
39361
  }
39199
39362
  const responseObj = persisted ?? raw;
39200
39363
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
39201
- return textResponse(rewriteNote ? `${rewriteNote}
39364
+ const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join("\n\n");
39365
+ return textResponse(notes ? `${notes}
39202
39366
 
39203
39367
  ${text}` : text);
39204
39368
  });
@@ -39206,8 +39370,8 @@ ${text}` : text);
39206
39370
  description: "List draft messages from the local OurFamilyWizard cache. Call ofw_sync_messages first if the cache is empty.",
39207
39371
  annotations: { readOnlyHint: true },
39208
39372
  inputSchema: {
39209
- page: external_exports.number().describe("Page number (default 1)").optional(),
39210
- size: external_exports.number().describe("Drafts per page (default 50)").optional()
39373
+ page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
39374
+ size: external_exports.number().int().min(1).describe("Drafts per page (default 50)").optional()
39211
39375
  }
39212
39376
  }, async (args) => {
39213
39377
  const page = args.page ?? 1;
@@ -39216,7 +39380,7 @@ ${text}` : text);
39216
39380
  const payload = drafts.length === 0 ? { drafts: [], note: "Cache empty. Call ofw_sync_messages to populate." } : { drafts };
39217
39381
  return jsonResponse(payload);
39218
39382
  });
39219
- server.registerTool("ofw_save_draft", {
39383
+ if (allowDrafts) server.registerTool("ofw_save_draft", {
39220
39384
  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
39385
  annotations: { readOnlyHint: false },
39222
39386
  inputSchema: {
@@ -39247,10 +39411,17 @@ ${text}` : text);
39247
39411
  includeOriginal: resolvedReplyTo !== null,
39248
39412
  replyToId: resolvedReplyTo
39249
39413
  };
39250
- const { id: newId, detail, raw } = await postMessageAndRefetch(client2, payload);
39414
+ const { id: newId, detail, raw } = await postMessageAndRefetch(
39415
+ client2,
39416
+ payload,
39417
+ SavedDraftDetailSchema,
39418
+ "ofw_save_draft"
39419
+ );
39251
39420
  let persisted = null;
39252
39421
  let replaceNote = null;
39422
+ let verifyNote = null;
39253
39423
  if (newId !== null) {
39424
+ verifyNote = verifyWriteLanded("draft", { subject: args.subject, body: args.body }, detail);
39254
39425
  persisted = {
39255
39426
  id: newId,
39256
39427
  subject: detail.subject ?? args.subject,
@@ -39273,12 +39444,12 @@ ${text}` : text);
39273
39444
  }
39274
39445
  const responseObj = persisted ?? raw;
39275
39446
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Draft saved.";
39276
- const notes = [rewriteNote, replaceNote].filter((n) => n !== null).join("\n\n");
39447
+ const notes = [rewriteNote, verifyNote, replaceNote].filter((n) => n !== null).join("\n\n");
39277
39448
  return textResponse(notes ? `${notes}
39278
39449
 
39279
39450
  ${text}` : text);
39280
39451
  });
39281
- server.registerTool("ofw_delete_draft", {
39452
+ if (allowDrafts) server.registerTool("ofw_delete_draft", {
39282
39453
  description: "Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache.",
39283
39454
  annotations: { destructiveHint: true },
39284
39455
  inputSchema: {
@@ -39293,8 +39464,8 @@ ${text}` : text);
39293
39464
  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
39465
  annotations: { readOnlyHint: true },
39295
39466
  inputSchema: {
39296
- page: external_exports.number().describe("Page (default 1)").optional(),
39297
- size: external_exports.number().describe("Per page (default 50)").optional()
39467
+ page: external_exports.number().int().min(1).describe("Page (default 1)").optional(),
39468
+ size: external_exports.number().int().min(1).describe("Per page (default 50)").optional()
39298
39469
  }
39299
39470
  }, async (args) => {
39300
39471
  const page = args.page ?? 1;
@@ -39315,7 +39486,7 @@ ${text}` : text);
39315
39486
  }
39316
39487
  return jsonResponse(unread);
39317
39488
  });
39318
- server.registerTool("ofw_upload_attachment", {
39489
+ if (allowDrafts) server.registerTool("ofw_upload_attachment", {
39319
39490
  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
39491
  annotations: { destructiveHint: false },
39321
39492
  inputSchema: {
@@ -39337,7 +39508,12 @@ ${text}` : text);
39337
39508
  form.append("label", args.label ?? fileName);
39338
39509
  form.append("fileName", fileName);
39339
39510
  form.append("shareClass", args.shareClass ?? "PRIVATE");
39340
- const meta3 = await client2.request("POST", "/pub/v3/myfiles/multipart", form);
39511
+ const meta3 = parseOFW(
39512
+ UploadedFileSchema,
39513
+ await client2.request("POST", "/pub/v3/myfiles/multipart", form),
39514
+ "POST /pub/v3/myfiles/multipart (ofw_upload_attachment)",
39515
+ "strict"
39516
+ );
39341
39517
  upsertAttachmentForMessage({
39342
39518
  fileId: meta3.fileId,
39343
39519
  fileName: meta3.fileName ?? fileName,
@@ -39462,6 +39638,7 @@ async function deleteOFWMessages(client2, ids) {
39462
39638
 
39463
39639
  // src/tools/calendar.ts
39464
39640
  function registerCalendarTools(server, client2) {
39641
+ const allowWrites = getWriteMode() === "all";
39465
39642
  server.registerTool("ofw_list_events", {
39466
39643
  description: "List OurFamilyWizard calendar events in a date range",
39467
39644
  annotations: { readOnlyHint: true },
@@ -39478,7 +39655,7 @@ function registerCalendarTools(server, client2) {
39478
39655
  );
39479
39656
  return jsonResponse(data);
39480
39657
  });
39481
- server.registerTool("ofw_create_event", {
39658
+ if (allowWrites) server.registerTool("ofw_create_event", {
39482
39659
  description: "Create a calendar event in OurFamilyWizard",
39483
39660
  annotations: { destructiveHint: false },
39484
39661
  inputSchema: {
@@ -39498,7 +39675,7 @@ function registerCalendarTools(server, client2) {
39498
39675
  const data = await client2.request("POST", "/pub/v1/calendar/events", args);
39499
39676
  return jsonResponse(data);
39500
39677
  });
39501
- server.registerTool("ofw_update_event", {
39678
+ if (allowWrites) server.registerTool("ofw_update_event", {
39502
39679
  description: "Update an existing OurFamilyWizard calendar event",
39503
39680
  annotations: { destructiveHint: true },
39504
39681
  inputSchema: {
@@ -39516,7 +39693,7 @@ function registerCalendarTools(server, client2) {
39516
39693
  const data = await client2.request("PUT", `/pub/v1/calendar/events/${encodeURIComponent(eventId)}`, updateData);
39517
39694
  return jsonResponse(data);
39518
39695
  });
39519
- server.registerTool("ofw_delete_event", {
39696
+ if (allowWrites) server.registerTool("ofw_delete_event", {
39520
39697
  description: "Delete an OurFamilyWizard calendar event",
39521
39698
  annotations: { destructiveHint: true },
39522
39699
  inputSchema: {
@@ -39530,6 +39707,7 @@ function registerCalendarTools(server, client2) {
39530
39707
 
39531
39708
  // src/tools/expenses.ts
39532
39709
  function registerExpenseTools(server, client2) {
39710
+ const allowWrites = getWriteMode() === "all";
39533
39711
  server.registerTool("ofw_get_expense_totals", {
39534
39712
  description: "Get OurFamilyWizard expense summary totals (owed/paid)",
39535
39713
  annotations: { readOnlyHint: true }
@@ -39541,8 +39719,8 @@ function registerExpenseTools(server, client2) {
39541
39719
  description: "List OurFamilyWizard expenses with pagination",
39542
39720
  annotations: { readOnlyHint: true },
39543
39721
  inputSchema: {
39544
- start: external_exports.number().describe("Start offset (default 0)").optional(),
39545
- max: external_exports.number().describe("Max results (default 20)").optional()
39722
+ start: external_exports.number().int().min(0).describe("Start offset (default 0)").optional(),
39723
+ max: external_exports.number().int().min(1).describe("Max results (default 20)").optional()
39546
39724
  }
39547
39725
  }, async (args) => {
39548
39726
  const start = args.start ?? 0;
@@ -39550,7 +39728,7 @@ function registerExpenseTools(server, client2) {
39550
39728
  const data = await client2.request("GET", `/pub/v2/expense/expenses?start=${start}&max=${max}`);
39551
39729
  return jsonResponse(data);
39552
39730
  });
39553
- server.registerTool("ofw_create_expense", {
39731
+ if (allowWrites) server.registerTool("ofw_create_expense", {
39554
39732
  description: "Log a new expense in OurFamilyWizard",
39555
39733
  annotations: { destructiveHint: false },
39556
39734
  inputSchema: {
@@ -39565,12 +39743,13 @@ function registerExpenseTools(server, client2) {
39565
39743
 
39566
39744
  // src/tools/journal.ts
39567
39745
  function registerJournalTools(server, client2) {
39746
+ const allowWrites = getWriteMode() === "all";
39568
39747
  server.registerTool("ofw_list_journal_entries", {
39569
39748
  description: "List OurFamilyWizard journal entries",
39570
39749
  annotations: { readOnlyHint: true },
39571
39750
  inputSchema: {
39572
- start: external_exports.number().describe("Start offset (default 1)").optional(),
39573
- max: external_exports.number().describe("Max results (default 10)").optional()
39751
+ start: external_exports.number().int().min(1).describe("Start offset (default 1)").optional(),
39752
+ max: external_exports.number().int().min(1).describe("Max results (default 10)").optional()
39574
39753
  }
39575
39754
  }, async (args) => {
39576
39755
  const start = args.start ?? 1;
@@ -39578,7 +39757,7 @@ function registerJournalTools(server, client2) {
39578
39757
  const data = await client2.request("GET", `/pub/v1/journals?start=${start}&max=${max}`);
39579
39758
  return jsonResponse(data);
39580
39759
  });
39581
- server.registerTool("ofw_create_journal_entry", {
39760
+ if (allowWrites) server.registerTool("ofw_create_journal_entry", {
39582
39761
  description: "Create a new journal entry in OurFamilyWizard",
39583
39762
  annotations: { destructiveHint: false },
39584
39763
  inputSchema: {
@@ -39604,7 +39783,7 @@ process.emit = function(event, ...args) {
39604
39783
  };
39605
39784
  await runMcp({
39606
39785
  name: "ofw",
39607
- version: "2.3.2",
39786
+ version: "2.4.0",
39608
39787
  // x-release-please-version
39609
39788
  deps: client,
39610
39789
  tools: [
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
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@ import { registerJournalTools } from './tools/journal.js';
24
24
  // always succeeds before any credential check runs.
25
25
  await runMcp({
26
26
  name: 'ofw',
27
- version: '2.3.2', // x-release-please-version
27
+ version: '2.4.0', // x-release-please-version
28
28
  deps: client,
29
29
  tools: [
30
30
  registerUserTools,