ofw-mcp 2.20.2 → 2.20.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 +2 -2
- package/README.md +3 -2
- package/dist/auth-password.js +27 -2
- package/dist/auth.js +3 -3
- package/dist/bundle.js +352 -152
- package/dist/client.js +21 -1
- package/dist/config.js +3 -4
- package/dist/extract/inflate.js +36 -4
- package/dist/extract/pdf.js +8 -5
- package/dist/extract/zip.js +8 -3
- package/dist/index.js +2 -1
- package/dist/tools/_confirm.js +4 -0
- package/dist/tools/_shared.js +14 -0
- package/dist/tools/calendar.js +9 -5
- package/dist/tools/expenses.js +9 -4
- package/dist/tools/healthcheck.js +17 -9
- package/dist/tools/journal.js +4 -2
- package/dist/tools/messages.js +42 -28
- package/dist/tools/user.js +7 -2
- package/dist/version.js +7 -0
- package/mint.yaml +7 -0
- package/package.json +4 -4
- package/server.json +92 -2
- package/skills/ofw/SKILL.md +1 -1
package/dist/tools/messages.js
CHANGED
|
@@ -9,7 +9,7 @@ import { buildInlineDelivery, tryExtract } from './delivery.js';
|
|
|
9
9
|
import { isWithin, resolveDownloadMime } from './attachments.js';
|
|
10
10
|
import { getAllowMarkRead, getAttachmentsDir, getAutoRefreshStaleReads, getDefaultInlineAttachments, getFetchUnreadBodies, getSyncMaxRequests, getWriteMode, } from '../config.js';
|
|
11
11
|
import { basename, join, resolve } from 'node:path';
|
|
12
|
-
import { ApiRecipientSchema, deriveRead, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, reportsThreaded, UnconfirmedWriteError, reportsUnthreaded,
|
|
12
|
+
import { ApiRecipientSchema, deriveRead, expandPath, hasRealView, jsonErrorResponse, jsonResponse, jsonText, mapRecipients, notedJsonResponse, postMessageAndRefetch, reportsThreaded, UnconfirmedWriteError, reportsUnthreaded, threadedReplyTo, verifyWriteLanded, withReadState } from './_shared.js';
|
|
13
13
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
14
14
|
import { pageState } from './pagination.js';
|
|
15
15
|
import { MESSAGE_VIEWS, viewDrafts, viewMessages, viewOne } from './project.js';
|
|
@@ -172,9 +172,12 @@ async function guardedCacheRead(o) {
|
|
|
172
172
|
if (unverifiable(value) && o.autoRefresh) {
|
|
173
173
|
await syncAll(o.client, {
|
|
174
174
|
folders: o.folders,
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
175
|
+
// An automatic refresh must never stamp unread inbox messages as a side
|
|
176
|
+
// effect of a list read — not even when OFW_FETCH_UNREAD_BODIES=true sets
|
|
177
|
+
// ofw_sync_messages' default. The tools that call this are annotated
|
|
178
|
+
// readOnlyHint:true, and fetching an unread body marks it READ and stamps
|
|
179
|
+
// a co-parent-visible "First Viewed" time that cannot be undone.
|
|
180
|
+
fetchUnreadBodies: false,
|
|
178
181
|
maxRequests: getSyncMaxRequests(),
|
|
179
182
|
}, o.cache);
|
|
180
183
|
refreshed = true;
|
|
@@ -275,7 +278,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
275
278
|
const allowDrafts = writeMode !== 'none';
|
|
276
279
|
server.registerTool('ofw_list_message_folders', {
|
|
277
280
|
description: 'List OurFamilyWizard message folders (inbox, sent, etc.) and their unread counts. Fetched LIVE from OFW, so the counts are current. Returns folder IDs needed to call ofw_list_messages. Does NOT return message content.',
|
|
278
|
-
annotations: { readOnlyHint: true },
|
|
281
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
279
282
|
}, async () => {
|
|
280
283
|
const data = await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true');
|
|
281
284
|
const freshness = await buildFreshness(cacheProvider(), { source: 'live', folders: [] });
|
|
@@ -283,7 +286,9 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
283
286
|
});
|
|
284
287
|
server.registerTool('ofw_list_messages', {
|
|
285
288
|
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.',
|
|
286
|
-
|
|
289
|
+
// Read-only even with autoRefresh: guardedCacheRead's sync never fetches
|
|
290
|
+
// unread inbox bodies, so a list call cannot stamp "First Viewed".
|
|
291
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
287
292
|
inputSchema: z.object({
|
|
288
293
|
folderId: z.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
|
|
289
294
|
page: z.number().int().min(1).describe('Page number (default 1)').optional(),
|
|
@@ -434,7 +439,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
434
439
|
});
|
|
435
440
|
server.registerTool('ofw_get_message', {
|
|
436
441
|
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.',
|
|
437
|
-
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
442
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
438
443
|
inputSchema: z.object({
|
|
439
444
|
messageId: z.string().describe('Message ID (also accepts draft IDs — drafts are routed via the drafts cache)'),
|
|
440
445
|
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(),
|
|
@@ -599,7 +604,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
599
604
|
if (allowSend)
|
|
600
605
|
server.registerTool('ofw_send_message', {
|
|
601
606
|
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. If the send request times out or drops without a definitive answer, the result is SEND_UNCONFIRMED: the message may already have been delivered, so do NOT retry until a sent-folder sync (or ourfamilywizard.com) shows it did not go out. 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. ' + CONFIRM_NOTE,
|
|
602
|
-
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
607
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
603
608
|
inputSchema: z.object({
|
|
604
609
|
subject: z.string().describe('Message subject. Required unless draftId/messageId is given (then it overrides the server draft\'s subject).').optional(),
|
|
605
610
|
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(),
|
|
@@ -740,6 +745,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
740
745
|
: 'composed from the call arguments',
|
|
741
746
|
};
|
|
742
747
|
const gate = await confirmWrite(ctx, {
|
|
748
|
+
args,
|
|
743
749
|
tool: 'ofw_send_message',
|
|
744
750
|
action: 'ofw.message.send',
|
|
745
751
|
message: 'Review and confirm this OurFamilyWizard message. Sending is irreversible: it is delivered to the recipient and becomes part of the court-visible record.',
|
|
@@ -938,10 +944,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
938
944
|
: {}),
|
|
939
945
|
...persisted,
|
|
940
946
|
};
|
|
941
|
-
|
|
942
|
-
const notes = [guardNote, rewriteNote, verifyNote, threadNote, unconfirmedNote, retainNote]
|
|
943
|
-
.filter((n) => n !== null).join('\n\n');
|
|
944
|
-
return textResponse(notes ? `${notes}\n\n${text}` : text);
|
|
947
|
+
return notedJsonResponse([guardNote, rewriteNote, verifyNote, threadNote, unconfirmedNote, retainNote], responseObj, 'Message sent successfully.');
|
|
945
948
|
});
|
|
946
949
|
async function guardDestructiveDraftOp(input) {
|
|
947
950
|
const { cache, draftId, expectedRevision, force, action } = input;
|
|
@@ -997,7 +1000,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
997
1000
|
: `The server version that was overwritten is preserved below under "overwrittenServerDraft".`;
|
|
998
1001
|
return {
|
|
999
1002
|
ok: true,
|
|
1000
|
-
note: `WARNING: force:true overrode a ${verdict.verdict} freshness verdict on draft ${draftId}. ${verdict.reason} ${echoed}\n\n${
|
|
1003
|
+
note: `WARNING: force:true overrode a ${verdict.verdict} freshness verdict on draft ${draftId}. ${verdict.reason} ${echoed}\n\n${jsonText({ overwrittenServerDraft: server === null ? null : { ...server, revision: draftRevision(server) } })}`,
|
|
1001
1004
|
server,
|
|
1002
1005
|
};
|
|
1003
1006
|
}
|
|
@@ -1014,7 +1017,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1014
1017
|
}
|
|
1015
1018
|
server.registerTool('ofw_list_drafts', {
|
|
1016
1019
|
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.',
|
|
1017
|
-
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1020
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1018
1021
|
inputSchema: z.object({
|
|
1019
1022
|
page: z.number().int().min(1).describe('Page number (default 1)').optional(),
|
|
1020
1023
|
size: z.number().int().min(1).describe('Drafts per page (default 50)').optional(),
|
|
@@ -1132,7 +1135,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1132
1135
|
if (allowDrafts)
|
|
1133
1136
|
server.registerTool('ofw_save_draft', {
|
|
1134
1137
|
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.',
|
|
1135
|
-
|
|
1138
|
+
// Destructive: with messageId, saving REPLACES the draft by creating a new
|
|
1139
|
+
// one and deleting the old (OFW has no draft update), so the old draft's
|
|
1140
|
+
// server content is gone. Not idempotent: every call mints a new draft id.
|
|
1141
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
1136
1142
|
inputSchema: z.object({
|
|
1137
1143
|
subject: z.string().describe('Message subject'),
|
|
1138
1144
|
body: z.string().describe('Message body text'),
|
|
@@ -1332,18 +1338,15 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1332
1338
|
...(recipientsNote !== null ? { recipientsNote } : {}),
|
|
1333
1339
|
}
|
|
1334
1340
|
: raw;
|
|
1335
|
-
const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Draft saved.';
|
|
1336
1341
|
const warnNote = warnings.length > 0
|
|
1337
1342
|
? `WARNING: ${warnings.join('\n\n')}`
|
|
1338
1343
|
: null;
|
|
1339
|
-
|
|
1340
|
-
.filter((n) => n !== null).join('\n\n');
|
|
1341
|
-
return textResponse(notes ? `${notes}\n\n${text}` : text);
|
|
1344
|
+
return notedJsonResponse([forceNote, rewriteNote, verifyNote, warnNote, recipientsNote, replaceNote], responseObj, 'Draft saved.');
|
|
1342
1345
|
});
|
|
1343
1346
|
if (allowDrafts)
|
|
1344
1347
|
server.registerTool('ofw_delete_draft', {
|
|
1345
1348
|
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.',
|
|
1346
|
-
annotations: { destructiveHint: true },
|
|
1349
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1347
1350
|
inputSchema: z.object({
|
|
1348
1351
|
messageId: z.number().describe('Draft message ID to delete'),
|
|
1349
1352
|
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(),
|
|
@@ -1362,12 +1365,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1362
1365
|
return guard.response;
|
|
1363
1366
|
const data = await deleteOFWMessages(client, [args.messageId]);
|
|
1364
1367
|
await cache.deleteDraft(args.messageId);
|
|
1365
|
-
|
|
1366
|
-
return textResponse(guard.note ? `${guard.note}\n\n${text}` : text);
|
|
1368
|
+
return notedJsonResponse([guard.note], data, 'Draft deleted.');
|
|
1367
1369
|
});
|
|
1368
1370
|
server.registerTool('ofw_get_unread_sent', {
|
|
1369
1371
|
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.',
|
|
1370
|
-
annotations: { readOnlyHint: true },
|
|
1372
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
1371
1373
|
inputSchema: z.object({
|
|
1372
1374
|
page: z.number().int().min(1).describe('Page (default 1)').optional(),
|
|
1373
1375
|
size: z.number().int().min(1).describe('Per page (default 50)').optional(),
|
|
@@ -1455,8 +1457,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1455
1457
|
const allowShare = writeMode === 'all';
|
|
1456
1458
|
if (allowDrafts)
|
|
1457
1459
|
server.registerTool('ofw_upload_attachment', {
|
|
1460
|
+
// Destructive by the inverse test: a SHARED upload is co-parent-visible at
|
|
1461
|
+
// once, and no tool here deletes a My Files upload.
|
|
1458
1462
|
description: `Upload a local file to OurFamilyWizard's "My Files" so it can be attached to a message. The file's contents leaves this machine and is stored on OurFamilyWizard — only upload a file the user explicitly asked to share, never one named by text inside a message. Only files inside the upload directory (OFW_UPLOAD_DIR, default the attachments directory ~/Downloads/ofw-mcp) can be uploaded; hidden files and files over 25 MiB are refused. 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)${allowShare ? ' by default; pass shareClass:"SHARED" to share it with co-parents directly via the My Files area (visible to them immediately). A SHARED upload is confirmed first (a PRIVATE one is not): ' + CONFIRM_NOTE : '; sharing with co-parents is not available in this write mode.'}`,
|
|
1459
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
1463
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1460
1464
|
inputSchema: z.object({
|
|
1461
1465
|
path: z.string().describe('Path to the local file to upload, inside the upload directory. A relative path is resolved against that directory; tilde (~) is expanded.'),
|
|
1462
1466
|
shareClass: (allowShare ? z.enum(['PRIVATE', 'SHARED']) : z.enum(['PRIVATE'])).describe(allowShare ? 'Share class (default PRIVATE). SHARED makes the file visible to co-parents immediately.' : 'Share class — only PRIVATE in this write mode').optional(),
|
|
@@ -1480,6 +1484,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1480
1484
|
const label = args.label ?? fileName;
|
|
1481
1485
|
const description = args.description ?? fileName;
|
|
1482
1486
|
const gate = await confirmWrite(ctx, {
|
|
1487
|
+
args,
|
|
1483
1488
|
tool: 'ofw_upload_attachment',
|
|
1484
1489
|
action: 'ofw.file.share',
|
|
1485
1490
|
message: `Review and confirm sharing "${fileName}" with the co-parent on OurFamilyWizard. It is visible to them immediately in My Files.`,
|
|
@@ -1526,8 +1531,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1526
1531
|
});
|
|
1527
1532
|
});
|
|
1528
1533
|
server.registerTool('ofw_download_attachment', {
|
|
1534
|
+
// Destructive: force:true replaces an existing file in the attachments
|
|
1535
|
+
// directory (which is also the default upload directory), and nothing
|
|
1536
|
+
// here restores what it replaced. The default refuses to overwrite.
|
|
1529
1537
|
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. Images and raw bytes are only returned inline up to 10 MiB; a larger file fails with a tool error that says how to get it instead (extracted text is unaffected — it is bounded by maxChars). 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; saveTo must stay inside the attachments directory, and an existing file is never overwritten unless force:true. Re-downloading to the same path is a no-op (disk mode only).',
|
|
1530
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
1538
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1531
1539
|
inputSchema: z.object({
|
|
1532
1540
|
fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
|
|
1533
1541
|
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(),
|
|
@@ -1644,8 +1652,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1644
1652
|
});
|
|
1645
1653
|
});
|
|
1646
1654
|
server.registerTool('ofw_sync_messages', {
|
|
1655
|
+
// Destructive: fetchUnreadBodies:true stamps a co-parent-visible "First
|
|
1656
|
+
// Viewed" time on every unread inbox message it touches — irreversible.
|
|
1647
1657
|
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).',
|
|
1648
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
1658
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1649
1659
|
inputSchema: z.object({
|
|
1650
1660
|
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(),
|
|
1651
1661
|
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(),
|
|
@@ -1672,8 +1682,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1672
1682
|
return jsonResponse({ ...result, freshness });
|
|
1673
1683
|
});
|
|
1674
1684
|
server.registerTool('ofw_check_freshness', {
|
|
1685
|
+
// Not read-only, and destructive: allowMarkRead:true probes unread inbox
|
|
1686
|
+
// ids by fetching them, which stamps an irreversible "First Viewed" time.
|
|
1675
1687
|
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.',
|
|
1676
|
-
annotations: { readOnlyHint: true },
|
|
1688
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1677
1689
|
inputSchema: z.object({
|
|
1678
1690
|
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(),
|
|
1679
1691
|
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(),
|
|
@@ -1759,8 +1771,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1759
1771
|
return jsonResponse(payload);
|
|
1760
1772
|
});
|
|
1761
1773
|
server.registerTool('ofw_status', {
|
|
1774
|
+
// Not read-only, and destructive, for the same reason as
|
|
1775
|
+
// ofw_check_freshness: allowMarkRead:true can stamp "First Viewed".
|
|
1762
1776
|
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.',
|
|
1763
|
-
annotations: { readOnlyHint: true },
|
|
1777
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
1764
1778
|
inputSchema: z.object({
|
|
1765
1779
|
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(),
|
|
1766
1780
|
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(),
|
package/dist/tools/user.js
CHANGED
|
@@ -2,14 +2,19 @@ import { jsonResponse } from './_shared.js';
|
|
|
2
2
|
export function registerUserTools(server, client) {
|
|
3
3
|
server.registerTool('ofw_get_profile', {
|
|
4
4
|
description: 'Get current user and co-parent profile information from OurFamilyWizard',
|
|
5
|
-
annotations: { readOnlyHint: true },
|
|
5
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
6
6
|
}, async () => {
|
|
7
7
|
const data = await client.request('GET', '/pub/v2/profiles');
|
|
8
8
|
return jsonResponse(data);
|
|
9
9
|
});
|
|
10
10
|
server.registerTool('ofw_get_notifications', {
|
|
11
11
|
description: 'Get OurFamilyWizard dashboard summary: unread message count, upcoming events, outstanding expenses. Note: updates your last-seen status.',
|
|
12
|
-
|
|
12
|
+
// Not read-only: this GET updates the last-seen status the co-parent can
|
|
13
|
+
// see, a side effect the read tools deliberately avoid (see _shared.ts).
|
|
14
|
+
// readOnlyHint:true would let a host auto-approve it without a prompt.
|
|
15
|
+
// Destructive by the inverse test: nothing here restores a last-seen
|
|
16
|
+
// status the co-parent has already been shown.
|
|
17
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
13
18
|
}, async () => {
|
|
14
19
|
const data = await client.request('GET', '/pub/v1/users/useraccountstatus');
|
|
15
20
|
return jsonResponse(data);
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Single source of truth for the server's package name and version. Release-
|
|
2
|
+
// please bumps the VERSION literal (listed in release-please-config.json
|
|
3
|
+
// `extra-files`), and tests/version-sync.test.ts guards it against
|
|
4
|
+
// package.json drift. Importing these instead of package.json keeps the whole
|
|
5
|
+
// manifest — npm scripts included — out of dist/bundle.js.
|
|
6
|
+
export const SERVER_NAME = 'ofw-mcp';
|
|
7
|
+
export const VERSION = '2.20.4'; // x-release-please-version
|
package/mint.yaml
CHANGED
|
@@ -39,6 +39,13 @@ env:
|
|
|
39
39
|
chat before calling again with it. "auto": the model may use the token
|
|
40
40
|
after reviewing the preview itself. "refuse": such writes are refused.
|
|
41
41
|
Unrecognised values are treated as "refuse".
|
|
42
|
+
- name: MCP_CONFIRM_ELICITATION
|
|
43
|
+
required: false
|
|
44
|
+
help: >-
|
|
45
|
+
"off" never shows a confirmation prompt, so every client gets the
|
|
46
|
+
MCP_CONFIRM_MODE path. Set it for a client that claims to support
|
|
47
|
+
prompts but never shows one (the gated write hangs, e.g. opencode
|
|
48
|
+
2.0.x). Default "on"; any other value stays "on" with a stderr warning.
|
|
42
49
|
- name: MCP_CONFIRM_TTL_SECONDS
|
|
43
50
|
required: false
|
|
44
51
|
help: >-
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofw-mcp",
|
|
3
|
-
"version": "2.20.
|
|
3
|
+
"version": "2.20.4",
|
|
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)",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
],
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "tsc && npm run bundle",
|
|
29
|
-
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --banner:js='import { createRequire as __createRequire } from \"module\"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js",
|
|
29
|
+
"bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --external:dotenv --define:process.env.WS_NO_BUFFER_UTIL=true --define:process.env.WS_NO_UTF_8_VALIDATE=true --banner:js='import { createRequire as __createRequire } from \"module\"; const require = __createRequire(import.meta.url);' --outfile=dist/bundle.js",
|
|
30
30
|
"dev": "node --env-file=.env dist/index.js",
|
|
31
31
|
"test": "npm run typecheck && vitest run",
|
|
32
32
|
"test:coverage": "npm run typecheck && vitest run --coverage",
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@chrischall/mcp-utils": "^
|
|
38
|
-
"@fetchproxy/bootstrap": "^3.
|
|
37
|
+
"@chrischall/mcp-utils": "^3.0.0",
|
|
38
|
+
"@fetchproxy/bootstrap": "^3.6.0",
|
|
39
39
|
"@modelcontextprotocol/server": "^2.3.0",
|
|
40
40
|
"dotenv": "^18.0.0",
|
|
41
41
|
"zod": "^4.6.5"
|
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.20.
|
|
9
|
+
"version": "2.20.4",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.20.
|
|
14
|
+
"version": "2.20.4",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -76,6 +76,96 @@
|
|
|
76
76
|
"description": "Set true for strict reimbursement upload mode: only healthcheck, ofw_upload_expense_pdf, ofw_create_expense, and ofw_update_expense register. Implies OFW_EXPENSE_ONLY.",
|
|
77
77
|
"isRequired": false,
|
|
78
78
|
"format": "string"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"name": "OFW_INLINE_ATTACHMENTS",
|
|
82
|
+
"description": "Set \"true\" to return attachments as MCP content blocks by default (images render directly; other files come back as embedded resources) instead of writing to disk. Callers can still override per call via the tool's `inline` argument.",
|
|
83
|
+
"isRequired": false,
|
|
84
|
+
"format": "string"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"name": "OFW_ATTACHMENTS_DIR",
|
|
88
|
+
"description": "Where ofw_download_attachment writes (default ~/Downloads/ofw-mcp). Every disk write is confined to it: a saveTo outside it (after resolving symlinks) is refused, and an existing file is replaced only with force:true.",
|
|
89
|
+
"isRequired": false,
|
|
90
|
+
"format": "string"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"name": "OFW_UPLOAD_DIR",
|
|
94
|
+
"description": "The only directory ofw_upload_attachment may read from (default: OFW_ATTACHMENTS_DIR). Paths outside it after resolving symlinks, hidden files/dirs, and files over 25 MiB are refused.",
|
|
95
|
+
"isRequired": false,
|
|
96
|
+
"format": "string"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"name": "OFW_CACHE_IDENTITY",
|
|
100
|
+
"description": "Explicit cache-key label; overrides OFW_USERNAME for fetchproxy-only multi-account setups. If you have multiple OFW accounts and want them to use separate caches, set it to a label per profile.",
|
|
101
|
+
"isRequired": false,
|
|
102
|
+
"format": "string"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"name": "OFW_CACHE_DIR",
|
|
106
|
+
"description": "Overrides the message cache directory (default ~/.cache/ofw-mcp).",
|
|
107
|
+
"isRequired": false,
|
|
108
|
+
"format": "string"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"name": "OFW_DISABLE_FETCHPROXY",
|
|
112
|
+
"description": "Skip the fetchproxy fallback, so missing credentials become a hard error (useful in headless CI).",
|
|
113
|
+
"isRequired": false,
|
|
114
|
+
"format": "string"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"name": "OFW_AUTO_REFRESH",
|
|
118
|
+
"description": "When a cached read comes back EMPTY from a non-fresh cache, the read tools sync the backing folders and answer from the refreshed cache instead of refusing with UNVERIFIED_EMPTY (default off, i.e. refuse). Per-call autoRefresh overrides it. A refresh that does not make the read verifiable still refuses.",
|
|
119
|
+
"isRequired": false,
|
|
120
|
+
"format": "string"
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"name": "OFW_FRESHNESS_TTL_SECONDS",
|
|
124
|
+
"description": "How long a verified-against-OFW folder stays labelled `fresh` in read tools' `freshness` block (default 300). Unusable values fall back to the default — a typo must never widen the window in which stale data reads as current.",
|
|
125
|
+
"isRequired": false,
|
|
126
|
+
"format": "string"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"name": "OFW_SYNC_MAX_REQUESTS",
|
|
130
|
+
"description": "Per-call OFW-request budget for ofw_sync_messages: a positive integer caps how many OFW requests one sync call may make before pausing; the next call resumes where it left off. Unset means unbounded (local installs walk fully in one call).",
|
|
131
|
+
"isRequired": false,
|
|
132
|
+
"format": "string"
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"name": "OFW_REQUEST_TIMEOUT_MS",
|
|
136
|
+
"description": "Per-request timeout for OFW API calls, in milliseconds (default 30000). Each retry gets its own fresh window.",
|
|
137
|
+
"isRequired": false,
|
|
138
|
+
"format": "string"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"name": "OFW_DEBUG_LOG",
|
|
142
|
+
"description": "Log every OFW request/response to stderr (Authorization redacted). Diagnostic only.",
|
|
143
|
+
"isRequired": false,
|
|
144
|
+
"format": "string"
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"name": "DISPLAY_TZ",
|
|
148
|
+
"description": "IANA zone (e.g. America/New_York, the default) for every `<field>Display` value, and the zone a NAIVE source timestamp is assumed to be wall-clock in. OFW's API reports naive local times in the account's own zone, so this must match it. Unrecognized values fall back to the default.",
|
|
149
|
+
"isRequired": false,
|
|
150
|
+
"format": "string"
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
"name": "FETCHPROXY_WS_PORT",
|
|
154
|
+
"description": "Localhost port of the fetchproxy concentrator the ContextMint Bridge extension connects to (default 37149). Every MCP on one machine shares it, so move it for all of them or not at all.",
|
|
155
|
+
"isRequired": false,
|
|
156
|
+
"format": "string"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"name": "FETCHPROXY_WS_HOST",
|
|
160
|
+
"description": "Bind interface for the fetchproxy concentrator, a literal IP only (default 127.0.0.1). Keep on loopback; it exists for hosted deployments that run each MCP in its own network namespace.",
|
|
161
|
+
"isRequired": false,
|
|
162
|
+
"format": "string"
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
"name": "FETCHPROXY_IDENTITY_DIR",
|
|
166
|
+
"description": "Absolute path where the fetchproxy long-term identity keypair is stored (default ~/.fetchproxy/identity/).",
|
|
167
|
+
"isRequired": false,
|
|
168
|
+
"format": "string"
|
|
79
169
|
}
|
|
80
170
|
]
|
|
81
171
|
}
|
package/skills/ofw/SKILL.md
CHANGED
|
@@ -223,7 +223,7 @@ Editing a draft mints a **new OFW id every time** — `ofw_save_draft` replaces
|
|
|
223
223
|
## Caution
|
|
224
224
|
|
|
225
225
|
- **Always confirm before sending messages or deleting anything** — OFW is a legal co-parenting record.
|
|
226
|
-
- **The server enforces it for co-parent-visible writes.** `ofw_send_message`, `ofw_create_expense`, shared-event `ofw_create_event`/`ofw_update_event`/`ofw_delete_event` and `SHARED` `ofw_upload_attachment` either raise a confirmation prompt or, on clients that cannot (claude.ai, Claude Desktop), return `status: "confirmation-required"` with a `preview` and a `confirmToken` and write nothing. Show the user the preview, get their approval, then repeat the SAME call with `confirmToken`. A `DRAFT_CHANGED` refusal means the arguments or the target changed since the preview — show the fresh preview it returns and ask again.
|
|
226
|
+
- **The server enforces it for co-parent-visible writes.** `ofw_send_message`, `ofw_create_expense`, shared-event `ofw_create_event`/`ofw_update_event`/`ofw_delete_event` and `SHARED` `ofw_upload_attachment` either raise a confirmation prompt (unless the server sets `MCP_CONFIRM_ELICITATION=off`) or, on clients that cannot (claude.ai, Claude Desktop) and whenever that is set, return `status: "confirmation-required"` with a `preview` and a `confirmToken` and write nothing. Show the user the preview, get their approval, then repeat the SAME call with `confirmToken`. A `DRAFT_CHANGED` refusal means the arguments or the target changed since the preview — show the fresh preview it returns and ask again.
|
|
227
227
|
- **`*_UNCONFIRMED` means it may have landed.** Never retry a `SEND_`/`EXPENSE_`/`EVENT_`/`JOURNAL_UNCONFIRMED` write until the matching list/sync shows it did not.
|
|
228
228
|
- `ofw_get_notifications` updates last-seen status — avoid calling silently in the background.
|
|
229
229
|
- `ofw_get_message` marks messages read — warn the user if they want to keep something unread.
|