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.
package/dist/client.js CHANGED
@@ -144,7 +144,10 @@ export class OFWClient {
144
144
  throw new Error('Rate limited by OFW API');
145
145
  }
146
146
  if (!response.ok) {
147
- throw new Error(`OFW API error: ${response.status} ${response.statusText} for ${method} ${path}`);
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}` : ''));
148
151
  }
149
152
  return response;
150
153
  }
package/dist/config.js CHANGED
@@ -51,6 +51,43 @@ export function getUploadDir() {
51
51
  return override.trim();
52
52
  return getAttachmentsDir();
53
53
  }
54
+ /**
55
+ * Structural surface restrictions for deployments dedicated to reimbursement
56
+ * automation.
57
+ *
58
+ * OFW_EXPENSE_ONLY=true removes every non-expense registrar except the
59
+ * healthcheck. Message, calendar, journal and profile/dashboard tools simply do
60
+ * not exist in the server's tools/list response.
61
+ *
62
+ * OFW_EXPENSE_UPLOAD_ONLY=true is stricter and implies OFW_EXPENSE_ONLY: only
63
+ * ofw_upload_expense_pdf + ofw_create_expense + ofw_update_expense (plus
64
+ * ofw_healthcheck) register.
65
+ * Expense reads such as totals/list are omitted as well.
66
+ *
67
+ * These are startup-time structural controls. They cannot be raised by a tool
68
+ * argument, prompt injection, or host permission setting.
69
+ *
70
+ * Unrecognized non-empty values fail CLOSED to true. A typo in a restriction
71
+ * flag must never silently widen the server surface.
72
+ */
73
+ function restrictiveBoolEnv(name) {
74
+ const raw = process.env[name];
75
+ if (typeof raw !== 'string' || raw.trim().length === 0)
76
+ return false;
77
+ const value = raw.trim().toLowerCase();
78
+ if (['1', 'true', 'yes', 'on'].includes(value))
79
+ return true;
80
+ if (['0', 'false', 'no', 'off'].includes(value))
81
+ return false;
82
+ console.error(`[ofw-mcp] Unrecognized ${name} "${raw.trim()}" — failing closed to "true" (restriction enabled). Valid values: true, false.`);
83
+ return true;
84
+ }
85
+ export function getExpenseUploadOnly() {
86
+ return restrictiveBoolEnv('OFW_EXPENSE_UPLOAD_ONLY');
87
+ }
88
+ export function getExpenseOnly() {
89
+ return getExpenseUploadOnly() || restrictiveBoolEnv('OFW_EXPENSE_ONLY');
90
+ }
54
91
  /**
55
92
  * Gate for write-tool registration, read at registration time (startup).
56
93
  *
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { registerExpenseTools } from './tools/expenses.js';
19
19
  import { registerJournalTools } from './tools/journal.js';
20
20
  import { OFWCache } from './cache/node.js';
21
21
  import { getCacheDbPath } from './config.js';
22
+ import { selectToolRegistrars } from './tool-surface.js';
22
23
  import { NodeAttachmentIO } from './tools/attachments.js';
23
24
  // The stdio server backs the message cache with a local `node:sqlite` file,
24
25
  // opened lazily on first use (so the server still boots and answers the host's
@@ -34,17 +35,20 @@ const nodeAttachmentIO = new NodeAttachmentIO();
34
35
  // is preserved: `client` is constructed at module load in ./client.js (auth is
35
36
  // resolved lazily on the first tool call), so the host's initial tools/list
36
37
  // always succeeds before any credential check runs.
38
+ const expenseRegistrar = (server, deps) => registerExpenseTools(server, deps, nodeAttachmentIO);
39
+ const messageRegistrar = (server, deps) => registerMessageTools(server, deps, nodeCacheProvider, nodeAttachmentIO);
40
+ const tools = selectToolRegistrars({
41
+ healthcheck: registerHealthcheckTools,
42
+ user: registerUserTools,
43
+ messages: messageRegistrar,
44
+ calendar: registerCalendarTools,
45
+ expenses: expenseRegistrar,
46
+ journal: registerJournalTools,
47
+ });
37
48
  await runMcp({
38
49
  name: 'ofw',
39
- version: '2.19.4', // x-release-please-version
50
+ version: '2.20.0', // x-release-please-version
40
51
  deps: client,
41
- tools: [
42
- registerHealthcheckTools,
43
- registerUserTools,
44
- (server, deps) => registerMessageTools(server, deps, nodeCacheProvider, nodeAttachmentIO),
45
- registerCalendarTools,
46
- registerExpenseTools,
47
- registerJournalTools,
48
- ],
52
+ tools,
49
53
  banner: '[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
50
54
  });
@@ -0,0 +1,14 @@
1
+ import { getExpenseOnly } from './config.js';
2
+ /**
3
+ * Select the registrars exposed by this deployment.
4
+ *
5
+ * Expense-only mode is structural: omitted registrars are never handed to
6
+ * runMcp, so their tools cannot appear in tools/list or be invoked by name.
7
+ * OFW_EXPENSE_UPLOAD_ONLY implies this same registrar restriction and the
8
+ * expense registrar itself removes its read tools.
9
+ */
10
+ export function selectToolRegistrars(r) {
11
+ if (getExpenseOnly())
12
+ return [r.healthcheck, r.expenses];
13
+ return [r.healthcheck, r.user, r.messages, r.calendar, r.expenses, r.journal];
14
+ }
@@ -292,11 +292,15 @@ export async function requestWrite(client, method, path, body) {
292
292
  * may have landed and names how to check before any retry.
293
293
  */
294
294
  export function unconfirmedWriteResponse(e, opts) {
295
+ // The web app is the fallback for a checkWith that names a tool. A checkWith
296
+ // that already sends the caller to the web app (a deployment where the list
297
+ // tool is not registered) needs no second mention of it.
298
+ const fallback = /ourfamilywizard\.com/i.test(opts.checkWith) ? '' : ' (or on ourfamilywizard.com)';
295
299
  return jsonErrorResponse({
296
300
  result: opts.result,
297
301
  mayHaveLanded: true,
298
302
  reason: `The request to ${opts.what} failed without a definitive answer from OFW: ${e.message}. It MAY HAVE BEEN APPLIED on OurFamilyWizard.`,
299
- remedy: `Do NOT retry blindly — a second attempt can put a duplicate on the co-parent-visible record. First check with ${opts.checkWith} (or on ourfamilywizard.com), and retry only once you have confirmed it did not land.`,
303
+ remedy: `Do NOT retry blindly — a second attempt can put a duplicate on the co-parent-visible record. First check with ${opts.checkWith}${fallback}, and retry only once you have confirmed it did not land.`,
300
304
  });
301
305
  }
302
306
  export async function postMessageAndRefetch(client, payload, detailSchema, ctx) {
@@ -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; '