ofw-mcp 2.20.2 → 2.20.4
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 +3 -2
- package/dist/auth-password.js +27 -2
- package/dist/auth.js +3 -3
- package/dist/bundle.js +352 -152
- package/dist/client.js +21 -1
- 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/tools/_confirm.js +4 -0
- package/dist/tools/_shared.js +14 -0
- package/dist/tools/calendar.js +9 -5
- package/dist/tools/expenses.js +9 -4
- 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 +7 -0
- package/package.json +4 -4
- package/server.json +92 -2
- package/skills/ofw/SKILL.md +1 -1
package/dist/client.js
CHANGED
|
@@ -100,6 +100,9 @@ export class OFWClient {
|
|
|
100
100
|
// deployment. Left undefined by the stdio path, which falls back to the
|
|
101
101
|
// global resolver, keeping that behaviour byte-for-byte identical.
|
|
102
102
|
authResolver;
|
|
103
|
+
// Which path minted the token currently held. Unset while the token is one
|
|
104
|
+
// the TokenManager restored from the session cache — nothing was minted.
|
|
105
|
+
mintedBy;
|
|
103
106
|
constructor(opts) {
|
|
104
107
|
this.authResolver = opts?.resolveAuth;
|
|
105
108
|
}
|
|
@@ -111,7 +114,8 @@ export class OFWClient {
|
|
|
111
114
|
// the expired placeholder that used to sit here would have meant the
|
|
112
115
|
// cache was written but never read.
|
|
113
116
|
const mint = async () => {
|
|
114
|
-
const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
|
|
117
|
+
const { token, expiresAt, source } = await (this.authResolver ?? resolveAuth)();
|
|
118
|
+
this.mintedBy = source;
|
|
115
119
|
return {
|
|
116
120
|
accessToken: token,
|
|
117
121
|
refreshToken: OFW_REFRESH_SENTINEL,
|
|
@@ -136,6 +140,22 @@ export class OFWClient {
|
|
|
136
140
|
}
|
|
137
141
|
return this.tokenManager;
|
|
138
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* The credential's source and expiry, answered from the TokenManager — the
|
|
145
|
+
* same token, cache and single-flight mint every tool uses.
|
|
146
|
+
*
|
|
147
|
+
* `ofw_healthcheck` used to call `resolveAuth()` directly, which is a full
|
|
148
|
+
* login (or a fresh browser-bridge spin-up) on every healthcheck, ignoring
|
|
149
|
+
* the six-hour token already held. Going through the manager means a held or
|
|
150
|
+
* cached token costs nothing, and a mint, when one is needed, is the one the
|
|
151
|
+
* next real tool call would have made anyway. A mint failure propagates
|
|
152
|
+
* unchanged so the healthcheck can classify it.
|
|
153
|
+
*/
|
|
154
|
+
async credentialStatus() {
|
|
155
|
+
const tokens = this.getTokenManager();
|
|
156
|
+
await tokens.getAccessToken();
|
|
157
|
+
return { source: this.mintedBy ?? 'cache', expiresAt: new Date(tokens.getExpiresAt()) };
|
|
158
|
+
}
|
|
139
159
|
async request(method, path, body) {
|
|
140
160
|
return this.fetchAuthed(method, path, body, 'application/json', async (response) => {
|
|
141
161
|
const text = await response.text();
|
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/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
|
@@ -208,7 +208,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
|
|
|
208
208
|
if (!uploadOnly)
|
|
209
209
|
server.registerTool('ofw_get_expense_totals', {
|
|
210
210
|
description: 'Get OurFamilyWizard expense summary totals (owed/paid)',
|
|
211
|
-
annotations: { readOnlyHint: true },
|
|
211
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
212
212
|
}, async () => {
|
|
213
213
|
const data = await client.request('GET', '/pub/v2/expense/expenses/totals');
|
|
214
214
|
return jsonResponse(data);
|
|
@@ -216,7 +216,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
|
|
|
216
216
|
if (!uploadOnly)
|
|
217
217
|
server.registerTool('ofw_list_expense_categories', {
|
|
218
218
|
description: 'List OurFamilyWizard expense categories, including preset and custom categories with their responsibility split metadata. Read-only.',
|
|
219
|
-
annotations: { readOnlyHint: true },
|
|
219
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
220
220
|
}, async () => {
|
|
221
221
|
const data = await client.request('GET', '/pub/v2/expense/categories');
|
|
222
222
|
return jsonResponse(data);
|
|
@@ -224,7 +224,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
|
|
|
224
224
|
if (!uploadOnly)
|
|
225
225
|
server.registerTool('ofw_list_expenses', {
|
|
226
226
|
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 },
|
|
227
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
228
228
|
inputSchema: z.object({
|
|
229
229
|
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
230
|
size: z.number().int().min(1).max(100).describe('Requested page size (default 20). OFW may cap or normalize this value.').optional(),
|
|
@@ -272,8 +272,10 @@ export function registerExpenseTools(server, client, attachmentIO) {
|
|
|
272
272
|
});
|
|
273
273
|
if (allowReceiptUploads)
|
|
274
274
|
server.registerTool('ofw_upload_expense_pdf', {
|
|
275
|
+
// Destructive by the inverse test: always uploaded SHARED, so the
|
|
276
|
+
// co-parent sees it in My Files at once, and no tool here deletes it.
|
|
275
277
|
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:
|
|
278
|
+
annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
|
|
277
279
|
inputSchema: z.object({
|
|
278
280
|
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
281
|
url: z.string().describe('Signed HTTPS oaiusercontent.com URL for a PDF supplied by the ChatGPT host. Mutually exclusive with path.').optional(),
|
|
@@ -309,6 +311,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
|
|
|
309
311
|
const label = args.label ?? fileName;
|
|
310
312
|
const description = args.description ?? fileName;
|
|
311
313
|
const gate = await confirmWrite(ctx, {
|
|
314
|
+
args,
|
|
312
315
|
tool: 'ofw_upload_expense_pdf',
|
|
313
316
|
action: 'ofw.file.share',
|
|
314
317
|
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 +458,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
|
|
|
455
458
|
});
|
|
456
459
|
}
|
|
457
460
|
const gate = await confirmWrite(ctx, {
|
|
461
|
+
args,
|
|
458
462
|
tool: 'ofw_update_expense',
|
|
459
463
|
action: 'ofw.expense.update',
|
|
460
464
|
message: next.isPrivate
|
|
@@ -558,6 +562,7 @@ export function registerExpenseTools(server, client, attachmentIO) {
|
|
|
558
562
|
payload.fileIds = [args.receiptFileId];
|
|
559
563
|
const isPrivate = args.privateExpense === true;
|
|
560
564
|
const gate = await confirmWrite(ctx, {
|
|
565
|
+
args,
|
|
561
566
|
tool: 'ofw_create_expense',
|
|
562
567
|
action: 'ofw.expense.create',
|
|
563
568
|
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'),
|