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/sync.js CHANGED
@@ -1,11 +1,28 @@
1
1
  import { setMeta, upsertMessage, getMessage, deleteMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
2
- import { mapRecipients } from './tools/_shared.js';
2
+ import { z } from 'zod';
3
+ import { ApiRecipientSchema, mapRecipients } from './tools/_shared.js';
4
+ import { parseOFW } from './validate.js';
5
+ // Each OFW message detail returns `files: [fileId, ...]`. We fetch the metadata
6
+ // for each file id (cheap JSON call) so the model can see filenames/mime types
7
+ // without downloading bytes. Bytes are pulled lazily by ofw_download_attachment.
8
+ // All sync-path schemas are validated LENIENT (issue #83): a mismatch logs a
9
+ // structured warning to stderr and the raw response flows on through the
10
+ // existing `??` fallbacks — a small OFW backend change degrades gracefully
11
+ // instead of bricking sync, but no longer silently. Loose objects keep
12
+ // unknown keys, so cached `metadata`/`listData` blobs stay verbatim.
13
+ const FileMetaSchema = z.looseObject({
14
+ fileId: z.number(),
15
+ label: z.string().optional(),
16
+ fileName: z.string().optional(),
17
+ fileType: z.string().optional(), // MIME
18
+ fileSize: z.number().optional(),
19
+ });
3
20
  // Fetches OFW attachment metadata for one file id and writes it to the cache.
4
21
  // Throws on network/HTTP errors — callers in bulk-sync paths wrap this in the
5
22
  // best-effort helper below; callers that need the result (download tool) let
6
23
  // the throw propagate.
7
24
  export async function fetchAttachmentMeta(client, fileId, messageId) {
8
- const meta = await client.request('GET', `/pub/v1/myfiles/${fileId}`);
25
+ const meta = parseOFW(FileMetaSchema, await client.request('GET', `/pub/v1/myfiles/${fileId}`), 'GET /pub/v1/myfiles/{fileId}');
9
26
  upsertAttachmentForMessage({
10
27
  fileId: meta.fileId ?? fileId,
11
28
  fileName: meta.fileName ?? `file-${fileId}`,
@@ -23,8 +40,11 @@ export async function fetchAttachmentMetaForMessage(client, messageId, fileIds)
23
40
  // attachment doesn't break the surrounding sync.
24
41
  await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client, fid, messageId)));
25
42
  }
43
+ const FoldersSchema = z.looseObject({
44
+ systemFolders: z.array(z.looseObject({ id: z.string(), folderType: z.string() })).optional(),
45
+ });
26
46
  export async function resolveFolderIds(client) {
27
- const data = await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true');
47
+ const data = parseOFW(FoldersSchema, await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true'), 'GET /pub/v1/messageFolders');
28
48
  const sys = data.systemFolders ?? [];
29
49
  const find = (type) => {
30
50
  const f = sys.find((x) => x.folderType === type);
@@ -40,6 +60,22 @@ export async function resolveFolderIds(client) {
40
60
  setMeta('drafts_folder_id', ids.drafts);
41
61
  return ids;
42
62
  }
63
+ // Required fields are the ones the sync loop reads unguarded (id keys the
64
+ // cache; showNeverViewed drives unread semantics — per CLAUDE.md it's the
65
+ // only reliable unread indicator, so its disappearance must warn loudly).
66
+ const ListItemSchema = z.looseObject({
67
+ id: z.number(),
68
+ subject: z.string(),
69
+ date: z.looseObject({ dateTime: z.string() }),
70
+ from: z.looseObject({ name: z.string().optional() }).optional(),
71
+ showNeverViewed: z.boolean(),
72
+ recipients: z.array(ApiRecipientSchema).optional(),
73
+ });
74
+ const ListResponseSchema = z.looseObject({ data: z.array(ListItemSchema).optional() });
75
+ const DetailResponseSchema = z.looseObject({
76
+ body: z.string().optional(),
77
+ files: z.array(z.number()).optional(),
78
+ });
43
79
  export async function syncMessageFolder(client, folder, folderId, opts) {
44
80
  let page = 1;
45
81
  let synced = 0;
@@ -47,7 +83,7 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
47
83
  const unread = [];
48
84
  while (true) {
49
85
  const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
50
- const list = await client.request('GET', path);
86
+ const list = parseOFW(ListResponseSchema, await client.request('GET', path), `GET /pub/v3/messages?folders={${folder}}`);
51
87
  const items = list.data ?? [];
52
88
  if (items.length === 0)
53
89
  break;
@@ -65,7 +101,7 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
65
101
  let fetchedBodyAt = null;
66
102
  let detailFileIds = [];
67
103
  if (shouldFetchBody) {
68
- const detail = await client.request('GET', `/pub/v3/messages/${item.id}`);
104
+ const detail = parseOFW(DetailResponseSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), 'GET /pub/v3/messages/{id} (sync)');
69
105
  body = detail.body ?? '';
70
106
  fetchedBodyAt = new Date().toISOString();
71
107
  if (Array.isArray(detail.files) && detail.files.length > 0) {
@@ -114,10 +150,33 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
114
150
  });
115
151
  return { synced, unread };
116
152
  }
153
+ const DraftListItemSchema = z.looseObject({
154
+ id: z.number(),
155
+ subject: z.string(),
156
+ date: z.looseObject({ dateTime: z.string() }),
157
+ replyToId: z.number().nullable().optional(),
158
+ recipients: z.array(ApiRecipientSchema).optional(),
159
+ });
160
+ const DraftListResponseSchema = z.looseObject({ data: z.array(DraftListItemSchema).optional() });
161
+ const DraftDetailSchema = z.looseObject({
162
+ body: z.string().optional(),
163
+ subject: z.string().optional(),
164
+ });
117
165
  export async function syncDrafts(client, draftsFolderId) {
118
- const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=1&size=50&sort=date&sortDirection=desc`;
119
- const list = await client.request('GET', path);
120
- const items = list.data ?? [];
166
+ // Walk every page. The reconciliation loop at the bottom deletes any
167
+ // cached draft that wasn't seen in the listing, so a partial walk would
168
+ // wrongly evict real drafts beyond the first page.
169
+ const items = [];
170
+ let page = 1;
171
+ while (true) {
172
+ const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
173
+ const list = parseOFW(DraftListResponseSchema, await client.request('GET', path), 'GET /pub/v3/messages?folders={drafts}');
174
+ const pageItems = list.data ?? [];
175
+ items.push(...pageItems);
176
+ if (pageItems.length < 50)
177
+ break;
178
+ page++;
179
+ }
121
180
  const seenIds = new Set();
122
181
  let synced = 0;
123
182
  for (const item of items) {
@@ -127,7 +186,7 @@ export async function syncDrafts(client, draftsFolderId) {
127
186
  // timestamp for drafts — direct UI edits don't bump it — so we can't
128
187
  // use it to skip the detail fetch. Always re-fetch; drafts are few.
129
188
  const existing = getDraft(item.id);
130
- const detail = await client.request('GET', `/pub/v3/messages/${item.id}`);
189
+ const detail = parseOFW(DraftDetailSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), 'GET /pub/v3/messages/{id} (drafts sync)');
131
190
  const row = {
132
191
  id: item.id,
133
192
  subject: detail.subject ?? item.subject ?? '(no subject)',
@@ -1,9 +1,18 @@
1
1
  import { expandPath as expandPathUtil, rawTextResult, textResult } from '@chrischall/mcp-utils';
2
+ import { z } from 'zod';
3
+ import { parseOFW } from '../validate.js';
2
4
  // Pretty-printed JSON tool result. Thin wrapper over @chrischall/mcp-utils'
3
5
  // `textResult` so the rest of the codebase keeps the local name.
4
6
  export const jsonResponse = textResult;
5
7
  // Raw-string tool result. Wrapper over @chrischall/mcp-utils' `rawTextResult`.
6
8
  export const textResponse = rawTextResult;
9
+ // OFW API shape for `recipients[]` on message/draft list and detail
10
+ // responses. Used wherever we validate the response of a `/pub/v3/messages*`
11
+ // call. Loose: unknown keys pass through (and survive into cached listData).
12
+ export const ApiRecipientSchema = z.looseObject({
13
+ user: z.looseObject({ id: z.number().optional(), name: z.string().optional() }).optional(),
14
+ viewed: z.looseObject({ dateTime: z.string() }).nullable().optional(),
15
+ });
7
16
  // Translates OFW API recipient shape into the cache's normalized Recipient.
8
17
  // Used wherever we surface or persist recipients (sync, get_message, send,
9
18
  // save_draft) — all five call sites had near-identical inline mappings.
@@ -17,6 +26,37 @@ export function mapRecipients(items) {
17
26
  // Expand a user-provided path: ~ → home, relative → absolute. Re-exports
18
27
  // @chrischall/mcp-utils' `expandPath`.
19
28
  export const expandPath = expandPathUtil;
29
+ /**
30
+ * Best-effort check that OFW actually persisted what we posted. OFW's
31
+ * draft-update path is known to silently no-op while echoing success in the
32
+ * POST response, so callers re-GET the detail and compare it to what was
33
+ * sent. Containment (not equality) because OFW legitimately transforms
34
+ * content — replies get the original message appended to the body
35
+ * (includeOriginal) and may get a subject prefix. Returns a WARNING string
36
+ * when the persisted content can't be confirmed to contain what was sent,
37
+ * else null.
38
+ */
39
+ export function verifyWriteLanded(kind, sent, persisted) {
40
+ const mismatches = [];
41
+ if (typeof persisted.subject !== 'string' || !persisted.subject.includes(sent.subject)) {
42
+ mismatches.push('subject');
43
+ }
44
+ if (typeof persisted.body !== 'string' || !persisted.body.includes(sent.body)) {
45
+ mismatches.push('body');
46
+ }
47
+ if (mismatches.length === 0)
48
+ return null;
49
+ return `WARNING: the ${kind} re-fetched from OFW does not contain the ${mismatches.join(' and ')} that was posted — OFW may have silently dropped or altered the write. Verify the ${kind} on ourfamilywizard.com before relying on it.`;
50
+ }
51
+ // POST /pub/v3/messages response: minimal, `{entityId: <id>}` or legacy
52
+ // `{id: <id>}`, sometimes an empty body (→ null). Validated STRICT: a
53
+ // mistyped id (e.g. entityId as a string) must throw rather than silently
54
+ // degrade into the "unconfirmed send" path when the write actually landed.
55
+ // Absence of both ids stays legal — callers handle it with a WARNING.
56
+ const PostMessagesResponseSchema = z.looseObject({
57
+ id: z.number().optional(),
58
+ entityId: z.number().optional(),
59
+ }).nullable();
20
60
  /**
21
61
  * POST a payload to /pub/v3/messages, then immediately GET the detail
22
62
  * endpoint for the resulting message id. This is the only correct way to
@@ -29,18 +69,22 @@ export const expandPath = expandPathUtil;
29
69
  * when the server silently no-ops, so the GET is also how we verify
30
70
  * the write landed (callers compare detail.body to args.body).
31
71
  *
72
+ * Both responses are validated STRICT against `detailSchema` / the POST
73
+ * schema (this is the write-verification boundary — issue #83); `ctx`
74
+ * names the calling tool in the error message.
75
+ *
32
76
  * Returns a discriminated union so callers can narrow with
33
77
  * `if (result.id !== null)`. When id is null (no id field in the
34
78
  * response — never observed in production, but defensive), `raw`
35
79
  * carries the POST response so the caller can still surface it.
36
80
  */
37
- export async function postMessageAndRefetch(client, payload) {
38
- const raw = await client.request('POST', '/pub/v3/messages', payload);
81
+ export async function postMessageAndRefetch(client, payload, detailSchema, ctx) {
82
+ const raw = parseOFW(PostMessagesResponseSchema, await client.request('POST', '/pub/v3/messages', payload), `POST /pub/v3/messages (${ctx})`, 'strict');
39
83
  const id = typeof raw?.id === 'number' ? raw.id
40
84
  : typeof raw?.entityId === 'number' ? raw.entityId
41
85
  : null;
42
86
  if (id === null)
43
87
  return { id: null, detail: null, raw };
44
- const detail = await client.request('GET', `/pub/v3/messages/${id}`);
88
+ const detail = parseOFW(detailSchema, await client.request('GET', `/pub/v3/messages/${id}`), `GET /pub/v3/messages/{id} (${ctx})`, 'strict');
45
89
  return { id, detail, raw };
46
90
  }
@@ -1,6 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { jsonResponse, textResponse } from './_shared.js';
3
+ import { getWriteMode } from '../config.js';
3
4
  export function registerCalendarTools(server, client) {
5
+ // Calendar writes land on the court-visible record — OFW_WRITE_MODE 'all' only.
6
+ const allowWrites = getWriteMode() === 'all';
4
7
  server.registerTool('ofw_list_events', {
5
8
  description: 'List OurFamilyWizard calendar events in a date range',
6
9
  annotations: { readOnlyHint: true },
@@ -14,52 +17,55 @@ export function registerCalendarTools(server, client) {
14
17
  const data = await client.request('GET', `/pub/v1/calendar/${variant}?startDate=${encodeURIComponent(args.startDate)}&endDate=${encodeURIComponent(args.endDate)}`);
15
18
  return jsonResponse(data);
16
19
  });
17
- server.registerTool('ofw_create_event', {
18
- description: 'Create a calendar event in OurFamilyWizard',
19
- annotations: { destructiveHint: false },
20
- inputSchema: {
21
- title: z.string(),
22
- startDate: z.string().describe('ISO datetime string'),
23
- endDate: z.string().describe('ISO datetime string'),
24
- allDay: z.boolean().optional(),
25
- location: z.string().optional(),
26
- reminder: z.string().describe('Reminder setting (e.g. "1 hour before")').optional(),
27
- privateEvent: z.boolean().optional(),
28
- eventFor: z.string().describe('neither | parent1 | parent2').optional(),
29
- dropOffParent: z.string().optional(),
30
- pickUpParent: z.string().optional(),
31
- children: z.array(z.number()).describe('Array of child IDs').optional(),
32
- },
33
- }, async (args) => {
34
- const data = await client.request('POST', '/pub/v1/calendar/events', args);
35
- return jsonResponse(data);
36
- });
37
- server.registerTool('ofw_update_event', {
38
- description: 'Update an existing OurFamilyWizard calendar event',
39
- annotations: { destructiveHint: true },
40
- inputSchema: {
41
- eventId: z.string(),
42
- title: z.string().optional(),
43
- startDate: z.string().optional(),
44
- endDate: z.string().optional(),
45
- allDay: z.boolean().optional(),
46
- location: z.string().optional(),
47
- reminder: z.string().optional(),
48
- privateEvent: z.boolean().optional(),
49
- },
50
- }, async (args) => {
51
- const { eventId, ...updateData } = args;
52
- const data = await client.request('PUT', `/pub/v1/calendar/events/${encodeURIComponent(eventId)}`, updateData);
53
- return jsonResponse(data);
54
- });
55
- server.registerTool('ofw_delete_event', {
56
- description: 'Delete an OurFamilyWizard calendar event',
57
- annotations: { destructiveHint: true },
58
- inputSchema: {
59
- eventId: z.string().describe('Event ID to delete'),
60
- },
61
- }, async (args) => {
62
- await client.request('DELETE', `/pub/v1/calendar/events/${encodeURIComponent(args.eventId)}`);
63
- return textResponse(`Event ${args.eventId} deleted`);
64
- });
20
+ if (allowWrites)
21
+ server.registerTool('ofw_create_event', {
22
+ description: 'Create a calendar event in OurFamilyWizard',
23
+ annotations: { destructiveHint: false },
24
+ inputSchema: {
25
+ title: z.string(),
26
+ startDate: z.string().describe('ISO datetime string'),
27
+ endDate: z.string().describe('ISO datetime string'),
28
+ allDay: z.boolean().optional(),
29
+ location: z.string().optional(),
30
+ reminder: z.string().describe('Reminder setting (e.g. "1 hour before")').optional(),
31
+ privateEvent: z.boolean().optional(),
32
+ eventFor: z.string().describe('neither | parent1 | parent2').optional(),
33
+ dropOffParent: z.string().optional(),
34
+ pickUpParent: z.string().optional(),
35
+ children: z.array(z.number()).describe('Array of child IDs').optional(),
36
+ },
37
+ }, async (args) => {
38
+ const data = await client.request('POST', '/pub/v1/calendar/events', args);
39
+ return jsonResponse(data);
40
+ });
41
+ if (allowWrites)
42
+ server.registerTool('ofw_update_event', {
43
+ description: 'Update an existing OurFamilyWizard calendar event',
44
+ annotations: { destructiveHint: true },
45
+ inputSchema: {
46
+ eventId: z.string(),
47
+ title: z.string().optional(),
48
+ startDate: z.string().optional(),
49
+ endDate: z.string().optional(),
50
+ allDay: z.boolean().optional(),
51
+ location: z.string().optional(),
52
+ reminder: z.string().optional(),
53
+ privateEvent: z.boolean().optional(),
54
+ },
55
+ }, async (args) => {
56
+ const { eventId, ...updateData } = args;
57
+ const data = await client.request('PUT', `/pub/v1/calendar/events/${encodeURIComponent(eventId)}`, updateData);
58
+ return jsonResponse(data);
59
+ });
60
+ if (allowWrites)
61
+ server.registerTool('ofw_delete_event', {
62
+ description: 'Delete an OurFamilyWizard calendar event',
63
+ annotations: { destructiveHint: true },
64
+ inputSchema: {
65
+ eventId: z.string().describe('Event ID to delete'),
66
+ },
67
+ }, async (args) => {
68
+ await client.request('DELETE', `/pub/v1/calendar/events/${encodeURIComponent(args.eventId)}`);
69
+ return textResponse(`Event ${args.eventId} deleted`);
70
+ });
65
71
  }
@@ -1,6 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { jsonResponse } from './_shared.js';
3
+ import { getWriteMode } from '../config.js';
3
4
  export function registerExpenseTools(server, client) {
5
+ // Expense writes land on the court-visible record — OFW_WRITE_MODE 'all' only.
6
+ const allowWrites = getWriteMode() === 'all';
4
7
  server.registerTool('ofw_get_expense_totals', {
5
8
  description: 'Get OurFamilyWizard expense summary totals (owed/paid)',
6
9
  annotations: { readOnlyHint: true },
@@ -12,8 +15,8 @@ export function registerExpenseTools(server, client) {
12
15
  description: 'List OurFamilyWizard expenses with pagination',
13
16
  annotations: { readOnlyHint: true },
14
17
  inputSchema: {
15
- start: z.number().describe('Start offset (default 0)').optional(),
16
- max: z.number().describe('Max results (default 20)').optional(),
18
+ start: z.number().int().min(0).describe('Start offset (default 0)').optional(),
19
+ max: z.number().int().min(1).describe('Max results (default 20)').optional(),
17
20
  },
18
21
  }, async (args) => {
19
22
  const start = args.start ?? 0;
@@ -21,15 +24,16 @@ export function registerExpenseTools(server, client) {
21
24
  const data = await client.request('GET', `/pub/v2/expense/expenses?start=${start}&max=${max}`);
22
25
  return jsonResponse(data);
23
26
  });
24
- server.registerTool('ofw_create_expense', {
25
- description: 'Log a new expense in OurFamilyWizard',
26
- annotations: { destructiveHint: false },
27
- inputSchema: {
28
- amount: z.number().describe('Expense amount'),
29
- description: z.string().describe('Expense description'),
30
- },
31
- }, async (args) => {
32
- const data = await client.request('POST', '/pub/v2/expense/expenses', args);
33
- return jsonResponse(data);
34
- });
27
+ if (allowWrites)
28
+ server.registerTool('ofw_create_expense', {
29
+ description: 'Log a new expense in OurFamilyWizard',
30
+ annotations: { destructiveHint: false },
31
+ inputSchema: {
32
+ amount: z.number().describe('Expense amount'),
33
+ description: z.string().describe('Expense description'),
34
+ },
35
+ }, async (args) => {
36
+ const data = await client.request('POST', '/pub/v2/expense/expenses', args);
37
+ return jsonResponse(data);
38
+ });
35
39
  }
@@ -1,12 +1,15 @@
1
1
  import { z } from 'zod';
2
2
  import { jsonResponse } from './_shared.js';
3
+ import { getWriteMode } from '../config.js';
3
4
  export function registerJournalTools(server, client) {
5
+ // Journal writes land on the court-visible record — OFW_WRITE_MODE 'all' only.
6
+ const allowWrites = getWriteMode() === 'all';
4
7
  server.registerTool('ofw_list_journal_entries', {
5
8
  description: 'List OurFamilyWizard journal entries',
6
9
  annotations: { readOnlyHint: true },
7
10
  inputSchema: {
8
- start: z.number().describe('Start offset (default 1)').optional(),
9
- max: z.number().describe('Max results (default 10)').optional(),
11
+ start: z.number().int().min(1).describe('Start offset (default 1)').optional(),
12
+ max: z.number().int().min(1).describe('Max results (default 10)').optional(),
10
13
  },
11
14
  }, async (args) => {
12
15
  // Journal API uses 1-based offset (unlike expenses which start at 0)
@@ -15,15 +18,16 @@ export function registerJournalTools(server, client) {
15
18
  const data = await client.request('GET', `/pub/v1/journals?start=${start}&max=${max}`);
16
19
  return jsonResponse(data);
17
20
  });
18
- server.registerTool('ofw_create_journal_entry', {
19
- description: 'Create a new journal entry in OurFamilyWizard',
20
- annotations: { destructiveHint: false },
21
- inputSchema: {
22
- title: z.string().describe('Entry title'),
23
- body: z.string().describe('Entry text content'),
24
- },
25
- }, async (args) => {
26
- const data = await client.request('POST', '/pub/v1/journals', args);
27
- return jsonResponse(data);
28
- });
21
+ if (allowWrites)
22
+ server.registerTool('ofw_create_journal_entry', {
23
+ description: 'Create a new journal entry in OurFamilyWizard',
24
+ annotations: { destructiveHint: false },
25
+ inputSchema: {
26
+ title: z.string().describe('Entry title'),
27
+ body: z.string().describe('Entry text content'),
28
+ },
29
+ }, async (args) => {
30
+ const data = await client.request('POST', '/pub/v1/journals', args);
31
+ return jsonResponse(data);
32
+ });
29
33
  }