ofw-mcp 2.20.3 → 2.20.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +7 -3
- package/dist/auth-password.js +27 -2
- package/dist/auth.js +3 -3
- package/dist/bundle.js +375 -157
- package/dist/client.js +34 -5
- package/dist/config.js +3 -4
- package/dist/extract/inflate.js +36 -4
- package/dist/extract/pdf.js +8 -5
- package/dist/extract/zip.js +8 -3
- package/dist/index.js +2 -1
- package/dist/protocol.js +18 -8
- package/dist/tools/_confirm.js +4 -0
- package/dist/tools/_shared.js +14 -0
- package/dist/tools/calendar.js +9 -5
- package/dist/tools/expenses.js +11 -5
- package/dist/tools/healthcheck.js +17 -9
- package/dist/tools/journal.js +4 -2
- package/dist/tools/messages.js +42 -28
- package/dist/tools/user.js +7 -2
- package/dist/version.js +7 -0
- package/mint.yaml +6 -1
- package/package.json +4 -4
- package/server.json +92 -2
- package/skills/ofw/SKILL.md +1 -1
package/dist/client.js
CHANGED
|
@@ -19,11 +19,20 @@ catch {
|
|
|
19
19
|
/* v8 ignore next -- only reached in a non-Node runtime: no .env to load */
|
|
20
20
|
}
|
|
21
21
|
// Set OFW_DEBUG_LOG=1 (or true/yes/on) to log every OFW request/response to
|
|
22
|
-
// stderr. Authorization is redacted.
|
|
23
|
-
// only when debugging, never in normal use.
|
|
22
|
+
// stderr. Authorization is redacted. Locally, bodies are logged in full — set
|
|
23
|
+
// this only when debugging, never in normal use.
|
|
24
24
|
function debugLogEnabled() {
|
|
25
25
|
return parseBoolEnv('OFW_DEBUG_LOG');
|
|
26
26
|
}
|
|
27
|
+
// On a hosted server (mcp-host injects MCP_DATA_DIR) stderr is captured by the
|
|
28
|
+
// host's log pipeline, which the operator can read — so request and response
|
|
29
|
+
// bodies (message text, drafts, expense ledgers) are replaced by their size.
|
|
30
|
+
// Locally stderr is the user's own terminal and bodies stay visible.
|
|
31
|
+
function debugBody(text) {
|
|
32
|
+
if (process.env.MCP_DATA_DIR)
|
|
33
|
+
return `<${text.length} chars, elided on a hosted server>`;
|
|
34
|
+
return text;
|
|
35
|
+
}
|
|
27
36
|
// Per-request timeout. Overridable via OFW_REQUEST_TIMEOUT_MS. The default
|
|
28
37
|
// (30s) is comfortably above OFW's typical p99 but low enough that a stuck
|
|
29
38
|
// upstream fails fast instead of burning the MCP client-side budget — which
|
|
@@ -100,6 +109,9 @@ export class OFWClient {
|
|
|
100
109
|
// deployment. Left undefined by the stdio path, which falls back to the
|
|
101
110
|
// global resolver, keeping that behaviour byte-for-byte identical.
|
|
102
111
|
authResolver;
|
|
112
|
+
// Which path minted the token currently held. Unset while the token is one
|
|
113
|
+
// the TokenManager restored from the session cache — nothing was minted.
|
|
114
|
+
mintedBy;
|
|
103
115
|
constructor(opts) {
|
|
104
116
|
this.authResolver = opts?.resolveAuth;
|
|
105
117
|
}
|
|
@@ -111,7 +123,8 @@ export class OFWClient {
|
|
|
111
123
|
// the expired placeholder that used to sit here would have meant the
|
|
112
124
|
// cache was written but never read.
|
|
113
125
|
const mint = async () => {
|
|
114
|
-
const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
|
|
126
|
+
const { token, expiresAt, source } = await (this.authResolver ?? resolveAuth)();
|
|
127
|
+
this.mintedBy = source;
|
|
115
128
|
return {
|
|
116
129
|
accessToken: token,
|
|
117
130
|
refreshToken: OFW_REFRESH_SENTINEL,
|
|
@@ -136,11 +149,27 @@ export class OFWClient {
|
|
|
136
149
|
}
|
|
137
150
|
return this.tokenManager;
|
|
138
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* The credential's source and expiry, answered from the TokenManager — the
|
|
154
|
+
* same token, cache and single-flight mint every tool uses.
|
|
155
|
+
*
|
|
156
|
+
* `ofw_healthcheck` used to call `resolveAuth()` directly, which is a full
|
|
157
|
+
* login (or a fresh browser-bridge spin-up) on every healthcheck, ignoring
|
|
158
|
+
* the six-hour token already held. Going through the manager means a held or
|
|
159
|
+
* cached token costs nothing, and a mint, when one is needed, is the one the
|
|
160
|
+
* next real tool call would have made anyway. A mint failure propagates
|
|
161
|
+
* unchanged so the healthcheck can classify it.
|
|
162
|
+
*/
|
|
163
|
+
async credentialStatus() {
|
|
164
|
+
const tokens = this.getTokenManager();
|
|
165
|
+
await tokens.getAccessToken();
|
|
166
|
+
return { source: this.mintedBy ?? 'cache', expiresAt: new Date(tokens.getExpiresAt()) };
|
|
167
|
+
}
|
|
139
168
|
async request(method, path, body) {
|
|
140
169
|
return this.fetchAuthed(method, path, body, 'application/json', async (response) => {
|
|
141
170
|
const text = await response.text();
|
|
142
171
|
if (debugLogEnabled()) {
|
|
143
|
-
console.error(`[ofw-debug] response body: ${text
|
|
172
|
+
console.error(`[ofw-debug] response body: ${text ? debugBody(text) : '<empty>'}`);
|
|
144
173
|
}
|
|
145
174
|
return (text ? JSON.parse(text) : null);
|
|
146
175
|
});
|
|
@@ -223,7 +252,7 @@ export class OFWClient {
|
|
|
223
252
|
? '<none>'
|
|
224
253
|
: isFormData
|
|
225
254
|
? `<FormData entries=${Array.from(body.keys()).join(',')}>`
|
|
226
|
-
: JSON.stringify(body);
|
|
255
|
+
: debugBody(JSON.stringify(body));
|
|
227
256
|
console.error(`[ofw-debug] → ${method} ${url}${isRetry ? ' (retry)' : ''}`);
|
|
228
257
|
// redactSecrets scrubs the Bearer token (and any other secret shapes)
|
|
229
258
|
// from the serialized header map — shared fleet redaction, never bespoke.
|
package/dist/config.js
CHANGED
|
@@ -14,10 +14,9 @@ function readCacheIdentity() {
|
|
|
14
14
|
return readEnvVar('OFW_CACHE_IDENTITY') ?? readEnvVar('OFW_USERNAME') ?? '_default';
|
|
15
15
|
}
|
|
16
16
|
export function getCacheDir() {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
return join(homedir(), '.cache', 'ofw-mcp');
|
|
17
|
+
// readEnvVar, not process.env: it also treats an unexpanded mcpb
|
|
18
|
+
// `${user_config.*}` placeholder as unset.
|
|
19
|
+
return readEnvVar('OFW_CACHE_DIR') ?? join(homedir(), '.cache', 'ofw-mcp');
|
|
21
20
|
}
|
|
22
21
|
export function getCacheDbPath() {
|
|
23
22
|
const identity = readCacheIdentity();
|
package/dist/extract/inflate.js
CHANGED
|
@@ -14,6 +14,28 @@
|
|
|
14
14
|
// whatever the file felt like claiming.
|
|
15
15
|
/** 32 MiB. Sized to fit comfortably inside a constrained memory budget. */
|
|
16
16
|
export const MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
|
|
17
|
+
/**
|
|
18
|
+
* 64 MiB: the most one DOCUMENT may decompress in total, across all its
|
|
19
|
+
* members or streams. The per-member cap alone does not bound memory: a
|
|
20
|
+
* workbook of 100 sheets that each inflate to just under 32 MiB, or a PDF
|
|
21
|
+
* whose every page references the same near-cap stream, would keep gigabytes
|
|
22
|
+
* of output alive from a few kilobytes of attachment.
|
|
23
|
+
*/
|
|
24
|
+
export const MAX_TOTAL_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
25
|
+
/** A running total of decompressed bytes for one document, shared by every read. */
|
|
26
|
+
export class DecompressionBudget {
|
|
27
|
+
limit;
|
|
28
|
+
used = 0;
|
|
29
|
+
constructor(limit = MAX_TOTAL_DECOMPRESSED_BYTES) {
|
|
30
|
+
this.limit = limit;
|
|
31
|
+
}
|
|
32
|
+
/** Charge `bytes`; throws once the document's total passes the limit. */
|
|
33
|
+
charge(bytes, label) {
|
|
34
|
+
this.used += bytes;
|
|
35
|
+
if (this.used > this.limit)
|
|
36
|
+
throw new DecompressionLimitError(label, this.limit, 'total');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
17
39
|
/**
|
|
18
40
|
* Thrown when decompression is aborted for exceeding its cap. Distinct from a
|
|
19
41
|
* decode failure so callers can tell "this file is hostile or absurd" from
|
|
@@ -21,19 +43,22 @@ export const MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
|
|
|
21
43
|
* routinely survivable.
|
|
22
44
|
*/
|
|
23
45
|
export class DecompressionLimitError extends Error {
|
|
24
|
-
constructor(label, limit) {
|
|
25
|
-
super(
|
|
46
|
+
constructor(label, limit, scope = 'member') {
|
|
47
|
+
super(scope === 'total'
|
|
48
|
+
? `${label} pushes the document past its ${limit}-byte total decompression cap`
|
|
49
|
+
: `${label} expands past the ${limit}-byte decompression cap`);
|
|
26
50
|
this.name = 'DecompressionLimitError';
|
|
27
51
|
}
|
|
28
52
|
}
|
|
29
53
|
/**
|
|
30
|
-
* Inflate `data`, aborting if the OUTPUT exceeds `limit` bytes
|
|
54
|
+
* Inflate `data`, aborting if the OUTPUT exceeds `limit` bytes, or pushes the
|
|
55
|
+
* document's shared `budget` (when given) past its total.
|
|
31
56
|
*
|
|
32
57
|
* `deflate-raw` is the ZIP member format; `deflate` is the zlib-wrapped form a
|
|
33
58
|
* PDF `/FlateDecode` stream uses. Both go through the WHATWG
|
|
34
59
|
* `DecompressionStream` so this runs unchanged wherever the standard exists.
|
|
35
60
|
*/
|
|
36
|
-
export async function inflateBounded(data, format, limit, label) {
|
|
61
|
+
export async function inflateBounded(data, format, limit, label, budget) {
|
|
37
62
|
const stream = new Blob([data]).stream()
|
|
38
63
|
.pipeThrough(new DecompressionStream(format));
|
|
39
64
|
const reader = stream.getReader();
|
|
@@ -49,6 +74,13 @@ export async function inflateBounded(data, format, limit, label) {
|
|
|
49
74
|
await reader.cancel();
|
|
50
75
|
throw new DecompressionLimitError(label, limit);
|
|
51
76
|
}
|
|
77
|
+
try {
|
|
78
|
+
budget?.charge(value.length, label);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
await reader.cancel();
|
|
82
|
+
throw err;
|
|
83
|
+
}
|
|
52
84
|
chunks.push(value);
|
|
53
85
|
}
|
|
54
86
|
return Buffer.concat(chunks);
|
package/dist/extract/pdf.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// What it must never do is fail silently. A scanned PDF has no text layer at
|
|
10
10
|
// all, and the honest answer for one is `textLayer: false` plus a note saying
|
|
11
11
|
// it needs OCR — not an empty string that reads like an empty document.
|
|
12
|
-
import { inflateBounded, DecompressionLimitError, MAX_DECOMPRESSED_BYTES, } from './inflate.js';
|
|
12
|
+
import { inflateBounded, DecompressionBudget, DecompressionLimitError, MAX_DECOMPRESSED_BYTES, } from './inflate.js';
|
|
13
13
|
const OBJ_HEADER = /(\d+)\s+\d+\s+obj\b/g;
|
|
14
14
|
function parseObjects(text) {
|
|
15
15
|
const objects = new Map();
|
|
@@ -79,19 +79,21 @@ function streamBytes(bytes, obj) {
|
|
|
79
79
|
return bytes.subarray(from, end === -1 ? undefined : end);
|
|
80
80
|
}
|
|
81
81
|
/** Decode a stream's bytes, honouring FlateDecode. Null for filters we can't read. */
|
|
82
|
-
async function decodeStream(bytes, obj) {
|
|
82
|
+
async function decodeStream(bytes, obj, budget) {
|
|
83
83
|
const raw = streamBytes(bytes, obj);
|
|
84
84
|
if (!raw)
|
|
85
85
|
return null;
|
|
86
86
|
const filter = /\/Filter\s*(\/\w+|\[[^\]]*\])/.exec(obj.body)?.[1] ?? '';
|
|
87
|
-
if (filter === '')
|
|
87
|
+
if (filter === '') {
|
|
88
|
+
budget.charge(raw.length, 'PDF stream');
|
|
88
89
|
return raw;
|
|
90
|
+
}
|
|
89
91
|
if (!filter.includes('FlateDecode'))
|
|
90
92
|
return null; // LZW/DCT/JPX: not text anyway
|
|
91
93
|
try {
|
|
92
94
|
// PDF stream dictionaries say nothing about inflated length, so the cap is
|
|
93
95
|
// the only thing standing between a crafted stream and the memory budget.
|
|
94
|
-
return await inflateBounded(raw, 'deflate', MAX_DECOMPRESSED_BYTES, 'PDF stream');
|
|
96
|
+
return await inflateBounded(raw, 'deflate', MAX_DECOMPRESSED_BYTES, 'PDF stream', budget);
|
|
95
97
|
}
|
|
96
98
|
catch (err) {
|
|
97
99
|
// A truncated or mis-bounded stream must not take the whole document down;
|
|
@@ -245,6 +247,7 @@ export async function extractPdf(bytes, opts = {}) {
|
|
|
245
247
|
const pageObjects = orderedPages(objects);
|
|
246
248
|
if (pageObjects.length === 0)
|
|
247
249
|
throw new Error('no pages found in the PDF');
|
|
250
|
+
const budget = new DecompressionBudget(opts.maxTotalDecompressedBytes);
|
|
248
251
|
const pages = [];
|
|
249
252
|
const omitted = [];
|
|
250
253
|
for (let i = 0; i < pageObjects.length; i++) {
|
|
@@ -259,7 +262,7 @@ export async function extractPdf(bytes, opts = {}) {
|
|
|
259
262
|
const streamObj = objects.get(ref);
|
|
260
263
|
if (!streamObj)
|
|
261
264
|
continue;
|
|
262
|
-
const decoded = await decodeStream(bytes, streamObj);
|
|
265
|
+
const decoded = await decodeStream(bytes, streamObj, budget);
|
|
263
266
|
if (decoded)
|
|
264
267
|
raw += `${decoded.toString('latin1')}\n`;
|
|
265
268
|
}
|
package/dist/extract/zip.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// same code runs on the stdio server and a hosted deployment. Using
|
|
10
10
|
// `node:zlib` here would tie this to Node; adding a userland inflate
|
|
11
11
|
// dependency would bloat it. Neither is necessary.
|
|
12
|
-
import { inflateBounded, MAX_DECOMPRESSED_BYTES } from './inflate.js';
|
|
12
|
+
import { DecompressionBudget, inflateBounded, MAX_DECOMPRESSED_BYTES } from './inflate.js';
|
|
13
13
|
const EOCD_SIG = 0x06054b50;
|
|
14
14
|
const CENTRAL_SIG = 0x02014b50;
|
|
15
15
|
const LOCAL_SIG = 0x04034b50;
|
|
@@ -64,7 +64,10 @@ export async function readZip(bytes, opts = {}) {
|
|
|
64
64
|
});
|
|
65
65
|
p += 46 + nameLen + extraLen + commentLen;
|
|
66
66
|
}
|
|
67
|
+
// Memoized members are bounded by the archive budget: every member read
|
|
68
|
+
// (STORED ones too, since central entries may alias one payload) is charged.
|
|
67
69
|
const cache = new Map();
|
|
70
|
+
const budget = new DecompressionBudget(opts.maxTotalUncompressedBytes);
|
|
68
71
|
async function read(name) {
|
|
69
72
|
const cached = cache.get(name);
|
|
70
73
|
if (cached)
|
|
@@ -87,10 +90,12 @@ export async function readZip(bytes, opts = {}) {
|
|
|
87
90
|
const start = lo + 30 + bytes.readUInt16LE(lo + 26) + bytes.readUInt16LE(lo + 28);
|
|
88
91
|
const raw = bytes.subarray(start, start + entry.compressedSize);
|
|
89
92
|
let out;
|
|
90
|
-
if (entry.method === 0)
|
|
93
|
+
if (entry.method === 0) {
|
|
94
|
+
budget.charge(raw.length, `ZIP member ${name}`);
|
|
91
95
|
out = Buffer.from(raw);
|
|
96
|
+
}
|
|
92
97
|
else if (entry.method === 8)
|
|
93
|
-
out = await inflateBounded(raw, 'deflate-raw', limit, `ZIP member ${name}
|
|
98
|
+
out = await inflateBounded(raw, 'deflate-raw', limit, `ZIP member ${name}`, budget);
|
|
94
99
|
else
|
|
95
100
|
throw new Error(`unsupported ZIP compression method ${entry.method} for ${name}`);
|
|
96
101
|
cache.set(name, out);
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { OFWCache } from './cache/node.js';
|
|
|
21
21
|
import { getCacheDbPath } from './config.js';
|
|
22
22
|
import { selectToolRegistrars } from './tool-surface.js';
|
|
23
23
|
import { NodeAttachmentIO } from './tools/attachments.js';
|
|
24
|
+
import { VERSION } from './version.js';
|
|
24
25
|
// The stdio server backs the message cache with a local `node:sqlite` file,
|
|
25
26
|
// opened lazily on first use (so the server still boots and answers the host's
|
|
26
27
|
// install-time tools/list probe when no cache path is configured). Both the
|
|
@@ -47,7 +48,7 @@ const tools = selectToolRegistrars({
|
|
|
47
48
|
});
|
|
48
49
|
await runMcp({
|
|
49
50
|
name: 'ofw',
|
|
50
|
-
version:
|
|
51
|
+
version: VERSION,
|
|
51
52
|
deps: client,
|
|
52
53
|
tools,
|
|
53
54
|
banner: '[ofw-mcp] This project was developed and is maintained by AI (Claude Sonnet 4.6). Use at your own discretion.',
|
package/dist/protocol.js
CHANGED
|
@@ -15,13 +15,23 @@ export const OFW_TOKEN_TTL_MS = 6 * 60 * 60 * 1000;
|
|
|
15
15
|
// How early we treat a token as expiring. Re-auth before this skew so a
|
|
16
16
|
// long-running request doesn't get a stale token mid-flight.
|
|
17
17
|
export const OFW_TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000;
|
|
18
|
-
// The only host
|
|
19
|
-
// so the two cannot drift.
|
|
18
|
+
// The only host the authenticated API path is ever allowed to contact.
|
|
19
|
+
// Derived from BASE_URL so the two cannot drift.
|
|
20
20
|
const ALLOWED_HOST = new URL(BASE_URL).host;
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* The one OTHER host family this server fetches: signed `*.oaiusercontent.com`
|
|
23
|
+
* file URLs that `ofw_upload_expense_pdf` downloads a receipt from (its `url`
|
|
24
|
+
* mode). That fetch carries no OFW credential and is checked per redirect hop
|
|
25
|
+
* by `assertReceiptUrl` in tools/expenses.ts, not by `assertOfwUrl`. mint.yaml's
|
|
26
|
+
* hosted egress allowlist names exactly these two hosts — tests pin it.
|
|
27
|
+
*/
|
|
28
|
+
export const RECEIPT_HOST_SUFFIX = '.oaiusercontent.com';
|
|
29
|
+
/**
|
|
30
|
+
* Enforced egress allowlist for every request that carries the OFW bearer
|
|
31
|
+
* token or a login: its fully-constructed URL passes through here before
|
|
32
|
+
* `fetch` (API calls in client.ts, the form login in auth-password.ts). The
|
|
33
|
+
* credential-free remote-receipt download is the one exception, held to
|
|
34
|
+
* `RECEIPT_HOST_SUFFIX` instead.
|
|
25
35
|
*
|
|
26
36
|
* Today this always passes, and that is the point. Every URL is
|
|
27
37
|
* `${BASE_URL}${path}` with a path that begins with '/', so no id or query
|
|
@@ -29,9 +39,9 @@ const ALLOWED_HOST = new URL(BASE_URL).host;
|
|
|
29
39
|
* the authority component when a '/' already precedes it. The check exists so
|
|
30
40
|
* that stays true after a future refactor: a code change (or a compromised
|
|
31
41
|
* dependency) that pointed `fetch` somewhere else would throw here instead of
|
|
32
|
-
* carrying the bearer token or a message body off-host. It makes "
|
|
33
|
-
* only ever
|
|
34
|
-
* promise.
|
|
42
|
+
* carrying the bearer token or a message body off-host. It makes "the
|
|
43
|
+
* credential only ever goes to OFW" a structural invariant rather than a
|
|
44
|
+
* code-review promise.
|
|
35
45
|
*
|
|
36
46
|
* Scope, stated honestly: this is a PRE-FLIGHT check on the URL we construct.
|
|
37
47
|
* It does not follow redirects, so it is not a complete egress control — an
|
package/dist/tools/_confirm.js
CHANGED
|
@@ -30,6 +30,10 @@ export function confirmWrite(ctx, opts) {
|
|
|
30
30
|
details: opts.preview,
|
|
31
31
|
unsupportedNote: 'Complete this action on ourfamilywizard.com instead.',
|
|
32
32
|
tool: opts.tool,
|
|
33
|
+
// One OFW account per process (OFW_USERNAME / the captured session), so
|
|
34
|
+
// there is no principal to distinguish; mcp-utils 3 makes saying so explicit.
|
|
35
|
+
account: undefined,
|
|
36
|
+
args: opts.args,
|
|
33
37
|
confirmToken: opts.confirmToken,
|
|
34
38
|
subject: () => ({
|
|
35
39
|
target: opts.target,
|
package/dist/tools/_shared.js
CHANGED
|
@@ -26,6 +26,20 @@ export function jsonResponse(data) {
|
|
|
26
26
|
}
|
|
27
27
|
// Raw-string tool result. Wrapper over @chrischall/mcp-utils' `rawTextResult`.
|
|
28
28
|
export const textResponse = rawTextResult;
|
|
29
|
+
// The JSON half of a mixed text response, through the same seam as
|
|
30
|
+
// jsonResponse: timestamps normalized, no formatting whitespace.
|
|
31
|
+
export function jsonText(data) {
|
|
32
|
+
return JSON.stringify(normalizeTimestampsInValue(data));
|
|
33
|
+
}
|
|
34
|
+
// A write tool's result: its transparency NOTEs/WARNINGs (null entries are
|
|
35
|
+
// skipped) ahead of the structured payload, or `fallback` when OFW returned
|
|
36
|
+
// nothing to show. The payload goes through jsonText, so a write tool cannot
|
|
37
|
+
// hand back the naive-local `sentAt`/`modifiedAt` the read tools never do.
|
|
38
|
+
export function notedJsonResponse(notes, data, fallback) {
|
|
39
|
+
const text = data ? jsonText(data) : fallback;
|
|
40
|
+
const prefix = notes.filter((n) => n !== null).join('\n\n');
|
|
41
|
+
return textResponse(prefix ? `${prefix}\n\n${text}` : text);
|
|
42
|
+
}
|
|
29
43
|
// A STRUCTURED failure: the machine-readable payload of `jsonResponse` plus
|
|
30
44
|
// `isError`, so a refusal can carry recovery data (e.g. the server draft body
|
|
31
45
|
// we declined to overwrite) without being mistaken for a successful write.
|
package/dist/tools/calendar.js
CHANGED
|
@@ -152,7 +152,7 @@ export function registerCalendarTools(server, client) {
|
|
|
152
152
|
const allowWrites = getCalendarWritesAllowed();
|
|
153
153
|
server.registerTool('ofw_list_events', {
|
|
154
154
|
description: 'List OurFamilyWizard calendar events in a date range',
|
|
155
|
-
annotations: { readOnlyHint: true },
|
|
155
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
156
156
|
inputSchema: z.object({
|
|
157
157
|
startDate: z.string().describe('Start date YYYY-MM-DD'),
|
|
158
158
|
endDate: z.string().describe('End date YYYY-MM-DD'),
|
|
@@ -166,9 +166,10 @@ export function registerCalendarTools(server, client) {
|
|
|
166
166
|
if (allowWrites)
|
|
167
167
|
server.registerTool('ofw_create_event', {
|
|
168
168
|
description: 'Create a calendar event in OurFamilyWizard. Unless privateEvent is true, the event is immediately visible to the co-parent — there is no draft stage — so a shared event is confirmed first (a private one is not). If the request fails without a definitive answer the result is EVENT_UNCONFIRMED: the event may already exist, so do NOT retry until ofw_list_events shows it did not land. ' + CONFIRM_NOTE,
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
169
|
+
// Destructive by the inverse test: a shared event is on the co-parent's
|
|
170
|
+
// calendar the moment it lands, and ofw_delete_event removes it but cannot
|
|
171
|
+
// un-show it. (A private one would be additive; the hint is per tool.)
|
|
172
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
172
173
|
inputSchema: z.object({
|
|
173
174
|
title: z.string(),
|
|
174
175
|
...eventWriteFields,
|
|
@@ -179,6 +180,7 @@ export function registerCalendarTools(server, client) {
|
|
|
179
180
|
const payload = buildEventPayload(fields);
|
|
180
181
|
if (!fields.privateEvent) {
|
|
181
182
|
const gate = await confirmWrite(ctx, {
|
|
183
|
+
args,
|
|
182
184
|
tool: 'ofw_create_event',
|
|
183
185
|
action: 'ofw.event.create',
|
|
184
186
|
message: 'Review and confirm this OurFamilyWizard calendar event. It is shared with the co-parent and appears on their calendar immediately.',
|
|
@@ -216,7 +218,7 @@ export function registerCalendarTools(server, client) {
|
|
|
216
218
|
if (allowWrites)
|
|
217
219
|
server.registerTool('ofw_update_event', {
|
|
218
220
|
description: 'Update an existing OurFamilyWizard calendar event. Fetches the event, applies the given changes, and writes the merged result back (OFW has no partial update). A change to an event the co-parent can see (shared before or after the change) is confirmed first; the confirmation is bound to the event exactly as read, so if it changes on OFW in between (say the co-parent edited it) the update is refused instead of overwriting their edit. If the write fails without a definitive answer, or lands but cannot be re-read to confirm it, the result is EVENT_UNCONFIRMED: the change may already be on OFW, so do NOT retry until ofw_list_events shows whether it landed. ' + CONFIRM_NOTE,
|
|
219
|
-
annotations: { destructiveHint: true },
|
|
221
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
220
222
|
inputSchema: z.object({
|
|
221
223
|
eventId: z.string().describe('Event id — the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event'),
|
|
222
224
|
title: z.string().optional(),
|
|
@@ -249,6 +251,7 @@ export function registerCalendarTools(server, client) {
|
|
|
249
251
|
if (current.publicFlag || !merged.privateEvent) {
|
|
250
252
|
const names = namesIn(current);
|
|
251
253
|
const gate = await confirmWrite(ctx, {
|
|
254
|
+
args,
|
|
252
255
|
tool: 'ofw_update_event',
|
|
253
256
|
action: 'ofw.event.update',
|
|
254
257
|
message: `Review and confirm this change to the OurFamilyWizard event "${current.title}". The co-parent sees the updated event on their calendar.`,
|
|
@@ -312,6 +315,7 @@ export function registerCalendarTools(server, client) {
|
|
|
312
315
|
if (current.publicFlag) {
|
|
313
316
|
const base = detailToWriteArgs(current);
|
|
314
317
|
const gate = await confirmWrite(ctx, {
|
|
318
|
+
args,
|
|
315
319
|
tool: 'ofw_delete_event',
|
|
316
320
|
action: 'ofw.event.delete',
|
|
317
321
|
message: `Review and confirm deleting the OurFamilyWizard event "${current.title}". It disappears from the co-parent's calendar.`,
|
package/dist/tools/expenses.js
CHANGED
|
@@ -5,6 +5,7 @@ import { CONFIRM_NOTE, confirmTokenParam, confirmWrite, stateRevision } from './
|
|
|
5
5
|
import { readUpstreamPaging } from './pagination.js';
|
|
6
6
|
import { getExpenseUploadOnly, getWriteMode } from '../config.js';
|
|
7
7
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
8
|
+
import { RECEIPT_HOST_SUFFIX } from '../protocol.js';
|
|
8
9
|
const UploadedExpenseFileSchema = z.looseObject({
|
|
9
10
|
fileId: z.number(),
|
|
10
11
|
fileName: z.string().optional(),
|
|
@@ -32,7 +33,7 @@ function assertReceiptUrl(url) {
|
|
|
32
33
|
if (url.protocol !== 'https:') {
|
|
33
34
|
throw new Error('Remote expense receipt URLs must use HTTPS.');
|
|
34
35
|
}
|
|
35
|
-
if (!url.hostname.toLowerCase().endsWith(
|
|
36
|
+
if (!url.hostname.toLowerCase().endsWith(RECEIPT_HOST_SUFFIX)) {
|
|
36
37
|
throw new Error('Remote expense receipt URLs must be signed oaiusercontent.com file URLs.');
|
|
37
38
|
}
|
|
38
39
|
}
|
|
@@ -208,7 +209,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
|
|
|
208
209
|
if (!uploadOnly)
|
|
209
210
|
server.registerTool('ofw_get_expense_totals', {
|
|
210
211
|
description: 'Get OurFamilyWizard expense summary totals (owed/paid)',
|
|
211
|
-
annotations: { readOnlyHint: true },
|
|
212
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
212
213
|
}, async () => {
|
|
213
214
|
const data = await client.request('GET', '/pub/v2/expense/expenses/totals');
|
|
214
215
|
return jsonResponse(data);
|
|
@@ -216,7 +217,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
|
|
|
216
217
|
if (!uploadOnly)
|
|
217
218
|
server.registerTool('ofw_list_expense_categories', {
|
|
218
219
|
description: 'List OurFamilyWizard expense categories, including preset and custom categories with their responsibility split metadata. Read-only.',
|
|
219
|
-
annotations: { readOnlyHint: true },
|
|
220
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
220
221
|
}, async () => {
|
|
221
222
|
const data = await client.request('GET', '/pub/v2/expense/categories');
|
|
222
223
|
return jsonResponse(data);
|
|
@@ -224,7 +225,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
|
|
|
224
225
|
if (!uploadOnly)
|
|
225
226
|
server.registerTool('ofw_list_expenses', {
|
|
226
227
|
description: 'List OurFamilyWizard expenses. OFW pages this endpoint with 1-based page/size parameters; its older start/max parameters are ignored and repeatedly return page 1. The response leads with hasMore and nextPage (null when exhausted) before the records. Continue by passing nextPage.',
|
|
227
|
-
annotations: { readOnlyHint: true },
|
|
228
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
228
229
|
inputSchema: z.object({
|
|
229
230
|
page: z.number().int().min(1).describe('1-based page number (default 1). To continue, pass the nextPage returned by the previous response.').optional(),
|
|
230
231
|
size: z.number().int().min(1).max(100).describe('Requested page size (default 20). OFW may cap or normalize this value.').optional(),
|
|
@@ -272,8 +273,10 @@ export function registerExpenseTools(server, client, attachmentIO) {
|
|
|
272
273
|
});
|
|
273
274
|
if (allowReceiptUploads)
|
|
274
275
|
server.registerTool('ofw_upload_expense_pdf', {
|
|
276
|
+
// Destructive by the inverse test: always uploaded SHARED, so the
|
|
277
|
+
// co-parent sees it in My Files at once, and no tool here deletes it.
|
|
275
278
|
description: 'Upload a PDF to OurFamilyWizard My Files for later attachment to an expense. Accepts either a local path or a signed ChatGPT/oaiusercontent HTTPS URL plus fileName. Exactly one of path or url must be supplied. This tool accepts PDF files only and uploads them using the same SHARED file metadata as the OFW expense form so the returned fileId can be attached to an expense. A SHARED file is visible to the co-parent in My Files immediately, whatever the visibility of the expense it is later attached to (that is set separately by ofw_create_expense privateExpense). ' + CONFIRM_NOTE,
|
|
276
|
-
annotations: { readOnlyHint: false, destructiveHint:
|
|
279
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
277
280
|
inputSchema: z.object({
|
|
278
281
|
path: z.string().describe('Path to a local PDF file inside the upload directory (OFW_UPLOAD_DIR). Tilde (~) is expanded by the configured attachment I/O implementation. Mutually exclusive with url.').optional(),
|
|
279
282
|
url: z.string().describe('Signed HTTPS oaiusercontent.com URL for a PDF supplied by the ChatGPT host. Mutually exclusive with path.').optional(),
|
|
@@ -309,6 +312,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
|
|
|
309
312
|
const label = args.label ?? fileName;
|
|
310
313
|
const description = args.description ?? fileName;
|
|
311
314
|
const gate = await confirmWrite(ctx, {
|
|
315
|
+
args,
|
|
312
316
|
tool: 'ofw_upload_expense_pdf',
|
|
313
317
|
action: 'ofw.file.share',
|
|
314
318
|
message: `Review and confirm uploading the receipt "${fileName}" to OurFamilyWizard. It is uploaded SHARED, so the co-parent can see it in My Files immediately — even if the expense it is attached to is private.`,
|
|
@@ -455,6 +459,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
|
|
|
455
459
|
});
|
|
456
460
|
}
|
|
457
461
|
const gate = await confirmWrite(ctx, {
|
|
462
|
+
args,
|
|
458
463
|
tool: 'ofw_update_expense',
|
|
459
464
|
action: 'ofw.expense.update',
|
|
460
465
|
message: next.isPrivate
|
|
@@ -558,6 +563,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
|
|
|
558
563
|
payload.fileIds = [args.receiptFileId];
|
|
559
564
|
const isPrivate = args.privateExpense === true;
|
|
560
565
|
const gate = await confirmWrite(ctx, {
|
|
566
|
+
args,
|
|
561
567
|
tool: 'ofw_create_expense',
|
|
562
568
|
action: 'ofw.expense.create',
|
|
563
569
|
message: isPrivate
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { registerCredentialHealthcheckTool } from '@chrischall/mcp-utils/healthcheck';
|
|
2
|
-
import {
|
|
2
|
+
import { isNoAuthConfigured, isBridgeDown } from '../auth.js';
|
|
3
3
|
/**
|
|
4
4
|
* `ofw_healthcheck` — the one call that answers "is this connector working?".
|
|
5
5
|
*
|
|
@@ -13,10 +13,17 @@ import { resolveAuth, isNoAuthConfigured, isBridgeDown } from '../auth.js';
|
|
|
13
13
|
* fetchproxy, and "which of those actually supplied it" is the first thing
|
|
14
14
|
* anyone needs when the connector misbehaves. That is why `source` is
|
|
15
15
|
* reported.
|
|
16
|
+
*
|
|
17
|
+
* The credential comes from the CLIENT (`credentialStatus`), not from a direct
|
|
18
|
+
* `resolveAuth()` call. That used to be a full login — or a fresh browser-bridge
|
|
19
|
+
* spin-up — on every healthcheck, ignoring the six-hour token the client
|
|
20
|
+
* already held; with a bad password each check re-POSTed it against OFW's
|
|
21
|
+
* failed-attempt counter (chrischall/fleet-audit#881). Through the client's
|
|
22
|
+
* TokenManager a held or cached token costs nothing, a needed mint is the one
|
|
23
|
+
* the next tool call would make anyway, and the probe then exercises that very
|
|
24
|
+
* token — so a passing healthcheck means the tools' own token works.
|
|
16
25
|
*/
|
|
17
|
-
export function registerHealthcheckTools(server, client
|
|
18
|
-
/** Seam: the auth resolver, injectable so tests need no network. */
|
|
19
|
-
resolve = resolveAuth) {
|
|
26
|
+
export function registerHealthcheckTools(server, client) {
|
|
20
27
|
registerCredentialHealthcheckTool({
|
|
21
28
|
server,
|
|
22
29
|
prefix: 'ofw',
|
|
@@ -27,12 +34,13 @@ resolve = resolveAuth) {
|
|
|
27
34
|
probePath: '/pub/v2/profiles',
|
|
28
35
|
resolveCredential: async () => {
|
|
29
36
|
try {
|
|
30
|
-
const
|
|
37
|
+
const { source, expiresAt } = await client.credentialStatus();
|
|
31
38
|
return {
|
|
32
|
-
source
|
|
39
|
+
source,
|
|
33
40
|
// Never the token. Expiry is the fact that explains a connector
|
|
34
|
-
// that worked an hour ago and does not now
|
|
35
|
-
|
|
41
|
+
// that worked an hour ago and does not now — and this is the one
|
|
42
|
+
// the client itself will re-authenticate at.
|
|
43
|
+
detail: { expires_at: expiresAt.toISOString() },
|
|
36
44
|
};
|
|
37
45
|
}
|
|
38
46
|
catch (e) {
|
|
@@ -73,7 +81,7 @@ resolve = resolveAuth) {
|
|
|
73
81
|
no_credential: 'No OFW credential is configured. Either set OFW_USERNAME + OFW_PASSWORD, or install ' +
|
|
74
82
|
'ContextMint Bridge and sign in to ourfamilywizard.com in a tab (unsetting ' +
|
|
75
83
|
'OFW_DISABLE_FETCHPROXY if you set it).',
|
|
76
|
-
credential_rejected: 'OurFamilyWizard rejected the credential. If it came from `env`, the password changed or ' +
|
|
84
|
+
credential_rejected: 'OurFamilyWizard rejected the credential. If it came from `env` or `cache`, the password changed or ' +
|
|
77
85
|
'the account is locked; if from `fetchproxy`, the browser session expired — sign in again ' +
|
|
78
86
|
'in the tab. Retrying will not fix either.',
|
|
79
87
|
},
|
package/dist/tools/journal.js
CHANGED
|
@@ -7,7 +7,7 @@ export function registerJournalTools(server, client) {
|
|
|
7
7
|
const allowWrites = getWriteMode() === 'all';
|
|
8
8
|
server.registerTool('ofw_list_journal_entries', {
|
|
9
9
|
description: 'List OurFamilyWizard journal entries. Offset-paged via start/max (1-based). The response leads with its paging state — `hasMore` and `nextStart` (null when the list is exhausted) — BEFORE the records, so a truncated or partially-read response still says whether more remain. Never state an entry count or an absence from one page.',
|
|
10
|
-
annotations: { readOnlyHint: true },
|
|
10
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
11
11
|
inputSchema: z.object({
|
|
12
12
|
start: z.number().int().min(1).describe('Start offset, 1-based (default 1). To continue a listing, pass the `nextStart` from the previous response.').optional(),
|
|
13
13
|
max: z.number().int().min(1).describe('Max results (default 10)').optional(),
|
|
@@ -38,8 +38,10 @@ export function registerJournalTools(server, client) {
|
|
|
38
38
|
});
|
|
39
39
|
if (allowWrites)
|
|
40
40
|
server.registerTool('ofw_create_journal_entry', {
|
|
41
|
+
// Destructive by the inverse test: the entry lands on the court-visible
|
|
42
|
+
// record and no tool here edits or deletes a journal entry.
|
|
41
43
|
description: 'Create a new journal entry in OurFamilyWizard',
|
|
42
|
-
annotations: {
|
|
44
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
43
45
|
inputSchema: z.object({
|
|
44
46
|
title: z.string().describe('Entry title'),
|
|
45
47
|
body: z.string().describe('Entry text content'),
|