ofw-mcp 2.20.0 → 2.20.1

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
@@ -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,49 @@ 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 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.
57
+ */
58
+ async function throwIfEdgeBlocked(response, method, path) {
59
+ if (response.ok)
60
+ return;
61
+ let edge = detectEdgeBlock({ headers: response.headers, status: response.status });
62
+ if (edge === null && response.status === 429 && !/json/i.test(response.headers.get('content-type') ?? '')) {
63
+ let body = '';
64
+ try {
65
+ body = await response.clone().text();
66
+ }
67
+ catch {
68
+ /* unreadable: not shown to be a block */
69
+ }
70
+ edge = detectEdgeBlock({ body, headers: response.headers, status: response.status });
71
+ }
72
+ if (edge !== null) {
73
+ await releaseBody(response);
74
+ throw new EdgeBlockedError(response.status, edge.vendor, { service: 'OurFamilyWizard', method, path });
75
+ }
76
+ }
77
+ /**
78
+ * Release a response's connection without reading it. A cancel that fails
79
+ * changes nothing about the answer, so its failure is swallowed.
80
+ */
81
+ async function releaseBody(response) {
82
+ try {
83
+ await response.body?.cancel();
84
+ }
85
+ catch {
86
+ /* already settled */
87
+ }
88
+ }
62
89
  export class OFWClient {
63
90
  // Bearer-token lifecycle is delegated to the shared, race-safe TokenManager
64
91
  // (proactive refresh inside the skew window, single-flight refresh so a burst
@@ -124,7 +151,7 @@ export class OFWClient {
124
151
  return {
125
152
  body: Buffer.from(await response.arrayBuffer()),
126
153
  contentType: response.headers.get('content-type'),
127
- suggestedFileName: parseContentDispositionFilename(response.headers.get('content-disposition') ?? ''),
154
+ suggestedFileName: parseContentDispositionFilename(response.headers.get('content-disposition') ?? '') ?? null,
128
155
  };
129
156
  }
130
157
  // Authenticated fetch for both JSON and binary callers. Auth (proactive
@@ -137,14 +164,30 @@ export class OFWClient {
137
164
  // preserving the prior bespoke-loop diagnostic.
138
165
  let attempt = 0;
139
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);
140
174
  if (response.status === 429) {
175
+ // The first 429's body is never read: release it before the replay.
176
+ await releaseBody(response);
141
177
  await new Promise((r) => setTimeout(r, 2000));
142
178
  response = await this.getTokenManager().withAuth((token) => this.fetchOnce(method, path, body, accept, token, true));
143
- if (response.status === 429)
179
+ await throwIfEdgeBlocked(response, method, path);
180
+ if (response.status === 429) {
181
+ await releaseBody(response);
144
182
  throw new Error('Rate limited by OFW API');
183
+ }
145
184
  }
146
185
  if (!response.ok) {
147
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
+ }
148
191
  const safeBody = redactSecrets(errorBody).replace(/\s+/g, ' ').trim().slice(0, 4000);
149
192
  throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}` +
150
193
  (safeBody ? ` — ${safeBody}` : ''));
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.0', // x-release-please-version
50
+ version: '2.20.1', // 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, existsSync, readFileSync, realpathSync, statSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs';
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 { fileBlob, expandPath } from '@chrischall/mcp-utils';
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
- if (bytes.length >= 8 && bytes.subarray(0, 8).equals(PNG_MAGIC))
88
- return 'image/png';
89
- if (bytes.length >= 3 && bytes.subarray(0, 3).equals(JPEG_MAGIC))
90
- return 'image/jpeg';
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
- if (!isWithin(realRoot, real))
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 anchor = realpathSync(deepestExisting(parent));
181
- if (anchor !== realRoot && !isWithin(realRoot, anchor)) {
182
- throw new Error(`Refusing to write ${dest}: it resolves outside the attachments directory (${root}).`);
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 removes a symlink itself, never its target.
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
- // 'wx' fails on ANY existing entry, a symlink (even dangling) included,
194
- // so nothing already at `dest` is ever clobbered or followed.
195
- writeFileSync(dest, bytes, { flag: 'wx', mode: 0o600 });
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.code === 'EEXIST') {
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;
@@ -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.0",
3
+ "version": "2.20.1",
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.8.0",
37
+ "@chrischall/mcp-utils": "^2.13.0",
38
38
  "@fetchproxy/bootstrap": "^3.4.1",
39
- "@modelcontextprotocol/server": "^2.0.0",
39
+ "@modelcontextprotocol/server": "^2.2.0",
40
40
  "dotenv": "^18.0.0",
41
41
  "zod": "^4.6.5"
42
42
  },
43
43
  "devDependencies": {
44
- "@modelcontextprotocol/client": "^2.0.0",
44
+ "@modelcontextprotocol/client": "^2.2.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.0",
9
+ "version": "2.20.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.20.0",
14
+ "version": "2.20.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },