ofw-mcp 2.3.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/LICENSE +21 -0
- package/README.md +42 -22
- package/dist/auth-password.js +7 -2
- package/dist/auth.js +3 -3
- package/dist/bundle.js +796 -156
- package/dist/cache.js +19 -1
- package/dist/client.js +66 -50
- package/dist/config.js +26 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +68 -9
- package/dist/tools/_shared.js +47 -3
- package/dist/tools/calendar.js +54 -48
- package/dist/tools/expenses.js +17 -13
- package/dist/tools/journal.js +17 -13
- package/dist/tools/messages.js +313 -242
- package/dist/validate.js +35 -0
- package/package.json +7 -3
- package/server.json +8 -2
package/dist/cache.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { mkdirSync, chmodSync, existsSync } from 'node:fs';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
import { getCacheDbPath } from './config.js';
|
|
5
5
|
let instance = null;
|
|
@@ -62,15 +62,33 @@ function migrate(db) {
|
|
|
62
62
|
db.exec(SCHEMA_V2);
|
|
63
63
|
db.prepare('INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run('schema_version', '2');
|
|
64
64
|
}
|
|
65
|
+
// The cache holds full co-parenting message history — keep it private to the
|
|
66
|
+
// owning user. Modes are asserted on every open (not just creation): mkdirSync
|
|
67
|
+
// `mode` and SQLite's default file mode only apply when the path is first
|
|
68
|
+
// created, so a pre-existing dir/db keeps whatever (world-readable) mode it
|
|
69
|
+
// had. The -wal/-shm siblings appear and disappear with WAL checkpoints, hence
|
|
70
|
+
// the existence check.
|
|
71
|
+
function enforceCachePermissions(dbPath) {
|
|
72
|
+
chmodSync(dirname(dbPath), 0o700);
|
|
73
|
+
chmodSync(dbPath, 0o600);
|
|
74
|
+
for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
75
|
+
if (existsSync(sibling))
|
|
76
|
+
chmodSync(sibling, 0o600);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
65
79
|
export function openCache() {
|
|
66
80
|
if (instance)
|
|
67
81
|
return instance;
|
|
68
82
|
const path = getCacheDbPath();
|
|
69
83
|
mkdirSync(dirname(path), { recursive: true });
|
|
70
84
|
const db = new DatabaseSync(path);
|
|
85
|
+
// First pass: lock down dir + db before WAL siblings exist.
|
|
86
|
+
enforceCachePermissions(path);
|
|
71
87
|
db.exec('PRAGMA journal_mode = WAL');
|
|
72
88
|
db.exec('PRAGMA foreign_keys = ON');
|
|
73
89
|
migrate(db);
|
|
90
|
+
// Second pass: the migration writes created -wal/-shm — lock those down too.
|
|
91
|
+
enforceCachePermissions(path);
|
|
74
92
|
instance = { db };
|
|
75
93
|
return instance;
|
|
76
94
|
}
|
package/dist/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadDotenvSafely } from '@chrischall/mcp-utils';
|
|
2
|
+
import { TokenManager } from '@chrischall/mcp-utils/session';
|
|
2
3
|
import { dirname, join } from 'path';
|
|
3
4
|
import { fileURLToPath } from 'url';
|
|
4
5
|
import { resolveAuth } from './auth.js';
|
|
@@ -51,12 +52,44 @@ function getRequestTimeoutMs() {
|
|
|
51
52
|
const n = Number(raw.trim());
|
|
52
53
|
return Number.isFinite(n) && n > 0 ? n : DEFAULT_REQUEST_TIMEOUT_MS;
|
|
53
54
|
}
|
|
55
|
+
// Sentinel "refresh token" handed to the shared TokenManager. OFW has no
|
|
56
|
+
// OAuth-style refresh token — every renewal re-runs the full `resolveAuth()`
|
|
57
|
+
// (password POST or fetchproxy snapshot). The TokenManager only refuses to
|
|
58
|
+
// refresh when its refresh token is `undefined`, so a non-empty placeholder
|
|
59
|
+
// keeps the single-flight refresh path live; the refresh callback ignores it.
|
|
60
|
+
const OFW_REFRESH_SENTINEL = 'ofw';
|
|
54
61
|
export class OFWClient {
|
|
55
|
-
token
|
|
56
|
-
|
|
62
|
+
// Bearer-token lifecycle is delegated to the shared, race-safe TokenManager
|
|
63
|
+
// (proactive refresh inside the skew window, single-flight refresh so a burst
|
|
64
|
+
// of concurrent callers coalesces onto ONE `resolveAuth()`, and a 401-replay
|
|
65
|
+
// guarded against double-refresh). It is created lazily, seeded with an
|
|
66
|
+
// already-expired placeholder token so the first request drives the refresh
|
|
67
|
+
// callback — i.e. the original "log in on first request" behavior.
|
|
68
|
+
tokenManager;
|
|
69
|
+
getTokenManager() {
|
|
70
|
+
if (!this.tokenManager) {
|
|
71
|
+
this.tokenManager = new TokenManager({
|
|
72
|
+
initial: { accessToken: '', refreshToken: OFW_REFRESH_SENTINEL, expiresAt: 0 },
|
|
73
|
+
skewMs: OFW_TOKEN_EXPIRY_SKEW_MS,
|
|
74
|
+
// Map OFW's mint/refresh onto the refresh callback. `resolveAuth()`
|
|
75
|
+
// returns a token and a best-effort expiry; when the fetchproxy path
|
|
76
|
+
// can't supply one we fall back to the same 6h estimate the password
|
|
77
|
+
// path uses (the 401-replay covers a wrong guess). We re-arm the
|
|
78
|
+
// sentinel so the manager can refresh again later.
|
|
79
|
+
refresh: async () => {
|
|
80
|
+
const { token, expiresAt } = await resolveAuth();
|
|
81
|
+
return {
|
|
82
|
+
accessToken: token,
|
|
83
|
+
refreshToken: OFW_REFRESH_SENTINEL,
|
|
84
|
+
expiresAt: (expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS)).getTime(),
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return this.tokenManager;
|
|
90
|
+
}
|
|
57
91
|
async request(method, path, body) {
|
|
58
|
-
await this.
|
|
59
|
-
const response = await this.fetchWithRetry(method, path, body, 'application/json', false);
|
|
92
|
+
const response = await this.fetchAuthed(method, path, body, 'application/json');
|
|
60
93
|
const text = await response.text();
|
|
61
94
|
if (debugLogEnabled()) {
|
|
62
95
|
console.error(`[ofw-debug] response body: ${text || '<empty>'}`);
|
|
@@ -65,23 +98,45 @@ export class OFWClient {
|
|
|
65
98
|
}
|
|
66
99
|
/** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
|
|
67
100
|
async requestBinary(method, path) {
|
|
68
|
-
await this.
|
|
69
|
-
const response = await this.fetchWithRetry(method, path, undefined, 'application/octet-stream', false);
|
|
101
|
+
const response = await this.fetchAuthed(method, path, undefined, 'application/octet-stream');
|
|
70
102
|
return {
|
|
71
103
|
body: Buffer.from(await response.arrayBuffer()),
|
|
72
104
|
contentType: response.headers.get('content-type'),
|
|
73
105
|
suggestedFileName: parseContentDispositionFilename(response.headers.get('content-disposition') ?? ''),
|
|
74
106
|
};
|
|
75
107
|
}
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
108
|
+
// Authenticated fetch for both JSON and binary callers. Auth (proactive
|
|
109
|
+
// refresh inside the skew window + one 401-replay, guarded against a
|
|
110
|
+
// double-refresh under concurrency) is delegated to the shared TokenManager's
|
|
111
|
+
// `withAuth`. The 429 wait-and-replay and the non-2xx → throw remain here.
|
|
112
|
+
async fetchAuthed(method, path, body, accept) {
|
|
113
|
+
// `withAuth` invokes `call` once, and again after a refresh on a 401. The
|
|
114
|
+
// second invocation is the replay — mark it `(retry)` in the debug log,
|
|
115
|
+
// preserving the prior bespoke-loop diagnostic.
|
|
116
|
+
let attempt = 0;
|
|
117
|
+
let response = await this.getTokenManager().withAuth((token) => this.fetchOnce(method, path, body, accept, token, attempt++ > 0));
|
|
118
|
+
if (response.status === 429) {
|
|
119
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
120
|
+
response = await this.getTokenManager().withAuth((token) => this.fetchOnce(method, path, body, accept, token, true));
|
|
121
|
+
if (response.status === 429)
|
|
122
|
+
throw new Error('Rate limited by OFW API');
|
|
123
|
+
}
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
|
|
126
|
+
}
|
|
127
|
+
return response;
|
|
128
|
+
}
|
|
129
|
+
// A single OFW API fetch with the bearer token supplied by `withAuth`.
|
|
130
|
+
// Carries the per-request timeout (AbortController + setTimeout so vitest
|
|
131
|
+
// fake timers can drive it and we attach a clear error message) and the
|
|
132
|
+
// OFW_DEBUG_LOG instrumentation. Returns the raw Response — 401/429/non-2xx
|
|
133
|
+
// handling lives in the callers (`withAuth` and `fetchAuthed`).
|
|
134
|
+
async fetchOnce(method, path, body, accept, token, isRetry = false) {
|
|
80
135
|
const isFormData = body instanceof FormData;
|
|
81
136
|
const headers = {
|
|
82
137
|
...OFW_PROTOCOL_HEADERS,
|
|
83
138
|
Accept: accept,
|
|
84
|
-
Authorization: `Bearer ${
|
|
139
|
+
Authorization: `Bearer ${token}`,
|
|
85
140
|
};
|
|
86
141
|
if (body !== undefined && !isFormData)
|
|
87
142
|
headers['Content-Type'] = 'application/json';
|
|
@@ -131,46 +186,7 @@ export class OFWClient {
|
|
|
131
186
|
if (debugLogEnabled()) {
|
|
132
187
|
console.error(`[ofw-debug] ← ${response.status} ${response.statusText} (${Date.now() - startedAt}ms)`);
|
|
133
188
|
}
|
|
134
|
-
if (response.status === 401 && !isRetry) {
|
|
135
|
-
this.token = null;
|
|
136
|
-
this.tokenExpiry = null;
|
|
137
|
-
await this.ensureAuthenticated();
|
|
138
|
-
return this.fetchWithRetry(method, path, body, accept, true);
|
|
139
|
-
}
|
|
140
|
-
if (response.status === 429) {
|
|
141
|
-
if (!isRetry) {
|
|
142
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
143
|
-
return this.fetchWithRetry(method, path, body, accept, true);
|
|
144
|
-
}
|
|
145
|
-
throw new Error('Rate limited by OFW API');
|
|
146
|
-
}
|
|
147
|
-
if (!response.ok) {
|
|
148
|
-
throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
|
|
149
|
-
}
|
|
150
189
|
return response;
|
|
151
190
|
}
|
|
152
|
-
async ensureAuthenticated() {
|
|
153
|
-
if (!this.isTokenExpiredSoon())
|
|
154
|
-
return;
|
|
155
|
-
await this.login();
|
|
156
|
-
}
|
|
157
|
-
// Auth resolution is delegated to `./auth.ts`. This client doesn't care
|
|
158
|
-
// whether the token came from a password POST or from a one-shot
|
|
159
|
-
// fetchproxy session-snapshot — it just consumes the result.
|
|
160
|
-
//
|
|
161
|
-
// If `expiresAt` is missing (the fetchproxy path on a tab whose
|
|
162
|
-
// browser didn't persist tokenExpiry), we fall back to the same 6h
|
|
163
|
-
// estimate the password path uses. The 401-replay path covers us if
|
|
164
|
-
// the estimate is wrong.
|
|
165
|
-
async login() {
|
|
166
|
-
const { token, expiresAt } = await resolveAuth();
|
|
167
|
-
this.token = token;
|
|
168
|
-
this.tokenExpiry = expiresAt ?? new Date(Date.now() + OFW_TOKEN_TTL_MS);
|
|
169
|
-
}
|
|
170
|
-
isTokenExpiredSoon() {
|
|
171
|
-
if (!this.token || !this.tokenExpiry)
|
|
172
|
-
return true;
|
|
173
|
-
return this.tokenExpiry.getTime() - Date.now() < OFW_TOKEN_EXPIRY_SKEW_MS;
|
|
174
|
-
}
|
|
175
191
|
}
|
|
176
192
|
export const client = new OFWClient();
|
package/dist/config.js
CHANGED
|
@@ -54,6 +54,32 @@ export function getAttachmentsDir() {
|
|
|
54
54
|
export function parseBoolEnv(name) {
|
|
55
55
|
return parseBoolEnvUtil(name);
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Gate for write-tool registration, read at registration time (startup).
|
|
59
|
+
*
|
|
60
|
+
* none No write tools are registered — pure read/sync/search surface.
|
|
61
|
+
* drafts Draft-level writes only (ofw_save_draft, ofw_delete_draft,
|
|
62
|
+
* ofw_upload_attachment). Nothing that lands on the court-visible
|
|
63
|
+
* record (send, calendar/expense/journal writes) is registered —
|
|
64
|
+
* the only way to send remains a human in the OFW web UI.
|
|
65
|
+
* all Every tool registers (the default; fully backward compatible).
|
|
66
|
+
*
|
|
67
|
+
* Unregistered tools cannot be invoked by any host permission setting or
|
|
68
|
+
* injected instruction — the gate is structural, not behavioral. An
|
|
69
|
+
* unrecognized value fails closed to 'none': this is a safety control, so a
|
|
70
|
+
* typo must never silently grant write access.
|
|
71
|
+
*/
|
|
72
|
+
export function getWriteMode() {
|
|
73
|
+
const raw = process.env.OFW_WRITE_MODE;
|
|
74
|
+
if (typeof raw !== 'string' || raw.trim().length === 0)
|
|
75
|
+
return 'all';
|
|
76
|
+
const mode = raw.trim().toLowerCase();
|
|
77
|
+
if (mode === 'none' || mode === 'drafts' || mode === 'all')
|
|
78
|
+
return mode;
|
|
79
|
+
// stdio transport: stderr only — stdout is reserved for JSON-RPC.
|
|
80
|
+
console.error(`[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" — failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`);
|
|
81
|
+
return 'none';
|
|
82
|
+
}
|
|
57
83
|
// Default for ofw_download_attachment's `inline` arg when the caller doesn't
|
|
58
84
|
// pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
|
|
59
85
|
// 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.4.0', // x-release-please-version
|
|
28
28
|
deps: client,
|
|
29
29
|
tools: [
|
|
30
30
|
registerUserTools,
|
package/dist/sync.js
CHANGED
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
import { setMeta, upsertMessage, getMessage, deleteMessage, setSyncState, upsertDraft, getDraft, deleteDraft, listDraftIds, upsertAttachmentForMessage, } from './cache.js';
|
|
2
|
-
import {
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { ApiRecipientSchema, mapRecipients } from './tools/_shared.js';
|
|
4
|
+
import { parseOFW } from './validate.js';
|
|
5
|
+
// Each OFW message detail returns `files: [fileId, ...]`. We fetch the metadata
|
|
6
|
+
// for each file id (cheap JSON call) so the model can see filenames/mime types
|
|
7
|
+
// without downloading bytes. Bytes are pulled lazily by ofw_download_attachment.
|
|
8
|
+
// All sync-path schemas are validated LENIENT (issue #83): a mismatch logs a
|
|
9
|
+
// structured warning to stderr and the raw response flows on through the
|
|
10
|
+
// existing `??` fallbacks — a small OFW backend change degrades gracefully
|
|
11
|
+
// instead of bricking sync, but no longer silently. Loose objects keep
|
|
12
|
+
// unknown keys, so cached `metadata`/`listData` blobs stay verbatim.
|
|
13
|
+
const FileMetaSchema = z.looseObject({
|
|
14
|
+
fileId: z.number(),
|
|
15
|
+
label: z.string().optional(),
|
|
16
|
+
fileName: z.string().optional(),
|
|
17
|
+
fileType: z.string().optional(), // MIME
|
|
18
|
+
fileSize: z.number().optional(),
|
|
19
|
+
});
|
|
3
20
|
// Fetches OFW attachment metadata for one file id and writes it to the cache.
|
|
4
21
|
// Throws on network/HTTP errors — callers in bulk-sync paths wrap this in the
|
|
5
22
|
// best-effort helper below; callers that need the result (download tool) let
|
|
6
23
|
// the throw propagate.
|
|
7
24
|
export async function fetchAttachmentMeta(client, fileId, messageId) {
|
|
8
|
-
const meta = await client.request('GET', `/pub/v1/myfiles/${fileId}`);
|
|
25
|
+
const meta = parseOFW(FileMetaSchema, await client.request('GET', `/pub/v1/myfiles/${fileId}`), 'GET /pub/v1/myfiles/{fileId}');
|
|
9
26
|
upsertAttachmentForMessage({
|
|
10
27
|
fileId: meta.fileId ?? fileId,
|
|
11
28
|
fileName: meta.fileName ?? `file-${fileId}`,
|
|
@@ -23,8 +40,11 @@ export async function fetchAttachmentMetaForMessage(client, messageId, fileIds)
|
|
|
23
40
|
// attachment doesn't break the surrounding sync.
|
|
24
41
|
await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client, fid, messageId)));
|
|
25
42
|
}
|
|
43
|
+
const FoldersSchema = z.looseObject({
|
|
44
|
+
systemFolders: z.array(z.looseObject({ id: z.string(), folderType: z.string() })).optional(),
|
|
45
|
+
});
|
|
26
46
|
export async function resolveFolderIds(client) {
|
|
27
|
-
const data = await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true');
|
|
47
|
+
const data = parseOFW(FoldersSchema, await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true'), 'GET /pub/v1/messageFolders');
|
|
28
48
|
const sys = data.systemFolders ?? [];
|
|
29
49
|
const find = (type) => {
|
|
30
50
|
const f = sys.find((x) => x.folderType === type);
|
|
@@ -40,6 +60,22 @@ export async function resolveFolderIds(client) {
|
|
|
40
60
|
setMeta('drafts_folder_id', ids.drafts);
|
|
41
61
|
return ids;
|
|
42
62
|
}
|
|
63
|
+
// Required fields are the ones the sync loop reads unguarded (id keys the
|
|
64
|
+
// cache; showNeverViewed drives unread semantics — per CLAUDE.md it's the
|
|
65
|
+
// only reliable unread indicator, so its disappearance must warn loudly).
|
|
66
|
+
const ListItemSchema = z.looseObject({
|
|
67
|
+
id: z.number(),
|
|
68
|
+
subject: z.string(),
|
|
69
|
+
date: z.looseObject({ dateTime: z.string() }),
|
|
70
|
+
from: z.looseObject({ name: z.string().optional() }).optional(),
|
|
71
|
+
showNeverViewed: z.boolean(),
|
|
72
|
+
recipients: z.array(ApiRecipientSchema).optional(),
|
|
73
|
+
});
|
|
74
|
+
const ListResponseSchema = z.looseObject({ data: z.array(ListItemSchema).optional() });
|
|
75
|
+
const DetailResponseSchema = z.looseObject({
|
|
76
|
+
body: z.string().optional(),
|
|
77
|
+
files: z.array(z.number()).optional(),
|
|
78
|
+
});
|
|
43
79
|
export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
44
80
|
let page = 1;
|
|
45
81
|
let synced = 0;
|
|
@@ -47,7 +83,7 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
|
47
83
|
const unread = [];
|
|
48
84
|
while (true) {
|
|
49
85
|
const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
50
|
-
const list = await client.request('GET', path);
|
|
86
|
+
const list = parseOFW(ListResponseSchema, await client.request('GET', path), `GET /pub/v3/messages?folders={${folder}}`);
|
|
51
87
|
const items = list.data ?? [];
|
|
52
88
|
if (items.length === 0)
|
|
53
89
|
break;
|
|
@@ -65,7 +101,7 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
|
65
101
|
let fetchedBodyAt = null;
|
|
66
102
|
let detailFileIds = [];
|
|
67
103
|
if (shouldFetchBody) {
|
|
68
|
-
const detail = await client.request('GET', `/pub/v3/messages/${item.id}`);
|
|
104
|
+
const detail = parseOFW(DetailResponseSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), 'GET /pub/v3/messages/{id} (sync)');
|
|
69
105
|
body = detail.body ?? '';
|
|
70
106
|
fetchedBodyAt = new Date().toISOString();
|
|
71
107
|
if (Array.isArray(detail.files) && detail.files.length > 0) {
|
|
@@ -114,10 +150,33 @@ export async function syncMessageFolder(client, folder, folderId, opts) {
|
|
|
114
150
|
});
|
|
115
151
|
return { synced, unread };
|
|
116
152
|
}
|
|
153
|
+
const DraftListItemSchema = z.looseObject({
|
|
154
|
+
id: z.number(),
|
|
155
|
+
subject: z.string(),
|
|
156
|
+
date: z.looseObject({ dateTime: z.string() }),
|
|
157
|
+
replyToId: z.number().nullable().optional(),
|
|
158
|
+
recipients: z.array(ApiRecipientSchema).optional(),
|
|
159
|
+
});
|
|
160
|
+
const DraftListResponseSchema = z.looseObject({ data: z.array(DraftListItemSchema).optional() });
|
|
161
|
+
const DraftDetailSchema = z.looseObject({
|
|
162
|
+
body: z.string().optional(),
|
|
163
|
+
subject: z.string().optional(),
|
|
164
|
+
});
|
|
117
165
|
export async function syncDrafts(client, draftsFolderId) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
166
|
+
// Walk every page. The reconciliation loop at the bottom deletes any
|
|
167
|
+
// cached draft that wasn't seen in the listing, so a partial walk would
|
|
168
|
+
// wrongly evict real drafts beyond the first page.
|
|
169
|
+
const items = [];
|
|
170
|
+
let page = 1;
|
|
171
|
+
while (true) {
|
|
172
|
+
const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
|
|
173
|
+
const list = parseOFW(DraftListResponseSchema, await client.request('GET', path), 'GET /pub/v3/messages?folders={drafts}');
|
|
174
|
+
const pageItems = list.data ?? [];
|
|
175
|
+
items.push(...pageItems);
|
|
176
|
+
if (pageItems.length < 50)
|
|
177
|
+
break;
|
|
178
|
+
page++;
|
|
179
|
+
}
|
|
121
180
|
const seenIds = new Set();
|
|
122
181
|
let synced = 0;
|
|
123
182
|
for (const item of items) {
|
|
@@ -127,7 +186,7 @@ export async function syncDrafts(client, draftsFolderId) {
|
|
|
127
186
|
// timestamp for drafts — direct UI edits don't bump it — so we can't
|
|
128
187
|
// use it to skip the detail fetch. Always re-fetch; drafts are few.
|
|
129
188
|
const existing = getDraft(item.id);
|
|
130
|
-
const detail = await client.request('GET', `/pub/v3/messages/${item.id}`);
|
|
189
|
+
const detail = parseOFW(DraftDetailSchema, await client.request('GET', `/pub/v3/messages/${item.id}`), 'GET /pub/v3/messages/{id} (drafts sync)');
|
|
131
190
|
const row = {
|
|
132
191
|
id: item.id,
|
|
133
192
|
subject: detail.subject ?? item.subject ?? '(no subject)',
|
package/dist/tools/_shared.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { expandPath as expandPathUtil, rawTextResult, textResult } from '@chrischall/mcp-utils';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { parseOFW } from '../validate.js';
|
|
2
4
|
// Pretty-printed JSON tool result. Thin wrapper over @chrischall/mcp-utils'
|
|
3
5
|
// `textResult` so the rest of the codebase keeps the local name.
|
|
4
6
|
export const jsonResponse = textResult;
|
|
5
7
|
// Raw-string tool result. Wrapper over @chrischall/mcp-utils' `rawTextResult`.
|
|
6
8
|
export const textResponse = rawTextResult;
|
|
9
|
+
// OFW API shape for `recipients[]` on message/draft list and detail
|
|
10
|
+
// responses. Used wherever we validate the response of a `/pub/v3/messages*`
|
|
11
|
+
// call. Loose: unknown keys pass through (and survive into cached listData).
|
|
12
|
+
export const ApiRecipientSchema = z.looseObject({
|
|
13
|
+
user: z.looseObject({ id: z.number().optional(), name: z.string().optional() }).optional(),
|
|
14
|
+
viewed: z.looseObject({ dateTime: z.string() }).nullable().optional(),
|
|
15
|
+
});
|
|
7
16
|
// Translates OFW API recipient shape into the cache's normalized Recipient.
|
|
8
17
|
// Used wherever we surface or persist recipients (sync, get_message, send,
|
|
9
18
|
// save_draft) — all five call sites had near-identical inline mappings.
|
|
@@ -17,6 +26,37 @@ export function mapRecipients(items) {
|
|
|
17
26
|
// Expand a user-provided path: ~ → home, relative → absolute. Re-exports
|
|
18
27
|
// @chrischall/mcp-utils' `expandPath`.
|
|
19
28
|
export const expandPath = expandPathUtil;
|
|
29
|
+
/**
|
|
30
|
+
* Best-effort check that OFW actually persisted what we posted. OFW's
|
|
31
|
+
* draft-update path is known to silently no-op while echoing success in the
|
|
32
|
+
* POST response, so callers re-GET the detail and compare it to what was
|
|
33
|
+
* sent. Containment (not equality) because OFW legitimately transforms
|
|
34
|
+
* content — replies get the original message appended to the body
|
|
35
|
+
* (includeOriginal) and may get a subject prefix. Returns a WARNING string
|
|
36
|
+
* when the persisted content can't be confirmed to contain what was sent,
|
|
37
|
+
* else null.
|
|
38
|
+
*/
|
|
39
|
+
export function verifyWriteLanded(kind, sent, persisted) {
|
|
40
|
+
const mismatches = [];
|
|
41
|
+
if (typeof persisted.subject !== 'string' || !persisted.subject.includes(sent.subject)) {
|
|
42
|
+
mismatches.push('subject');
|
|
43
|
+
}
|
|
44
|
+
if (typeof persisted.body !== 'string' || !persisted.body.includes(sent.body)) {
|
|
45
|
+
mismatches.push('body');
|
|
46
|
+
}
|
|
47
|
+
if (mismatches.length === 0)
|
|
48
|
+
return null;
|
|
49
|
+
return `WARNING: the ${kind} re-fetched from OFW does not contain the ${mismatches.join(' and ')} that was posted — OFW may have silently dropped or altered the write. Verify the ${kind} on ourfamilywizard.com before relying on it.`;
|
|
50
|
+
}
|
|
51
|
+
// POST /pub/v3/messages response: minimal, `{entityId: <id>}` or legacy
|
|
52
|
+
// `{id: <id>}`, sometimes an empty body (→ null). Validated STRICT: a
|
|
53
|
+
// mistyped id (e.g. entityId as a string) must throw rather than silently
|
|
54
|
+
// degrade into the "unconfirmed send" path when the write actually landed.
|
|
55
|
+
// Absence of both ids stays legal — callers handle it with a WARNING.
|
|
56
|
+
const PostMessagesResponseSchema = z.looseObject({
|
|
57
|
+
id: z.number().optional(),
|
|
58
|
+
entityId: z.number().optional(),
|
|
59
|
+
}).nullable();
|
|
20
60
|
/**
|
|
21
61
|
* POST a payload to /pub/v3/messages, then immediately GET the detail
|
|
22
62
|
* endpoint for the resulting message id. This is the only correct way to
|
|
@@ -29,18 +69,22 @@ export const expandPath = expandPathUtil;
|
|
|
29
69
|
* when the server silently no-ops, so the GET is also how we verify
|
|
30
70
|
* the write landed (callers compare detail.body to args.body).
|
|
31
71
|
*
|
|
72
|
+
* Both responses are validated STRICT against `detailSchema` / the POST
|
|
73
|
+
* schema (this is the write-verification boundary — issue #83); `ctx`
|
|
74
|
+
* names the calling tool in the error message.
|
|
75
|
+
*
|
|
32
76
|
* Returns a discriminated union so callers can narrow with
|
|
33
77
|
* `if (result.id !== null)`. When id is null (no id field in the
|
|
34
78
|
* response — never observed in production, but defensive), `raw`
|
|
35
79
|
* carries the POST response so the caller can still surface it.
|
|
36
80
|
*/
|
|
37
|
-
export async function postMessageAndRefetch(client, payload) {
|
|
38
|
-
const raw = await client.request('POST', '/pub/v3/messages', payload);
|
|
81
|
+
export async function postMessageAndRefetch(client, payload, detailSchema, ctx) {
|
|
82
|
+
const raw = parseOFW(PostMessagesResponseSchema, await client.request('POST', '/pub/v3/messages', payload), `POST /pub/v3/messages (${ctx})`, 'strict');
|
|
39
83
|
const id = typeof raw?.id === 'number' ? raw.id
|
|
40
84
|
: typeof raw?.entityId === 'number' ? raw.entityId
|
|
41
85
|
: null;
|
|
42
86
|
if (id === null)
|
|
43
87
|
return { id: null, detail: null, raw };
|
|
44
|
-
const detail = await client.request('GET', `/pub/v3/messages/${id}`);
|
|
88
|
+
const detail = parseOFW(detailSchema, await client.request('GET', `/pub/v3/messages/${id}`), `GET /pub/v3/messages/{id} (${ctx})`, 'strict');
|
|
45
89
|
return { id, detail, raw };
|
|
46
90
|
}
|
package/dist/tools/calendar.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { jsonResponse, textResponse } from './_shared.js';
|
|
3
|
+
import { getWriteMode } from '../config.js';
|
|
3
4
|
export function registerCalendarTools(server, client) {
|
|
5
|
+
// Calendar writes land on the court-visible record — OFW_WRITE_MODE 'all' only.
|
|
6
|
+
const allowWrites = getWriteMode() === 'all';
|
|
4
7
|
server.registerTool('ofw_list_events', {
|
|
5
8
|
description: 'List OurFamilyWizard calendar events in a date range',
|
|
6
9
|
annotations: { readOnlyHint: true },
|
|
@@ -14,52 +17,55 @@ export function registerCalendarTools(server, client) {
|
|
|
14
17
|
const data = await client.request('GET', `/pub/v1/calendar/${variant}?startDate=${encodeURIComponent(args.startDate)}&endDate=${encodeURIComponent(args.endDate)}`);
|
|
15
18
|
return jsonResponse(data);
|
|
16
19
|
});
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
20
|
+
if (allowWrites)
|
|
21
|
+
server.registerTool('ofw_create_event', {
|
|
22
|
+
description: 'Create a calendar event in OurFamilyWizard',
|
|
23
|
+
annotations: { destructiveHint: false },
|
|
24
|
+
inputSchema: {
|
|
25
|
+
title: z.string(),
|
|
26
|
+
startDate: z.string().describe('ISO datetime string'),
|
|
27
|
+
endDate: z.string().describe('ISO datetime string'),
|
|
28
|
+
allDay: z.boolean().optional(),
|
|
29
|
+
location: z.string().optional(),
|
|
30
|
+
reminder: z.string().describe('Reminder setting (e.g. "1 hour before")').optional(),
|
|
31
|
+
privateEvent: z.boolean().optional(),
|
|
32
|
+
eventFor: z.string().describe('neither | parent1 | parent2').optional(),
|
|
33
|
+
dropOffParent: z.string().optional(),
|
|
34
|
+
pickUpParent: z.string().optional(),
|
|
35
|
+
children: z.array(z.number()).describe('Array of child IDs').optional(),
|
|
36
|
+
},
|
|
37
|
+
}, async (args) => {
|
|
38
|
+
const data = await client.request('POST', '/pub/v1/calendar/events', args);
|
|
39
|
+
return jsonResponse(data);
|
|
40
|
+
});
|
|
41
|
+
if (allowWrites)
|
|
42
|
+
server.registerTool('ofw_update_event', {
|
|
43
|
+
description: 'Update an existing OurFamilyWizard calendar event',
|
|
44
|
+
annotations: { destructiveHint: true },
|
|
45
|
+
inputSchema: {
|
|
46
|
+
eventId: z.string(),
|
|
47
|
+
title: z.string().optional(),
|
|
48
|
+
startDate: z.string().optional(),
|
|
49
|
+
endDate: z.string().optional(),
|
|
50
|
+
allDay: z.boolean().optional(),
|
|
51
|
+
location: z.string().optional(),
|
|
52
|
+
reminder: z.string().optional(),
|
|
53
|
+
privateEvent: z.boolean().optional(),
|
|
54
|
+
},
|
|
55
|
+
}, async (args) => {
|
|
56
|
+
const { eventId, ...updateData } = args;
|
|
57
|
+
const data = await client.request('PUT', `/pub/v1/calendar/events/${encodeURIComponent(eventId)}`, updateData);
|
|
58
|
+
return jsonResponse(data);
|
|
59
|
+
});
|
|
60
|
+
if (allowWrites)
|
|
61
|
+
server.registerTool('ofw_delete_event', {
|
|
62
|
+
description: 'Delete an OurFamilyWizard calendar event',
|
|
63
|
+
annotations: { destructiveHint: true },
|
|
64
|
+
inputSchema: {
|
|
65
|
+
eventId: z.string().describe('Event ID to delete'),
|
|
66
|
+
},
|
|
67
|
+
}, async (args) => {
|
|
68
|
+
await client.request('DELETE', `/pub/v1/calendar/events/${encodeURIComponent(args.eventId)}`);
|
|
69
|
+
return textResponse(`Event ${args.eventId} deleted`);
|
|
70
|
+
});
|
|
65
71
|
}
|
package/dist/tools/expenses.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { jsonResponse } from './_shared.js';
|
|
3
|
+
import { getWriteMode } from '../config.js';
|
|
3
4
|
export function registerExpenseTools(server, client) {
|
|
5
|
+
// Expense writes land on the court-visible record — OFW_WRITE_MODE 'all' only.
|
|
6
|
+
const allowWrites = getWriteMode() === 'all';
|
|
4
7
|
server.registerTool('ofw_get_expense_totals', {
|
|
5
8
|
description: 'Get OurFamilyWizard expense summary totals (owed/paid)',
|
|
6
9
|
annotations: { readOnlyHint: true },
|
|
@@ -12,8 +15,8 @@ export function registerExpenseTools(server, client) {
|
|
|
12
15
|
description: 'List OurFamilyWizard expenses with pagination',
|
|
13
16
|
annotations: { readOnlyHint: true },
|
|
14
17
|
inputSchema: {
|
|
15
|
-
start: z.number().describe('Start offset (default 0)').optional(),
|
|
16
|
-
max: z.number().describe('Max results (default 20)').optional(),
|
|
18
|
+
start: z.number().int().min(0).describe('Start offset (default 0)').optional(),
|
|
19
|
+
max: z.number().int().min(1).describe('Max results (default 20)').optional(),
|
|
17
20
|
},
|
|
18
21
|
}, async (args) => {
|
|
19
22
|
const start = args.start ?? 0;
|
|
@@ -21,15 +24,16 @@ export function registerExpenseTools(server, client) {
|
|
|
21
24
|
const data = await client.request('GET', `/pub/v2/expense/expenses?start=${start}&max=${max}`);
|
|
22
25
|
return jsonResponse(data);
|
|
23
26
|
});
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
if (allowWrites)
|
|
28
|
+
server.registerTool('ofw_create_expense', {
|
|
29
|
+
description: 'Log a new expense in OurFamilyWizard',
|
|
30
|
+
annotations: { destructiveHint: false },
|
|
31
|
+
inputSchema: {
|
|
32
|
+
amount: z.number().describe('Expense amount'),
|
|
33
|
+
description: z.string().describe('Expense description'),
|
|
34
|
+
},
|
|
35
|
+
}, async (args) => {
|
|
36
|
+
const data = await client.request('POST', '/pub/v2/expense/expenses', args);
|
|
37
|
+
return jsonResponse(data);
|
|
38
|
+
});
|
|
35
39
|
}
|