ofw-mcp 2.19.3 → 2.19.4
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 +46 -8
- package/dist/auth-password.js +39 -1
- package/dist/bundle.js +841 -122
- package/dist/config.js +4 -0
- package/dist/index.js +1 -1
- package/dist/tools/_confirm.js +49 -0
- package/dist/tools/_shared.js +30 -0
- package/dist/tools/attachments.js +16 -5
- package/dist/tools/calendar.js +143 -15
- package/dist/tools/draft-freshness.js +1 -1
- package/dist/tools/expenses.js +39 -5
- package/dist/tools/journal.js +14 -2
- package/dist/tools/messages.js +112 -8
- package/mint.yaml +20 -0
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +2 -0
package/dist/config.js
CHANGED
|
@@ -33,6 +33,10 @@ export function getAttachmentsDir() {
|
|
|
33
33
|
// Claude Desktop, so files written there are unreadable to the model that
|
|
34
34
|
// just downloaded them. Downloads is the standard "user-accessible files"
|
|
35
35
|
// location across macOS/Linux/Windows.
|
|
36
|
+
return getDefaultAttachmentsDir();
|
|
37
|
+
}
|
|
38
|
+
/** The dedicated default attachments directory, `~/Downloads/ofw-mcp`. */
|
|
39
|
+
export function getDefaultAttachmentsDir() {
|
|
36
40
|
return join(homedir(), 'Downloads', 'ofw-mcp');
|
|
37
41
|
}
|
|
38
42
|
/**
|
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.19.
|
|
39
|
+
version: '2.19.4', // x-release-please-version
|
|
40
40
|
deps: client,
|
|
41
41
|
tools: [
|
|
42
42
|
registerHealthcheckTools,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { confirmationFromEnv, confirmTokenParam, requireConfirmationWithFallback } from '@chrischall/mcp-utils';
|
|
2
|
+
import { fnv1a64 } from './draft-freshness.js';
|
|
3
|
+
export { confirmTokenParam };
|
|
4
|
+
/** The sentence every confirm-gated tool's description ends with. */
|
|
5
|
+
export const CONFIRM_NOTE = 'Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call '
|
|
6
|
+
+ 'performs NO write and returns a preview of exactly what would happen plus a confirmToken, and only a repeat '
|
|
7
|
+
+ 'call with that token proceeds (see MCP_CONFIRM_MODE).';
|
|
8
|
+
/**
|
|
9
|
+
* Confirm-gate for a write that reaches the co-parent or the court-visible
|
|
10
|
+
* record. A client that can show a confirmation prompt is asked; one that
|
|
11
|
+
* cannot (claude.ai, Claude Desktop — measured in mcp-utils
|
|
12
|
+
* docs/CLIENT-BEHAVIOUR.md) gets the two-phase token flow governed by
|
|
13
|
+
* `MCP_CONFIRM_MODE`: phase 1 performs no write and returns the preview plus a
|
|
14
|
+
* `confirmToken`; only a repeat call with that token proceeds. The token is
|
|
15
|
+
* bound to this tool, the target, its revision and the exact payload, is
|
|
16
|
+
* single-use and expires (`MCP_CONFIRM_TTL_SECONDS`).
|
|
17
|
+
*
|
|
18
|
+
* Call it on EVERY invocation with the freshly-built payload and a freshly
|
|
19
|
+
* read revision — the caller's own re-read is what makes a stale token fail.
|
|
20
|
+
* `OFW_WRITE_MODE` stays the structural layer underneath: a tool this gate
|
|
21
|
+
* protects is still not registered at all below its write mode.
|
|
22
|
+
*
|
|
23
|
+
* Returns `undefined` to proceed with the write, otherwise the result to
|
|
24
|
+
* return unchanged.
|
|
25
|
+
*/
|
|
26
|
+
export function confirmWrite(ctx, opts) {
|
|
27
|
+
return requireConfirmationWithFallback(ctx, confirmationFromEnv({
|
|
28
|
+
action: opts.action,
|
|
29
|
+
message: opts.message,
|
|
30
|
+
details: opts.preview,
|
|
31
|
+
unsupportedNote: 'Complete this action on ourfamilywizard.com instead.',
|
|
32
|
+
tool: opts.tool,
|
|
33
|
+
confirmToken: opts.confirmToken,
|
|
34
|
+
subject: () => ({
|
|
35
|
+
target: opts.target,
|
|
36
|
+
...(opts.revision !== undefined ? { revision: opts.revision } : {}),
|
|
37
|
+
payload: opts.payload,
|
|
38
|
+
preview: opts.preview,
|
|
39
|
+
}),
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A revision string for a target as just read from OFW (an event detail), for
|
|
44
|
+
* `ConfirmWriteOptions.revision`. Any change to the value rotates it, so a
|
|
45
|
+
* token minted against one state cannot act on another.
|
|
46
|
+
*/
|
|
47
|
+
export function stateRevision(value) {
|
|
48
|
+
return `s1:${fnv1a64(JSON.stringify(value))}`;
|
|
49
|
+
}
|
package/dist/tools/_shared.js
CHANGED
|
@@ -269,6 +269,36 @@ function isDefinitiveRejection(e) {
|
|
|
269
269
|
const m = /OFW API error: (4\d\d)\b/.exec(e.message);
|
|
270
270
|
return m !== null && m[1] !== '408';
|
|
271
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* Issue a write that lands on the shared, court-visible record (an expense,
|
|
274
|
+
* a calendar change, a journal entry) and classify how it failed. A
|
|
275
|
+
* definitive rejection (4xx, repeated 429) is rethrown as-is: nothing landed
|
|
276
|
+
* and a retry is safe. Anything else — a timeout, a dropped connection, a
|
|
277
|
+
* 5xx, a cancellation — is an UnconfirmedWriteError, because OFW may already
|
|
278
|
+
* have accepted the write, and a model that reads a plain "request timed out"
|
|
279
|
+
* retries it and puts a duplicate in front of the co-parent.
|
|
280
|
+
*/
|
|
281
|
+
export async function requestWrite(client, method, path, body) {
|
|
282
|
+
try {
|
|
283
|
+
return await client.request(method, path, body);
|
|
284
|
+
}
|
|
285
|
+
catch (e) {
|
|
286
|
+
throw isDefinitiveRejection(e) ? e : new UnconfirmedWriteError(null, e);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The `<X>_UNCONFIRMED` result for a write whose outcome is unknown: an
|
|
291
|
+
* error result (so it is not read as success) that says in words the write
|
|
292
|
+
* may have landed and names how to check before any retry.
|
|
293
|
+
*/
|
|
294
|
+
export function unconfirmedWriteResponse(e, opts) {
|
|
295
|
+
return jsonErrorResponse({
|
|
296
|
+
result: opts.result,
|
|
297
|
+
mayHaveLanded: true,
|
|
298
|
+
reason: `The request to ${opts.what} failed without a definitive answer from OFW: ${e.message}. It MAY HAVE BEEN APPLIED on OurFamilyWizard.`,
|
|
299
|
+
remedy: `Do NOT retry blindly — a second attempt can put a duplicate on the co-parent-visible record. First check with ${opts.checkWith} (or on ourfamilywizard.com), and retry only once you have confirmed it did not land.`,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
272
302
|
export async function postMessageAndRefetch(client, payload, detailSchema, ctx) {
|
|
273
303
|
let posted;
|
|
274
304
|
try {
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
// disk injects an inline, filesystem-free implementation.
|
|
9
9
|
// Keeping the interface here means src/tools/messages.ts imports nothing from
|
|
10
10
|
// node:fs.
|
|
11
|
-
import { existsSync, readFileSync, realpathSync, statSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { chmodSync, existsSync, readFileSync, realpathSync, statSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
13
|
-
import { getUploadDir } from '../config.js';
|
|
13
|
+
import { getDefaultAttachmentsDir, getUploadDir } from '../config.js';
|
|
14
14
|
import { fileBlob, expandPath } from '@chrischall/mcp-utils';
|
|
15
15
|
/**
|
|
16
16
|
* True when `candidate` is strictly inside `root` (both absolute). Lexical:
|
|
@@ -147,7 +147,9 @@ export class NodeAttachmentIO {
|
|
|
147
147
|
const fileName = basename(abs);
|
|
148
148
|
const mimeType = mimeFromName(fileName);
|
|
149
149
|
// fileBlob streams the file off disk (a file-backed Blob) instead of buffering it.
|
|
150
|
-
|
|
150
|
+
// allowedRoots makes it re-check confinement (through symlinks) at open time,
|
|
151
|
+
// closing the window between the checks above and the open.
|
|
152
|
+
const blob = await fileBlob(real, { type: mimeType, allowedRoots: [realRoot] });
|
|
151
153
|
return { blob, fileName, mimeType, sizeBytes: stat.size };
|
|
152
154
|
}
|
|
153
155
|
readDownloaded(path) {
|
|
@@ -163,14 +165,23 @@ export class NodeAttachmentIO {
|
|
|
163
165
|
// boundary. Check the REAL path of the nearest existing ancestor before
|
|
164
166
|
// creating anything, so a symlinked directory inside the root cannot carry
|
|
165
167
|
// the write (or even the mkdir) somewhere else.
|
|
166
|
-
|
|
168
|
+
//
|
|
169
|
+
// Directories are created 0700: the listing itself is sensitive (entries
|
|
170
|
+
// are `<fileId>-<filename>`, with names the co-parent chose), so 0600
|
|
171
|
+
// bytes in a world-listable directory would still leak them. The
|
|
172
|
+
// DEDICATED default dir is also tightened if it already exists (an older
|
|
173
|
+
// version created it 0755); a directory the user configured themselves
|
|
174
|
+
// keeps its mode, since it may be shared on purpose.
|
|
175
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
176
|
+
if (resolve(root) === resolve(getDefaultAttachmentsDir()))
|
|
177
|
+
chmodSync(root, 0o700);
|
|
167
178
|
const realRoot = realpathSync(root);
|
|
168
179
|
const parent = dirname(dest);
|
|
169
180
|
const anchor = realpathSync(deepestExisting(parent));
|
|
170
181
|
if (anchor !== realRoot && !isWithin(realRoot, anchor)) {
|
|
171
182
|
throw new Error(`Refusing to write ${dest}: it resolves outside the attachments directory (${root}).`);
|
|
172
183
|
}
|
|
173
|
-
mkdirSync(parent, { recursive: true });
|
|
184
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
174
185
|
if (overwrite) {
|
|
175
186
|
// unlink removes a symlink itself, never its target.
|
|
176
187
|
try {
|
package/dist/tools/calendar.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { jsonResponse, textResponse } from './_shared.js';
|
|
2
|
+
import { jsonResponse, requestWrite, textResponse, UnconfirmedWriteError, unconfirmedWriteResponse } from './_shared.js';
|
|
3
|
+
import { CONFIRM_NOTE, confirmTokenParam, confirmWrite, stateRevision } from './_confirm.js';
|
|
3
4
|
import { getCalendarWritesAllowed } from '../config.js';
|
|
4
5
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
5
6
|
// OFW's real event-write API (reverse-engineered from the web app bundle):
|
|
@@ -104,6 +105,47 @@ function detailToWriteArgs(d) {
|
|
|
104
105
|
pickUpParentId: d.pickUpParent?.userId,
|
|
105
106
|
};
|
|
106
107
|
}
|
|
108
|
+
const SHARED = 'shared with co-parent';
|
|
109
|
+
const PRIVATE = 'private (only you)';
|
|
110
|
+
/**
|
|
111
|
+
* The human-readable form of an event for a confirmation preview: the fields
|
|
112
|
+
* a person checks (title, when, where, who sees it), with tagged users named
|
|
113
|
+
* wherever OFW's own event detail named them. An id OFW gave no name for is
|
|
114
|
+
* reported with `name: null` — never an invented one.
|
|
115
|
+
*/
|
|
116
|
+
function describeEvent(a, names = new Map()) {
|
|
117
|
+
const who = (id) => ({ userId: id, name: names.get(id) ?? null });
|
|
118
|
+
const out = {
|
|
119
|
+
title: a.title,
|
|
120
|
+
startDate: a.startDate,
|
|
121
|
+
endDate: a.endDate ?? a.startDate,
|
|
122
|
+
...(a.allDay ? { allDay: true } : { startTime: a.startTime, endTime: a.endTime }),
|
|
123
|
+
visibility: a.privateEvent ? PRIVATE : SHARED,
|
|
124
|
+
};
|
|
125
|
+
if (a.location)
|
|
126
|
+
out.location = a.location;
|
|
127
|
+
if (a.notes)
|
|
128
|
+
out.notes = a.notes;
|
|
129
|
+
if (a.children !== undefined)
|
|
130
|
+
out.children = a.children.map((id) => who(id));
|
|
131
|
+
if (a.eventParentId !== undefined)
|
|
132
|
+
out.eventParent = who(a.eventParentId);
|
|
133
|
+
if (a.dropOffParentId !== undefined)
|
|
134
|
+
out.dropOffParent = who(a.dropOffParentId);
|
|
135
|
+
if (a.pickUpParentId !== undefined)
|
|
136
|
+
out.pickUpParent = who(a.pickUpParentId);
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
/** userId → name for every tagged person the event detail names. */
|
|
140
|
+
function namesIn(d) {
|
|
141
|
+
const names = new Map();
|
|
142
|
+
for (const ref of [...(d.children ?? []), d.eventParent, d.dropOffParent, d.pickUpParent]) {
|
|
143
|
+
const name = ref?.name;
|
|
144
|
+
if (ref && typeof name === 'string' && name)
|
|
145
|
+
names.set(ref.userId, name);
|
|
146
|
+
}
|
|
147
|
+
return names;
|
|
148
|
+
}
|
|
107
149
|
export function registerCalendarTools(server, client) {
|
|
108
150
|
// Calendar writes land on the court-visible record with no draft stage, but
|
|
109
151
|
// events are reversible — 'all' mode, or 'drafts' + OFW_CALENDAR_WRITES=true.
|
|
@@ -123,14 +165,48 @@ export function registerCalendarTools(server, client) {
|
|
|
123
165
|
});
|
|
124
166
|
if (allowWrites)
|
|
125
167
|
server.registerTool('ofw_create_event', {
|
|
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
|
-
|
|
168
|
+
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 — so a shared event is confirmed first (a private one is not). If the request fails without a definitive answer the result is EVENT_UNCONFIRMED: the event may already exist, so do NOT retry until ofw_list_events shows it did not land. ' + CONFIRM_NOTE,
|
|
169
|
+
// Additive, so not destructive — but it lands on a shared calendar
|
|
170
|
+
// outside this machine, which openWorldHint says to the host.
|
|
171
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
128
172
|
inputSchema: z.object({
|
|
129
173
|
title: z.string(),
|
|
130
174
|
...eventWriteFields,
|
|
175
|
+
confirmToken: confirmTokenParam,
|
|
131
176
|
}),
|
|
132
|
-
}, async (args) => {
|
|
133
|
-
const
|
|
177
|
+
}, async (args, ctx) => {
|
|
178
|
+
const { confirmToken, ...fields } = args;
|
|
179
|
+
const payload = buildEventPayload(fields);
|
|
180
|
+
if (!fields.privateEvent) {
|
|
181
|
+
const gate = await confirmWrite(ctx, {
|
|
182
|
+
tool: 'ofw_create_event',
|
|
183
|
+
action: 'ofw.event.create',
|
|
184
|
+
message: 'Review and confirm this OurFamilyWizard calendar event. It is shared with the co-parent and appears on their calendar immediately.',
|
|
185
|
+
target: 'event:new',
|
|
186
|
+
payload,
|
|
187
|
+
preview: {
|
|
188
|
+
action: 'Create shared OurFamilyWizard calendar event',
|
|
189
|
+
event: describeEvent(fields),
|
|
190
|
+
warning: 'Visible to the co-parent immediately; part of the court-visible record.',
|
|
191
|
+
},
|
|
192
|
+
confirmToken,
|
|
193
|
+
});
|
|
194
|
+
if (gate)
|
|
195
|
+
return gate;
|
|
196
|
+
}
|
|
197
|
+
let raw;
|
|
198
|
+
try {
|
|
199
|
+
raw = await requestWrite(client, 'POST', '/pub/v3/events', payload);
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
if (!(e instanceof UnconfirmedWriteError))
|
|
203
|
+
throw e;
|
|
204
|
+
return unconfirmedWriteResponse(e, {
|
|
205
|
+
result: 'EVENT_UNCONFIRMED',
|
|
206
|
+
what: 'create this event',
|
|
207
|
+
checkWith: `ofw_list_events for ${fields.startDate} (look for "${fields.title}")`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
134
210
|
const event = parseLenient(eventDetailSchema, raw, { label: 'ofw-mcp', context: 'POST /pub/v3/events', mode: 'strict' });
|
|
135
211
|
return jsonResponse({
|
|
136
212
|
note: `Event created. Use eventRecurrenceId ${event.eventRecurrenceId} as eventId for ofw_update_event/ofw_delete_event.`,
|
|
@@ -139,7 +215,7 @@ export function registerCalendarTools(server, client) {
|
|
|
139
215
|
});
|
|
140
216
|
if (allowWrites)
|
|
141
217
|
server.registerTool('ofw_update_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).',
|
|
218
|
+
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). A change to an event the co-parent can see (shared before or after the change) is confirmed first; the confirmation is bound to the event exactly as read, so if it changes on OFW in between (say the co-parent edited it) the update is refused instead of overwriting their edit. ' + CONFIRM_NOTE,
|
|
143
219
|
annotations: { destructiveHint: true },
|
|
144
220
|
inputSchema: z.object({
|
|
145
221
|
eventId: z.string().describe('Event id — the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
|
|
@@ -157,15 +233,41 @@ export function registerCalendarTools(server, client) {
|
|
|
157
233
|
eventParentId: eventWriteFields.eventParentId,
|
|
158
234
|
dropOffParentId: eventWriteFields.dropOffParentId,
|
|
159
235
|
pickUpParentId: eventWriteFields.pickUpParentId,
|
|
236
|
+
confirmToken: confirmTokenParam,
|
|
160
237
|
}),
|
|
161
|
-
}, async (args) => {
|
|
162
|
-
const { eventId, ...changes } = args;
|
|
238
|
+
}, async (args, ctx) => {
|
|
239
|
+
const { eventId, confirmToken, ...changes } = args;
|
|
163
240
|
const id = encodeURIComponent(eventId);
|
|
241
|
+
// Re-read on EVERY call (phase 1 and phase 2 alike): the merge base and
|
|
242
|
+
// the confirmation's revision both come from this read.
|
|
164
243
|
const rawDetail = await client.request('GET', `/pub/v3/events/${id}`);
|
|
165
244
|
const current = parseLenient(eventDetailSchema, rawDetail, { label: 'ofw-mcp', context: `GET /pub/v3/events/${eventId}`, mode: 'strict' });
|
|
166
245
|
const defined = Object.fromEntries(Object.entries(changes).filter(([, v]) => v !== undefined));
|
|
167
|
-
const
|
|
168
|
-
|
|
246
|
+
const base = detailToWriteArgs(current);
|
|
247
|
+
const merged = { ...base, ...defined };
|
|
248
|
+
const payload = buildEventPayload(merged);
|
|
249
|
+
if (current.publicFlag || !merged.privateEvent) {
|
|
250
|
+
const names = namesIn(current);
|
|
251
|
+
const gate = await confirmWrite(ctx, {
|
|
252
|
+
tool: 'ofw_update_event',
|
|
253
|
+
action: 'ofw.event.update',
|
|
254
|
+
message: `Review and confirm this change to the OurFamilyWizard event "${current.title}". The co-parent sees the updated event on their calendar.`,
|
|
255
|
+
target: `event:${eventId}`,
|
|
256
|
+
revision: stateRevision(base),
|
|
257
|
+
payload,
|
|
258
|
+
preview: {
|
|
259
|
+
action: 'Update OurFamilyWizard calendar event',
|
|
260
|
+
eventId,
|
|
261
|
+
before: describeEvent(base, names),
|
|
262
|
+
after: describeEvent(merged, names),
|
|
263
|
+
warning: 'The co-parent sees this change; part of the court-visible record.',
|
|
264
|
+
},
|
|
265
|
+
confirmToken,
|
|
266
|
+
});
|
|
267
|
+
if (gate)
|
|
268
|
+
return gate;
|
|
269
|
+
}
|
|
270
|
+
await client.request('PUT', `/pub/v3/events/${id}`, payload);
|
|
169
271
|
// PUT responses aren't documented — re-fetch the detail as authoritative state.
|
|
170
272
|
const rawAfter = await client.request('GET', `/pub/v3/events/${id}`);
|
|
171
273
|
const event = parseLenient(eventDetailSchema, rawAfter, { label: 'ofw-mcp', context: `GET /pub/v3/events/${eventId} (post-update)`, mode: 'strict' });
|
|
@@ -173,15 +275,41 @@ export function registerCalendarTools(server, client) {
|
|
|
173
275
|
});
|
|
174
276
|
if (allowWrites)
|
|
175
277
|
server.registerTool('ofw_delete_event', {
|
|
176
|
-
description: 'Delete an OurFamilyWizard calendar event',
|
|
177
|
-
annotations: { destructiveHint: true },
|
|
278
|
+
description: 'Delete an OurFamilyWizard calendar event. Reads the event first; deleting one the co-parent can see is confirmed first, with a preview of exactly which event (title, date, time) is removed, and is refused if the event changed on OFW after that preview. ' + CONFIRM_NOTE,
|
|
279
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
178
280
|
inputSchema: z.object({
|
|
179
281
|
eventId: z.string().describe('Event id — the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
|
|
180
282
|
includeFuture: z.boolean().describe('For repeating events: also delete future occurrences (default false)').optional(),
|
|
283
|
+
confirmToken: confirmTokenParam,
|
|
181
284
|
}),
|
|
182
|
-
}, async (args) => {
|
|
285
|
+
}, async (args, ctx) => {
|
|
183
286
|
const includeFuture = args.includeFuture ?? false;
|
|
184
|
-
|
|
185
|
-
|
|
287
|
+
const id = encodeURIComponent(args.eventId);
|
|
288
|
+
const rawDetail = await client.request('GET', `/pub/v3/events/${id}`);
|
|
289
|
+
const current = parseLenient(eventDetailSchema, rawDetail, { label: 'ofw-mcp', context: `GET /pub/v3/events/${args.eventId}`, mode: 'strict' });
|
|
290
|
+
if (current.publicFlag) {
|
|
291
|
+
const base = detailToWriteArgs(current);
|
|
292
|
+
const gate = await confirmWrite(ctx, {
|
|
293
|
+
tool: 'ofw_delete_event',
|
|
294
|
+
action: 'ofw.event.delete',
|
|
295
|
+
message: `Review and confirm deleting the OurFamilyWizard event "${current.title}". It disappears from the co-parent's calendar.`,
|
|
296
|
+
target: `event:${args.eventId}`,
|
|
297
|
+
revision: stateRevision(base),
|
|
298
|
+
payload: { eventId: args.eventId, includeFuture },
|
|
299
|
+
preview: {
|
|
300
|
+
action: 'Delete shared OurFamilyWizard calendar event',
|
|
301
|
+
eventId: args.eventId,
|
|
302
|
+
event: describeEvent(base, namesIn(current)),
|
|
303
|
+
includeFuture,
|
|
304
|
+
...(includeFuture ? { note: 'Future occurrences of this repeating event are deleted too.' } : {}),
|
|
305
|
+
warning: 'Removed from the co-parent\'s calendar.',
|
|
306
|
+
},
|
|
307
|
+
confirmToken: args.confirmToken,
|
|
308
|
+
});
|
|
309
|
+
if (gate)
|
|
310
|
+
return gate;
|
|
311
|
+
}
|
|
312
|
+
await client.request('DELETE', `/pub/v3/events/${id}?includeFuture=${includeFuture}`);
|
|
313
|
+
return textResponse(`Event ${args.eventId} ("${current.title}") deleted`);
|
|
186
314
|
});
|
|
187
315
|
}
|
|
@@ -11,7 +11,7 @@ export class DraftFreshnessError extends Error {
|
|
|
11
11
|
const FNV_OFFSET = 0xcbf29ce484222325n;
|
|
12
12
|
const FNV_PRIME = 0x100000001b3n;
|
|
13
13
|
const MASK64 = 0xffffffffffffffffn;
|
|
14
|
-
function fnv1a64(s) {
|
|
14
|
+
export function fnv1a64(s) {
|
|
15
15
|
let h = FNV_OFFSET;
|
|
16
16
|
for (let i = 0; i < s.length; i++) {
|
|
17
17
|
h = (h ^ BigInt(s.charCodeAt(i))) * FNV_PRIME & MASK64;
|
package/dist/tools/expenses.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { jsonResponse } from './_shared.js';
|
|
2
|
+
import { jsonResponse, requestWrite, UnconfirmedWriteError, unconfirmedWriteResponse } from './_shared.js';
|
|
3
|
+
import { CONFIRM_NOTE, confirmTokenParam, confirmWrite } from './_confirm.js';
|
|
3
4
|
import { offsetState, readUpstreamPaging, withPaginationFirst } from './pagination.js';
|
|
4
5
|
import { getWriteMode } from '../config.js';
|
|
5
6
|
export function registerExpenseTools(server, client) {
|
|
@@ -44,14 +45,47 @@ export function registerExpenseTools(server, client) {
|
|
|
44
45
|
});
|
|
45
46
|
if (allowWrites)
|
|
46
47
|
server.registerTool('ofw_create_expense', {
|
|
47
|
-
description: 'Log a new expense in OurFamilyWizard',
|
|
48
|
-
|
|
48
|
+
description: 'Log a new expense in OurFamilyWizard. The expense is a money claim that appears in the shared ledger in front of the co-parent immediately, and this server cannot delete it. If the request fails without a definitive answer the result is EXPENSE_UNCONFIRMED: the expense may already exist, so do NOT retry until ofw_list_expenses shows it did not land. ' + CONFIRM_NOTE,
|
|
49
|
+
// Not a harmless local write: the claim is co-parent-visible at once and
|
|
50
|
+
// this server has no way to take it back. destructiveHint keeps a host
|
|
51
|
+
// that auto-approves "non-destructive" tools from running it silently.
|
|
52
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
49
53
|
inputSchema: z.object({
|
|
50
54
|
amount: z.number().describe('Expense amount'),
|
|
51
55
|
description: z.string().describe('Expense description'),
|
|
56
|
+
confirmToken: confirmTokenParam,
|
|
52
57
|
}),
|
|
53
|
-
}, async (args) => {
|
|
54
|
-
const
|
|
58
|
+
}, async (args, ctx) => {
|
|
59
|
+
const { confirmToken, ...payload } = args;
|
|
60
|
+
const gate = await confirmWrite(ctx, {
|
|
61
|
+
tool: 'ofw_create_expense',
|
|
62
|
+
action: 'ofw.expense.create',
|
|
63
|
+
message: 'Review and confirm this OurFamilyWizard expense. It is logged in the shared ledger the co-parent sees immediately, and cannot be deleted through this server.',
|
|
64
|
+
target: 'expense:new',
|
|
65
|
+
payload,
|
|
66
|
+
preview: {
|
|
67
|
+
action: 'Log OurFamilyWizard expense',
|
|
68
|
+
amount: payload.amount,
|
|
69
|
+
description: payload.description,
|
|
70
|
+
warning: 'Visible to the co-parent immediately as a claim in the shared expense ledger; part of the court-visible record.',
|
|
71
|
+
},
|
|
72
|
+
confirmToken,
|
|
73
|
+
});
|
|
74
|
+
if (gate)
|
|
75
|
+
return gate;
|
|
76
|
+
let data;
|
|
77
|
+
try {
|
|
78
|
+
data = await requestWrite(client, 'POST', '/pub/v2/expense/expenses', payload);
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
if (!(e instanceof UnconfirmedWriteError))
|
|
82
|
+
throw e;
|
|
83
|
+
return unconfirmedWriteResponse(e, {
|
|
84
|
+
result: 'EXPENSE_UNCONFIRMED',
|
|
85
|
+
what: 'log this expense',
|
|
86
|
+
checkWith: 'ofw_list_expenses (look for this amount and description among the newest expenses)',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
55
89
|
return jsonResponse(data);
|
|
56
90
|
});
|
|
57
91
|
}
|
package/dist/tools/journal.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { jsonResponse } from './_shared.js';
|
|
2
|
+
import { jsonResponse, requestWrite, UnconfirmedWriteError, unconfirmedWriteResponse } from './_shared.js';
|
|
3
3
|
import { offsetState, readUpstreamPaging, withPaginationFirst } from './pagination.js';
|
|
4
4
|
import { getWriteMode } from '../config.js';
|
|
5
5
|
export function registerJournalTools(server, client) {
|
|
@@ -45,7 +45,19 @@ export function registerJournalTools(server, client) {
|
|
|
45
45
|
body: z.string().describe('Entry text content'),
|
|
46
46
|
}),
|
|
47
47
|
}, async (args) => {
|
|
48
|
-
|
|
48
|
+
let data;
|
|
49
|
+
try {
|
|
50
|
+
data = await requestWrite(client, 'POST', '/pub/v1/journals', args);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
if (!(e instanceof UnconfirmedWriteError))
|
|
54
|
+
throw e;
|
|
55
|
+
return unconfirmedWriteResponse(e, {
|
|
56
|
+
result: 'JOURNAL_UNCONFIRMED',
|
|
57
|
+
what: 'create this journal entry',
|
|
58
|
+
checkWith: 'ofw_list_journal_entries (look for this title among the newest entries)',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
49
61
|
return jsonResponse(data);
|
|
50
62
|
});
|
|
51
63
|
}
|