ofw-mcp 2.20.0 → 2.20.2
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 +1 -1
- package/dist/auth-password.js +17 -1
- package/dist/bundle.js +1437 -479
- package/dist/client.js +123 -63
- package/dist/index.js +1 -1
- package/dist/tools/attachments.js +32 -32
- package/dist/tools/calendar.js +40 -7
- package/dist/tools/messages.js +1 -1
- package/package.json +4 -4
- package/server.json +2 -2
package/dist/client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadDotenvSafely, parseBoolEnv, redactSecrets, withAmbientCancellation } from '@chrischall/mcp-utils';
|
|
1
|
+
import { EdgeBlockedError, detectEdgeBlock, loadDotenvSafely, parseContentDispositionFilename, parseBoolEnv, redactSecrets, withAmbientCancellation, } 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';
|
|
@@ -18,22 +18,6 @@ try {
|
|
|
18
18
|
catch {
|
|
19
19
|
/* v8 ignore next -- only reached in a non-Node runtime: no .env to load */
|
|
20
20
|
}
|
|
21
|
-
// Parse a Content-Disposition header for a filename. Prefers RFC 6266
|
|
22
|
-
// `filename*=UTF-8''…` (percent-decoded) and falls back to `filename="…"`.
|
|
23
|
-
function parseContentDispositionFilename(cd) {
|
|
24
|
-
const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
|
|
25
|
-
if (extMatch) {
|
|
26
|
-
const raw = extMatch[1].trim().replace(/^"|"$/g, '');
|
|
27
|
-
try {
|
|
28
|
-
return decodeURIComponent(raw);
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
return raw;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
const m = /filename="?([^";]+)"?/i.exec(cd);
|
|
35
|
-
return m ? m[1] : null;
|
|
36
|
-
}
|
|
37
21
|
// Set OFW_DEBUG_LOG=1 (or true/yes/on) to log every OFW request/response to
|
|
38
22
|
// stderr. Authorization is redacted. Bodies are logged in full — set this
|
|
39
23
|
// only when debugging, never in normal use.
|
|
@@ -59,6 +43,48 @@ function getRequestTimeoutMs() {
|
|
|
59
43
|
// refresh when its refresh token is `undefined`, so a non-empty placeholder
|
|
60
44
|
// keeps the single-flight refresh path live; the refresh callback ignores it.
|
|
61
45
|
const OFW_REFRESH_SENTINEL = 'ofw';
|
|
46
|
+
/**
|
|
47
|
+
* Throw {@link EdgeBlockedError} when `response` is a CDN/WAF refusal.
|
|
48
|
+
*
|
|
49
|
+
* Its HEADERS are always judged (Cloudflare's `cf-mitigated`). Its BODY is
|
|
50
|
+
* judged for a 401 or 429 before releasing it for a replay. A challenge page
|
|
51
|
+
* without the header must not spend a login or be reported as a rate limit.
|
|
52
|
+
* The 401 body is read from a clone (a final 401 still needs its error body);
|
|
53
|
+
* the 429 body can be consumed directly since it is always released. JSON is
|
|
54
|
+
* never probed — no refusal page is. Other non-2xx bodies are judged where
|
|
55
|
+
* the error body is read anyway; a 2xx body is never consumed here.
|
|
56
|
+
*/
|
|
57
|
+
async function throwIfEdgeBlocked(response, method, path) {
|
|
58
|
+
if (response.ok)
|
|
59
|
+
return;
|
|
60
|
+
let edge = detectEdgeBlock({ headers: response.headers, status: response.status });
|
|
61
|
+
if (edge === null && (response.status === 401 || response.status === 429) && !/json/i.test(response.headers.get('content-type') ?? '')) {
|
|
62
|
+
let body = '';
|
|
63
|
+
try {
|
|
64
|
+
body = await (response.status === 401 ? response.clone() : response).text();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* unreadable: not shown to be a block */
|
|
68
|
+
}
|
|
69
|
+
edge = detectEdgeBlock({ body, headers: response.headers, status: response.status });
|
|
70
|
+
}
|
|
71
|
+
if (edge !== null) {
|
|
72
|
+
await releaseBody(response);
|
|
73
|
+
throw new EdgeBlockedError(response.status, edge.vendor, { service: 'OurFamilyWizard', method, path });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Release a response's connection without reading it. A cancel that fails
|
|
78
|
+
* changes nothing about the answer, so its failure is swallowed.
|
|
79
|
+
*/
|
|
80
|
+
async function releaseBody(response) {
|
|
81
|
+
try {
|
|
82
|
+
await response.body?.cancel();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
/* already settled */
|
|
86
|
+
}
|
|
87
|
+
}
|
|
62
88
|
export class OFWClient {
|
|
63
89
|
// Bearer-token lifecycle is delegated to the shared, race-safe TokenManager
|
|
64
90
|
// (proactive refresh inside the skew window, single-flight refresh so a burst
|
|
@@ -111,52 +137,75 @@ export class OFWClient {
|
|
|
111
137
|
return this.tokenManager;
|
|
112
138
|
}
|
|
113
139
|
async request(method, path, body) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
140
|
+
return this.fetchAuthed(method, path, body, 'application/json', async (response) => {
|
|
141
|
+
const text = await response.text();
|
|
142
|
+
if (debugLogEnabled()) {
|
|
143
|
+
console.error(`[ofw-debug] response body: ${text || '<empty>'}`);
|
|
144
|
+
}
|
|
145
|
+
return (text ? JSON.parse(text) : null);
|
|
146
|
+
});
|
|
120
147
|
}
|
|
121
148
|
/** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
|
|
122
149
|
async requestBinary(method, path) {
|
|
123
|
-
|
|
124
|
-
return {
|
|
150
|
+
return this.fetchAuthed(method, path, undefined, 'application/octet-stream', async (response) => ({
|
|
125
151
|
body: Buffer.from(await response.arrayBuffer()),
|
|
126
152
|
contentType: response.headers.get('content-type'),
|
|
127
|
-
suggestedFileName: parseContentDispositionFilename(response.headers.get('content-disposition') ?? ''),
|
|
128
|
-
};
|
|
153
|
+
suggestedFileName: parseContentDispositionFilename(response.headers.get('content-disposition') ?? '') ?? null,
|
|
154
|
+
}));
|
|
129
155
|
}
|
|
130
156
|
// Authenticated fetch for both JSON and binary callers. Auth (proactive
|
|
131
157
|
// refresh inside the skew window + one 401-replay, guarded against a
|
|
132
158
|
// double-refresh under concurrency) is delegated to the shared TokenManager's
|
|
133
159
|
// `withAuth`. The 429 wait-and-replay and the non-2xx → throw remain here.
|
|
134
|
-
async fetchAuthed(method, path, body, accept) {
|
|
135
|
-
// `withAuth` invokes `call` once, and again after a refresh on a 401. The
|
|
136
|
-
// second invocation is the replay — mark it `(retry)` in the debug log,
|
|
137
|
-
// preserving the prior bespoke-loop diagnostic.
|
|
160
|
+
async fetchAuthed(method, path, body, accept, consume) {
|
|
138
161
|
let attempt = 0;
|
|
139
|
-
let
|
|
162
|
+
let result;
|
|
163
|
+
// Keep all response I/O inside fetchOnce's per-attempt deadline. Returning
|
|
164
|
+
// the Response still lets TokenManager own its one 401 refresh/replay.
|
|
165
|
+
const call = async (retry) => {
|
|
166
|
+
let authAttempt = 0;
|
|
167
|
+
return this.getTokenManager().withAuth((token) => {
|
|
168
|
+
const firstAuthAttempt = authAttempt++ === 0;
|
|
169
|
+
return this.fetchOnce(method, path, body, accept, token, async (response) => {
|
|
170
|
+
await throwIfEdgeBlocked(response, method, path);
|
|
171
|
+
if (response.status === 401 && firstAuthAttempt) {
|
|
172
|
+
await releaseBody(response);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (response.status === 429) {
|
|
176
|
+
await releaseBody(response);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (!response.ok) {
|
|
180
|
+
const errorBody = await response.text();
|
|
181
|
+
const edge = detectEdgeBlock({ body: errorBody, headers: response.headers, status: response.status });
|
|
182
|
+
if (edge !== null) {
|
|
183
|
+
throw new EdgeBlockedError(response.status, edge.vendor, { service: 'OurFamilyWizard', method, path });
|
|
184
|
+
}
|
|
185
|
+
const safeBody = redactSecrets(errorBody).replace(/\s+/g, ' ').trim().slice(0, 4000);
|
|
186
|
+
throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}` +
|
|
187
|
+
(safeBody ? ` — ${safeBody}` : ''));
|
|
188
|
+
}
|
|
189
|
+
result = await consume(response);
|
|
190
|
+
}, retry || attempt++ > 0);
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
const response = await call(false);
|
|
140
194
|
if (response.status === 429) {
|
|
195
|
+
// The attempt is released and its timer cleared before the backoff.
|
|
141
196
|
await new Promise((r) => setTimeout(r, 2000));
|
|
142
|
-
|
|
143
|
-
if (response.status === 429)
|
|
197
|
+
if ((await call(true)).status === 429) {
|
|
144
198
|
throw new Error('Rate limited by OFW API');
|
|
199
|
+
}
|
|
145
200
|
}
|
|
146
|
-
|
|
147
|
-
const errorBody = await response.text();
|
|
148
|
-
const safeBody = redactSecrets(errorBody).replace(/\s+/g, ' ').trim().slice(0, 4000);
|
|
149
|
-
throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}` +
|
|
150
|
-
(safeBody ? ` — ${safeBody}` : ''));
|
|
151
|
-
}
|
|
152
|
-
return response;
|
|
201
|
+
return result;
|
|
153
202
|
}
|
|
154
203
|
// A single OFW API fetch with the bearer token supplied by `withAuth`.
|
|
155
204
|
// Carries the per-request timeout (AbortController + setTimeout so vitest
|
|
156
205
|
// fake timers can drive it and we attach a clear error message) and the
|
|
157
|
-
// OFW_DEBUG_LOG instrumentation.
|
|
158
|
-
//
|
|
159
|
-
async fetchOnce(method, path, body, accept, token, isRetry = false) {
|
|
206
|
+
// OFW_DEBUG_LOG instrumentation. The handler completes body consumption or
|
|
207
|
+
// release before this attempt returns its Response to `withAuth`.
|
|
208
|
+
async fetchOnce(method, path, body, accept, token, handleResponse, isRetry = false) {
|
|
160
209
|
const isFormData = body instanceof FormData;
|
|
161
210
|
const headers = {
|
|
162
211
|
...OFW_PROTOCOL_HEADERS,
|
|
@@ -188,22 +237,36 @@ export class OFWClient {
|
|
|
188
237
|
const ac = new AbortController();
|
|
189
238
|
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
190
239
|
const startedAt = Date.now();
|
|
191
|
-
|
|
240
|
+
// Passing our own signal guarantees a signal even without an ambient caller.
|
|
241
|
+
const signal = withAmbientCancellation(ac.signal);
|
|
242
|
+
let onAbort;
|
|
243
|
+
// Race the complete attempt, not just fetch: body.cancel() may not settle
|
|
244
|
+
// on abort, and edge-probe/release helpers deliberately swallow I/O errors.
|
|
245
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
246
|
+
onAbort = () => reject(signal.reason);
|
|
247
|
+
if (signal.aborted)
|
|
248
|
+
onAbort();
|
|
249
|
+
else
|
|
250
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
251
|
+
});
|
|
192
252
|
try {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
253
|
+
return await Promise.race([aborted, (async () => {
|
|
254
|
+
signal.throwIfAborted();
|
|
255
|
+
const response = await fetch(url, {
|
|
256
|
+
method,
|
|
257
|
+
headers,
|
|
258
|
+
// The caller's cancellation and our timeout both cover headers,
|
|
259
|
+
// body reads and releases; only our controller diagnoses slowness.
|
|
260
|
+
signal,
|
|
261
|
+
...(body !== undefined ? { body: isFormData ? body : JSON.stringify(body) } : {}),
|
|
262
|
+
});
|
|
263
|
+
if (debugLogEnabled()) {
|
|
264
|
+
console.error(`[ofw-debug] ← ${response.status} ${response.statusText} (${Date.now() - startedAt}ms)`);
|
|
265
|
+
}
|
|
266
|
+
await handleResponse(response);
|
|
267
|
+
signal.throwIfAborted();
|
|
268
|
+
return response;
|
|
269
|
+
})()]);
|
|
207
270
|
}
|
|
208
271
|
catch (err) {
|
|
209
272
|
const elapsed = Date.now() - startedAt;
|
|
@@ -220,11 +283,8 @@ export class OFWClient {
|
|
|
220
283
|
}
|
|
221
284
|
finally {
|
|
222
285
|
clearTimeout(timer);
|
|
286
|
+
signal.removeEventListener('abort', onAbort);
|
|
223
287
|
}
|
|
224
|
-
if (debugLogEnabled()) {
|
|
225
|
-
console.error(`[ofw-debug] ← ${response.status} ${response.statusText} (${Date.now() - startedAt}ms)`);
|
|
226
|
-
}
|
|
227
|
-
return response;
|
|
228
288
|
}
|
|
229
289
|
}
|
|
230
290
|
export const client = new OFWClient();
|
package/dist/index.js
CHANGED
|
@@ -47,7 +47,7 @@ const tools = selectToolRegistrars({
|
|
|
47
47
|
});
|
|
48
48
|
await runMcp({
|
|
49
49
|
name: 'ofw',
|
|
50
|
-
version: '2.20.
|
|
50
|
+
version: '2.20.2', // x-release-please-version
|
|
51
51
|
deps: client,
|
|
52
52
|
tools,
|
|
53
53
|
banner: '[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
// disk injects an inline, filesystem-free implementation.
|
|
9
9
|
// Keeping the interface here means src/tools/messages.ts imports nothing from
|
|
10
10
|
// node:fs.
|
|
11
|
-
import { chmodSync,
|
|
11
|
+
import { chmodSync, readFileSync, realpathSync, statSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
12
12
|
import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
13
13
|
import { getDefaultAttachmentsDir, getUploadDir } from '../config.js';
|
|
14
|
-
import {
|
|
14
|
+
import { assertPathWithinRoots, expandPath, fileBlob, FileWriteRefusedError, sniffMimeBytes, writeFileSafe, } from '@chrischall/mcp-utils';
|
|
15
15
|
/**
|
|
16
16
|
* True when `candidate` is strictly inside `root` (both absolute). Lexical:
|
|
17
17
|
* resolve symlinks first when that matters.
|
|
@@ -20,13 +20,6 @@ export function isWithin(root, candidate) {
|
|
|
20
20
|
const rel = relative(root, candidate);
|
|
21
21
|
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
22
22
|
}
|
|
23
|
-
/** The deepest ancestor of `path` (itself included) that exists on disk. */
|
|
24
|
-
function deepestExisting(path) {
|
|
25
|
-
let current = path;
|
|
26
|
-
while (!existsSync(current))
|
|
27
|
-
current = dirname(current);
|
|
28
|
-
return current;
|
|
29
|
-
}
|
|
30
23
|
// Lightweight mime sniff from extension. OFW re-derives mime from the filename
|
|
31
24
|
// server-side anyway, so this is just a polite Content-Type for the Blob.
|
|
32
25
|
const MIME_BY_EXT = {
|
|
@@ -81,20 +74,11 @@ export function normalizeMimeType(raw) {
|
|
|
81
74
|
* onto them), so the actual bytes are the authoritative signal. Returns the
|
|
82
75
|
* bare media type, or null when the bytes aren't a PNG/JPEG/GIF/WEBP.
|
|
83
76
|
*/
|
|
84
|
-
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
85
|
-
const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
|
|
86
77
|
export function sniffImageMime(bytes) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (bytes.length >= 6 && bytes.toString('ascii', 0, 4) === 'GIF8')
|
|
92
|
-
return 'image/gif';
|
|
93
|
-
if (bytes.length >= 12 &&
|
|
94
|
-
bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') {
|
|
95
|
-
return 'image/webp';
|
|
96
|
-
}
|
|
97
|
-
return null;
|
|
78
|
+
// mcp-utils' shared magic-byte table, narrowed to what a host renders inline
|
|
79
|
+
// (it also names PDF, zip, MIDI and HEIC/MP4 — not ImageContent material).
|
|
80
|
+
const sniffed = sniffMimeBytes(bytes);
|
|
81
|
+
return sniffed !== undefined && HOST_RENDERABLE_IMAGE_MIMES.has(sniffed) ? sniffed : null;
|
|
98
82
|
}
|
|
99
83
|
/**
|
|
100
84
|
* Resolve the MIME type to report for downloaded bytes, in priority order:
|
|
@@ -129,12 +113,18 @@ export class NodeAttachmentIO {
|
|
|
129
113
|
// against the upload dir instead, and only expand a leading ~.
|
|
130
114
|
const abs = resolve(root, path.startsWith('~') ? expandPath(path) : path);
|
|
131
115
|
const outside = new Error(`Refusing to upload ${abs}: it is outside the upload directory (${root}). Only files placed in that directory can be uploaded — ask the user to copy the file there, or set OFW_UPLOAD_DIR.`);
|
|
116
|
+
// Lexical check first (a path that merely names somewhere else is refused
|
|
117
|
+
// even if a symlink there leads back in), then the shared real-path check.
|
|
132
118
|
if (!isWithin(root, abs))
|
|
133
119
|
throw outside;
|
|
134
120
|
const real = realpathSync(abs); // throws if missing
|
|
135
121
|
const realRoot = realpathSync(root);
|
|
136
|
-
|
|
122
|
+
try {
|
|
123
|
+
assertPathWithinRoots(real, [realRoot]);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
137
126
|
throw outside;
|
|
127
|
+
}
|
|
138
128
|
if (relative(realRoot, real).split(sep).some((segment) => segment.startsWith('.'))) {
|
|
139
129
|
throw new Error(`Refusing to upload ${abs}: hidden files and files in hidden directories (dotfiles, credential stores) are never uploaded.`);
|
|
140
130
|
}
|
|
@@ -160,7 +150,7 @@ export class NodeAttachmentIO {
|
|
|
160
150
|
return null;
|
|
161
151
|
}
|
|
162
152
|
}
|
|
163
|
-
writeDownload(dest, bytes, { root, overwrite }) {
|
|
153
|
+
async writeDownload(dest, bytes, { root, overwrite }) {
|
|
164
154
|
// The bytes are co-parent-supplied, so where they land is the security
|
|
165
155
|
// boundary. Check the REAL path of the nearest existing ancestor before
|
|
166
156
|
// creating anything, so a symlinked directory inside the root cannot carry
|
|
@@ -177,25 +167,35 @@ export class NodeAttachmentIO {
|
|
|
177
167
|
chmodSync(root, 0o700);
|
|
178
168
|
const realRoot = realpathSync(root);
|
|
179
169
|
const parent = dirname(dest);
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
170
|
+
const outside = () => new Error(`Refusing to write ${dest}: it resolves outside the attachments directory (${root}).`);
|
|
171
|
+
// Real path of the nearest existing ancestor (mcp-utils assertPathWithinRoots).
|
|
172
|
+
try {
|
|
173
|
+
assertPathWithinRoots(parent, [realRoot]);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
throw outside();
|
|
183
177
|
}
|
|
184
178
|
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
185
179
|
if (overwrite) {
|
|
186
|
-
// unlink
|
|
180
|
+
// Replace by unlink + exclusive create, NOT an O_TRUNC overwrite: unlink
|
|
181
|
+
// removes a symlink (or a hard link) itself, never its target, so a link
|
|
182
|
+
// planted at `dest` can't make "replace" rewrite a file elsewhere.
|
|
187
183
|
try {
|
|
188
184
|
unlinkSync(dest);
|
|
189
185
|
}
|
|
190
186
|
catch { /* nothing there to replace */ }
|
|
191
187
|
}
|
|
192
188
|
try {
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
189
|
+
// writeFileSafe: an O_CREAT|O_EXCL|O_NOFOLLOW open, so ANY existing
|
|
190
|
+
// entry — a symlink (even dangling) included — is refused, never
|
|
191
|
+
// clobbered or followed. allowedRoots re-confines the parent at write
|
|
192
|
+
// time, closing the window since the check above (and the mkdir).
|
|
193
|
+
await writeFileSafe(dest, bytes, { mode: 0o600, allowedRoots: [realRoot] });
|
|
196
194
|
}
|
|
197
195
|
catch (e) {
|
|
198
|
-
if (e
|
|
196
|
+
if (e instanceof FileWriteRefusedError) {
|
|
197
|
+
if (e.reason === 'outside-roots')
|
|
198
|
+
throw outside();
|
|
199
199
|
throw new Error(`Refusing to overwrite ${dest}: a file already exists there. Pass force:true to replace it, or choose another saveTo.`);
|
|
200
200
|
}
|
|
201
201
|
throw e;
|
package/dist/tools/calendar.js
CHANGED
|
@@ -215,7 +215,7 @@ export function registerCalendarTools(server, client) {
|
|
|
215
215
|
});
|
|
216
216
|
if (allowWrites)
|
|
217
217
|
server.registerTool('ofw_update_event', {
|
|
218
|
-
description: 'Update an existing OurFamilyWizard calendar event. Fetches the event, applies the given changes, and writes the merged result back (OFW has no partial update). A change to an event the co-parent can see (shared before or after the change) is confirmed first; the confirmation is bound to the event exactly as read, so if it changes on OFW in between (say the co-parent edited it) the update is refused instead of overwriting their edit. ' + CONFIRM_NOTE,
|
|
218
|
+
description: 'Update an existing OurFamilyWizard calendar event. Fetches the event, applies the given changes, and writes the merged result back (OFW has no partial update). A change to an event the co-parent can see (shared before or after the change) is confirmed first; the confirmation is bound to the event exactly as read, so if it changes on OFW in between (say the co-parent edited it) the update is refused instead of overwriting their edit. If the write fails without a definitive answer, or lands but cannot be re-read to confirm it, the result is EVENT_UNCONFIRMED: the change may already be on OFW, so do NOT retry until ofw_list_events shows whether it landed. ' + CONFIRM_NOTE,
|
|
219
219
|
annotations: { destructiveHint: true },
|
|
220
220
|
inputSchema: z.object({
|
|
221
221
|
eventId: z.string().describe('Event id — the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
|
|
@@ -267,15 +267,37 @@ export function registerCalendarTools(server, client) {
|
|
|
267
267
|
if (gate)
|
|
268
268
|
return gate;
|
|
269
269
|
}
|
|
270
|
-
|
|
270
|
+
try {
|
|
271
|
+
await requestWrite(client, 'PUT', `/pub/v3/events/${id}`, payload);
|
|
272
|
+
}
|
|
273
|
+
catch (e) {
|
|
274
|
+
if (!(e instanceof UnconfirmedWriteError))
|
|
275
|
+
throw e;
|
|
276
|
+
return unconfirmedWriteResponse(e, {
|
|
277
|
+
result: 'EVENT_UNCONFIRMED',
|
|
278
|
+
what: `update event ${eventId}`,
|
|
279
|
+
checkWith: 'ofw_list_events for this event and date range',
|
|
280
|
+
});
|
|
281
|
+
}
|
|
271
282
|
// PUT responses aren't documented — re-fetch the detail as authoritative state.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
283
|
+
// Once PUT succeeded, even a definitive rejection of this GET is not a
|
|
284
|
+
// rejection of the write. Do not present a failed verification as a safe retry.
|
|
285
|
+
try {
|
|
286
|
+
const rawAfter = await client.request('GET', `/pub/v3/events/${id}`);
|
|
287
|
+
const event = parseLenient(eventDetailSchema, rawAfter, { label: 'ofw-mcp', context: `GET /pub/v3/events/${eventId} (post-update)`, mode: 'strict' });
|
|
288
|
+
return jsonResponse({ note: 'Event updated; returning re-fetched event state.', event });
|
|
289
|
+
}
|
|
290
|
+
catch (e) {
|
|
291
|
+
return unconfirmedWriteResponse(new UnconfirmedWriteError(null, e), {
|
|
292
|
+
result: 'EVENT_UNCONFIRMED',
|
|
293
|
+
what: `verify the update to event ${eventId}`,
|
|
294
|
+
checkWith: 'ofw_list_events for this event and date range',
|
|
295
|
+
});
|
|
296
|
+
}
|
|
275
297
|
});
|
|
276
298
|
if (allowWrites)
|
|
277
299
|
server.registerTool('ofw_delete_event', {
|
|
278
|
-
description: 'Delete an OurFamilyWizard calendar event. Reads the event first; deleting one the co-parent can see is confirmed first, with a preview of exactly which event (title, date, time) is removed, and is refused if the event changed on OFW after that preview. ' + CONFIRM_NOTE,
|
|
300
|
+
description: 'Delete an OurFamilyWizard calendar event. Reads the event first; deleting one the co-parent can see is confirmed first, with a preview of exactly which event (title, date, time) is removed, and is refused if the event changed on OFW after that preview. If the request fails without a definitive answer the result is EVENT_UNCONFIRMED: the event may already be gone, so do NOT retry until ofw_list_events shows whether it is still there. ' + CONFIRM_NOTE,
|
|
279
301
|
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
280
302
|
inputSchema: z.object({
|
|
281
303
|
eventId: z.string().describe('Event id — the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
|
|
@@ -309,7 +331,18 @@ export function registerCalendarTools(server, client) {
|
|
|
309
331
|
if (gate)
|
|
310
332
|
return gate;
|
|
311
333
|
}
|
|
312
|
-
|
|
334
|
+
try {
|
|
335
|
+
await requestWrite(client, 'DELETE', `/pub/v3/events/${id}?includeFuture=${includeFuture}`);
|
|
336
|
+
}
|
|
337
|
+
catch (e) {
|
|
338
|
+
if (!(e instanceof UnconfirmedWriteError))
|
|
339
|
+
throw e;
|
|
340
|
+
return unconfirmedWriteResponse(e, {
|
|
341
|
+
result: 'EVENT_UNCONFIRMED',
|
|
342
|
+
what: `delete event ${args.eventId}${includeFuture ? ' and its future occurrences' : ''}`,
|
|
343
|
+
checkWith: 'ofw_list_events for this event and date range (including future occurrences if requested)',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
313
346
|
return textResponse(`Event ${args.eventId} ("${current.title}") deleted`);
|
|
314
347
|
});
|
|
315
348
|
}
|
package/dist/tools/messages.js
CHANGED
|
@@ -1630,7 +1630,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1630
1630
|
}
|
|
1631
1631
|
}
|
|
1632
1632
|
const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
|
|
1633
|
-
attachmentIO.writeDownload(dest, response.body, { root, overwrite: args.force === true });
|
|
1633
|
+
await attachmentIO.writeDownload(dest, response.body, { root, overwrite: args.force === true });
|
|
1634
1634
|
await cache.markAttachmentDownloaded(fileId, dest);
|
|
1635
1635
|
const fileName = response.suggestedFileName ?? cached.fileName;
|
|
1636
1636
|
const mimeType = resolveDownloadMime(response.body, response.contentType ?? cached.mimeType, fileName);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ofw-mcp",
|
|
3
|
-
"version": "2.20.
|
|
3
|
+
"version": "2.20.2",
|
|
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)",
|
|
@@ -34,14 +34,14 @@
|
|
|
34
34
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@chrischall/mcp-utils": "^2.
|
|
37
|
+
"@chrischall/mcp-utils": "^2.14.0",
|
|
38
38
|
"@fetchproxy/bootstrap": "^3.4.1",
|
|
39
|
-
"@modelcontextprotocol/server": "^2.
|
|
39
|
+
"@modelcontextprotocol/server": "^2.3.0",
|
|
40
40
|
"dotenv": "^18.0.0",
|
|
41
41
|
"zod": "^4.6.5"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@modelcontextprotocol/client": "^2.
|
|
44
|
+
"@modelcontextprotocol/client": "^2.3.0",
|
|
45
45
|
"@types/node": "^26.0.0",
|
|
46
46
|
"@vitest/coverage-v8": "^5.0.0",
|
|
47
47
|
"esbuild": "^0.28.0",
|
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.2",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ofw-mcp",
|
|
14
|
-
"version": "2.20.
|
|
14
|
+
"version": "2.20.2",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|