ofw-mcp 2.16.2 → 2.17.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/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.16.2', // x-release-please-version
39
+ version: '2.17.0', // x-release-please-version
40
40
  deps: client,
41
41
  tools: [
42
42
  registerHealthcheckTools,
@@ -111,11 +111,11 @@ export function registerCalendarTools(server, client) {
111
111
  server.registerTool('ofw_list_events', {
112
112
  description: 'List OurFamilyWizard calendar events in a date range',
113
113
  annotations: { readOnlyHint: true },
114
- inputSchema: {
114
+ inputSchema: z.object({
115
115
  startDate: z.string().describe('Start date YYYY-MM-DD'),
116
116
  endDate: z.string().describe('End date YYYY-MM-DD'),
117
117
  detailed: z.boolean().describe('Return full event details (default false)').optional(),
118
- },
118
+ }),
119
119
  }, async (args) => {
120
120
  const variant = args.detailed ? 'detailed' : 'basic';
121
121
  const data = await client.request('GET', `/pub/v1/calendar/${variant}?startDate=${encodeURIComponent(args.startDate)}&endDate=${encodeURIComponent(args.endDate)}`);
@@ -125,10 +125,10 @@ export function registerCalendarTools(server, client) {
125
125
  server.registerTool('ofw_create_event', {
126
126
  description: 'Create a calendar event in OurFamilyWizard. Unless privateEvent is true, the event is immediately visible to the co-parent — there is no draft stage.',
127
127
  annotations: { destructiveHint: false },
128
- inputSchema: {
128
+ inputSchema: z.object({
129
129
  title: z.string(),
130
130
  ...eventWriteFields,
131
- },
131
+ }),
132
132
  }, async (args) => {
133
133
  const raw = await client.request('POST', '/pub/v3/events', buildEventPayload(args));
134
134
  const event = parseLenient(eventDetailSchema, raw, { label: 'ofw-mcp', context: 'POST /pub/v3/events', mode: 'strict' });
@@ -141,7 +141,7 @@ export function registerCalendarTools(server, client) {
141
141
  server.registerTool('ofw_update_event', {
142
142
  description: 'Update an existing OurFamilyWizard calendar event. Fetches the event, applies the given changes, and writes the merged result back (OFW has no partial update).',
143
143
  annotations: { destructiveHint: true },
144
- inputSchema: {
144
+ inputSchema: z.object({
145
145
  eventId: z.string().describe('Event id — the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
146
146
  title: z.string().optional(),
147
147
  startDate: eventWriteFields.startDate.optional(),
@@ -157,7 +157,7 @@ export function registerCalendarTools(server, client) {
157
157
  eventParentId: eventWriteFields.eventParentId,
158
158
  dropOffParentId: eventWriteFields.dropOffParentId,
159
159
  pickUpParentId: eventWriteFields.pickUpParentId,
160
- },
160
+ }),
161
161
  }, async (args) => {
162
162
  const { eventId, ...changes } = args;
163
163
  const id = encodeURIComponent(eventId);
@@ -175,10 +175,10 @@ export function registerCalendarTools(server, client) {
175
175
  server.registerTool('ofw_delete_event', {
176
176
  description: 'Delete an OurFamilyWizard calendar event',
177
177
  annotations: { destructiveHint: true },
178
- inputSchema: {
178
+ inputSchema: z.object({
179
179
  eventId: z.string().describe('Event id — the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
180
180
  includeFuture: z.boolean().describe('For repeating events: also delete future occurrences (default false)').optional(),
181
- },
181
+ }),
182
182
  }, async (args) => {
183
183
  const includeFuture = args.includeFuture ?? false;
184
184
  await client.request('DELETE', `/pub/v3/events/${encodeURIComponent(args.eventId)}?includeFuture=${includeFuture}`);
@@ -15,10 +15,10 @@ export function registerExpenseTools(server, client) {
15
15
  server.registerTool('ofw_list_expenses', {
16
16
  description: 'List OurFamilyWizard expenses. Offset-paged via start/max. The response leads with its paging state — `hasMore` and `nextStart` (null when the list is exhausted) — BEFORE the records, so a truncated or partially-read response still says whether more remain. Never state an expense total or an absence from one page.',
17
17
  annotations: { readOnlyHint: true },
18
- inputSchema: {
18
+ inputSchema: z.object({
19
19
  start: z.number().int().min(0).describe('Start offset, 0-based (default 0). To continue a listing, pass the `nextStart` from the previous response.').optional(),
20
20
  max: z.number().int().min(1).describe('Max results (default 20)').optional(),
21
- },
21
+ }),
22
22
  }, async (args) => {
23
23
  const start = args.start ?? 0;
24
24
  const max = args.max ?? 20;
@@ -46,10 +46,10 @@ export function registerExpenseTools(server, client) {
46
46
  server.registerTool('ofw_create_expense', {
47
47
  description: 'Log a new expense in OurFamilyWizard',
48
48
  annotations: { destructiveHint: false },
49
- inputSchema: {
49
+ inputSchema: z.object({
50
50
  amount: z.number().describe('Expense amount'),
51
51
  description: z.string().describe('Expense description'),
52
- },
52
+ }),
53
53
  }, async (args) => {
54
54
  const data = await client.request('POST', '/pub/v2/expense/expenses', args);
55
55
  return jsonResponse(data);
@@ -8,10 +8,10 @@ export function registerJournalTools(server, client) {
8
8
  server.registerTool('ofw_list_journal_entries', {
9
9
  description: 'List OurFamilyWizard journal entries. Offset-paged via start/max (1-based). The response leads with its paging state — `hasMore` and `nextStart` (null when the list is exhausted) — BEFORE the records, so a truncated or partially-read response still says whether more remain. Never state an entry count or an absence from one page.',
10
10
  annotations: { readOnlyHint: true },
11
- inputSchema: {
11
+ inputSchema: z.object({
12
12
  start: z.number().int().min(1).describe('Start offset, 1-based (default 1). To continue a listing, pass the `nextStart` from the previous response.').optional(),
13
13
  max: z.number().int().min(1).describe('Max results (default 10)').optional(),
14
- },
14
+ }),
15
15
  }, async (args) => {
16
16
  // Journal API uses 1-based offset (unlike expenses which start at 0)
17
17
  const start = args.start ?? 1;
@@ -40,10 +40,10 @@ export function registerJournalTools(server, client) {
40
40
  server.registerTool('ofw_create_journal_entry', {
41
41
  description: 'Create a new journal entry in OurFamilyWizard',
42
42
  annotations: { destructiveHint: false },
43
- inputSchema: {
43
+ inputSchema: z.object({
44
44
  title: z.string().describe('Entry title'),
45
45
  body: z.string().describe('Entry text content'),
46
- },
46
+ }),
47
47
  }, async (args) => {
48
48
  const data = await client.request('POST', '/pub/v1/journals', args);
49
49
  return jsonResponse(data);
@@ -259,7 +259,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
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
261
  annotations: { readOnlyHint: false },
262
- inputSchema: {
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(),
265
265
  size: z.number().int().min(1).describe('Messages per page (default 50)').optional(),
@@ -271,7 +271,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
271
271
  view: viewParam(MESSAGE_VIEWS, {
272
272
  note: 'compact omits OurFamilyWizard\'s raw `listData` echo, which duplicates this record\'s own id, subject, sentAt, recipients and read flag; the sender is promoted to `from`, and `files`/`replied` are kept. Pass "full" for the echo.',
273
273
  }),
274
- },
274
+ }),
275
275
  }, async (args) => {
276
276
  const page = args.page ?? 1;
277
277
  const size = args.size ?? 50;
@@ -388,13 +388,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
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
390
  annotations: { readOnlyHint: false },
391
- inputSchema: {
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(),
394
394
  view: viewParam(MESSAGE_VIEWS, {
395
395
  note: 'compact omits OurFamilyWizard\'s raw `listData` echo, which duplicates this record\'s own id, subject, sentAt, recipients and read flag; the sender is promoted to `from`, and `files`/`replied` are kept. Pass "full" for the echo.',
396
396
  }),
397
- },
397
+ }),
398
398
  }, async (args) => {
399
399
  const id = Number(args.messageId);
400
400
  const view = resolveView(args.view, MESSAGE_VIEWS);
@@ -553,7 +553,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
553
553
  server.registerTool('ofw_send_message', {
554
554
  description: 'Send a message via OurFamilyWizard — the ONE irreversible operation here, so it carries the strongest guard. TO SEND AN EXISTING DRAFT (the safe default): pass draftId (or messageId — same thing). The tool re-reads the draft from OFW and sends the SERVER\'S version, so what goes out is what is on OurFamilyWizard, not what this session remembers — subject/body act only as explicit overrides. It is guarded exactly like ofw_save_draft: pass expectedRevision to assert which version you are sending; if the draft changed on OFW since you read it — or no longer exists (it may already have been SENT) — the send is REFUSED with the current server content echoed back, and nothing goes out. RECIPIENTS: OurFamilyWizard does not persist recipients on drafts, so recipientIds is usually still required at send time (ids from ofw_get_profile). After the send is CONFIRMED (OFW returned the new message id and the re-fetched sent record matches what was posted), the source draft is deleted automatically; pass deleteDraftOnSuccess:false to keep it. On ANY failure or ambiguity the draft is never deleted — the response carries draftRetained:true with the reason. TO COMPOSE FROM SCRATCH: supply subject/body/recipientIds with no draftId. If replyToId is provided (or inherited from the draft), the cache may rewrite it to the latest reply in the same thread (a note is included when this happens). ATTACHMENTS: when sending by draftId, the server draft\'s own attachments carry over automatically; myFileIDs (from ofw_upload_attachment) overrides or attaches files on a fresh compose. The response leads with sentMessageId and the stable draftKey, and reports threaded (whether OFW actually linked the reply) and draftDeleted.',
555
555
  annotations: { destructiveHint: true },
556
- inputSchema: {
556
+ inputSchema: z.object({
557
557
  subject: z.string().describe('Message subject. Required unless draftId/messageId is given (then it overrides the server draft\'s subject).').optional(),
558
558
  body: z.string().describe('Message body text. Required unless draftId/messageId is given (then it overrides the server draft\'s body — omit it to send exactly what is on OurFamilyWizard).').optional(),
559
559
  recipientIds: z.array(z.number()).describe('Array of recipient user IDs (get from ofw_get_profile). Usually required even when sending a draft: OurFamilyWizard does not persist recipients on drafts.').optional(),
@@ -564,7 +564,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
564
564
  deleteDraftOnSuccess: z.boolean().describe('Default true. Delete the source draft after — and ONLY after — the send is confirmed (new message id returned and the re-fetched sent record checks out). Set false to keep the draft. On a failed or unverifiable send the draft is ALWAYS kept, regardless of this flag.').optional(),
565
565
  force: z.boolean().describe('Default false. Send even when the draft changed on OurFamilyWizard since you read it, or its current state could not be read. Only use after showing the user the conflict.').optional(),
566
566
  myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment) to attach to the message. When sending by draftId, omit it to carry the server draft\'s own attachments over; passing it overrides them.').optional(),
567
- },
567
+ }),
568
568
  }, async (args) => {
569
569
  if (args.messageId !== undefined && args.draftId !== undefined && args.messageId !== args.draftId) {
570
570
  throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
@@ -891,7 +891,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
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
893
  annotations: { readOnlyHint: false },
894
- inputSchema: {
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(),
897
897
  verify: z.boolean().describe('Default true: when the drafts cache is not verified-fresh, run a drafts sync first (cheap — one list page plus one detail per draft) so the response is server-confirmed in one call. Set false to serve straight from the local cache with no OFW requests.').optional(),
@@ -899,7 +899,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
899
899
  view: viewParam(MESSAGE_VIEWS, {
900
900
  note: 'compact omits OurFamilyWizard\'s raw `listData` echo, which duplicates this draft\'s own id, subject, modifiedAt and recipients. `revision`, `draftKey` and `cacheStatus` are kept on both rungs.',
901
901
  }),
902
- },
902
+ }),
903
903
  }, async (args) => {
904
904
  const page = args.page ?? 1;
905
905
  const size = args.size ?? 50;
@@ -1009,7 +1009,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
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
1011
  annotations: { readOnlyHint: false },
1012
- inputSchema: {
1012
+ inputSchema: z.object({
1013
1013
  subject: z.string().describe('Message subject'),
1014
1014
  body: z.string().describe('Message body text'),
1015
1015
  recipientIds: z.array(z.number()).describe('Array of recipient user IDs (optional for drafts)').optional(),
@@ -1018,7 +1018,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1018
1018
  myFileIDs: z.array(z.number()).describe('Attachment file ids (from ofw_upload_attachment)').optional(),
1019
1019
  expectedRevision: z.string().describe('With messageId: the `revision` you got from ofw_list_drafts/ofw_get_message for that draft. Asserts you are replacing THAT version. If the draft changed on OFW since, the write is refused and the current server body is returned. Omit and the tool compares the server against the local cache instead — omitting never means "overwrite anyway".').optional(),
1020
1020
  force: z.boolean().describe('Default false. Overwrite even when the draft changed on OurFamilyWizard since you read it. The discarded server version is echoed back in the response. Only use after showing the user the conflict.').optional(),
1021
- },
1021
+ }),
1022
1022
  }, async (args) => {
1023
1023
  const cache = cacheProvider();
1024
1024
  // Guard BEFORE the POST: refusing after creating a replacement would leave
@@ -1220,11 +1220,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1220
1220
  server.registerTool('ofw_delete_draft', {
1221
1221
  description: 'Delete a draft message from OurFamilyWizard. Also removes the draft from the local cache. Before deleting, the draft is re-read from OFW and the delete is REFUSED if it changed since you last read it (the current server body is returned so nothing is lost) — pass expectedRevision to assert which version you mean, or force:true to delete regardless.',
1222
1222
  annotations: { destructiveHint: true },
1223
- inputSchema: {
1223
+ inputSchema: z.object({
1224
1224
  messageId: z.number().describe('Draft message ID to delete'),
1225
1225
  expectedRevision: z.string().describe('The `revision` you got from ofw_list_drafts/ofw_get_message. Asserts you are deleting THAT version; if the draft changed on OFW since, the delete is refused and the current server body returned.').optional(),
1226
1226
  force: z.boolean().describe('Default false. Delete even if the draft changed on OurFamilyWizard since you read it. The discarded server version is echoed back in the response.').optional(),
1227
- },
1227
+ }),
1228
1228
  }, async (args) => {
1229
1229
  const cache = cacheProvider();
1230
1230
  const guard = await guardDestructiveDraftOp({
@@ -1244,7 +1244,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
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
1246
  annotations: { readOnlyHint: false },
1247
- inputSchema: {
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(),
1250
1250
  autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
@@ -1253,7 +1253,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1253
1253
  // already narrower than anything the compact projection would produce.
1254
1254
  // A parameter offering a rung that changes nothing is the no-op schema
1255
1255
  // `viewParam` exists to refuse.
1256
- },
1256
+ }),
1257
1257
  }, async (args) => {
1258
1258
  const page = args.page ?? 1;
1259
1259
  const size = args.size ?? 50;
@@ -1328,12 +1328,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1328
1328
  server.registerTool('ofw_upload_attachment', {
1329
1329
  description: 'Upload a local file to OurFamilyWizard\'s "My Files" so it can be attached to a message. Returns the fileId — 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.',
1330
1330
  annotations: { destructiveHint: false },
1331
- inputSchema: {
1331
+ inputSchema: z.object({
1332
1332
  path: z.string().describe('Absolute path to the local file to upload. Tilde (~) is expanded.'),
1333
1333
  shareClass: z.enum(['PRIVATE', 'SHARED']).describe('Share class (default PRIVATE)').optional(),
1334
1334
  label: z.string().describe('Display label for the file in OFW (default: filename)').optional(),
1335
1335
  description: z.string().describe('Description shown in OFW My Files (default: filename)').optional(),
1336
- },
1336
+ }),
1337
1337
  }, async (args) => {
1338
1338
  // Resolve the upload source through the injected attachment-I/O boundary
1339
1339
  // (disk read on node; an in-memory source on a hosted deployment).
@@ -1371,7 +1371,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
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
1373
  annotations: { readOnlyHint: false },
1374
- inputSchema: {
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(),
1377
1377
  saveTo: z.string().describe('Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/Downloads/ofw-mcp/<fileId>-<filename>. Ignored when inline is in effect.').optional(),
@@ -1379,7 +1379,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1379
1379
  extract: z.boolean().describe('Whether to extract readable content from the file. Default: on for inline delivery of any non-image type, off in disk mode. Set false to get the raw bytes inline instead of extracted text (e.g. to hash or re-upload the file); set true in disk mode to get both the saved path and the extracted content.').optional(),
1380
1380
  maxChars: z.number().int().min(500).max(500_000).describe('Ceiling on extracted characters (default 50000). Over it, content is clipped on a row/line boundary, `truncated` is set, and anything dropped whole is listed in `extracted.omitted`.').optional(),
1381
1381
  parts: z.string().describe('Which sheets / slides / pages to extract, e.g. "1-3,5" (1-based positions) or a sheet name like "2026". A bare number matches either a position or a name. Omit for everything. Unselected parts are listed in `extracted.omitted`.').optional(),
1382
- },
1382
+ }),
1383
1383
  }, async (args) => {
1384
1384
  const fileId = args.fileId;
1385
1385
  const cache = cacheProvider();
@@ -1478,12 +1478,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
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
1480
  annotations: { readOnlyHint: false },
1481
- inputSchema: {
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(),
1484
1484
  deep: z.boolean().describe('If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.').optional(),
1485
1485
  maxRequests: z.number().int().min(1).describe('Maximum OFW requests this single call may make before pausing. When hit, the response reports done:false — call again with the same arguments to continue. Omit to use the server default (OFW_SYNC_MAX_REQUESTS, or unbounded on local installs).').optional(),
1486
- },
1486
+ }),
1487
1487
  }, async (args) => {
1488
1488
  const cache = cacheProvider();
1489
1489
  const result = await syncAll(client, {
@@ -1506,11 +1506,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
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
1508
  annotations: { readOnlyHint: false },
1509
- inputSchema: {
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(),
1512
1512
  allowMarkRead: z.boolean().describe('Default false. Probing an id whose cached state cannot rule out an unread INBOX message requires fetching its detail, which marks it READ on OurFamilyWizard and stamps a co-parent-visible "First Viewed" time — irreversible. Such ids are skipped (reason:"WOULD_MARK_READ") unless you set this to true. The server-wide OFW_ALLOW_MARK_READ=false is a ceiling this cannot raise.').optional(),
1513
- },
1513
+ }),
1514
1514
  }, async (args) => {
1515
1515
  const cache = cacheProvider();
1516
1516
  // The server-wide ceiling wins: a per-call allowMarkRead:true (or an
@@ -1593,12 +1593,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
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
1595
  annotations: { readOnlyHint: false },
1596
- inputSchema: {
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(),
1599
1599
  includeDraftInventory: z.boolean().describe('Return the full current draft list, verified against OurFamilyWizard first. Defaults to TRUE when neither ids nor draftKeys is given (so a bare ofw_status() is a complete status snapshot), otherwise false.').optional(),
1600
1600
  allowMarkRead: z.boolean().describe('Default false. An id whose cached state cannot rule out an unread INBOX message can only be probed by fetching its detail, which marks it READ on OurFamilyWizard — irreversible and co-parent-visible. Those are skipped unless this is true. Cached drafts, sent messages and already-read messages are always probed. Capped by OFW_ALLOW_MARK_READ.').optional(),
1601
- },
1601
+ }),
1602
1602
  }, async (args) => {
1603
1603
  const cache = cacheProvider();
1604
1604
  const allowMarkRead = getAllowMarkRead() && (args.allowMarkRead ?? false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.16.2",
3
+ "version": "2.17.0",
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,13 +34,14 @@
34
34
  "typecheck": "tsc -p tsconfig.json --noEmit"
35
35
  },
36
36
  "dependencies": {
37
- "@chrischall/mcp-utils": "^0.27.1",
38
- "@fetchproxy/bootstrap": "^2.2.0",
39
- "@modelcontextprotocol/sdk": "^1.30.0",
37
+ "@chrischall/mcp-utils": "^0.28.0",
38
+ "@fetchproxy/bootstrap": "^3.0.1",
39
+ "@modelcontextprotocol/server": "^2.0.0",
40
40
  "dotenv": "^17.4.2",
41
- "zod": "^4.5.4"
41
+ "zod": "^4.6.2"
42
42
  },
43
43
  "devDependencies": {
44
+ "@modelcontextprotocol/client": "^2.0.0",
44
45
  "@types/node": "^26.0.0",
45
46
  "@vitest/coverage-v8": "^5.0.0",
46
47
  "esbuild": "^0.28.0",
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.16.2",
9
+ "version": "2.17.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.16.2",
14
+ "version": "2.17.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },