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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +27 -4
- package/dist/auth.js +4 -4
- package/dist/bundle.js +1199 -180
- package/dist/client.js +4 -1
- package/dist/config.js +37 -0
- package/dist/index.js +13 -9
- package/dist/tool-surface.js +14 -0
- package/dist/tools/_shared.js +5 -1
- package/dist/tools/delivery.js +42 -2
- package/dist/tools/expenses.js +563 -56
- package/dist/tools/healthcheck.js +2 -2
- package/dist/tools/messages.js +2 -1
- package/package.json +3 -3
- package/server.json +14 -2
- package/skills/ofw/SKILL.md +11 -4
- package/skills/ofw-fpx/SKILL.md +5 -2
- package/skills/ofw-fpx/references/requests.md +58 -4
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
|
-
|
|
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.
|
|
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
|
+
}
|
package/dist/tools/_shared.js
CHANGED
|
@@ -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}
|
|
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) {
|
package/dist/tools/delivery.js
CHANGED
|
@@ -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
|
|
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; '
|