ofw-mcp 2.19.4 → 2.19.5

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/index.js CHANGED
@@ -36,7 +36,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
36
36
  // always succeeds before any credential check runs.
37
37
  await runMcp({
38
38
  name: 'ofw',
39
- version: '2.19.4', // x-release-please-version
39
+ version: '2.19.5', // x-release-please-version
40
40
  deps: client,
41
41
  tools: [
42
42
  registerHealthcheckTools,
@@ -14,12 +14,29 @@
14
14
  // 2. extractable document → the FILE'S TEXT, as text (see src/extract)
15
15
  // 3. raw bytes → base64 EmbeddedResource, as before
16
16
  //
17
+ // Rungs 1 and 3 are bounded by MAX_INLINE_BYTES (10 MiB raw; see below).
17
18
  // Rung 3 never disappears, so nothing regresses; rung 2 is what makes a
18
19
  // spreadsheet, PDF, Word or PowerPoint attachment readable at all. When a rung
19
20
  // is skipped or fails, the response says so by name in `deliveryAttempts` —
20
21
  // a caller must never be left guessing why it got bytes instead of content.
21
22
  import { extractAttachment } from '../extract/index.js';
22
23
  import { isHostRenderableImage } from './attachments.js';
24
+ /**
25
+ * Upper bound on the RAW bytes this tool will return inline (as ImageContent
26
+ * or a base64 EmbeddedResource). Extracted text is not subject to it — that is
27
+ * already bounded by `maxChars`.
28
+ *
29
+ * Why 10 MiB: mcp-host, the hosted runtime that runs this MCP for claude.ai,
30
+ * caps any single child result at 14 MiB of serialized JSON-RPC
31
+ * (`CHILD_RESULT_MAX_BYTES`, chrischall/mcp-host#952) and replaces anything
32
+ * larger with a generic "result too large" tool error. 10 MiB raw is ~13.3 MiB
33
+ * as base64, which with the JSON-RPC envelope still fits under 14 MiB — so this
34
+ * bound fires first, with a message that says what to do instead. claude.ai
35
+ * itself accepts images up to 10 MB each (measured 2026-09-27), and before
36
+ * #952 anything over 10 MiB killed the child process outright. Real traffic
37
+ * for this tool has peaked at ~3 MB, so normal use never reaches it.
38
+ */
39
+ export const MAX_INLINE_BYTES = 10 * 1024 * 1024;
23
40
  /**
24
41
  * Attempt extraction, converting every failure into a REASON rather than an
25
42
  * exception: a format we cannot read must still be delivered as bytes, and the
@@ -42,6 +59,26 @@ export async function tryExtract(bytes, mimeType, fileName, opts) {
42
59
  return { reason: `extraction failed: ${err instanceof Error ? err.message : String(err)}` };
43
60
  }
44
61
  }
62
+ /**
63
+ * Refuse to inline more than {@link MAX_INLINE_BYTES}. This is a tool error
64
+ * rather than a fallback: the only non-inline channel is disk mode, which the
65
+ * hosted runtime (the one the cap protects) does not have, and silently
66
+ * writing to disk when the caller asked for content would not be honest.
67
+ */
68
+ function assertInlineSize(input, rawRequested) {
69
+ const { bytes, fileName, fileId, diskAvailable } = input;
70
+ if (bytes.length <= MAX_INLINE_BYTES)
71
+ return;
72
+ const hints = [];
73
+ if (rawRequested)
74
+ hints.push('omit extract:false to get the file\'s extracted text instead, if it is a readable document type');
75
+ if (diskAvailable)
76
+ hints.push('pass inline:false to save it to disk and get the path');
77
+ hints.push('or open it directly in OurFamilyWizard');
78
+ throw new Error(`Attachment ${fileId} (${fileName}) is ${bytes.length} bytes, over the ${MAX_INLINE_BYTES / (1024 * 1024)} MiB `
79
+ + `limit for returning a file inline (a larger result would exceed the host's response size limit). `
80
+ + `To get it: ${hints.join('; ')}.`);
81
+ }
45
82
  /**
46
83
  * Build the content blocks for an inline download by walking the ladder.
47
84
  * The first block is always a JSON meta block naming `deliveredVia`, so the
@@ -57,6 +94,7 @@ export async function buildInlineDelivery(input) {
57
94
  const block = () => ({ type: 'text', text: JSON.stringify(meta, null, 2) });
58
95
  // Rung 1 — the host renders these itself, and a picture beats a description.
59
96
  if (isHostRenderableImage(mimeType)) {
97
+ assertInlineSize(input, false);
60
98
  meta.deliveredVia = 'image';
61
99
  return { content: [block(), { type: 'image', data: bytes.toString('base64'), mimeType }] };
62
100
  }
@@ -80,8 +118,10 @@ export async function buildInlineDelivery(input) {
80
118
  /* v8 ignore next -- tryExtract always sets `reason` when it returns no extraction */
81
119
  attempts.push(outcome.reason ?? 'extraction produced no content');
82
120
  }
83
- // Rung 3 — the bytes themselves. Always available, so a fetch that succeeded
84
- // never ends with the caller holding nothing.
121
+ // Rung 3 — the bytes themselves. Always available up to MAX_INLINE_BYTES, so
122
+ // a fetch that succeeded never ends with the caller holding nothing; beyond
123
+ // it the caller gets a tool error that says how to reach the file instead.
124
+ assertInlineSize(input, options.extract === false);
85
125
  meta.deliveredVia = 'blob';
86
126
  meta.deliveryAttempts = attempts;
87
127
  meta.note = 'Returned as raw bytes. Some hosts cannot render an embedded resource of this type; '
@@ -62,7 +62,7 @@ resolve = resolveAuth) {
62
62
  kind: 'transport',
63
63
  // The upstream `.hint` rides along in `error.message` — it carries
64
64
  // the actionable "click the toolbar icon" copy this cannot know.
65
- hint: 'The fetchproxy bridge is down, so the browser path could not be tried. This is ' +
65
+ hint: 'ContextMint Bridge is down, so the browser path could not be tried. This is ' +
66
66
  'not a credential problem: OFW_USERNAME/OFW_PASSWORD, if set, were not reached ' +
67
67
  'either. See error.message for the extension-specific fix.',
68
68
  }
@@ -71,7 +71,7 @@ resolve = resolveAuth) {
71
71
  // Now means exactly what it says: nothing is set up. A configured path
72
72
  // that was tried and failed no longer lands here.
73
73
  no_credential: 'No OFW credential is configured. Either set OFW_USERNAME + OFW_PASSWORD, or install ' +
74
- 'the fetchproxy extension and sign in to ourfamilywizard.com in a tab (unsetting ' +
74
+ 'ContextMint Bridge and sign in to ourfamilywizard.com in a tab (unsetting ' +
75
75
  'OFW_DISABLE_FETCHPROXY if you set it).',
76
76
  credential_rejected: 'OurFamilyWizard rejected the credential. If it came from `env`, the password changed or ' +
77
77
  'the account is locked; if from `fetchproxy`, the browser session expired — sign in again ' +
@@ -1526,7 +1526,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1526
1526
  });
1527
1527
  });
1528
1528
  server.registerTool('ofw_download_attachment', {
1529
- description: 'Download an OFW message attachment by fileId and return content you can actually read. Inline delivery walks a ladder and returns the first rung that works: (1) host-renderable images (PNG/JPEG/GIF/WEBP) come back as ImageContent; (2) .xlsx/.csv/.tsv, .pdf, .docx, .pptx and text files come back as EXTRACTED CONTENT — per-sheet CSV, per-page/slide text, document text — in the response JSON under `extracted`; (3) anything else comes back as an EmbeddedResource blob of the raw bytes. The meta block names the rung as `deliveredVia` and, when it falls through to bytes, lists what was tried in `deliveryAttempts`. Reported mime types are always normalized to a bare media type (no charset/name parameters). In disk mode the bytes are saved to ~/Downloads/ofw-mcp/ and the response carries the absolute path; pass extract:true to ALSO get the extracted content in that response. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var. On a hosted deployment with no filesystem, disk mode is unavailable, so inline is forced (forcedInline:true) rather than failing — a saveTo path never costs you the content. fileId comes from attachments[].fileId on ofw_get_message. Override disk destination with OFW_ATTACHMENTS_DIR or saveTo; saveTo must stay inside the attachments directory, and an existing file is never overwritten unless force:true. Re-downloading to the same path is a no-op (disk mode only).',
1529
+ description: 'Download an OFW message attachment by fileId and return content you can actually read. Inline delivery walks a ladder and returns the first rung that works: (1) host-renderable images (PNG/JPEG/GIF/WEBP) come back as ImageContent; (2) .xlsx/.csv/.tsv, .pdf, .docx, .pptx and text files come back as EXTRACTED CONTENT — per-sheet CSV, per-page/slide text, document text — in the response JSON under `extracted`; (3) anything else comes back as an EmbeddedResource blob of the raw bytes. Images and raw bytes are only returned inline up to 10 MiB; a larger file fails with a tool error that says how to get it instead (extracted text is unaffected — it is bounded by maxChars). The meta block names the rung as `deliveredVia` and, when it falls through to bytes, lists what was tried in `deliveryAttempts`. Reported mime types are always normalized to a bare media type (no charset/name parameters). In disk mode the bytes are saved to ~/Downloads/ofw-mcp/ and the response carries the absolute path; pass extract:true to ALSO get the extracted content in that response. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var. On a hosted deployment with no filesystem, disk mode is unavailable, so inline is forced (forcedInline:true) rather than failing — a saveTo path never costs you the content. fileId comes from attachments[].fileId on ofw_get_message. Override disk destination with OFW_ATTACHMENTS_DIR or saveTo; saveTo must stay inside the attachments directory, and an existing file is never overwritten unless force:true. Re-downloading to the same path is a no-op (disk mode only).',
1530
1530
  annotations: { readOnlyHint: false, destructiveHint: false },
1531
1531
  inputSchema: z.object({
1532
1532
  fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
@@ -1581,6 +1581,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
1581
1581
  // ends up holding something readable.
1582
1582
  return await buildInlineDelivery({
1583
1583
  fileId, fileName, mimeType, bytes, forcedInline, options: deliveryOptions,
1584
+ diskAvailable: attachmentIO.supportsDisk,
1584
1585
  });
1585
1586
  }
1586
1587
  let dest;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ofw-mcp",
3
- "version": "2.19.4",
3
+ "version": "2.19.5",
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,8 +34,8 @@
34
34
  "typecheck": "tsc -p tsconfig.json --noEmit"
35
35
  },
36
36
  "dependencies": {
37
- "@chrischall/mcp-utils": "^2.6.0",
38
- "@fetchproxy/bootstrap": "^3.2.0",
37
+ "@chrischall/mcp-utils": "^2.8.0",
38
+ "@fetchproxy/bootstrap": "^3.4.1",
39
39
  "@modelcontextprotocol/server": "^2.0.0",
40
40
  "dotenv": "^18.0.0",
41
41
  "zod": "^4.6.5"
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.19.4",
9
+ "version": "2.19.5",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.19.4",
14
+ "version": "2.19.5",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -100,7 +100,7 @@ Always pass `--config ~/.mcporter/mcporter.json` unless a local `config/mcporter
100
100
  | `ofw_save_draft(subject, body, recipientIds?, messageId?, replyToId?, myFileIDs?, expectedRevision?, force?)` | Create a new draft. Pass `messageId` to **replace** an existing draft: the tool creates a fresh draft and deletes the old one (OFW's update-in-place endpoint silently no-ops). The returned `id` is the NEW id; the response leads with `draftKey`, which stays the same across every edit — **track that, not the id**. Note: OFW does **not** store recipients on drafts — `recipientIds` are accepted but come back empty (a one-line NOTE says so; supply them at send time instead). Threading warnings fire only on genuine drops — a draft echoing `inReplyTo`/`showContext` IS threaded. |
101
101
  | `ofw_delete_draft(messageId)` | Delete a draft. |
102
102
  | `ofw_upload_attachment(path, shareClass?, label?, description?)` | Upload a local file to My Files; returns a fileId to pass into `myFileIDs`. Only files inside the upload directory (`OFW_UPLOAD_DIR`, default `~/Downloads/ofw-mcp`) can be uploaded; hidden files are refused. `shareClass:"SHARED"` needs write mode `all`. |
103
- | `ofw_download_attachment(fileId, inline?, saveTo?, force?, extract?, maxChars?, parts?)` | Download an attachment. Inline delivery returns the first rung that works: image → `ImageContent`; .xlsx/.csv/.pdf/.docx/.pptx/text → **extracted content** under `extracted` (per-sheet CSV, per-page/slide text); anything else → raw bytes. Default writes to `~/Downloads/ofw-mcp/` (add `extract:true` for content too). Use `parts:"1-2"` / a sheet name and `maxChars` on large files. |
103
+ | `ofw_download_attachment(fileId, inline?, saveTo?, force?, extract?, maxChars?, parts?)` | Download an attachment. Inline delivery returns the first rung that works: image → `ImageContent`; .xlsx/.csv/.pdf/.docx/.pptx/text → **extracted content** under `extracted` (per-sheet CSV, per-page/slide text); anything else → raw bytes. Default writes to `~/Downloads/ofw-mcp/` (add `extract:true` for content too). Use `parts:"1-2"` / a sheet name and `maxChars` on large files. Images and raw bytes over 10 MiB are not returned inline (tool error) — use disk mode or open the file in OFW. |
104
104
  | `ofw_check_freshness(folders?, messageIds?, allowMarkRead?)` | Cheap live check that the cache still matches OFW — one request for folder counts plus one per id, no bodies, no sync. Each id gets a live `state` (`draft`/`sent`/`received`/`deleted`/`unknown`) plus `folder` and `sentAt`. Probes ids cached as drafts, as sent, or as already-read inbox messages freely; anything else needs `allowMarkRead:true` (it would mark an inbox message read). |
105
105
  | `ofw_status(ids?, draftKeys?, includeDraftInventory?, allowMarkRead?)` | **The status call.** One live round trip. With no arguments: the full, server-verified draft inventory. With `ids`/`draftKeys`: each one's live lifecycle state. Top-level `complete` is true only when every part was verified live. |
106
106
 
@@ -33,13 +33,16 @@ dry-run/confirm here — curl just does it. Treat every write like the MCP's
33
33
  npm install -g @fetchproxy/cli # provides `fpx`
34
34
  fpx profile add ofw --domain ourfamilywizard.com
35
35
  fpx profile declare ofw --local-storage auth --local-storage tokenExpiry
36
- fpx pair -p ofw # prints a pair code → approve in Transporter
36
+ fpx pair -p ofw # prints a pair code → approve in ContextMint Bridge
37
37
  ```
38
38
 
39
- Requirements: the **Transporter** browser extension installed, with an
39
+ Requirements: the **ContextMint Bridge** browser extension installed
40
+ ([releases](https://github.com/nullnet-app/contextmint-bridge/releases) —
41
+ Chrome: load the Chrome zip unpacked; Safari isn't available yet, so use Chrome for now), with an
40
42
  open, signed-in `ofw.ourfamilywizard.com` (or `www.ourfamilywizard.com`)
41
43
  tab, and its Chrome **Site access** allowing `ourfamilywizard.com`. Pairing
42
44
  persists across invocations.
45
+ ContextMint Bridge is the fetchproxy extension renamed, same maintainer (see https://github.com/chrischall/fetchproxy#extension); source at https://github.com/nullnet-app/contextmint-bridge — build it, or verify a release zip with `shasum -a 256 -c contextmint-bridge-chrome-<version>.zip.sha256`.
43
46
 
44
47
  ## Capture the token (once per shell / whenever it goes stale)
45
48