ofw-mcp 2.4.4 → 2.5.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +7 -3
- package/dist/bundle.js +294 -44
- package/dist/config.js +21 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +18 -2
- package/dist/tools/_shared.js +23 -6
- package/dist/tools/calendar.js +145 -29
- package/dist/tools/messages.js +29 -3
- package/package.json +3 -3
- package/server.json +8 -2
- package/skills/ofw-fpx/SKILL.md +106 -0
- package/skills/ofw-fpx/references/requests.md +252 -0
package/dist/sync.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { setMeta, upsertMessage, getMessage, deleteMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { ApiRecipientSchema, mapRecipients } from './tools/_shared.js';
|
|
3
|
+
import { ApiRecipientSchema, hasRealView, mapRecipients } from './tools/_shared.js';
|
|
4
4
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
5
5
|
// Each OFW message detail returns `files: [fileId, ...]`. We fetch the metadata
|
|
6
6
|
// for each file id (cheap JSON call) so the model can see filenames/mime types
|
|
@@ -75,6 +75,9 @@ const ListResponseSchema = z.looseObject({ data: z.array(ListItemSchema).optiona
|
|
|
75
75
|
const DetailResponseSchema = z.looseObject({
|
|
76
76
|
body: z.string().optional(),
|
|
77
77
|
files: z.array(z.number()).optional(),
|
|
78
|
+
// The detail endpoint carries the REAL recipient view timestamps (the list
|
|
79
|
+
// endpoint only has an epoch placeholder) — used by the view-status refresh.
|
|
80
|
+
recipients: z.array(ApiRecipientSchema).optional(),
|
|
78
81
|
});
|
|
79
82
|
export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
80
83
|
let page = 1;
|
|
@@ -92,8 +95,21 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
|
92
95
|
if (newestId === null || item.id > newestId)
|
|
93
96
|
newestId = item.id;
|
|
94
97
|
const existing = getMessage(item.id);
|
|
95
|
-
if (existing)
|
|
98
|
+
if (existing) {
|
|
99
|
+
// A sent message's read status changes AFTER it's first cached, when
|
|
100
|
+
// the recipient opens it — so we can't just skip existing rows. The
|
|
101
|
+
// list item carries the reliable `showNeverViewed` boolean but only an
|
|
102
|
+
// epoch placeholder for the timestamp; the real "First Viewed" time is
|
|
103
|
+
// on the detail endpoint. So when a sent message has flipped to read
|
|
104
|
+
// and we don't yet hold a real viewed time, re-fetch detail to capture
|
|
105
|
+
// it (no body re-fetch — only the recipient view fields can change).
|
|
106
|
+
if (folder === 'sent' && item.showNeverViewed === false && !hasRealView(existing.recipients)) {
|
|
107
|
+
const detail = parseLenient(DetailResponseSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (view-status refresh)' });
|
|
108
|
+
upsertMessage({ ...existing, recipients: mapRecipients(detail.recipients), listData: item });
|
|
109
|
+
synced++;
|
|
110
|
+
}
|
|
96
111
|
continue;
|
|
112
|
+
}
|
|
97
113
|
pageHadNewItem = true;
|
|
98
114
|
const isInboxUnread = folder === 'inbox' && item.showNeverViewed === true;
|
|
99
115
|
const shouldFetchBody = !isInboxUnread || opts.fetchUnreadBodies;
|
package/dist/tools/_shared.js
CHANGED
|
@@ -15,13 +15,30 @@ export const ApiRecipientSchema = z.looseObject({
|
|
|
15
15
|
});
|
|
16
16
|
// Translates OFW API recipient shape into the cache's normalized Recipient.
|
|
17
17
|
// Used wherever we surface or persist recipients (sync, get_message, send,
|
|
18
|
-
// save_draft)
|
|
18
|
+
// save_draft).
|
|
19
|
+
//
|
|
20
|
+
// `viewedAt` is the recipient's true "First Viewed" time, or null if not yet
|
|
21
|
+
// viewed. Only the DETAIL endpoint (/pub/v3/messages/{id}) carries a real
|
|
22
|
+
// timestamp; the LIST endpoint returns an epoch-zero PLACEHOLDER
|
|
23
|
+
// ("1970-01-01T00:00:00") in the SAME field even for read messages (on the
|
|
24
|
+
// list, read status lives in `showNeverViewed`, not the timestamp). So treat
|
|
25
|
+
// the epoch placeholder as "no real view time" — otherwise a list-sourced row
|
|
26
|
+
// reports a bogus 1970 read time. A detail re-fetch is what fills in the truth.
|
|
19
27
|
export function mapRecipients(items) {
|
|
20
|
-
return (items ?? []).map((r) =>
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
})
|
|
28
|
+
return (items ?? []).map((r) => {
|
|
29
|
+
const dt = r.viewed?.dateTime;
|
|
30
|
+
const viewedAt = typeof dt === 'string' && !dt.startsWith('1970-01-01') ? dt : null;
|
|
31
|
+
return { userId: r.user?.id ?? 0, name: r.user?.name ?? '', viewedAt };
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
// True if any recipient has a *real* "First Viewed" time — i.e. present and
|
|
35
|
+
// not the epoch-zero placeholder. After mapRecipients a fresh `viewedAt` is
|
|
36
|
+
// only ever a real timestamp or null, but a cache row written by older code
|
|
37
|
+
// (which trusted the list endpoint's `viewed`) may still hold the literal
|
|
38
|
+
// "1970-01-01T00:00:00". Treating that as "not viewed" lets sync/get_message
|
|
39
|
+
// re-fetch detail and self-heal the stale row to the real timestamp.
|
|
40
|
+
export function hasRealView(recipients) {
|
|
41
|
+
return recipients.some((r) => r.viewedAt !== null && !r.viewedAt.startsWith('1970-01-01'));
|
|
25
42
|
}
|
|
26
43
|
// Expand a user-provided path: ~ → home, relative → absolute. Re-exports
|
|
27
44
|
// @chrischall/mcp-utils' `expandPath`.
|
package/dist/tools/calendar.js
CHANGED
|
@@ -1,9 +1,113 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { jsonResponse, textResponse } from './_shared.js';
|
|
3
|
-
import {
|
|
3
|
+
import { getCalendarWritesAllowed } from '../config.js';
|
|
4
|
+
import { parseLenient } from '@chrischall/mcp-utils';
|
|
5
|
+
// OFW's real event-write API (reverse-engineered from the web app bundle):
|
|
6
|
+
// POST /pub/v3/events create — 201 with the full event
|
|
7
|
+
// GET /pub/v3/events/{eventRecurrenceId} detail
|
|
8
|
+
// PUT /pub/v3/events/{eventRecurrenceId} update — full payload, not a patch
|
|
9
|
+
// DELETE /pub/v3/events/{eventRecurrenceId}?includeFuture=<bool>
|
|
10
|
+
// The id in every URL is `eventRecurrenceId` — the same value calendar
|
|
11
|
+
// listings expose as `id`. (`eventId` in the response is a different,
|
|
12
|
+
// internal identifier; never put it in a URL.)
|
|
13
|
+
// Payload gotchas: dates are `YYYY-MM-DD` with separate `HH:mm` times;
|
|
14
|
+
// privacy is `publicFlag` (true = shared with co-parent); parent ids must be
|
|
15
|
+
// OMITTED when unset — the web form's "0" placeholders draw a 409
|
|
16
|
+
// "Must be a parent" from the API.
|
|
17
|
+
const ofwDate = z.looseObject({ dateTime: z.string() });
|
|
18
|
+
const userRef = z.looseObject({ userId: z.number() });
|
|
19
|
+
const eventDetailSchema = z.looseObject({
|
|
20
|
+
eventRecurrenceId: z.number(),
|
|
21
|
+
title: z.string(),
|
|
22
|
+
allDay: z.boolean(),
|
|
23
|
+
publicFlag: z.boolean(),
|
|
24
|
+
startDate: ofwDate,
|
|
25
|
+
endDate: ofwDate,
|
|
26
|
+
location: z.string().nullish(),
|
|
27
|
+
notes: z.string().nullish(),
|
|
28
|
+
reminderMinutes: z.number().nullish(),
|
|
29
|
+
children: z.array(userRef).nullish(),
|
|
30
|
+
eventParent: userRef.nullish(),
|
|
31
|
+
dropOffParent: userRef.nullish(),
|
|
32
|
+
pickUpParent: userRef.nullish(),
|
|
33
|
+
});
|
|
34
|
+
const eventWriteFields = {
|
|
35
|
+
startDate: z.string().describe('Start date YYYY-MM-DD'),
|
|
36
|
+
endDate: z.string().describe('End date YYYY-MM-DD (default: startDate)').optional(),
|
|
37
|
+
startTime: z.string().describe('Start time HH:mm, 24-hour (required unless allDay)').optional(),
|
|
38
|
+
endTime: z.string().describe('End time HH:mm, 24-hour (required unless allDay)').optional(),
|
|
39
|
+
allDay: z.boolean().optional(),
|
|
40
|
+
privateEvent: z.boolean().describe('true = visible only to you; default false = shared with co-parent').optional(),
|
|
41
|
+
location: z.string().optional(),
|
|
42
|
+
notes: z.string().optional(),
|
|
43
|
+
reminderMinutes: z.number().int().min(0).optional(),
|
|
44
|
+
children: z.array(z.number()).describe('Child userIds to tag (see ofw_get_profile)').optional(),
|
|
45
|
+
eventParentId: z.number().describe("userId of the parent the event is 'for'").optional(),
|
|
46
|
+
dropOffParentId: z.number().describe('userId of the drop-off parent').optional(),
|
|
47
|
+
pickUpParentId: z.number().describe('userId of the pick-up parent').optional(),
|
|
48
|
+
};
|
|
49
|
+
function buildEventPayload(a) {
|
|
50
|
+
const allDay = a.allDay ?? false;
|
|
51
|
+
if (!allDay && (!a.startTime || !a.endTime)) {
|
|
52
|
+
throw new Error('startTime and endTime (HH:mm) are required unless allDay is true');
|
|
53
|
+
}
|
|
54
|
+
const payload = {
|
|
55
|
+
title: a.title,
|
|
56
|
+
startDate: a.startDate,
|
|
57
|
+
endDate: a.endDate ?? a.startDate,
|
|
58
|
+
// The web form always sends times; for all-day events it uses 01:00/02:00
|
|
59
|
+
// placeholders that OFW ignores.
|
|
60
|
+
startTime: a.startTime ?? '01:00',
|
|
61
|
+
endTime: a.endTime ?? '02:00',
|
|
62
|
+
allDay,
|
|
63
|
+
publicFlag: !(a.privateEvent ?? false),
|
|
64
|
+
};
|
|
65
|
+
if (a.location)
|
|
66
|
+
payload.location = a.location;
|
|
67
|
+
if (a.notes)
|
|
68
|
+
payload.notes = a.notes;
|
|
69
|
+
if (a.reminderMinutes !== undefined)
|
|
70
|
+
payload.reminderMinutes = String(a.reminderMinutes);
|
|
71
|
+
// Omitted `children` PRESERVES existing tags on PUT; an explicit [] CLEARS
|
|
72
|
+
// them (verified live 2026-07-13) — so send the array whenever it's defined.
|
|
73
|
+
if (a.children !== undefined)
|
|
74
|
+
payload.children = a.children;
|
|
75
|
+
if (a.eventParentId !== undefined)
|
|
76
|
+
payload.eventParentId = String(a.eventParentId);
|
|
77
|
+
if (a.dropOffParentId !== undefined)
|
|
78
|
+
payload.dropOffParentId = String(a.dropOffParentId);
|
|
79
|
+
if (a.pickUpParentId !== undefined)
|
|
80
|
+
payload.pickUpParentId = String(a.pickUpParentId);
|
|
81
|
+
return payload;
|
|
82
|
+
}
|
|
83
|
+
// Map a detail response back onto write-args so ofw_update_event can send the
|
|
84
|
+
// full payload OFW's PUT expects while the caller only names the changes.
|
|
85
|
+
function detailToWriteArgs(d) {
|
|
86
|
+
const [startDate, startClock] = d.startDate.dateTime.split('T');
|
|
87
|
+
const [endDate, endClock] = d.endDate.dateTime.split('T');
|
|
88
|
+
return {
|
|
89
|
+
title: d.title,
|
|
90
|
+
startDate,
|
|
91
|
+
endDate,
|
|
92
|
+
startTime: (startClock ?? '01:00:00').slice(0, 5),
|
|
93
|
+
endTime: (endClock ?? '02:00:00').slice(0, 5),
|
|
94
|
+
allDay: d.allDay,
|
|
95
|
+
privateEvent: !d.publicFlag,
|
|
96
|
+
location: d.location ?? undefined,
|
|
97
|
+
notes: d.notes ?? undefined,
|
|
98
|
+
reminderMinutes: d.reminderMinutes ?? undefined,
|
|
99
|
+
// Untagged (nullish or empty) → undefined, so the merged PUT omits the
|
|
100
|
+
// field (omission preserves; only a CALLER-supplied [] should clear).
|
|
101
|
+
children: d.children?.length ? d.children.map((c) => c.userId) : undefined,
|
|
102
|
+
eventParentId: d.eventParent?.userId,
|
|
103
|
+
dropOffParentId: d.dropOffParent?.userId,
|
|
104
|
+
pickUpParentId: d.pickUpParent?.userId,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
4
107
|
export function registerCalendarTools(server, client) {
|
|
5
|
-
// Calendar writes land on the court-visible record
|
|
6
|
-
|
|
108
|
+
// Calendar writes land on the court-visible record with no draft stage, but
|
|
109
|
+
// events are reversible — 'all' mode, or 'drafts' + OFW_CALENDAR_WRITES=true.
|
|
110
|
+
const allowWrites = getCalendarWritesAllowed();
|
|
7
111
|
server.registerTool('ofw_list_events', {
|
|
8
112
|
description: 'List OurFamilyWizard calendar events in a date range',
|
|
9
113
|
annotations: { readOnlyHint: true },
|
|
@@ -19,53 +123,65 @@ export function registerCalendarTools(server, client) {
|
|
|
19
123
|
});
|
|
20
124
|
if (allowWrites)
|
|
21
125
|
server.registerTool('ofw_create_event', {
|
|
22
|
-
description: 'Create a calendar event in OurFamilyWizard',
|
|
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.',
|
|
23
127
|
annotations: { destructiveHint: false },
|
|
24
128
|
inputSchema: {
|
|
25
129
|
title: z.string(),
|
|
26
|
-
|
|
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(),
|
|
130
|
+
...eventWriteFields,
|
|
36
131
|
},
|
|
37
132
|
}, async (args) => {
|
|
38
|
-
const
|
|
39
|
-
|
|
133
|
+
const raw = await client.request('POST', '/pub/v3/events', buildEventPayload(args));
|
|
134
|
+
const event = parseLenient(eventDetailSchema, raw, { label: 'ofw-mcp', context: 'POST /pub/v3/events', mode: 'strict' });
|
|
135
|
+
return jsonResponse({
|
|
136
|
+
note: `Event created. Use eventRecurrenceId ${event.eventRecurrenceId} as eventId for ofw_update_event/ofw_delete_event.`,
|
|
137
|
+
event,
|
|
138
|
+
});
|
|
40
139
|
});
|
|
41
140
|
if (allowWrites)
|
|
42
141
|
server.registerTool('ofw_update_event', {
|
|
43
|
-
description: 'Update an existing OurFamilyWizard calendar event',
|
|
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).',
|
|
44
143
|
annotations: { destructiveHint: true },
|
|
45
144
|
inputSchema: {
|
|
46
|
-
eventId: z.string(),
|
|
145
|
+
eventId: z.string().describe('Event id — the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
|
|
47
146
|
title: z.string().optional(),
|
|
48
|
-
startDate:
|
|
49
|
-
endDate:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
privateEvent:
|
|
147
|
+
startDate: eventWriteFields.startDate.optional(),
|
|
148
|
+
endDate: eventWriteFields.endDate,
|
|
149
|
+
startTime: eventWriteFields.startTime,
|
|
150
|
+
endTime: eventWriteFields.endTime,
|
|
151
|
+
allDay: eventWriteFields.allDay,
|
|
152
|
+
privateEvent: eventWriteFields.privateEvent,
|
|
153
|
+
location: eventWriteFields.location,
|
|
154
|
+
notes: eventWriteFields.notes,
|
|
155
|
+
reminderMinutes: eventWriteFields.reminderMinutes,
|
|
156
|
+
children: z.array(z.number()).describe('Child userIds to tag; pass [] to remove all child tags (omit to keep current tags)').optional(),
|
|
157
|
+
eventParentId: eventWriteFields.eventParentId,
|
|
158
|
+
dropOffParentId: eventWriteFields.dropOffParentId,
|
|
159
|
+
pickUpParentId: eventWriteFields.pickUpParentId,
|
|
54
160
|
},
|
|
55
161
|
}, async (args) => {
|
|
56
|
-
const { eventId, ...
|
|
57
|
-
const
|
|
58
|
-
|
|
162
|
+
const { eventId, ...changes } = args;
|
|
163
|
+
const id = encodeURIComponent(eventId);
|
|
164
|
+
const rawDetail = await client.request('GET', `/pub/v3/events/${id}`);
|
|
165
|
+
const current = parseLenient(eventDetailSchema, rawDetail, { label: 'ofw-mcp', context: `GET /pub/v3/events/${eventId}`, mode: 'strict' });
|
|
166
|
+
const defined = Object.fromEntries(Object.entries(changes).filter(([, v]) => v !== undefined));
|
|
167
|
+
const merged = { ...detailToWriteArgs(current), ...defined };
|
|
168
|
+
await client.request('PUT', `/pub/v3/events/${id}`, buildEventPayload(merged));
|
|
169
|
+
// PUT responses aren't documented — re-fetch the detail as authoritative state.
|
|
170
|
+
const rawAfter = await client.request('GET', `/pub/v3/events/${id}`);
|
|
171
|
+
const event = parseLenient(eventDetailSchema, rawAfter, { label: 'ofw-mcp', context: `GET /pub/v3/events/${eventId} (post-update)`, mode: 'strict' });
|
|
172
|
+
return jsonResponse({ note: 'Event updated; returning re-fetched event state.', event });
|
|
59
173
|
});
|
|
60
174
|
if (allowWrites)
|
|
61
175
|
server.registerTool('ofw_delete_event', {
|
|
62
176
|
description: 'Delete an OurFamilyWizard calendar event',
|
|
63
177
|
annotations: { destructiveHint: true },
|
|
64
178
|
inputSchema: {
|
|
65
|
-
eventId: z.string().describe('Event
|
|
179
|
+
eventId: z.string().describe('Event id — the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
|
|
180
|
+
includeFuture: z.boolean().describe('For repeating events: also delete future occurrences (default false)').optional(),
|
|
66
181
|
},
|
|
67
182
|
}, async (args) => {
|
|
68
|
-
|
|
183
|
+
const includeFuture = args.includeFuture ?? false;
|
|
184
|
+
await client.request('DELETE', `/pub/v3/events/${encodeURIComponent(args.eventId)}?includeFuture=${includeFuture}`);
|
|
69
185
|
return textResponse(`Event ${args.eventId} deleted`);
|
|
70
186
|
});
|
|
71
187
|
}
|
package/dist/tools/messages.js
CHANGED
|
@@ -5,7 +5,7 @@ import { getAttachmentsDir, getDefaultInlineAttachments, getWriteMode } from '..
|
|
|
5
5
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { basename, dirname, extname, join } from 'node:path';
|
|
7
7
|
import { fileBlob } from '@chrischall/mcp-utils';
|
|
8
|
-
import { ApiRecipientSchema, expandPath, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded } from './_shared.js';
|
|
8
|
+
import { ApiRecipientSchema, expandPath, hasRealView, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded } from './_shared.js';
|
|
9
9
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
10
10
|
// Schemas for the load-bearing fields of each /pub/v3 response this file
|
|
11
11
|
// reads (issue #83). Loose: unknown keys pass through into cached listData.
|
|
@@ -183,6 +183,32 @@ export function registerMessageTools(server, client) {
|
|
|
183
183
|
}
|
|
184
184
|
const cached = getMessage(id);
|
|
185
185
|
if (cached && cached.body !== null) {
|
|
186
|
+
let row = cached;
|
|
187
|
+
// Refresh view status for a sent message we still believe is unviewed:
|
|
188
|
+
// the recipient may have opened it since the last sync, and the detail
|
|
189
|
+
// endpoint carries the real "First Viewed" timestamp (a list-synced row
|
|
190
|
+
// only knows the showNeverViewed boolean / epoch placeholder). Best-
|
|
191
|
+
// effort and one-way — once a real viewed time is cached we stop re-
|
|
192
|
+
// fetching. Sent-only: re-hitting an unread INBOX detail would mark it
|
|
193
|
+
// read on OFW.
|
|
194
|
+
if (cached.folder === 'sent' && !hasRealView(cached.recipients)) {
|
|
195
|
+
try {
|
|
196
|
+
const detail = parseLenient(MessageDetailSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (view-status refresh)' });
|
|
197
|
+
const recipients = mapRecipients(detail.recipients);
|
|
198
|
+
// Keep the raw listData read-flag in step with the refreshed
|
|
199
|
+
// recipients so `showNeverViewed` can't contradict `viewedAt`.
|
|
200
|
+
// (Spreading a null/absent listData is a no-op, so no guard needed.)
|
|
201
|
+
row = {
|
|
202
|
+
...cached,
|
|
203
|
+
recipients,
|
|
204
|
+
listData: { ...cached.listData, showNeverViewed: !hasRealView(recipients) },
|
|
205
|
+
};
|
|
206
|
+
upsertMessage(row);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Best-effort: fall back to the cached row on any fetch/parse error.
|
|
210
|
+
}
|
|
211
|
+
}
|
|
186
212
|
let attachments = listAttachmentsForMessage(id);
|
|
187
213
|
// Lazy attachment backfill. The list-endpoint payload (stored in
|
|
188
214
|
// listData) hints at attachments via `files: <count>` but doesn't
|
|
@@ -191,7 +217,7 @@ export function registerMessageTools(server, client) {
|
|
|
191
217
|
// attachments table is empty even though OFW has files. Re-hit
|
|
192
218
|
// detail to harvest fileIds (idempotent: body is already cached so
|
|
193
219
|
// OFW state isn't changing).
|
|
194
|
-
if (attachments.length === 0 && listDataHintsAtFiles(
|
|
220
|
+
if (attachments.length === 0 && listDataHintsAtFiles(row.listData)) {
|
|
195
221
|
try {
|
|
196
222
|
const detail = parseLenient(DetailFilesSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (attachment backfill)' });
|
|
197
223
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
@@ -203,7 +229,7 @@ export function registerMessageTools(server, client) {
|
|
|
203
229
|
// Backfill is best-effort. Fall through with whatever we have.
|
|
204
230
|
}
|
|
205
231
|
}
|
|
206
|
-
return jsonResponse({ ...
|
|
232
|
+
return jsonResponse({ ...row, attachments });
|
|
207
233
|
}
|
|
208
234
|
const detail = parseLenient(MessageDetailSchema, await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (ofw_get_message)' });
|
|
209
235
|
const folder = cached?.folder ?? 'inbox';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofw-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.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)",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"test:watch": "vitest"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@chrischall/mcp-utils": "^0.
|
|
35
|
+
"@chrischall/mcp-utils": "^0.13.0",
|
|
36
36
|
"@fetchproxy/bootstrap": "^1.3.0",
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
38
38
|
"dotenv": "^17.4.2",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"@types/node": "^26.0.0",
|
|
43
43
|
"@vitest/coverage-v8": "^4.1.7",
|
|
44
44
|
"esbuild": "^0.28.0",
|
|
45
|
-
"typescript": "^
|
|
45
|
+
"typescript": "^7.0.2",
|
|
46
46
|
"vitest": "^4.1.7"
|
|
47
47
|
}
|
|
48
48
|
}
|
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.
|
|
9
|
+
"version": "2.5.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.5.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -34,6 +34,12 @@
|
|
|
34
34
|
"description": "Write-tool gate: \"none\" registers no write tools; \"drafts\" registers draft-level writes only (save/delete drafts, upload attachments); \"all\" registers everything (default). Unrecognized values fail closed to \"none\".",
|
|
35
35
|
"isRequired": false,
|
|
36
36
|
"format": "string"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "OFW_CALENDAR_WRITES",
|
|
40
|
+
"description": "Set to \"true\" to register calendar write tools (create/update/delete event) in \"drafts\" write mode. Events have no draft stage but are reversible. Never overrides \"none\".",
|
|
41
|
+
"isRequired": false,
|
|
42
|
+
"format": "string"
|
|
37
43
|
}
|
|
38
44
|
]
|
|
39
45
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ofw-fpx
|
|
3
|
+
description: >-
|
|
4
|
+
Access OurFamilyWizard (OFW) — messages, calendar, expenses, journal —
|
|
5
|
+
from a shell with the fpx CLI (@fetchproxy/cli) instead of running the
|
|
6
|
+
ofw-mcp server: capture the signed-in web app's Bearer token once via the
|
|
7
|
+
browser bridge, then curl the REST API directly. Use when you want OFW
|
|
8
|
+
data without the MCP, in a script, or on a machine where the MCP isn't
|
|
9
|
+
installed.
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# OurFamilyWizard via fpx + curl (no MCP)
|
|
13
|
+
|
|
14
|
+
OFW's app (`ofw.ourfamilywizard.com`) has no API key a script can request —
|
|
15
|
+
the only credential is the Bearer token the web app itself mints on login
|
|
16
|
+
and stores in `localStorage["auth"]` (with `localStorage["tokenExpiry"]`
|
|
17
|
+
alongside). Once you have that token the API itself has **no bot wall** —
|
|
18
|
+
`ofw-mcp`'s own `src/client.ts` calls it with plain Node `fetch` for every
|
|
19
|
+
request. So this skill is **hybrid**: `fpx` captures the token from a
|
|
20
|
+
signed-in browser tab ONCE, then plain `curl` does every read/write from
|
|
21
|
+
then on. fetchproxy never touches the actual API calls.
|
|
22
|
+
|
|
23
|
+
**This is a shared family-court record.** Every write here (`send`,
|
|
24
|
+
`create_event`, `create_expense`, `create_journal_entry`,
|
|
25
|
+
`upload_attachment`, the `delete_*`/bulk-delete calls) lands on the same
|
|
26
|
+
record the MCP's `OFW_WRITE_MODE` gate exists to protect. There is no
|
|
27
|
+
dry-run/confirm here — curl just does it. Treat every write like the MCP's
|
|
28
|
+
`all` mode: real, permanent, and visible to your co-parent.
|
|
29
|
+
|
|
30
|
+
## One-time setup
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npm install -g @fetchproxy/cli # provides `fpx`
|
|
34
|
+
fpx profile add ofw --domain ourfamilywizard.com
|
|
35
|
+
fpx profile declare ofw --local-storage auth --local-storage tokenExpiry
|
|
36
|
+
fpx pair -p ofw # prints a pair code → approve in Transporter
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Requirements: the **Transporter** browser extension installed, with an
|
|
40
|
+
open, signed-in `ofw.ourfamilywizard.com` (or `www.ourfamilywizard.com`)
|
|
41
|
+
tab, and its Chrome **Site access** allowing `ourfamilywizard.com`. Pairing
|
|
42
|
+
persists across invocations.
|
|
43
|
+
|
|
44
|
+
## Capture the token (once per shell / whenever it goes stale)
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
LS=$(fpx local-storage auth tokenExpiry -p ofw)
|
|
48
|
+
TOKEN=$(jq -r '.auth' <<<"$LS")
|
|
49
|
+
EXPIRES=$(jq -r '.tokenExpiry' <<<"$LS")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
If `auth` comes back empty, sign into OFW in the browser tab first — the
|
|
53
|
+
same precondition `ofw-mcp`'s own fetchproxy fallback documents in
|
|
54
|
+
`src/auth.ts`.
|
|
55
|
+
|
|
56
|
+
## Core call
|
|
57
|
+
|
|
58
|
+
Every request needs the bearer token plus OFW's two protocol headers
|
|
59
|
+
(sent on every call, not just login):
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
curl -s 'https://ofw.ourfamilywizard.com/pub/v2/profiles' \
|
|
63
|
+
-H "Authorization: Bearer $TOKEN" \
|
|
64
|
+
-H 'ofw-client: WebApplication' \
|
|
65
|
+
-H 'ofw-version: 1.0.0' \
|
|
66
|
+
| jq .
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`ofw-version` is OFW's wire-protocol version (see `src/protocol.ts`),
|
|
70
|
+
unrelated to any package version — send it as-is. Writes add
|
|
71
|
+
`-H 'Content-Type: application/json' --data '...'` for JSON bodies, or
|
|
72
|
+
`-F` multipart fields for uploads/deletes — both shown per-endpoint in
|
|
73
|
+
`references/requests.md`.
|
|
74
|
+
|
|
75
|
+
## The one rule: re-GET after every message POST to confirm it landed
|
|
76
|
+
|
|
77
|
+
OFW's `POST /pub/v3/messages` response is minimal (`{"entityId": <id>}` or
|
|
78
|
+
legacy `{"id": <id>}`) and — worse — its draft-*replace* path silently
|
|
79
|
+
no-ops while still echoing success. Never trust the POST status alone for
|
|
80
|
+
a send or draft save: immediately `GET /pub/v3/messages/{id}` with the
|
|
81
|
+
returned id and check the body/subject actually match what you sent. See
|
|
82
|
+
§2/§3 in `references/requests.md`.
|
|
83
|
+
|
|
84
|
+
## Auth-error handling
|
|
85
|
+
|
|
86
|
+
- **401** — the token expired or was invalidated. OFW has no refresh-token
|
|
87
|
+
flow; re-mint by reloading/re-signing-in on the `ourfamilywizard.com`
|
|
88
|
+
tab, then re-run the capture step above.
|
|
89
|
+
- **429** — OFW's own client waits 2s and retries exactly once
|
|
90
|
+
(`src/client.ts`); do the same: `sleep 2` and resend the identical
|
|
91
|
+
request. A second 429 is a real rate-limit — back off further.
|
|
92
|
+
- Any other non-2xx is a real upstream error — surface the response body.
|
|
93
|
+
|
|
94
|
+
All 20 endpoint operations, request bodies, and `jq` projections are in
|
|
95
|
+
`references/requests.md`, transcribed from `src/tools/*.ts`, `src/sync.ts`,
|
|
96
|
+
and `src/tools/_shared.ts` — nothing here is guessed.
|
|
97
|
+
|
|
98
|
+
## Notes
|
|
99
|
+
|
|
100
|
+
- Base URL is `https://ofw.ourfamilywizard.com` for every endpoint (the
|
|
101
|
+
`www.` host serves the web app UI, not the API).
|
|
102
|
+
- `ofw-mcp` maintains a local SQLite message cache for fast list/search;
|
|
103
|
+
this skill has no cache — every list call here goes straight to OFW, and
|
|
104
|
+
`GET /pub/v3/messages/{id}` on an **unread inbox message marks it read**
|
|
105
|
+
on OFW, exactly as it does for the MCP.
|
|
106
|
+
- This project is developed and maintained by AI (Claude).
|