ofw-mcp 2.18.0 → 2.19.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.
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "OurFamilyWizard tools for Claude Code",
9
- "version": "2.18.0"
9
+ "version": "2.19.1"
10
10
  },
11
11
  "plugins": [
12
12
  {
@@ -14,7 +14,7 @@
14
14
  "displayName": "OurFamilyWizard",
15
15
  "source": "./",
16
16
  "description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
17
- "version": "2.18.0",
17
+ "version": "2.19.1",
18
18
  "author": {
19
19
  "name": "Chris Chall"
20
20
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ofw",
3
3
  "displayName": "OurFamilyWizard",
4
- "version": "2.18.0",
4
+ "version": "2.19.1",
5
5
  "description": "OurFamilyWizard co-parenting tools for Claude — messages, calendar, expenses, and journal via MCP",
6
6
  "author": {
7
7
  "name": "Chris Chall"
@@ -9,6 +9,7 @@
9
9
  // This file exists as a standalone helper (not a method on `OFWClient`) so
10
10
  // `resolveAuth()` in `./auth.ts` can call it without a Client instance, and
11
11
  // so tests can mock it at the module boundary.
12
+ import { currentCallSignal } from '@chrischall/mcp-utils';
12
13
  import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, assertOfwUrl } from './protocol.js';
13
14
  export async function loginWithPassword(username, password) {
14
15
  // Step 1: get a SESSION cookie (Spring Security refuses the POST without it).
@@ -17,6 +18,10 @@ export async function loginWithPassword(username, password) {
17
18
  const initResponse = await fetch(initUrl, {
18
19
  headers: { ...OFW_PROTOCOL_HEADERS },
19
20
  redirect: 'manual',
21
+ // Honour a caller who has given up (mcp-utils `cancel`). A sign-in
22
+ // nobody is waiting for should not keep hitting OFW, which counts
23
+ // failed attempts against the account.
24
+ signal: currentCallSignal(),
20
25
  });
21
26
  // headers.get('set-cookie') folds multiple Set-Cookie headers into one
22
27
  // comma-joined string; getSetCookie() preserves them individually. Echo
@@ -30,6 +35,7 @@ export async function loginWithPassword(username, password) {
30
35
  assertOfwUrl(loginUrl);
31
36
  const response = await fetch(loginUrl, {
32
37
  method: 'POST',
38
+ signal: currentCallSignal(),
33
39
  headers: {
34
40
  ...OFW_PROTOCOL_HEADERS,
35
41
  Accept: 'application/json',
package/dist/bundle.js CHANGED
@@ -3710,6 +3710,24 @@ var require_websocket_server = __commonJS({
3710
3710
  }
3711
3711
  });
3712
3712
 
3713
+ // node_modules/@chrischall/mcp-utils/dist/cancel/index.js
3714
+ import { AsyncLocalStorage } from "node:async_hooks";
3715
+ var storage = new AsyncLocalStorage();
3716
+ function withCallSignal(signal, fn) {
3717
+ return signal ? storage.run({ signal }, fn) : fn();
3718
+ }
3719
+ function currentCallSignal() {
3720
+ return storage.getStore()?.signal;
3721
+ }
3722
+ function withAmbientCancellation(own2) {
3723
+ const ambient = currentCallSignal();
3724
+ if (!ambient)
3725
+ return own2;
3726
+ if (!own2)
3727
+ return ambient;
3728
+ return AbortSignal.any([own2, ambient]);
3729
+ }
3730
+
3713
3731
  // node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
3714
3732
  var __create2 = Object.create;
3715
3733
  var __defProp2 = Object.defineProperty;
@@ -38155,17 +38173,41 @@ Hint: ${err.hint}`);
38155
38173
  }
38156
38174
  throw err;
38157
38175
  }
38176
+ function callSignalFrom(args) {
38177
+ const ctx = args.at(-1);
38178
+ if (typeof ctx !== "object" || ctx === null)
38179
+ return void 0;
38180
+ const req = ctx.mcpReq;
38181
+ if (typeof req !== "object" || req === null)
38182
+ return void 0;
38183
+ const signal = req.signal;
38184
+ return signal instanceof AbortSignal ? signal : void 0;
38185
+ }
38158
38186
  function surfaceToolHints(server) {
38159
38187
  const register2 = server.registerTool.bind(server);
38160
- server.registerTool = (name, config2, cb) => register2(name, config2, (...args) => {
38161
- let result;
38162
- try {
38163
- result = cb(...args);
38164
- } catch (err) {
38165
- return hintResultOrRethrow(err);
38166
- }
38167
- return result instanceof Promise ? result.catch(hintResultOrRethrow) : result;
38168
- });
38188
+ server.registerTool = (name, config2, cb) => register2(name, config2, (...args) => (
38189
+ // THE CALLER'S CANCELLATION, made ambient for the whole handler
38190
+ // (`cancel/index.ts`). The SDK delivers it and the fleet ignored it:
38191
+ // measured against @modelcontextprotocol/server 2.0.0, a cancelled
38192
+ // call aborts `ctx.mcpReq.signal` with the caller's reason and the
38193
+ // handler runs to completion regardless — so the HTTP request stays
38194
+ // in flight, the child keeps burning metered CPU, and the upstream
38195
+ // keeps being hit for somebody who has gone. claude.ai sent 101
38196
+ // cancellations in the week to 2026-09-20.
38197
+ //
38198
+ // Here because this is the one wrapper every tool already passes
38199
+ // through: threading the signal by hand would mean editing several
38200
+ // hundred handlers and missing exactly the ones nobody edits.
38201
+ withCallSignal(callSignalFrom(args), () => {
38202
+ let result;
38203
+ try {
38204
+ result = cb(...args);
38205
+ } catch (err) {
38206
+ return hintResultOrRethrow(err);
38207
+ }
38208
+ return result instanceof Promise ? result.catch(hintResultOrRethrow) : result;
38209
+ })
38210
+ ));
38169
38211
  }
38170
38212
  async function createMcpServer(opts) {
38171
38213
  const server = new McpServer({ name: opts.name, version: opts.version }, { supportedProtocolVersions: [...SERVER_PROTOCOL_VERSIONS] });
@@ -43922,13 +43964,18 @@ async function loginWithPassword(username, password) {
43922
43964
  assertOfwUrl(initUrl);
43923
43965
  const initResponse = await fetch(initUrl, {
43924
43966
  headers: { ...OFW_PROTOCOL_HEADERS },
43925
- redirect: "manual"
43967
+ redirect: "manual",
43968
+ // Honour a caller who has given up (mcp-utils `cancel`). A sign-in
43969
+ // nobody is waiting for should not keep hitting OFW, which counts
43970
+ // failed attempts against the account.
43971
+ signal: currentCallSignal()
43926
43972
  });
43927
43973
  const sessionCookie = initResponse.headers.getSetCookie().map((c) => c.split(";")[0]).join("; ");
43928
43974
  const loginUrl = `${BASE_URL}/ofw/login`;
43929
43975
  assertOfwUrl(loginUrl);
43930
43976
  const response = await fetch(loginUrl, {
43931
43977
  method: "POST",
43978
+ signal: currentCallSignal(),
43932
43979
  headers: {
43933
43980
  ...OFW_PROTOCOL_HEADERS,
43934
43981
  Accept: "application/json",
@@ -43965,7 +44012,7 @@ async function loginWithPassword(username, password) {
43965
44012
  // package.json
43966
44013
  var package_default = {
43967
44014
  name: "ofw-mcp",
43968
- version: "2.18.0",
44015
+ version: "2.19.1",
43969
44016
  license: "MIT",
43970
44017
  mcpName: "io.github.chrischall/ofw-mcp",
43971
44018
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
@@ -43999,10 +44046,10 @@ var package_default = {
43999
44046
  typecheck: "tsc -p tsconfig.json --noEmit"
44000
44047
  },
44001
44048
  dependencies: {
44002
- "@chrischall/mcp-utils": "^1.0.0",
44049
+ "@chrischall/mcp-utils": "^2.0.0",
44003
44050
  "@fetchproxy/bootstrap": "^3.0.1",
44004
44051
  "@modelcontextprotocol/server": "^2.0.0",
44005
- dotenv: "^17.4.2",
44052
+ dotenv: "^18.0.0",
44006
44053
  zod: "^4.6.2"
44007
44054
  },
44008
44055
  devDependencies: {
@@ -44278,7 +44325,15 @@ var OFWClient = class {
44278
44325
  response = await fetch(url2, {
44279
44326
  method,
44280
44327
  headers,
44281
- signal: ac.signal,
44328
+ // THE CALLER'S CANCELLATION, folded in with our timeout (mcp-utils
44329
+ // `cancel`). Until this the only thing that could stop an OFW
44330
+ // request was the timeout below, so a cancelled tool call held it
44331
+ // open for the full budget while the child burned the CPU
44332
+ // mcp-host meters it on. The timeout diagnosis below is unaffected
44333
+ // because it asks `ac.signal`, OUR controller — a caller's abort
44334
+ // falls through to the generic path rather than being reported as
44335
+ // OFW being slow.
44336
+ signal: withAmbientCancellation(ac.signal),
44282
44337
  ...body !== void 0 ? { body: isFormData ? body : JSON.stringify(body) } : {}
44283
44338
  });
44284
44339
  } catch (err) {
@@ -44603,7 +44658,7 @@ function registerUserTools(server, client2) {
44603
44658
  });
44604
44659
  server.registerTool("ofw_get_notifications", {
44605
44660
  description: "Get OurFamilyWizard dashboard summary: unread message count, upcoming events, outstanding expenses. Note: updates your last-seen status.",
44606
- annotations: { readOnlyHint: false }
44661
+ annotations: { readOnlyHint: true }
44607
44662
  }, async () => {
44608
44663
  const data = await client2.request("GET", "/pub/v1/users/useraccountstatus");
44609
44664
  return jsonResponse(data);
@@ -46888,7 +46943,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
46888
46943
  });
46889
46944
  server.registerTool("ofw_list_messages", {
46890
46945
  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 (1-based `page`) 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. Results are newest-first by default; `sort:"oldest"` starts at the old end of a range instead of paging to it. Returns an explicit `complete` boolean describing the RESULT SET: true means "this is every message on OurFamilyWizard matching these filters as of freshness.asOf" \u2014 check it before asserting a count. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as an absence; pass autoRefresh:true to sync and answer instead.',
46891
- annotations: { readOnlyHint: false },
46946
+ annotations: { readOnlyHint: true },
46892
46947
  inputSchema: external_exports.object({
46893
46948
  folderId: external_exports.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
46894
46949
  page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
@@ -46983,7 +47038,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
46983
47038
  });
46984
47039
  server.registerTool("ofw_get_message", {
46985
47040
  description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW \u2014 and for an UNREAD INBOX message that fetch marks it read and stamps a "First Viewed" time the co-parent can see, which is part of the record and cannot be undone. Pass allowMarkRead:false to refuse such a fetch instead (cached bodies, sent messages and already-read messages are unaffected, because none of them stamp anything). For ids that match a draft (in the drafts cache), the response carries folder="drafts" and the body/subject/recipients reflect the drafts cache (which ofw_sync_messages keeps fresh) \u2014 drafts have no `fromUser`, and `sentAt`/`fetchedBodyAt` mirror the draft\'s `modifiedAt`. For inbox/sent messages, folder is "inbox" or "sent" as before.',
46986
- annotations: { readOnlyHint: false },
47041
+ annotations: { readOnlyHint: false, destructiveHint: true },
46987
47042
  inputSchema: external_exports.object({
46988
47043
  messageId: external_exports.string().describe("Message ID (also accepts draft IDs \u2014 drafts are routed via the drafts cache)"),
46989
47044
  allowMarkRead: external_exports.boolean().describe("Default true (the long-standing behaviour). Set false to refuse a fetch that would mark an unread INBOX message as READ on OurFamilyWizard \u2014 an irreversible, co-parent-visible change to the record. Reads that cannot stamp anything (a cached body, a sent message, an already-read message) still succeed. The server-wide OFW_ALLOW_MARK_READ=false is a ceiling this argument cannot raise.").optional(),
@@ -47378,7 +47433,7 @@ ${JSON.stringify(
47378
47433
  }
47379
47434
  server.registerTool("ofw_list_drafts", {
47380
47435
  description: 'List draft messages, verified against OurFamilyWizard in ONE call: when the local drafts cache is not verified-fresh, a cheap drafts sync runs first by default (verify:true), so the answer is server-confirmed without a second call. Pass verify:false to answer purely from the cache (no OFW requests). Returns an explicit `complete` boolean describing the RESULT SET: true means "these are ALL the drafts on OurFamilyWizard as of freshness.asOf" \u2014 check it before saying "you have N drafts". Each draft carries its `draftKey` (stable across the create-then-delete churn of editing) when one is known. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY"); pass autoRefresh:true to sync and answer instead.',
47381
- annotations: { readOnlyHint: false },
47436
+ annotations: { readOnlyHint: false, destructiveHint: false },
47382
47437
  inputSchema: external_exports.object({
47383
47438
  page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
47384
47439
  size: external_exports.number().int().min(1).describe("Drafts per page (default 50)").optional(),
@@ -47476,7 +47531,7 @@ ${JSON.stringify(
47476
47531
  });
47477
47532
  if (allowDrafts) server.registerTool("ofw_save_draft", {
47478
47533
  description: "Save a message as a draft in OurFamilyWizard. RECIPIENTS: OurFamilyWizard does NOT persist recipients on drafts \u2014 recipientIds are accepted but the saved draft comes back with none (documented OFW behavior, noted once in the response, not warned about; supply recipientIds at send time instead). IDENTITY: the response leads with `draftKey`, the stable identity that survives editing \u2014 key off it, because the `id` changes on EVERY edit (replacing a draft creates a NEW draft and deletes the old one; OFW's update-in-place endpoint silently no-ops, so we never use it). Pass messageId to replace an existing draft; the response.id will be the NEW id, and a transparency NOTE documents the swap and which fields were carried over. THREADING: if replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included). The threading verdict is read from OFW's full echo (replyToId/inReplyTo/showContext) \u2014 a warning appears ONLY when the reply linkage was genuinely dropped or re-targeted, and the response's top-level replyToId/inReplyTo always agree with its listData. Attach files via myFileIDs (from ofw_upload_attachment). After saving, the tool re-fetches the draft from OFW, and the returned `revision` reflects that authoritative state (so it will match on your next edit). SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if its subject/body/recipients changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). A pure replyToId normalization by OFW is NOT treated as a conflict. The refusal returns the current server body under serverBody \u2014 merge your edit into it and retry with expectedRevision.",
47479
- annotations: { readOnlyHint: false },
47534
+ annotations: { readOnlyHint: false, destructiveHint: false },
47480
47535
  inputSchema: external_exports.object({
47481
47536
  subject: external_exports.string().describe("Message subject"),
47482
47537
  body: external_exports.string().describe("Message body text"),
@@ -47657,7 +47712,7 @@ ${text}` : text);
47657
47712
  });
47658
47713
  server.registerTool("ofw_get_unread_sent", {
47659
47714
  description: 'List sent messages that have not been read by one or more recipients. Reads from local cache. Returns `complete` describing whether every sent message was scanned. An empty SENT cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as "nothing sent"; pass autoRefresh:true to sync and answer instead.',
47660
- annotations: { readOnlyHint: false },
47715
+ annotations: { readOnlyHint: true },
47661
47716
  inputSchema: external_exports.object({
47662
47717
  page: external_exports.number().int().min(1).describe("Page (default 1)").optional(),
47663
47718
  size: external_exports.number().int().min(1).describe("Per page (default 50)").optional(),
@@ -47774,7 +47829,7 @@ ${text}` : text);
47774
47829
  });
47775
47830
  server.registerTool("ofw_download_attachment", {
47776
47831
  description: "Download an OFW message attachment by fileId and return content you can actually read. Inline delivery walks a ladder and returns the first rung that works: (1) host-renderable images (PNG/JPEG/GIF/WEBP) come back as ImageContent; (2) .xlsx/.csv/.tsv, .pdf, .docx, .pptx and text files come back as EXTRACTED CONTENT \u2014 per-sheet CSV, per-page/slide text, document text \u2014 in the response JSON under `extracted`; (3) anything else comes back as an EmbeddedResource blob of the raw bytes. The meta block names the rung as `deliveredVia` and, when it falls through to bytes, lists what was tried in `deliveryAttempts`. Reported mime types are always normalized to a bare media type (no charset/name parameters). In disk mode the bytes are saved to ~/Downloads/ofw-mcp/ and the response carries the absolute path; pass extract:true to ALSO get the extracted content in that response. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var. On a hosted deployment with no filesystem, disk mode is unavailable, so inline is forced (forcedInline:true) rather than failing \u2014 a saveTo path never costs you the content. 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).",
47777
- annotations: { readOnlyHint: false },
47832
+ annotations: { readOnlyHint: true },
47778
47833
  inputSchema: external_exports.object({
47779
47834
  fileId: external_exports.number().describe("Attachment file id (from ofw_get_message \u2192 attachments[].fileId)"),
47780
47835
  inline: external_exports.boolean().describe("If true, return content inline as MCP content blocks and skip the disk write. If false, write to disk and return the path \u2014 except on a hosted deployment with no filesystem, where inline is forced (forcedInline:true) so the content is still returned. If omitted, falls back to the OFW_INLINE_ATTACHMENTS env var (default: false = disk).").optional(),
@@ -47861,7 +47916,7 @@ ${text}` : text);
47861
47916
  });
47862
47917
  server.registerTool("ofw_sync_messages", {
47863
47918
  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. EVERY call re-checks the newest page first, so new messages are picked up promptly even while an old-history backfill is still running; only then does it spend what is left of its budget advancing that backfill. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps). Sync is BOUNDED and RESUMABLE: on hosted deployments a per-call OFW-request budget (env OFW_SYNC_MAX_REQUESTS, or the maxRequests argument) caps how far one call walks; when the budget is hit the response reports done:false with a note \u2014 call again with the SAME arguments to resume. done:false means older history is still being backfilled; it does NOT mean recent messages are missing. Local installs are unbounded by default (done is always true).",
47864
- annotations: { readOnlyHint: false },
47919
+ annotations: { readOnlyHint: false, destructiveHint: false },
47865
47920
  inputSchema: external_exports.object({
47866
47921
  folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).min(1).describe("Folders to sync (default: all three). Must be non-empty if given \u2014 an empty list would sync nothing while reporting success.").optional(),
47867
47922
  fetchUnreadBodies: external_exports.boolean().describe('If true, also fetch bodies for unread inbox messages \u2014 which marks each one READ on OurFamilyWizard and stamps a co-parent-visible "First Viewed" time that cannot be undone. Defaults to the OFW_FETCH_UNREAD_BODIES env var (false unless set), and is forced off entirely when OFW_ALLOW_MARK_READ=false.').optional(),
@@ -47887,7 +47942,7 @@ ${text}` : text);
47887
47942
  });
47888
47943
  server.registerTool("ofw_check_freshness", {
47889
47944
  description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state \u2014 especially "draft X is still sitting unsent". Costs one OFW request for the folder check plus one per messageId. For each folder it returns the live server count next to the cached count. For each id it returns a LIVE lifecycle `state` \u2014 "draft" | "sent" | "received" | "deleted" | "unknown" \u2014 alongside `folder`, `sentAt`, `existsOnServer` and a content comparison. `state` is the field that answers "is this still a draft?": a draft that has been SENT still exists on the server, so existsOnServer:true never distinguished the two. A cached draft whose state is no longer "draft" reports inSync:false even when its text is byte-identical. Content is compared by revision hash, because OFW draft timestamps do NOT change when a draft is edited in the web app. Does not fetch bodies into the cache, does not touch attachments, and does not depend on sync state. For draftKeys, or a full live draft inventory, use ofw_status.',
47890
- annotations: { readOnlyHint: false },
47945
+ annotations: { readOnlyHint: true },
47891
47946
  inputSchema: external_exports.object({
47892
47947
  folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).min(1).describe("Folders to compare cached vs live counts for. Defaults to all three when messageIds is not given. Must be non-empty if given.").optional(),
47893
47948
  messageIds: external_exports.array(external_exports.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}). Ids cached as drafts, as sent messages, or as already-read inbox messages are probed freely \u2014 none of those can stamp the record. Anything else is skipped \u2014 see allowMarkRead.`).optional(),
@@ -47946,7 +48001,7 @@ ${text}` : text);
47946
48001
  });
47947
48002
  server.registerTool("ofw_status", {
47948
48003
  description: 'ONE live call that answers "where does everything stand?". This is the call that should back any status summary about drafts or specific messages \u2014 never session memory, and never a cached read alone. With no arguments it returns the FULL current draft inventory, verified against OurFamilyWizard. Pass ids and/or draftKeys to get each one\'s live lifecycle `state` ("draft" | "sent" | "received" | "deleted" | "unknown") with `sentAt` and `viewedAt`. A draftKey is the stable identity ofw_save_draft returns: editing a draft mints a new OFW id every time (create-then-delete), so the key is the only way to ask "what happened to the thing I was working on?" \u2014 it resolves to the chain\'s current id and keeps resolving after the draft is SENT (state:"sent" with sentMessageId). The top-level `complete` is true ONLY when every part of this snapshot was verified live; if it is false, do not state a draft count or a lifecycle claim from this payload.',
47949
- annotations: { readOnlyHint: false },
48004
+ annotations: { readOnlyHint: true },
47950
48005
  inputSchema: external_exports.object({
47951
48006
  ids: external_exports.array(external_exports.number()).describe(`Message/draft ids to resolve to a live state (combined with draftKeys, max ${MAX_FRESHNESS_IDS} probes per call).`).optional(),
47952
48007
  draftKeys: external_exports.array(external_exports.string()).describe("Stable draft keys (from ofw_save_draft / ofw_list_drafts) to resolve to their CURRENT id and state.").optional(),
@@ -48952,7 +49007,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
48952
49007
  var nodeAttachmentIO = new NodeAttachmentIO();
48953
49008
  await runMcp({
48954
49009
  name: "ofw",
48955
- version: "2.18.0",
49010
+ version: "2.19.1",
48956
49011
  // x-release-please-version
48957
49012
  deps: client,
48958
49013
  tools: [
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { loadDotenvSafely, parseBoolEnv, redactSecrets } from '@chrischall/mcp-utils';
1
+ import { loadDotenvSafely, parseBoolEnv, redactSecrets, withAmbientCancellation } from '@chrischall/mcp-utils';
2
2
  import { TokenManager } from '@chrischall/mcp-utils/session';
3
3
  import { dirname, join } from 'path';
4
4
  import { fileURLToPath } from 'url';
@@ -190,7 +190,15 @@ export class OFWClient {
190
190
  response = await fetch(url, {
191
191
  method,
192
192
  headers,
193
- signal: ac.signal,
193
+ // THE CALLER'S CANCELLATION, folded in with our timeout (mcp-utils
194
+ // `cancel`). Until this the only thing that could stop an OFW
195
+ // request was the timeout below, so a cancelled tool call held it
196
+ // open for the full budget while the child burned the CPU
197
+ // mcp-host meters it on. The timeout diagnosis below is unaffected
198
+ // because it asks `ac.signal`, OUR controller — a caller's abort
199
+ // falls through to the generic path rather than being reported as
200
+ // OFW being slow.
201
+ signal: withAmbientCancellation(ac.signal),
194
202
  ...(body !== undefined ? { body: isFormData ? body : JSON.stringify(body) } : {}),
195
203
  });
196
204
  }
package/dist/index.js CHANGED
@@ -36,7 +36,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
36
36
  // always succeeds before any credential check runs.
37
37
  await runMcp({
38
38
  name: 'ofw',
39
- version: '2.18.0', // x-release-please-version
39
+ version: '2.19.1', // x-release-please-version
40
40
  deps: client,
41
41
  tools: [
42
42
  registerHealthcheckTools,
@@ -258,7 +258,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
258
258
  });
259
259
  server.registerTool('ofw_list_messages', {
260
260
  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 (1-based `page`) but if you know what you want (a date range, a topic), prefer the filters over walking pages — the cache may have 1000+ messages. Results are newest-first by default; `sort:"oldest"` starts at the old end of a range instead of paging to it. Returns an explicit `complete` boolean describing the RESULT SET: true means "this is every message on OurFamilyWizard matching these filters as of freshness.asOf" — check it before asserting a count. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as an absence; pass autoRefresh:true to sync and answer instead.',
261
- annotations: { readOnlyHint: false },
261
+ annotations: { readOnlyHint: true },
262
262
  inputSchema: z.object({
263
263
  folderId: z.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
264
264
  page: z.number().int().min(1).describe('Page number (default 1)').optional(),
@@ -387,7 +387,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
387
387
  });
388
388
  server.registerTool('ofw_get_message', {
389
389
  description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW — and for an UNREAD INBOX message that fetch marks it read and stamps a "First Viewed" time the co-parent can see, which is part of the record and cannot be undone. Pass allowMarkRead:false to refuse such a fetch instead (cached bodies, sent messages and already-read messages are unaffected, because none of them stamp anything). For ids that match a draft (in the drafts cache), the response carries folder="drafts" and the body/subject/recipients reflect the drafts cache (which ofw_sync_messages keeps fresh) — drafts have no `fromUser`, and `sentAt`/`fetchedBodyAt` mirror the draft\'s `modifiedAt`. For inbox/sent messages, folder is "inbox" or "sent" as before.',
390
- annotations: { readOnlyHint: false },
390
+ annotations: { readOnlyHint: false, destructiveHint: true },
391
391
  inputSchema: z.object({
392
392
  messageId: z.string().describe('Message ID (also accepts draft IDs — drafts are routed via the drafts cache)'),
393
393
  allowMarkRead: z.boolean().describe('Default true (the long-standing behaviour). Set false to refuse a fetch that would mark an unread INBOX message as READ on OurFamilyWizard — an irreversible, co-parent-visible change to the record. Reads that cannot stamp anything (a cached body, a sent message, an already-read message) still succeed. The server-wide OFW_ALLOW_MARK_READ=false is a ceiling this argument cannot raise.').optional(),
@@ -890,7 +890,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
890
890
  }
891
891
  server.registerTool('ofw_list_drafts', {
892
892
  description: 'List draft messages, verified against OurFamilyWizard in ONE call: when the local drafts cache is not verified-fresh, a cheap drafts sync runs first by default (verify:true), so the answer is server-confirmed without a second call. Pass verify:false to answer purely from the cache (no OFW requests). Returns an explicit `complete` boolean describing the RESULT SET: true means "these are ALL the drafts on OurFamilyWizard as of freshness.asOf" — check it before saying "you have N drafts". Each draft carries its `draftKey` (stable across the create-then-delete churn of editing) when one is known. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY"); pass autoRefresh:true to sync and answer instead.',
893
- annotations: { readOnlyHint: false },
893
+ annotations: { readOnlyHint: false, destructiveHint: false },
894
894
  inputSchema: z.object({
895
895
  page: z.number().int().min(1).describe('Page number (default 1)').optional(),
896
896
  size: z.number().int().min(1).describe('Drafts per page (default 50)').optional(),
@@ -1008,7 +1008,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1008
1008
  if (allowDrafts)
1009
1009
  server.registerTool('ofw_save_draft', {
1010
1010
  description: 'Save a message as a draft in OurFamilyWizard. RECIPIENTS: OurFamilyWizard does NOT persist recipients on drafts — recipientIds are accepted but the saved draft comes back with none (documented OFW behavior, noted once in the response, not warned about; supply recipientIds at send time instead). IDENTITY: the response leads with `draftKey`, the stable identity that survives editing — key off it, because the `id` changes on EVERY edit (replacing a draft creates a NEW draft and deletes the old one; OFW\'s update-in-place endpoint silently no-ops, so we never use it). Pass messageId to replace an existing draft; the response.id will be the NEW id, and a transparency NOTE documents the swap and which fields were carried over. THREADING: if replyToId is provided, the cache may rewrite it to the latest reply in the thread (note included). The threading verdict is read from OFW\'s full echo (replyToId/inReplyTo/showContext) — a warning appears ONLY when the reply linkage was genuinely dropped or re-targeted, and the response\'s top-level replyToId/inReplyTo always agree with its listData. Attach files via myFileIDs (from ofw_upload_attachment). After saving, the tool re-fetches the draft from OFW, and the returned `revision` reflects that authoritative state (so it will match on your next edit). SAFETY: because replacing DESTROYS the old draft rather than merging, passing messageId first re-reads that draft from OFW and REFUSES the write if its subject/body/recipients changed since you read it (drafts edited in the OFW web app do not bump any timestamp, so the local cache can be silently behind). A pure replyToId normalization by OFW is NOT treated as a conflict. The refusal returns the current server body under serverBody — merge your edit into it and retry with expectedRevision.',
1011
- annotations: { readOnlyHint: false },
1011
+ annotations: { readOnlyHint: false, destructiveHint: false },
1012
1012
  inputSchema: z.object({
1013
1013
  subject: z.string().describe('Message subject'),
1014
1014
  body: z.string().describe('Message body text'),
@@ -1243,7 +1243,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1243
1243
  });
1244
1244
  server.registerTool('ofw_get_unread_sent', {
1245
1245
  description: 'List sent messages that have not been read by one or more recipients. Reads from local cache. Returns `complete` describing whether every sent message was scanned. An empty SENT cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as "nothing sent"; pass autoRefresh:true to sync and answer instead.',
1246
- annotations: { readOnlyHint: false },
1246
+ annotations: { readOnlyHint: true },
1247
1247
  inputSchema: z.object({
1248
1248
  page: z.number().int().min(1).describe('Page (default 1)').optional(),
1249
1249
  size: z.number().int().min(1).describe('Per page (default 50)').optional(),
@@ -1370,7 +1370,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1370
1370
  });
1371
1371
  server.registerTool('ofw_download_attachment', {
1372
1372
  description: 'Download an OFW message attachment by fileId and return content you can actually read. Inline delivery walks a ladder and returns the first rung that works: (1) host-renderable images (PNG/JPEG/GIF/WEBP) come back as ImageContent; (2) .xlsx/.csv/.tsv, .pdf, .docx, .pptx and text files come back as EXTRACTED CONTENT — per-sheet CSV, per-page/slide text, document text — in the response JSON under `extracted`; (3) anything else comes back as an EmbeddedResource blob of the raw bytes. The meta block names the rung as `deliveredVia` and, when it falls through to bytes, lists what was tried in `deliveryAttempts`. Reported mime types are always normalized to a bare media type (no charset/name parameters). In disk mode the bytes are saved to ~/Downloads/ofw-mcp/ and the response carries the absolute path; pass extract:true to ALSO get the extracted content in that response. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var. On a hosted deployment with no filesystem, disk mode is unavailable, so inline is forced (forcedInline:true) rather than failing — a saveTo path never costs you the content. 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).',
1373
- annotations: { readOnlyHint: false },
1373
+ annotations: { readOnlyHint: true },
1374
1374
  inputSchema: z.object({
1375
1375
  fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
1376
1376
  inline: z.boolean().describe('If true, return content inline as MCP content blocks and skip the disk write. If false, write to disk and return the path — except on a hosted deployment with no filesystem, where inline is forced (forcedInline:true) so the content is still returned. If omitted, falls back to the OFW_INLINE_ATTACHMENTS env var (default: false = disk).').optional(),
@@ -1477,7 +1477,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1477
1477
  });
1478
1478
  server.registerTool('ofw_sync_messages', {
1479
1479
  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. EVERY call re-checks the newest page first, so new messages are picked up promptly even while an old-history backfill is still running; only then does it spend what is left of its budget advancing that backfill. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps). Sync is BOUNDED and RESUMABLE: on hosted deployments a per-call OFW-request budget (env OFW_SYNC_MAX_REQUESTS, or the maxRequests argument) caps how far one call walks; when the budget is hit the response reports done:false with a note — call again with the SAME arguments to resume. done:false means older history is still being backfilled; it does NOT mean recent messages are missing. Local installs are unbounded by default (done is always true).',
1480
- annotations: { readOnlyHint: false },
1480
+ annotations: { readOnlyHint: false, destructiveHint: false },
1481
1481
  inputSchema: z.object({
1482
1482
  folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).min(1).describe('Folders to sync (default: all three). Must be non-empty if given — an empty list would sync nothing while reporting success.').optional(),
1483
1483
  fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages — which marks each one READ on OurFamilyWizard and stamps a co-parent-visible "First Viewed" time that cannot be undone. Defaults to the OFW_FETCH_UNREAD_BODIES env var (false unless set), and is forced off entirely when OFW_ALLOW_MARK_READ=false.').optional(),
@@ -1505,7 +1505,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1505
1505
  });
1506
1506
  server.registerTool('ofw_check_freshness', {
1507
1507
  description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state — especially "draft X is still sitting unsent". Costs one OFW request for the folder check plus one per messageId. For each folder it returns the live server count next to the cached count. For each id it returns a LIVE lifecycle `state` — "draft" | "sent" | "received" | "deleted" | "unknown" — alongside `folder`, `sentAt`, `existsOnServer` and a content comparison. `state` is the field that answers "is this still a draft?": a draft that has been SENT still exists on the server, so existsOnServer:true never distinguished the two. A cached draft whose state is no longer "draft" reports inSync:false even when its text is byte-identical. Content is compared by revision hash, because OFW draft timestamps do NOT change when a draft is edited in the web app. Does not fetch bodies into the cache, does not touch attachments, and does not depend on sync state. For draftKeys, or a full live draft inventory, use ofw_status.',
1508
- annotations: { readOnlyHint: false },
1508
+ annotations: { readOnlyHint: true },
1509
1509
  inputSchema: z.object({
1510
1510
  folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).min(1).describe('Folders to compare cached vs live counts for. Defaults to all three when messageIds is not given. Must be non-empty if given.').optional(),
1511
1511
  messageIds: z.array(z.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}). Ids cached as drafts, as sent messages, or as already-read inbox messages are probed freely — none of those can stamp the record. Anything else is skipped — see allowMarkRead.`).optional(),
@@ -1592,7 +1592,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1592
1592
  });
1593
1593
  server.registerTool('ofw_status', {
1594
1594
  description: 'ONE live call that answers "where does everything stand?". This is the call that should back any status summary about drafts or specific messages — never session memory, and never a cached read alone. With no arguments it returns the FULL current draft inventory, verified against OurFamilyWizard. Pass ids and/or draftKeys to get each one\'s live lifecycle `state` ("draft" | "sent" | "received" | "deleted" | "unknown") with `sentAt` and `viewedAt`. A draftKey is the stable identity ofw_save_draft returns: editing a draft mints a new OFW id every time (create-then-delete), so the key is the only way to ask "what happened to the thing I was working on?" — it resolves to the chain\'s current id and keeps resolving after the draft is SENT (state:"sent" with sentMessageId). The top-level `complete` is true ONLY when every part of this snapshot was verified live; if it is false, do not state a draft count or a lifecycle claim from this payload.',
1595
- annotations: { readOnlyHint: false },
1595
+ annotations: { readOnlyHint: true },
1596
1596
  inputSchema: z.object({
1597
1597
  ids: z.array(z.number()).describe(`Message/draft ids to resolve to a live state (combined with draftKeys, max ${MAX_FRESHNESS_IDS} probes per call).`).optional(),
1598
1598
  draftKeys: z.array(z.string()).describe('Stable draft keys (from ofw_save_draft / ofw_list_drafts) to resolve to their CURRENT id and state.').optional(),
@@ -9,7 +9,7 @@ export function registerUserTools(server, client) {
9
9
  });
10
10
  server.registerTool('ofw_get_notifications', {
11
11
  description: 'Get OurFamilyWizard dashboard summary: unread message count, upcoming events, outstanding expenses. Note: updates your last-seen status.',
12
- annotations: { readOnlyHint: false },
12
+ annotations: { readOnlyHint: true },
13
13
  }, async () => {
14
14
  const data = await client.request('GET', '/pub/v1/users/useraccountstatus');
15
15
  return jsonResponse(data);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.18.0",
3
+ "version": "2.19.1",
4
4
  "license": "MIT",
5
5
  "mcpName": "io.github.chrischall/ofw-mcp",
6
6
  "description": "OurFamilyWizard MCP server for Claude — developed and maintained by AI (Claude Code)",
@@ -34,10 +34,10 @@
34
34
  "typecheck": "tsc -p tsconfig.json --noEmit"
35
35
  },
36
36
  "dependencies": {
37
- "@chrischall/mcp-utils": "^1.0.0",
37
+ "@chrischall/mcp-utils": "^2.0.0",
38
38
  "@fetchproxy/bootstrap": "^3.0.1",
39
39
  "@modelcontextprotocol/server": "^2.0.0",
40
- "dotenv": "^17.4.2",
40
+ "dotenv": "^18.0.0",
41
41
  "zod": "^4.6.2"
42
42
  },
43
43
  "devDependencies": {
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/ofw-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.18.0",
9
+ "version": "2.19.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.18.0",
14
+ "version": "2.19.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },