ofw-mcp 2.19.4 → 2.20.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.
@@ -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.20.0",
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.20.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ofw-mcp",
14
- "version": "2.19.4",
14
+ "version": "2.20.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -64,6 +64,18 @@
64
64
  "description": "Absolute path for the session cache file. Defaults to $MCP_DATA_DIR/.ofw-mcp/session.json.",
65
65
  "isRequired": false,
66
66
  "format": "string"
67
+ },
68
+ {
69
+ "name": "OFW_EXPENSE_ONLY",
70
+ "description": "Set true to register only healthcheck plus expense tools. All profile/dashboard, message, calendar, and journal tools are structurally absent.",
71
+ "isRequired": false,
72
+ "format": "string"
73
+ },
74
+ {
75
+ "name": "OFW_EXPENSE_UPLOAD_ONLY",
76
+ "description": "Set true for strict reimbursement upload mode: only healthcheck, ofw_upload_expense_pdf, ofw_create_expense, and ofw_update_expense register. Implies OFW_EXPENSE_ONLY.",
77
+ "isRequired": false,
78
+ "format": "string"
67
79
  }
68
80
  ]
69
81
  }
@@ -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
 
@@ -112,12 +112,19 @@ Always pass `--config ~/.mcporter/mcporter.json` unless a local `config/mcporter
112
112
  | `ofw_update_event(eventId, ...)` | Partial update — only pass fields to change |
113
113
  | `ofw_delete_event(eventId)` | Permanent delete |
114
114
 
115
+ ### Restricted expense deployments
116
+
117
+ When `OFW_EXPENSE_ONLY=true`, only the healthcheck and expense tools exist. When `OFW_EXPENSE_UPLOAD_ONLY=true`, only `ofw_healthcheck`, `ofw_upload_expense_pdf`, `ofw_create_expense`, and `ofw_update_expense` exist. Do not suggest message/calendar/journal/profile tools in those deployments; they are structurally unregistered.
118
+
115
119
  ### Expenses
116
120
  | Tool | Notes |
117
121
  |------|-------|
118
122
  | `ofw_get_expense_totals` | Summary of owed/paid totals |
119
- | `ofw_list_expenses(start?, max?)` | Paginated; default max 20 |
120
- | `ofw_create_expense(amount, description)` | Log a new expense |
123
+ | `ofw_list_expenses(page?, size?)` | Paginated (1-based page); default size 20; follow `nextPage` |
124
+ | `ofw_list_expense_categories` | Category ids and split metadata (use the id in create/update) |
125
+ | `ofw_upload_expense_pdf(path \| url, fileName?, label?, description?)` | Upload one receipt PDF to My Files (SHARED, co-parent-visible even on a private expense) for later attachment; confirm-gated; mode `all` only |
126
+ | `ofw_create_expense(title, amount, purchaseDate, categoryId, payerId, children, description?, privateExpense?, receiptFileId?)` | Log a new expense; confirm-gated; optionally private with one receipt |
127
+ | `ofw_update_expense(expenseId, …only the fields to change…)` | Reads the expense and keeps every field you omit; `privateExpense:false` publishes a private expense; `receiptFileId` replaces all receipts, `null` clears it or `description`; refuses as `EXPENSE_FIELDS_UNREADABLE` (naming the fields) rather than erase one it can't read; confirm-gated |
121
128
 
122
129
  ### Journal
123
130
  | Tool | Notes |
@@ -166,7 +173,7 @@ Message and draft reads come from a local cache, so **a result can be stale with
166
173
  |---|---|
167
174
  | How old is this data? | `freshness` — `staleness` (`fresh`/`unverified`/`stale`), `asOf`, `ageSeconds`, a quotable `warning` |
168
175
  | Is this the WHOLE answer? | `complete` on `ofw_list_messages` / `ofw_list_drafts` / `ofw_get_unread_sent` / `ofw_status` |
169
- | If not, how do I get the rest? | `nextPage` (message tools) or `nextStart` (`ofw_list_expenses` / `ofw_list_journal_entries`) — null means there is no more |
176
+ | If not, how do I get the rest? | `nextPage` (message tools and `ofw_list_expenses`) or `nextStart` (`ofw_list_journal_entries`) — null means there is no more |
170
177
  | Is this entity still what I think it is? | `state` from `ofw_status` / `ofw_check_freshness` |
171
178
 
172
179
  Rules:
@@ -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
 
@@ -197,18 +197,72 @@ curl -s -X DELETE "https://ofw.ourfamilywizard.com/pub/v1/calendar/events/${EVEN
197
197
  curl -s 'https://ofw.ourfamilywizard.com/pub/v2/expense/expenses/totals' "${AUTH_HEADERS[@]}" | jq .
198
198
  ```
199
199
 
200
- **List expenses** (offset-based, 0-indexed `start`):
200
+ **List expenses** (page-based, 1-indexed `page`):
201
+
202
+ OFW currently ignores the legacy `start`/`max` query parameters and returns
203
+ page 1 repeatedly. Use `page`/`size` and follow the response metadata's
204
+ `last` flag / `currentPage`.
205
+
206
+ ```sh
207
+ curl -s 'https://ofw.ourfamilywizard.com/pub/v2/expense/expenses?page=1&size=20' "${AUTH_HEADERS[@]}" | jq .
208
+ ```
209
+
210
+ **Upload a receipt PDF** (write to My Files). It must be `SHARED` — that is the
211
+ share class OFW accepts in an expense's `fileIds` — and a SHARED file is
212
+ visible to the co-parent in My Files as soon as it lands:
201
213
 
202
214
  ```sh
203
- curl -s 'https://ofw.ourfamilywizard.com/pub/v2/expense/expenses?start=0&max=20' "${AUTH_HEADERS[@]}" | jq .
215
+ curl -s -X POST 'https://ofw.ourfamilywizard.com/pub/v3/myfiles/multipart' \
216
+ "${AUTH_HEADERS[@]}" \
217
+ -F "file=@/path/to/receipt.pdf;type=application/pdf" \
218
+ -F 'source=expense' \
219
+ -F 'description=receipt.pdf' \
220
+ -F 'label=receipt.pdf' \
221
+ -F 'fileName=receipt.pdf' \
222
+ -F 'shared=true' \
223
+ -F 'shareClass=SHARED' | jq .
204
224
  ```
205
225
 
226
+ Use the returned `fileId` as `receiptFileId` when creating the expense.
227
+
206
228
  **Create an expense** (write):
207
229
 
230
+ The current OFW expense form requires a complete expense record. In addition to
231
+ the amount, send the expense title, purchase date, category id, parent who owes
232
+ (`payerId`), and at least one child user id. Description, visibility, and a
233
+ single receipt file are optional.
234
+
235
+ ```sh
236
+ curl -s -X POST 'https://ofw.ourfamilywizard.com/pub/v2/expense' \
237
+ "${AUTH_HEADERS[@]}" -H 'Content-Type: application/json' \
238
+ --data '{
239
+ "title":"Soccer cleats",
240
+ "amount":45.00,
241
+ "purchaseDate":"2026-09-28",
242
+ "categoryId":1,
243
+ "payerId":<PAYER_USER_ID>,
244
+ "children":[<CHILD_USER_ID>],
245
+ "description":"Cleats for soccer",
246
+ "isPrivate":false,
247
+ "fileIds":[<RECEIPT_FILE_ID>]
248
+ }' | jq .
249
+ ```
250
+
251
+ Use OFW numeric ids, not display labels, for `categoryId`, `payerId`, and
252
+ `children`. For a private expense (visible only to you), send `isPrivate:true`;
253
+ the MCP tools expose this as `privateExpense`. The MCP tools attach at most one
254
+ receipt, sent as a one-element `fileIds` array.
255
+
256
+ **Update an expense** (write; full payload, not a patch — e.g. publish a
257
+ private expense by sending `isPrivate:false`). A field left out of the PUT is
258
+ erased, so GET the expense first and send back every field you are not
259
+ changing (the MCP's `ofw_update_expense` does this for you):
260
+
208
261
  ```sh
209
- curl -s -X POST 'https://ofw.ourfamilywizard.com/pub/v2/expense/expenses' \
262
+ curl -s "https://ofw.ourfamilywizard.com/pub/v2/expense/expenses/${EXPENSE_ID}" "${AUTH_HEADERS[@]}" | jq .
263
+ curl -s -X PUT "https://ofw.ourfamilywizard.com/pub/v2/expense/expenses/${EXPENSE_ID}" \
210
264
  "${AUTH_HEADERS[@]}" -H 'Content-Type: application/json' \
211
- --data '{"amount": 45.00, "description": "Cleats for soccer"}' | jq .
265
+ --data '{ …every current field…, "isPrivate":false }' | jq .
212
266
  ```
213
267
 
214
268
  ## 8. Journal