ofw-mcp 2.20.1 → 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/dist/client.js CHANGED
@@ -47,22 +47,21 @@ const OFW_REFRESH_SENTINEL = 'ofw';
47
47
  * Throw {@link EdgeBlockedError} when `response` is a CDN/WAF refusal.
48
48
  *
49
49
  * Its HEADERS are always judged (Cloudflare's `cf-mitigated`). Its BODY is
50
- * judged only for a 429: that response is about to be waited out and replayed,
51
- * or turned into "Rate limited", so a challenge page served as 429 without the
52
- * header would otherwise never reach the body check below the retry and would
53
- * be reported as a rate limit (chrischall/mcp-host#1015). The body is read
54
- * from a clone, and never when it is JSON — no refusal page is. Other non-2xx
55
- * bodies are judged where the error body is read anyway, in `fetchAuthed`, and
56
- * a 2xx body is never consumed here.
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.
57
56
  */
58
57
  async function throwIfEdgeBlocked(response, method, path) {
59
58
  if (response.ok)
60
59
  return;
61
60
  let edge = detectEdgeBlock({ headers: response.headers, status: response.status });
62
- if (edge === null && response.status === 429 && !/json/i.test(response.headers.get('content-type') ?? '')) {
61
+ if (edge === null && (response.status === 401 || response.status === 429) && !/json/i.test(response.headers.get('content-type') ?? '')) {
63
62
  let body = '';
64
63
  try {
65
- body = await response.clone().text();
64
+ body = await (response.status === 401 ? response.clone() : response).text();
66
65
  }
67
66
  catch {
68
67
  /* unreadable: not shown to be a block */
@@ -138,68 +137,75 @@ export class OFWClient {
138
137
  return this.tokenManager;
139
138
  }
140
139
  async request(method, path, body) {
141
- const response = await this.fetchAuthed(method, path, body, 'application/json');
142
- const text = await response.text();
143
- if (debugLogEnabled()) {
144
- console.error(`[ofw-debug] response body: ${text || '<empty>'}`);
145
- }
146
- return (text ? JSON.parse(text) : null);
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
+ });
147
147
  }
148
148
  /** Like `request`, but returns the raw bytes plus Content-Type/-Disposition metadata. */
149
149
  async requestBinary(method, path) {
150
- const response = await this.fetchAuthed(method, path, undefined, 'application/octet-stream');
151
- return {
150
+ return this.fetchAuthed(method, path, undefined, 'application/octet-stream', async (response) => ({
152
151
  body: Buffer.from(await response.arrayBuffer()),
153
152
  contentType: response.headers.get('content-type'),
154
153
  suggestedFileName: parseContentDispositionFilename(response.headers.get('content-disposition') ?? '') ?? null,
155
- };
154
+ }));
156
155
  }
157
156
  // Authenticated fetch for both JSON and binary callers. Auth (proactive
158
157
  // refresh inside the skew window + one 401-replay, guarded against a
159
158
  // double-refresh under concurrency) is delegated to the shared TokenManager's
160
159
  // `withAuth`. The 429 wait-and-replay and the non-2xx → throw remain here.
161
- async fetchAuthed(method, path, body, accept) {
162
- // `withAuth` invokes `call` once, and again after a refresh on a 401. The
163
- // second invocation is the replay — mark it `(retry)` in the debug log,
164
- // preserving the prior bespoke-loop diagnostic.
160
+ async fetchAuthed(method, path, body, accept, consume) {
165
161
  let attempt = 0;
166
- let response = await this.getTokenManager().withAuth((token) => this.fetchOnce(method, path, body, accept, token, attempt++ > 0));
167
- // A CDN/WAF refusal page is not OFW answering: the token was never judged.
168
- // `withAuth` already declines to spend a re-login on one; checked again
169
- // here BEFORE the 429 replay (a challenge can arrive as 429, and waiting it
170
- // out only repeats the block) and before the generic error below, so every
171
- // tool sees an `EdgeBlockedError` it can branch on instead of an
172
- // "OFW API error" with the page dumped into it (chrischall/mcp-host#1015).
173
- await throwIfEdgeBlocked(response, method, path);
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);
174
194
  if (response.status === 429) {
175
- // The first 429's body is never read: release it before the replay.
176
- await releaseBody(response);
195
+ // The attempt is released and its timer cleared before the backoff.
177
196
  await new Promise((r) => setTimeout(r, 2000));
178
- response = await this.getTokenManager().withAuth((token) => this.fetchOnce(method, path, body, accept, token, true));
179
- await throwIfEdgeBlocked(response, method, path);
180
- if (response.status === 429) {
181
- await releaseBody(response);
197
+ if ((await call(true)).status === 429) {
182
198
  throw new Error('Rate limited by OFW API');
183
199
  }
184
200
  }
185
- if (!response.ok) {
186
- const errorBody = await response.text();
187
- const edge = detectEdgeBlock({ body: errorBody, headers: response.headers, status: response.status });
188
- if (edge !== null) {
189
- throw new EdgeBlockedError(response.status, edge.vendor, { service: 'OurFamilyWizard', method, path });
190
- }
191
- const safeBody = redactSecrets(errorBody).replace(/\s+/g, ' ').trim().slice(0, 4000);
192
- throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}` +
193
- (safeBody ? ` — ${safeBody}` : ''));
194
- }
195
- return response;
201
+ return result;
196
202
  }
197
203
  // A single OFW API fetch with the bearer token supplied by `withAuth`.
198
204
  // Carries the per-request timeout (AbortController + setTimeout so vitest
199
205
  // fake timers can drive it and we attach a clear error message) and the
200
- // OFW_DEBUG_LOG instrumentation. Returns the raw Response — 401/429/non-2xx
201
- // handling lives in the callers (`withAuth` and `fetchAuthed`).
202
- 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) {
203
209
  const isFormData = body instanceof FormData;
204
210
  const headers = {
205
211
  ...OFW_PROTOCOL_HEADERS,
@@ -231,22 +237,36 @@ export class OFWClient {
231
237
  const ac = new AbortController();
232
238
  const timer = setTimeout(() => ac.abort(), timeoutMs);
233
239
  const startedAt = Date.now();
234
- let response;
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
+ });
235
252
  try {
236
- response = await fetch(url, {
237
- method,
238
- headers,
239
- // THE CALLER'S CANCELLATION, folded in with our timeout (mcp-utils
240
- // `cancel`). Until this the only thing that could stop an OFW
241
- // request was the timeout below, so a cancelled tool call held it
242
- // open for the full budget while the child burned the CPU
243
- // mcp-host meters it on. The timeout diagnosis below is unaffected
244
- // because it asks `ac.signal`, OUR controller — a caller's abort
245
- // falls through to the generic path rather than being reported as
246
- // OFW being slow.
247
- signal: withAmbientCancellation(ac.signal),
248
- ...(body !== undefined ? { body: isFormData ? body : JSON.stringify(body) } : {}),
249
- });
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
+ })()]);
250
270
  }
251
271
  catch (err) {
252
272
  const elapsed = Date.now() - startedAt;
@@ -263,11 +283,8 @@ export class OFWClient {
263
283
  }
264
284
  finally {
265
285
  clearTimeout(timer);
286
+ signal.removeEventListener('abort', onAbort);
266
287
  }
267
- if (debugLogEnabled()) {
268
- console.error(`[ofw-debug] ← ${response.status} ${response.statusText} (${Date.now() - startedAt}ms)`);
269
- }
270
- return response;
271
288
  }
272
289
  }
273
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.1', // x-release-please-version
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.',
@@ -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
- await client.request('PUT', `/pub/v3/events/${id}`, payload);
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
- const rawAfter = await client.request('GET', `/pub/v3/events/${id}`);
273
- const event = parseLenient(eventDetailSchema, rawAfter, { label: 'ofw-mcp', context: `GET /pub/v3/events/${eventId} (post-update)`, mode: 'strict' });
274
- return jsonResponse({ note: 'Event updated; returning re-fetched event state.', event });
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
- await client.request('DELETE', `/pub/v3/events/${id}?includeFuture=${includeFuture}`);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.20.1",
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.13.0",
37
+ "@chrischall/mcp-utils": "^2.14.0",
38
38
  "@fetchproxy/bootstrap": "^3.4.1",
39
- "@modelcontextprotocol/server": "^2.2.0",
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.2.0",
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.1",
9
+ "version": "2.20.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.20.1",
14
+ "version": "2.20.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },