ofw-mcp 2.4.3 → 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/auth.js +6 -14
- package/dist/bundle.js +404 -141
- package/dist/client.js +4 -10
- package/dist/config.js +23 -21
- package/dist/index.js +1 -1
- package/dist/sync.js +25 -9
- package/dist/tools/_shared.js +35 -9
- package/dist/tools/calendar.js +145 -29
- package/dist/tools/messages.js +40 -9
- package/package.json +4 -4
- package/server.json +8 -2
- package/skills/ofw-fpx/SKILL.md +106 -0
- package/skills/ofw-fpx/references/requests.md +252 -0
- package/dist/validate.js +0 -35
package/dist/client.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { loadDotenvSafely } from '@chrischall/mcp-utils';
|
|
1
|
+
import { loadDotenvSafely, parseBoolEnv, redactSecrets } from '@chrischall/mcp-utils';
|
|
2
2
|
import { TokenManager } from '@chrischall/mcp-utils/session';
|
|
3
3
|
import { dirname, join } from 'path';
|
|
4
4
|
import { fileURLToPath } from 'url';
|
|
5
5
|
import { resolveAuth } from './auth.js';
|
|
6
|
-
import { parseBoolEnv } from './config.js';
|
|
7
6
|
import { BASE_URL, OFW_PROTOCOL_HEADERS, OFW_TOKEN_TTL_MS, OFW_TOKEN_EXPIRY_SKEW_MS } from './protocol.js';
|
|
8
7
|
// Load .env for local dev; silently skip if dotenv is unavailable (e.g. mcpb
|
|
9
8
|
// bundle). loadDotenvSafely applies override:false + quiet:true and swallows a
|
|
@@ -32,13 +31,6 @@ function parseContentDispositionFilename(cd) {
|
|
|
32
31
|
function debugLogEnabled() {
|
|
33
32
|
return parseBoolEnv('OFW_DEBUG_LOG');
|
|
34
33
|
}
|
|
35
|
-
function redactHeaders(h) {
|
|
36
|
-
const out = { ...h };
|
|
37
|
-
/* v8 ignore next -- request headers always carry Authorization (set in request()); the guard is defensive for arbitrary header maps */
|
|
38
|
-
if (out.Authorization)
|
|
39
|
-
out.Authorization = `Bearer ${out.Authorization.slice(7, 17)}…`;
|
|
40
|
-
return out;
|
|
41
|
-
}
|
|
42
34
|
// Per-request timeout. Overridable via OFW_REQUEST_TIMEOUT_MS. The default
|
|
43
35
|
// (30s) is comfortably above OFW's typical p99 but low enough that a stuck
|
|
44
36
|
// upstream fails fast instead of burning the MCP client-side budget — which
|
|
@@ -148,7 +140,9 @@ export class OFWClient {
|
|
|
148
140
|
? `<FormData entries=${Array.from(body.keys()).join(',')}>`
|
|
149
141
|
: JSON.stringify(body);
|
|
150
142
|
console.error(`[ofw-debug] → ${method} ${url}${isRetry ? ' (retry)' : ''}`);
|
|
151
|
-
|
|
143
|
+
// redactSecrets scrubs the Bearer token (and any other secret shapes)
|
|
144
|
+
// from the serialized header map — shared fleet redaction, never bespoke.
|
|
145
|
+
console.error(`[ofw-debug] headers: ${redactSecrets(JSON.stringify(headers))}`);
|
|
152
146
|
console.error(`[ofw-debug] body: ${bodyPreview}`);
|
|
153
147
|
}
|
|
154
148
|
// AbortController + setTimeout (not AbortSignal.timeout) so vitest fake
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { parseBoolEnv
|
|
4
|
+
import { parseBoolEnv, readEnvVar } from '@chrischall/mcp-utils';
|
|
5
5
|
// Cache identity drives the per-user SQLite DB filename. Order of preference:
|
|
6
6
|
// 1. OFW_CACHE_IDENTITY — explicit override for users who want to label the
|
|
7
7
|
// cache themselves (e.g. when authing via fetchproxy and OFW_USERNAME is
|
|
@@ -11,13 +11,7 @@ import { parseBoolEnv as parseBoolEnvUtil } from '@chrischall/mcp-utils';
|
|
|
11
11
|
// Single-user installs are fine on this; multi-account users should set
|
|
12
12
|
// OFW_CACHE_IDENTITY explicitly so their caches don't collide.
|
|
13
13
|
function readCacheIdentity() {
|
|
14
|
-
|
|
15
|
-
if (typeof explicit === 'string' && explicit.trim().length > 0)
|
|
16
|
-
return explicit.trim();
|
|
17
|
-
const username = process.env.OFW_USERNAME;
|
|
18
|
-
if (typeof username === 'string' && username.trim().length > 0)
|
|
19
|
-
return username.trim();
|
|
20
|
-
return '_default';
|
|
14
|
+
return readEnvVar('OFW_CACHE_IDENTITY') ?? readEnvVar('OFW_USERNAME') ?? '_default';
|
|
21
15
|
}
|
|
22
16
|
export function getCacheDir() {
|
|
23
17
|
const override = process.env.OFW_CACHE_DIR;
|
|
@@ -41,19 +35,6 @@ export function getAttachmentsDir() {
|
|
|
41
35
|
// location across macOS/Linux/Windows.
|
|
42
36
|
return join(homedir(), 'Downloads', 'ofw-mcp');
|
|
43
37
|
}
|
|
44
|
-
/**
|
|
45
|
-
* True when a boolean-shaped env var is set to "1", "true", "yes", or "on"
|
|
46
|
-
* (case-insensitive, trimmed). Anything else — unset, empty, or other
|
|
47
|
-
* values — is false. Used for OFW_INLINE_ATTACHMENTS, OFW_DISABLE_FETCHPROXY,
|
|
48
|
-
* OFW_DEBUG_LOG, etc.
|
|
49
|
-
*
|
|
50
|
-
* Delegates to @chrischall/mcp-utils' `parseBoolEnv` (which also recognizes
|
|
51
|
-
* the falsy set 0/false/no/off — behavior-equivalent here since callers only
|
|
52
|
-
* care about the truthy case and everything else defaults to false).
|
|
53
|
-
*/
|
|
54
|
-
export function parseBoolEnv(name) {
|
|
55
|
-
return parseBoolEnvUtil(name);
|
|
56
|
-
}
|
|
57
38
|
/**
|
|
58
39
|
* Gate for write-tool registration, read at registration time (startup).
|
|
59
40
|
*
|
|
@@ -80,6 +61,27 @@ export function getWriteMode() {
|
|
|
80
61
|
console.error(`[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" — failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`);
|
|
81
62
|
return 'none';
|
|
82
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Calendar-write opt-in for 'drafts' deployments.
|
|
66
|
+
*
|
|
67
|
+
* Messages have a draft stage (the human sends from the web UI), so 'drafts'
|
|
68
|
+
* mode keeps a human between model output and the court-visible record.
|
|
69
|
+
* Calendar events have no draft stage — but unlike a sent message they are
|
|
70
|
+
* fully reversible (editable and deletable), so a drafts-mode user may accept
|
|
71
|
+
* direct calendar writes without accepting sends. Setting
|
|
72
|
+
* OFW_CALENDAR_WRITES=true registers the calendar write tools
|
|
73
|
+
* (ofw_create_event, ofw_update_event, ofw_delete_event) alongside the
|
|
74
|
+
* draft-level message writes.
|
|
75
|
+
*
|
|
76
|
+
* The flag never overrides 'none': that mode is the hard read-only guarantee,
|
|
77
|
+
* including the fail-closed result of an unrecognized OFW_WRITE_MODE.
|
|
78
|
+
*/
|
|
79
|
+
export function getCalendarWritesAllowed() {
|
|
80
|
+
const mode = getWriteMode();
|
|
81
|
+
if (mode === 'all')
|
|
82
|
+
return true;
|
|
83
|
+
return mode === 'drafts' && parseBoolEnv('OFW_CALENDAR_WRITES');
|
|
84
|
+
}
|
|
83
85
|
// Default for ofw_download_attachment's `inline` arg when the caller doesn't
|
|
84
86
|
// pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
|
|
85
87
|
// MCP content blocks by default (skipping disk) — useful on sandboxed MCP
|
package/dist/index.js
CHANGED
|
@@ -24,7 +24,7 @@ import { registerJournalTools } from './tools/journal.js';
|
|
|
24
24
|
// always succeeds before any credential check runs.
|
|
25
25
|
await runMcp({
|
|
26
26
|
name: 'ofw',
|
|
27
|
-
version: '2.
|
|
27
|
+
version: '2.5.0', // x-release-please-version
|
|
28
28
|
deps: client,
|
|
29
29
|
tools: [
|
|
30
30
|
registerUserTools,
|
package/dist/sync.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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';
|
|
4
|
-
import {
|
|
3
|
+
import { ApiRecipientSchema, hasRealView, mapRecipients } from './tools/_shared.js';
|
|
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
|
|
7
7
|
// without downloading bytes. Bytes are pulled lazily by ofw_download_attachment.
|
|
@@ -22,7 +22,7 @@ const FileMetaSchema = z.looseObject({
|
|
|
22
22
|
// best-effort helper below; callers that need the result (download tool) let
|
|
23
23
|
// the throw propagate.
|
|
24
24
|
export async function fetchAttachmentMeta(client, fileId, messageId) {
|
|
25
|
-
const meta =
|
|
25
|
+
const meta = parseLenient(FileMetaSchema, await client.request('GET', `/pub/v1/myfiles/${fileId}`), { label: 'ofw-mcp', context: 'GET /pub/v1/myfiles/{fileId}' });
|
|
26
26
|
upsertAttachmentForMessage({
|
|
27
27
|
fileId: meta.fileId ?? fileId,
|
|
28
28
|
fileName: meta.fileName ?? `file-${fileId}`,
|
|
@@ -44,7 +44,7 @@ const FoldersSchema = z.looseObject({
|
|
|
44
44
|
systemFolders: z.array(z.looseObject({ id: z.string(), folderType: z.string() })).optional(),
|
|
45
45
|
});
|
|
46
46
|
export async function resolveFolderIds(client) {
|
|
47
|
-
const data =
|
|
47
|
+
const data = parseLenient(FoldersSchema, await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true'), { label: 'ofw-mcp', context: 'GET /pub/v1/messageFolders' });
|
|
48
48
|
const sys = data.systemFolders ?? [];
|
|
49
49
|
const find = (type) => {
|
|
50
50
|
const f = sys.find((x) => x.folderType === type);
|
|
@@ -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;
|
|
@@ -83,7 +86,7 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
|
83
86
|
const unread = [];
|
|
84
87
|
while (true) {
|
|
85
88
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
86
|
-
const list =
|
|
89
|
+
const list = parseLenient(ListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: `GET /pub/v3/messages?folders={${folder}}` });
|
|
87
90
|
const items = list.data ?? [];
|
|
88
91
|
if (items.length === 0)
|
|
89
92
|
break;
|
|
@@ -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;
|
|
@@ -101,7 +117,7 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
|
101
117
|
let fetchedBodyAt = null;
|
|
102
118
|
let detailFileIds = [];
|
|
103
119
|
if (shouldFetchBody) {
|
|
104
|
-
const detail =
|
|
120
|
+
const detail = parseLenient(DetailResponseSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (sync)' });
|
|
105
121
|
body = detail.body ?? '';
|
|
106
122
|
fetchedBodyAt = new Date().toISOString();
|
|
107
123
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
@@ -170,7 +186,7 @@ export async function syncDrafts(client, draftsFolderId) {
|
|
|
170
186
|
let page = 1;
|
|
171
187
|
while (true) {
|
|
172
188
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
173
|
-
const list =
|
|
189
|
+
const list = parseLenient(DraftListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: 'GET /pub/v3/messages?folders={drafts}' });
|
|
174
190
|
const pageItems = list.data ?? [];
|
|
175
191
|
items.push(...pageItems);
|
|
176
192
|
if (pageItems.length < 50)
|
|
@@ -186,7 +202,7 @@ export async function syncDrafts(client, draftsFolderId) {
|
|
|
186
202
|
// timestamp for drafts — direct UI edits don't bump it — so we can't
|
|
187
203
|
// use it to skip the detail fetch. Always re-fetch; drafts are few.
|
|
188
204
|
const existing = getDraft(item.id);
|
|
189
|
-
const detail =
|
|
205
|
+
const detail = parseLenient(DraftDetailSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (drafts sync)' });
|
|
190
206
|
const row = {
|
|
191
207
|
id: item.id,
|
|
192
208
|
subject: detail.subject ?? item.subject ?? '(no subject)',
|
package/dist/tools/_shared.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { expandPath as expandPathUtil, rawTextResult, textResult } from '@chrischall/mcp-utils';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import { parseLenient } from '@chrischall/mcp-utils';
|
|
4
4
|
// Pretty-printed JSON tool result. Thin wrapper over @chrischall/mcp-utils'
|
|
5
5
|
// `textResult` so the rest of the codebase keeps the local name.
|
|
6
6
|
export const jsonResponse = textResult;
|
|
@@ -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`.
|
|
@@ -77,14 +94,23 @@ const PostMessagesResponseSchema = z.looseObject({
|
|
|
77
94
|
* `if (result.id !== null)`. When id is null (no id field in the
|
|
78
95
|
* response — never observed in production, but defensive), `raw`
|
|
79
96
|
* carries the POST response so the caller can still surface it.
|
|
97
|
+
*
|
|
98
|
+
* The generic is parametrized on the schema's OUTPUT type `T`
|
|
99
|
+
* (`detailSchema: z.ZodType<T>`, `detail: T`) rather than on the schema
|
|
100
|
+
* type itself. This mirrors `parseLenient`'s own signature
|
|
101
|
+
* (`<T>(schema: ZodType<T>, …): T`) exactly, so `T` is inferred straight
|
|
102
|
+
* from the schema and flows into the return type with no `as` cast — the
|
|
103
|
+
* compiler verifies that `detail` matches `detailSchema`'s output. (A
|
|
104
|
+
* `<S extends z.ZodType>` constraint would widen the output to `unknown`
|
|
105
|
+
* and force a cast at this call site.)
|
|
80
106
|
*/
|
|
81
107
|
export async function postMessageAndRefetch(client, payload, detailSchema, ctx) {
|
|
82
|
-
const raw =
|
|
108
|
+
const raw = parseLenient(PostMessagesResponseSchema, await client.request('POST', '/pub/v3/messages', payload), { label: 'ofw-mcp', context: `POST /pub/v3/messages (${ctx})`, mode: 'strict' });
|
|
83
109
|
const id = typeof raw?.id === 'number' ? raw.id
|
|
84
110
|
: typeof raw?.entityId === 'number' ? raw.entityId
|
|
85
111
|
: null;
|
|
86
112
|
if (id === null)
|
|
87
113
|
return { id: null, detail: null, raw };
|
|
88
|
-
const detail =
|
|
114
|
+
const detail = parseLenient(detailSchema, await client.request('GET', `/pub/v3/messages/${id}`), { label: 'ofw-mcp', context: `GET /pub/v3/messages/{id} (${ctx})`, mode: 'strict' });
|
|
89
115
|
return { id, detail, raw };
|
|
90
116
|
}
|
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,8 +5,8 @@ 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';
|
|
9
|
-
import {
|
|
8
|
+
import { ApiRecipientSchema, expandPath, hasRealView, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded } from './_shared.js';
|
|
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.
|
|
12
12
|
const DateSchema = z.looseObject({ dateTime: z.string() });
|
|
@@ -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,9 +217,9 @@ 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
|
-
const detail =
|
|
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) {
|
|
198
224
|
await fetchAttachmentMetaForMessage(client, id, detail.files);
|
|
199
225
|
attachments = listAttachmentsForMessage(id);
|
|
@@ -203,9 +229,9 @@ 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
|
-
const detail =
|
|
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';
|
|
210
236
|
const row = {
|
|
211
237
|
id: detail.id,
|
|
@@ -508,7 +534,7 @@ export function registerMessageTools(server, client) {
|
|
|
508
534
|
form.append('label', args.label ?? fileName);
|
|
509
535
|
form.append('fileName', fileName);
|
|
510
536
|
form.append('shareClass', args.shareClass ?? 'PRIVATE');
|
|
511
|
-
const meta =
|
|
537
|
+
const meta = parseLenient(UploadedFileSchema, await client.request('POST', '/pub/v3/myfiles/multipart', form), { label: 'ofw-mcp', context: 'POST /pub/v3/myfiles/multipart (ofw_upload_attachment)', mode: 'strict' });
|
|
512
538
|
// Cache metadata so subsequent ofw_get_message calls can surface it and
|
|
513
539
|
// ofw_download_attachment can short-circuit. messageId is 0 (the
|
|
514
540
|
// not-yet-linked sentinel) until a message actually references this file.
|
|
@@ -583,14 +609,19 @@ export function registerMessageTools(server, client) {
|
|
|
583
609
|
} }] };
|
|
584
610
|
}
|
|
585
611
|
let dest;
|
|
612
|
+
// The filename comes from OFW file metadata — i.e. it is controlled by the
|
|
613
|
+
// co-parent who uploaded the attachment. basename() it before interpolating
|
|
614
|
+
// into a path so a crafted `../…` name can't escape the target directory
|
|
615
|
+
// (the upload path at :549 already applies basename to its input).
|
|
616
|
+
const safeName = basename(cached.fileName);
|
|
586
617
|
if (args.saveTo) {
|
|
587
618
|
// Treat saveTo as a directory if it ends with a separator; otherwise as a full path.
|
|
588
619
|
const isDirArg = args.saveTo.endsWith('/') || args.saveTo.endsWith('\\');
|
|
589
620
|
const abs = expandPath(args.saveTo);
|
|
590
|
-
dest = isDirArg ? join(abs, `${fileId}-${
|
|
621
|
+
dest = isDirArg ? join(abs, `${fileId}-${safeName}`) : abs;
|
|
591
622
|
}
|
|
592
623
|
else {
|
|
593
|
-
dest = join(getAttachmentsDir(), `${fileId}-${
|
|
624
|
+
dest = join(getAttachmentsDir(), `${fileId}-${safeName}`);
|
|
594
625
|
}
|
|
595
626
|
if (!args.force && cached.downloadedPath === dest) {
|
|
596
627
|
return jsonResponse({
|
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,17 +32,17 @@
|
|
|
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",
|
|
39
39
|
"zod": "^4.4.3"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"@types/node": "^
|
|
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
|
}
|